pax_global_header00006660000000000000000000000064152505101240014505gustar00rootroot0000000000000052 comment=8026deeaac371a5d9a3edbe4886d58f61139d464 scrapy-scrapy-5569de3/000077500000000000000000000000001525051012400146535ustar00rootroot00000000000000scrapy-scrapy-5569de3/.git-blame-ignore-revs000066400000000000000000000005021525051012400207500ustar00rootroot00000000000000# .git-blame-ignore-revs # adding black formatter to all the code e211ec0aa26ecae0da8ae55d064ea60e1efe4d0d # reapplying black to the code with default line length 303f0a70fcf8067adf0a909c2096a5009162383a # reapplying black again and removing line length on pre-commit black config c5cdd0d30ceb68ccba04af0e71d1b8e6678e2962 scrapy-scrapy-5569de3/.gitattributes000066400000000000000000000000341525051012400175430ustar00rootroot00000000000000tests/sample_data/** binary scrapy-scrapy-5569de3/.github/000077500000000000000000000000001525051012400162135ustar00rootroot00000000000000scrapy-scrapy-5569de3/.github/ISSUE_TEMPLATE/000077500000000000000000000000001525051012400203765ustar00rootroot00000000000000scrapy-scrapy-5569de3/.github/ISSUE_TEMPLATE/bug_report.md000066400000000000000000000027631525051012400231000ustar00rootroot00000000000000--- name: Bug report about: Report a problem to help us improve --- ### Description [Description of the issue] ### Steps to Reproduce 1. [First Step] 2. [Second Step] 3. [and so on...] **Expected behavior:** [What you expect to happen] **Actual behavior:** [What actually happens] **Reproduces how often:** [What percentage of the time does it reproduce?] ### Versions Please paste here the output of executing `scrapy version --verbose` in the command line. ### Additional context Any additional information, configuration, data or output from commands that might be necessary to reproduce or understand the issue. Please try not to include screenshots of code or the command line, paste the contents as text instead. You can use [GitHub Flavored Markdown](https://help.github.com/en/articles/creating-and-highlighting-code-blocks) to make the text look better. scrapy-scrapy-5569de3/.github/ISSUE_TEMPLATE/feature_request.md000066400000000000000000000023701525051012400241250ustar00rootroot00000000000000--- name: Feature request about: Suggest an idea for an enhancement or new feature --- ## Summary One paragraph explanation of the feature. ## Motivation Why are we doing this? What use cases does it support? What is the expected outcome? ## Describe alternatives you've considered A clear and concise description of the alternative solutions you've considered. Be sure to explain why Scrapy's existing customizability isn't suitable for this feature. ## Additional context Any additional information about the feature request here. scrapy-scrapy-5569de3/.github/ISSUE_TEMPLATE/question.md000066400000000000000000000006441525051012400225730ustar00rootroot00000000000000--- name: Question / Help about: Ask a question about Scrapy or ask for help with your Scrapy code. --- Thanks for taking an interest in Scrapy! The Scrapy GitHub issue tracker is not meant for questions or help. Please ask for help in the [Scrapy community resources](https://scrapy.org/community/) instead. The GitHub issue tracker's purpose is to deal with bug reports and feature requests for the project itself. scrapy-scrapy-5569de3/.github/dependabot.yml000066400000000000000000000003331525051012400210420ustar00rootroot00000000000000version: 2 updates: - package-ecosystem: github-actions directory: "/" schedule: interval: monthly groups: github-actions: patterns: - "*" cooldown: default-days: 7 scrapy-scrapy-5569de3/.github/workflows/000077500000000000000000000000001525051012400202505ustar00rootroot00000000000000scrapy-scrapy-5569de3/.github/workflows/checks.yml000066400000000000000000000041301525051012400222310ustar00rootroot00000000000000name: Checks permissions: contents: read on: push: branches: - master - '[0-9]+.[0-9]+' pull_request: types: [opened, synchronize, reopened, closed] concurrency: group: ${{github.workflow}}-${{ github.head_ref || github.ref }} cancel-in-progress: true jobs: checks: if: github.event.action != 'closed' runs-on: ubuntu-latest timeout-minutes: 10 env: # Make uv use the interpreter that actions/setup-python installed instead # of downloading one of its own. UV_PYTHON_PREFERENCE: only-system strategy: fail-fast: false matrix: include: - python-version: "3.14" env: TOXENV: pylint - python-version: "3.10" env: TOXENV: mypy - python-version: "3.10" env: TOXENV: mypy-tests # Keep in sync with pyproject.toml tool.sphinx-scrapy.python-version. - python-version: "3.14" env: TOXENV: docs - python-version: "3.13" env: TOXENV: docs-tests - python-version: "3.14" env: TOXENV: twinecheck steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: ${{ matrix.python-version }} - name: Set up uv uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: cache-dependency-glob: | docs/requirements.txt pyproject.toml tox.ini - name: Run check env: ${{ matrix.env }} run: uvx --with tox-uv tox pre-commit: if: github.event.action != 'closed' runs-on: ubuntu-latest timeout-minutes: 10 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - uses: pre-commit/action@2c7b3805fd2a0fd8c1884dcaebf91fc102a13ecd # v3.0.1 scrapy-scrapy-5569de3/.github/workflows/codspeed.yml000066400000000000000000000071171525051012400225670ustar00rootroot00000000000000--- name: codspeed on: push: branches: - master pull_request: types: [opened, synchronize, reopened, closed] paths: - scrapy/** - tests/benchmarks/** - .github/workflows/codspeed.yml - tox.ini workflow_dispatch: concurrency: group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} cancel-in-progress: true permissions: {} jobs: benchmark: if: github.event.action != 'closed' runs-on: ubuntu-latest timeout-minutes: 25 env: # Make uv use the interpreter that actions/setup-python installed # instead of downloading one of its own. UV_PYTHON_PREFERENCE: only-system permissions: contents: read id-token: write # OIDC authentication with CodSpeed steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Set up Python 3.14 uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: '3.14' - name: Set up uv uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: cache-dependency-glob: | pyproject.toml tox.ini - name: Install dependencies # tox must stay on PATH for the CodSpeed action to invoke it. run: | uv tool install --with tox-uv tox tox -n -e benchmark - name: Run benchmarks uses: CodSpeedHQ/action@373d6868929f444bc08d901fd0eb0ad52a8875ea # v5.2.1 with: mode: simulation run: tox -e benchmark benchmark-walltime: # Only benchmarks marked "walltime" (ones where a real concurrency # win can't be shown by instruction counting) run here, on # CodSpeed's dedicated, minutes-limited macro runner; everything # else runs above, on a regular GitHub-hosted runner. if: github.event.action != 'closed' runs-on: codspeed-macro env: UV_PYTHON_PREFERENCE: only-system permissions: contents: read id-token: write # OIDC authentication with CodSpeed steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Set up Python 3.14 uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: '3.14' - name: Set up uv uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: cache-dependency-glob: | pyproject.toml tox.ini - name: Install dependencies run: | uv tool install --with tox-uv tox tox -n -e benchmark - name: Run benchmarks uses: CodSpeedHQ/action@373d6868929f444bc08d901fd0eb0ad52a8875ea # v5.2.1 with: mode: walltime # No benchmark is walltime-marked on some branches (e.g. # this one), which would otherwise fail the step. allow-empty: true run: tox -e benchmark -- tests/benchmarks -m walltime scrapy-scrapy-5569de3/.github/workflows/flag-prs-for-triage.yml000066400000000000000000000331001525051012400245400ustar00rootroot00000000000000name: Flag PRs for triage # Labels pull requests whose author's public activity suggests that an LLM is # writing them without supervision, and records the evidence in the workflow # run summary so that triaging one does not require reading a user profile. # # Four independent signals, any of which is enough to label. Each one abstains # when the data it needs is unavailable, so a missing signal never counts # against an author: # # - Rejection burst: pull requests of theirs closed unmerged elsewhere within # the last month. Volume of rejections in absolute terms separates spraying # from ordinary contribution far better than a merge ratio does, since # ratios reward authors who accumulate merges in trivial repositories. # - Spray breadth: unrelated repositories they open pull requests against # within one week. Breadth catches an agent on its first day, before any of # its pull requests have been closed, and it comes from the event feed, so it # also covers authors that the search API refuses to return. # - Assistant voice: their recent comments across GitHub read as assistant # output rather than as a developer talking, by section headings, bullet # lists, em dash density or stock acknowledgement phrases. # - Agent branch: the branch name carries an agent prefix. # # Authors that the organisations behind this repository already trust are left # alone before any of that runs: public members of those organisations, and # authors with a track record of pull requests merged into their repositories. # Trust from a merge record rather than from a list of names keeps the exemption # in step with who is actually contributing. # # Deliberately not used: account age, fork age, follower count, total pull # request count and cross-repository merge ratio. All of them were measured # against hand-labelled pull requests and either failed to separate or, in the # case of the merge ratio, inverted on held-out data. # # The label is advisory, and it says the author's history is worth a look # before reviewing in depth; it does not say the pull request is bad. # # The workflow only reads pull request and public activity metadata through the # API, it never checks out or runs pull request code, so pull_request_target is # safe here. on: # zizmor: ignore[dangerous-triggers] pull_request_target: types: [opened] permissions: contents: read pull-requests: write jobs: flag-pr-for-triage: name: Label PR if the author's activity suggests unsupervised LLM use runs-on: ubuntu-latest steps: - name: Score the author and label the PR uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | const LABEL = 'needs triage'; const RETRIES = 5; const RETRY_WAIT_MS = 60000; const REJECTION_WINDOW_DAYS = 30; const MIN_REJECTIONS = 1; const MIN_COMMENTS = 2; const MAX_REPOS_PER_WEEK = 2; const VOICE = { structure: 0.10, emDashPerKChar: 0.30, acknowledgement: 0.40 }; const EVENT_PAGES = 3; const TRUSTED_ORGS = ['scrapy', 'scrapy-plugins', 'scrapinghub', 'zytedata']; const MIN_TRUSTED_MERGES = 10; const AGENT_BRANCH = /^(agent|codex|claude|cursor|devin|copilot|jules|bot)[\/_-]/i; const { owner, repo } = context.repo; const pr = context.payload.pull_request; const author = pr.user.login; if (pr.user.type === 'Bot' || ['MEMBER', 'OWNER', 'COLLABORATOR'].includes(pr.author_association)) { core.info(`Skipping PR #${pr.number} by ${author} (${pr.user.type}, ${pr.author_association}).`); return; } // Rate and abuse limits reset on the order of a minute, so waiting // is enough; other errors are not worth retrying. const retriable = new Set([403, 429, 500, 502, 503, 504]); const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); async function withRetries(description, call) { for (let attempt = 1; ; attempt++) { try { return await call(); } catch (error) { if (!retriable.has(error.status) || attempt > RETRIES) throw error; const reset = Number(error.response?.headers?.['x-ratelimit-reset']) * 1000 - Date.now(); const after = Number(error.response?.headers?.['retry-after']) * 1000; const wait = Math.min(Math.max(after || reset || RETRY_WAIT_MS, RETRY_WAIT_MS), 15 * RETRY_WAIT_MS); core.info(`${description} failed with ${error.status}, retrying in ${Math.round(wait / 1000)}s (attempt ${attempt}/${RETRIES}).`); await sleep(wait); } } } // Accounts excluded from search, deleted users and the like leave a // signal unmeasurable rather than negative. const orNull = promise => promise.catch(error => { if ([404, 410, 422].includes(error.status)) return null; throw error; }); // author_association only reports membership of the organisation // that owns this repository, and only when it is public, so trust // in the author is established here instead. const trustedOrg = (await Promise.all(TRUSTED_ORGS.map(org => orNull(withRetries(`Checking public membership of ${org}`, () => github.rest.orgs.checkPublicMembershipForUser({ org, username: author }), )).then(response => response && org), ))).find(Boolean); if (trustedOrg) { core.info(`Skipping PR #${pr.number} by ${author} (public member of ${trustedOrg}).`); return; } // Repeating a qualifier narrows the search instead of widening it, // hence the explicit disjunction. const trustedMerges = await orNull(withRetries('Counting merged PRs in trusted organisations', () => github.rest.search.issuesAndPullRequests({ q: `author:${author} type:pr is:merged` + ` (${TRUSTED_ORGS.map(org => `org:${org}`).join(' OR ')})`, advanced_search: 'true', per_page: 1, }).then(response => response.data.total_count), )); if (trustedMerges >= MIN_TRUSTED_MERGES) { core.info(`Skipping PR #${pr.number} by ${author}` + ` (${trustedMerges} PR(s) merged into ${TRUSTED_ORGS.join(', ')}).`); return; } const opened = new Date(pr.created_at); const daysBefore = date => (opened - new Date(date)) / 86400000; // Signal 1: pull requests closed unmerged elsewhere, recently. const search = await orNull(withRetries('Searching for PRs by the author', () => github.rest.search.issuesAndPullRequests({ q: `author:${author} type:pr`, advanced_search: 'true', sort: 'created', order: 'desc', per_page: 100, }).then(response => response.data), )); let rejections = null; if (search) { rejections = search.items.filter(item => { const itemOwner = item.repository_url.split('/repos/')[1].split('/')[0].toLowerCase(); return itemOwner !== author.toLowerCase() && item.state === 'closed' && !item.pull_request?.merged_at && daysBefore(item.created_at) >= 0 && daysBefore(item.created_at) <= REJECTION_WINDOW_DAYS; }).map(item => item.html_url); } // Signal 2: how their recent comments across GitHub read. const events = []; for (let page = 1; page <= EVENT_PAGES; page++) { const batch = await orNull(withRetries(`Reading public events page ${page}`, () => github.rest.activity.listPublicEventsForUser({ username: author, per_page: 100, page, }).then(response => response.data), )); if (!batch?.length) break; events.push(...batch); if (batch.length < 100) break; } const comments = events .filter(event => ['IssueCommentEvent', 'PullRequestReviewCommentEvent'].includes(event.type)) .map(event => event.payload?.comment?.body) .filter(Boolean); // Signal 3: how many unrelated projects they open pull requests // against in a single week. Breadth rather than volume: a focused // contributor sends many pull requests to few repositories, while // an unattended agent sprays a few across many. Taken from the // event feed, which unlike search covers authors that search // refuses to return. const weeks = {}; for (const event of events) { if (event.type !== 'PullRequestEvent' || event.payload?.action !== 'opened') continue; const name = event.repo?.name; if (!name || name.toLowerCase().startsWith(`${author.toLowerCase()}/`)) continue; const week = Math.floor(new Date(event.created_at) / (7 * 86400000)); (weeks[week] ??= new Set()).add(name); } const breadth = events.length ? Math.max(0, ...Object.values(weeks).map(repos => repos.size)) : null; const STRUCTURE = [/^\s*#{2,3}\s/m, /^\s*[-*]\s.+\n\s*[-*]\s/m, /\*\*[^*]+\*\*/, /```/]; const ACKNOWLEDGEMENT = [ /thanks for (the )?(review|feedback|pointing|catching|flagging|clarif)/i, /you'?re (absolutely )?right/i, /great catch/i, /that makes sense/i, /i'?ll (continue|investigate|update|submit|look into|make sure)/i, /let me know (if|whether)/i, /happy to (update|adjust|revise|change)/i, /i understand that/i, /thanks for your time/i, /just following up/i, /hope (this|that) helps/i, /please let me know/i, /i'?ve (updated|addressed|fixed)/i, ]; let voice = null; if (comments.length >= MIN_COMMENTS) { const chars = comments.reduce((total, body) => total + body.length, 0); const rate = patterns => comments.filter(body => patterns.some(re => re.test(body))).length / comments.length; voice = { comments: comments.length, structure: rate(STRUCTURE), acknowledgement: rate(ACKNOWLEDGEMENT), emDashPerKChar: 1000 * comments.reduce((total, body) => total + (body.match(/—/g) || []).length, 0) / chars, }; } const reasons = []; if (rejections && rejections.length >= MIN_REJECTIONS) { reasons.push(`${rejections.length} PR(s) of theirs closed unmerged elsewhere in the last` + ` ${REJECTION_WINDOW_DAYS} days: ${rejections.slice(0, 10).join(' ')}`); } if (voice && (voice.structure > VOICE.structure || voice.emDashPerKChar > VOICE.emDashPerKChar || voice.acknowledgement > VOICE.acknowledgement)) { reasons.push(`comment style over ${voice.comments} recent comments:` + ` ${(100 * voice.structure).toFixed(0)}% structured,` + ` ${(100 * voice.acknowledgement).toFixed(0)}% stock acknowledgements,` + ` ${voice.emDashPerKChar.toFixed(2)} em dashes per 1000 characters`); } if (breadth !== null && breadth > MAX_REPOS_PER_WEEK) { reasons.push(`opened pull requests against ${breadth} unrelated repositories within a week`); } if (AGENT_BRANCH.test(pr.head?.ref || '')) { reasons.push(`branch name carries an agent prefix: ${pr.head.ref}`); } await core.summary .addHeading(`PR #${pr.number} by ${author}`, 3) .addList([ rejections === null ? 'recent rejections elsewhere: unmeasurable, the author cannot be searched' : `recent rejections elsewhere: ${rejections.length}`, voice === null ? `comment style: unmeasurable, fewer than ${MIN_COMMENTS} recent comments found` : `comment style: ${(100 * voice.structure).toFixed(0)}% structured,` + ` ${(100 * voice.acknowledgement).toFixed(0)}% stock acknowledgements,` + ` ${voice.emDashPerKChar.toFixed(2)} em dashes per 1000 characters` + ` over ${voice.comments} comments`, breadth === null ? 'repositories per week: unmeasurable, no public events found' : `repositories per week, at most: ${breadth}`, `branch: ${pr.head?.ref ?? 'unknown'}`, `verdict: ${reasons.length ? `labelled "${LABEL}"` : 'not labelled'}`, ]) .addRaw(reasons.length ? `\n${reasons.map(reason => `- ${reason}`).join('\n')}\n` : '') .write(); if (!reasons.length) { core.info(`Not labelling PR #${pr.number}.`); return; } await withRetries('Adding the label', () => github.rest.issues.addLabels({ owner, repo, issue_number: pr.number, labels: [LABEL] }), ); core.info(`Labelled PR #${pr.number}: ${reasons.join(' | ')}`); scrapy-scrapy-5569de3/.github/workflows/publish.yml000066400000000000000000000024311525051012400224410ustar00rootroot00000000000000name: Publish permissions: contents: read on: push: tags: - '[0-9]+.[0-9]+.[0-9]+' concurrency: group: ${{github.workflow}}-${{ github.ref }} cancel-in-progress: true jobs: build: name: Build distribution runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.14" - run: | python -m pip install --upgrade build python -m build - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: python-package-distributions path: dist/ publish: name: Upload release to PyPI needs: - build runs-on: ubuntu-latest environment: name: pypi url: https://pypi.org/p/Scrapy permissions: id-token: write steps: - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: python-package-distributions path: dist/ - name: Publish to PyPI uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 scrapy-scrapy-5569de3/.github/workflows/tests-macos.yml000066400000000000000000000046261525051012400232450ustar00rootroot00000000000000name: macOS permissions: contents: read on: push: branches: - master - '[0-9]+.[0-9]+' pull_request: types: [opened, synchronize, reopened, closed] concurrency: group: ${{github.workflow}}-${{ github.head_ref || github.ref }} cancel-in-progress: true jobs: tests: if: github.event.action != 'closed' name: tests (${{ matrix.python-version }}, ${{ matrix.env.TOXENV }}) runs-on: macos-latest timeout-minutes: 15 env: PYTEST_ADDOPTS: ${{ matrix.coverage && '-n logical' || '-n logical --no-cov' }} # Make uv use the interpreter that actions/setup-python installed instead # of downloading one of its own. UV_PYTHON_PREFERENCE: only-system strategy: fail-fast: false matrix: include: - python-version: "3.10" env: TOXENV: py - python-version: "3.14" env: TOXENV: py coverage: true - python-version: "3.14" env: TOXENV: no-reactor # 3.15 pre-release - python-version: "3.15.0-rc.1" env: TOXENV: py - python-version: "3.15.0-rc.1" env: TOXENV: no-reactor steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: ${{ matrix.python-version }} - name: Set up uv uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: cache-dependency-glob: | pyproject.toml tox.ini - name: Install mitmproxy env: # mitmproxy needs a newer Python than the oldest matrix entries, so let # uv download one where no system interpreter is new enough. UV_PYTHON_PREFERENCE: system run: uv tool install mitmproxy - name: Run tests env: ${{ matrix.env }} run: uvx --with tox-uv tox - name: Upload coverage report if: ${{ matrix.coverage }} uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 - name: Upload test results if: ${{ !cancelled() }} uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 with: report_type: test_results scrapy-scrapy-5569de3/.github/workflows/tests-ubuntu.yml000066400000000000000000000104001525051012400234500ustar00rootroot00000000000000name: Ubuntu permissions: contents: read on: push: branches: - master - '[0-9]+.[0-9]+' pull_request: types: [opened, synchronize, reopened, closed] concurrency: group: ${{github.workflow}}-${{ github.head_ref || github.ref }} cancel-in-progress: true jobs: tests: if: github.event.action != 'closed' name: tests (${{ matrix.python-version }}, ${{ matrix.env.TOXENV }}) runs-on: ubuntu-latest timeout-minutes: 20 env: PYTEST_ADDOPTS: ${{ matrix.coverage && '-n logical' || '-n logical --no-cov' }} # Make uv use the interpreter that actions/setup-python installed instead # of downloading one of its own. UV_PYTHON_PREFERENCE: only-system strategy: fail-fast: false matrix: include: - python-version: "3.10" env: TOXENV: py - python-version: "3.11" env: TOXENV: py - python-version: "3.12" env: TOXENV: py - python-version: "3.13" env: TOXENV: py - python-version: "3.14" env: TOXENV: py coverage: true - python-version: "3.14" env: TOXENV: default-reactor coverage: true - python-version: "3.14" env: TOXENV: no-reactor coverage: true # min deps - python-version: "3.10.19" env: TOXENV: min coverage: true - python-version: "3.10.19" env: TOXENV: min-default-reactor coverage: true # pinned due to https://github.com/pypy/pypy/issues/5388 - python-version: pypy3.11-7.3.20 env: TOXENV: min-pypy3 - python-version: "3.10.19" env: TOXENV: min-extra-deps coverage: true - python-version: "3.10.19" env: TOXENV: min-botocore coverage: true - python-version: "3.14" env: TOXENV: extra-deps coverage: true - python-version: "3.14" env: TOXENV: no-reactor-extra-deps coverage: true # pinned due to https://github.com/pypy/pypy/issues/5388 - python-version: pypy3.11-7.3.20 env: TOXENV: pypy3-extra-deps coverage: true - python-version: "3.14" env: TOXENV: botocore coverage: true # 3.15 pre-release - python-version: "3.15.0-rc.1" env: TOXENV: py - python-version: "3.15.0-rc.1" env: TOXENV: default-reactor - python-version: "3.15.0-rc.1" env: TOXENV: no-reactor steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: ${{ matrix.python-version }} - name: Install system libraries if: contains(matrix.python-version, 'pypy') || contains(matrix.env.TOXENV, 'min') run: | sudo apt-get update sudo apt-get install libxml2-dev libxslt-dev - name: Set up uv uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: cache-dependency-glob: | pyproject.toml tox.ini - name: Install mitmproxy env: # mitmproxy needs a newer Python than the oldest matrix entries, so let # uv download one where no system interpreter is new enough. UV_PYTHON_PREFERENCE: system # mitmproxy has no PyPy wheels, so run it on CPython regardless of the # interpreter under test. run: uv tool install --python cpython mitmproxy - name: Run tests env: ${{ matrix.env }} run: uvx --with tox-uv tox - name: Upload coverage report if: ${{ matrix.coverage }} uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 - name: Upload test results if: ${{ !cancelled() }} uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 with: report_type: test_results scrapy-scrapy-5569de3/.github/workflows/tests-vcs-deps.yml000066400000000000000000000031311525051012400236550ustar00rootroot00000000000000name: VCS dependencies permissions: contents: read on: schedule: - cron: '0 4 * * *' workflow_dispatch: concurrency: group: ${{github.workflow}}-${{ github.ref }} cancel-in-progress: true jobs: tests: name: tests runs-on: ubuntu-latest timeout-minutes: 30 env: # A development branch of a dependency can make a test hang forever, so # tests get a time limit here that they do not need elsewhere. PYTEST_ADDOPTS: -n logical --no-cov --timeout=120 TOXENV: vcs-deps UV_PYTHON_PREFERENCE: only-system steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Set up Python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.15" allow-prereleases: true # Dependencies that ship wheels on PyPI are built from source here, so # their build dependencies are needed: libxml2 and libxslt for lxml, # libjpeg and zlib for Pillow, and autotools for the libuv bundled in # uvloop. - name: Install system libraries run: | sudo apt-get update sudo apt-get install automake libjpeg-dev libtool libxml2-dev libxslt-dev zlib1g-dev - name: Set up uv uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: cache-dependency-glob: | pyproject.toml tox.ini - name: Install mitmproxy run: uv tool install --python cpython mitmproxy - name: Run tests run: uvx --with tox-uv tox scrapy-scrapy-5569de3/.github/workflows/tests-windows.yml000066400000000000000000000053701525051012400236320ustar00rootroot00000000000000name: Windows permissions: contents: read on: push: branches: - master - '[0-9]+.[0-9]+' pull_request: types: [opened, synchronize, reopened, closed] concurrency: group: ${{github.workflow}}-${{ github.head_ref || github.ref }} cancel-in-progress: true jobs: tests: if: github.event.action != 'closed' name: tests (${{ matrix.python-version }}, ${{ matrix.env.TOXENV }}) runs-on: windows-latest timeout-minutes: 20 env: PYTEST_ADDOPTS: ${{ matrix.coverage && '-n logical' || '-n logical --no-cov' }} # Make uv use the interpreter that actions/setup-python installed instead # of downloading one of its own. UV_PYTHON_PREFERENCE: only-system strategy: fail-fast: false matrix: include: - python-version: "3.10" env: TOXENV: py - python-version: "3.14" env: TOXENV: py coverage: true - python-version: "3.14" env: TOXENV: no-reactor # min deps - python-version: "3.10.11" env: TOXENV: min - python-version: "3.10.11" env: TOXENV: min-extra-deps - python-version: "3.14" env: TOXENV: extra-deps # 3.15 pre-release - python-version: "3.15.0-rc.1" env: TOXENV: py - python-version: "3.15.0-rc.1" env: TOXENV: default-reactor - python-version: "3.15.0-rc.1" env: TOXENV: no-reactor steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: ${{ matrix.python-version }} - name: Set up uv uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: cache-dependency-glob: | pyproject.toml tox.ini - name: Install mitmproxy env: # mitmproxy needs a newer Python than the oldest matrix entries, so let # uv download one where no system interpreter is new enough. UV_PYTHON_PREFERENCE: system run: uv tool install mitmproxy - name: Run tests env: ${{ matrix.env }} run: uvx --with tox-uv tox - name: Upload coverage report if: ${{ matrix.coverage }} uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 - name: Upload test results if: ${{ !cancelled() }} uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 with: report_type: test_results scrapy-scrapy-5569de3/.gitignore000066400000000000000000000005141525051012400166430ustar00rootroot00000000000000/.vagrant /scrapy.iml *.pyc _trial_temp* dropin.cache docs/_build *egg-info .tox/ venv/ .venv/ build/ dist/ .idea/ .vscode/ htmlcov/ .pytest_cache/ .coverage .coverage.* coverage.* *.junit.xml test-output.* .cache/ .mypy_cache/ /tests/keys/localhost.crt /tests/keys/localhost.key # Windows Thumbs.db # OSX miscellaneous .DS_Store scrapy-scrapy-5569de3/.pre-commit-config.yaml000066400000000000000000000015101525051012400211310ustar00rootroot00000000000000exclude: | (?x)( ^docs/_static| ^docs/_tests| ^tests/sample_data ) repos: - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.15.20 hooks: - id: ruff-check args: [ --fix ] - id: ruff-format - repo: https://github.com/adamchainz/blacken-docs rev: 1.20.0 hooks: - id: blacken-docs additional_dependencies: - black==26.5.1 - repo: https://github.com/pre-commit/pre-commit-hooks rev: v6.0.0 hooks: - id: end-of-file-fixer - id: trailing-whitespace - repo: https://github.com/sphinx-contrib/sphinx-lint rev: v1.0.2 hooks: - id: sphinx-lint - repo: https://github.com/scrapy/sphinx-scrapy rev: 0.9.0 hooks: - id: sphinx-scrapy - repo: https://github.com/zizmorcore/zizmor-pre-commit rev: v1.28.0 hooks: - id: zizmor args: [--no-progress, --fix] scrapy-scrapy-5569de3/.readthedocs.yml000066400000000000000000000003251525051012400177410ustar00rootroot00000000000000version: 2 build: os: ubuntu-24.04 tools: python: "3.14" commands: - pip install tox - tox -e docs - mkdir -p $READTHEDOCS_OUTPUT/html - cp -a docs/_build/all/. $READTHEDOCS_OUTPUT/html/ scrapy-scrapy-5569de3/AUTHORS000066400000000000000000000024041525051012400157230ustar00rootroot00000000000000Scrapy was brought to life by Shane Evans while hacking a scraping framework prototype for Mydeco (mydeco.com). It soon became maintained, extended and improved by Insophia (insophia.com), with the initial sponsorship of Mydeco to bootstrap the project. In mid-2011, Scrapinghub (now Zyte) became the new official maintainer. Here is the list of the primary authors & contributors: * Pablo Hoffman * Daniel Graña * Martin Olveyra * Gabriel García * Michael Cetrulo * Artem Bogomyagkov * Damian Canabal * Andres Moreira * Ismael Carnales * Matías Aguirre * German Hoffmann * Anibal Pacheco * Bruno Deferrari * Shane Evans * Ezequiel Rivero * Patrick Mezard * Rolando Espinoza * Ping Yin * Lucian Ursu * Shuaib Khan * Didier Deshommes * Vikas Dhiman * Jochen Maes * Darian Moody * Jordi Lonch * Zuhao Wan * Steven Almeroth * Tom Mortimer-Jones * Chris Tilden * Alexandr N Zamaraev * Emanuel Schorsch * Michal Danilak * Natan Lao * Hasnain Lakhani * Pedro Faustino * Alex Cepoi * Ilya Baryshev * Libor Nenadál * Jae-Myoung Yu * Vladislav Poluhin * Marc Abramowitz * Valentin-Costel Hăloiu * Jason Yeo * Сергей Прохоров * Simon Ratne * Julien Duponchelle * Jochen Maes * Vikas Dhiman * Juan Picca * Nicolás Ramírez scrapy-scrapy-5569de3/CITATION.cff000066400000000000000000000002531525051012400165450ustar00rootroot00000000000000cff-version: 1.2.0 message: If you use Scrapy in published research, please cite it as below. title: Scrapy authors: - name: Scrapy contributors url: https://scrapy.org scrapy-scrapy-5569de3/CODE_OF_CONDUCT.md000066400000000000000000000125551525051012400174620ustar00rootroot00000000000000 # Contributor Covenant Code of Conduct ## Our Pledge We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation. We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. ## Our Standards Examples of behavior that contributes to a positive environment for our community include: * Demonstrating empathy and kindness toward other people * Being respectful of differing opinions, viewpoints, and experiences * Giving and gracefully accepting constructive feedback * Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience * Focusing on what is best not just for us as individuals, but for the overall community Examples of unacceptable behavior include: * The use of sexualized language or imagery, and sexual attention or advances of any kind * Trolling, insulting or derogatory comments, and personal or political attacks * Public or private harassment * Publishing others' private information, such as a physical or email address, without their explicit permission * Other conduct which could reasonably be considered inappropriate in a professional setting ## Enforcement Responsibilities Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. ## Scope This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at opensource@zyte.com. All complaints will be reviewed and investigated promptly and fairly. All community leaders are obligated to respect the privacy and security of the reporter of any incident. ## Enforcement Guidelines Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: ### 1. Correction **Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. **Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. ### 2. Warning **Community Impact**: A violation through a single incident or series of actions. **Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. ### 3. Temporary Ban **Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. **Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. ### 4. Permanent Ban **Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. **Consequence**: A permanent ban from any sort of public interaction within the community. ## Attribution This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.1, available at [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. For answers to common questions about this code of conduct, see the FAQ at [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at [https://www.contributor-covenant.org/translations][translations]. [homepage]: https://www.contributor-covenant.org [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html [Mozilla CoC]: https://github.com/mozilla/diversity [FAQ]: https://www.contributor-covenant.org/faq [translations]: https://www.contributor-covenant.org/translations scrapy-scrapy-5569de3/CONTRIBUTING.md000066400000000000000000000004571525051012400171120ustar00rootroot00000000000000The guidelines for contributing are available here: https://docs.scrapy.org/en/master/contributing.html Please do not abuse the issue tracker for support questions. If your issue topic can be rephrased to "How to ...?", please use the support channels to get it answered: https://scrapy.org/community/ scrapy-scrapy-5569de3/INSTALL.md000066400000000000000000000002331525051012400163010ustar00rootroot00000000000000For information about installing Scrapy see: * [Local docs](docs/intro/install.rst) * [Online docs](https://docs.scrapy.org/en/latest/intro/install.html) scrapy-scrapy-5569de3/LICENSE000066400000000000000000000027551525051012400156710ustar00rootroot00000000000000Copyright (c) Scrapy developers. All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this list of conditions, and the following disclaimer. 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions, and the following disclaimer in the documentation and/or other materials provided with the distribution. 3. Neither the name of Scrapy nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. scrapy-scrapy-5569de3/NEWS000066400000000000000000000000221525051012400153440ustar00rootroot00000000000000See docs/news.rst scrapy-scrapy-5569de3/README.rst000066400000000000000000000034011525051012400163400ustar00rootroot00000000000000|logo| .. |logo| image:: https://raw.githubusercontent.com/scrapy/scrapy/master/docs/_static/logo.svg :target: https://scrapy.org :alt: Scrapy :width: 480px |version| |python_version| |tests| |coverage| |conda| |deepwiki| .. |version| image:: https://img.shields.io/pypi/v/Scrapy.svg :target: https://pypi.org/pypi/Scrapy :alt: PyPI Version .. |python_version| image:: https://img.shields.io/pypi/pyversions/Scrapy.svg :target: https://pypi.org/pypi/Scrapy :alt: Supported Python Versions .. |tests| image:: https://img.shields.io/github/check-runs/scrapy/scrapy/master?label=tests :target: https://github.com/scrapy/scrapy/actions?query=branch%3Amaster :alt: Tests .. |coverage| image:: https://img.shields.io/codecov/c/github/scrapy/scrapy/master.svg :target: https://codecov.io/github/scrapy/scrapy?branch=master :alt: Coverage report .. |conda| image:: https://anaconda.org/conda-forge/scrapy/badges/version.svg :target: https://anaconda.org/conda-forge/scrapy :alt: Conda Version .. |deepwiki| image:: https://deepwiki.com/badge.svg :target: https://deepwiki.com/scrapy/scrapy :alt: Ask DeepWiki Scrapy_ is a web scraping framework to extract structured data from websites. It is cross-platform, and requires Python 3.10+. It is maintained by Zyte_ (formerly Scrapinghub) and `many other contributors`_. .. _many other contributors: https://github.com/scrapy/scrapy/graphs/contributors .. _Scrapy: https://scrapy.org/ .. _Zyte: https://www.zyte.com/ Install with: .. code:: bash pip install scrapy And follow the documentation_ to learn how to use it. .. _documentation: https://docs.scrapy.org/en/latest/ If you wish to contribute, see Contributing_. .. _Contributing: https://docs.scrapy.org/en/master/contributing.html scrapy-scrapy-5569de3/SECURITY.md000066400000000000000000000004631525051012400164470ustar00rootroot00000000000000# Security Policy ## Supported Versions | Version | Supported | | ------- | ------------------ | | 2.19.x | :white_check_mark: | | < 2.19.x | :x: | ## Reporting a Vulnerability Please report the vulnerability using https://github.com/scrapy/scrapy/security/advisories/new. scrapy-scrapy-5569de3/codecov.yml000066400000000000000000000001201525051012400170110ustar00rootroot00000000000000comment: layout: "header, diff, tree" coverage: status: project: false scrapy-scrapy-5569de3/conftest.py000066400000000000000000000175421525051012400170630ustar00rootroot00000000000000from __future__ import annotations import os from importlib.util import find_spec from pathlib import Path from typing import TYPE_CHECKING import pytest from twisted.web.http import H2_ENABLED from scrapy.core.engine import ExecutionEngine from scrapy.utils.reactor import set_asyncio_event_loop_policy from scrapy.utils.reactorless import install_reactor_import_hook from tests.keys import generate_keys from tests.mockserver.http import MockServer from tests.mockserver.mitm_proxy import MitmProxy, mitmdump_cmd if TYPE_CHECKING: from collections.abc import Generator def _py_files(folder): return (str(p) for p in Path(folder).rglob("*.py")) collect_ignore = [ # may need extra deps "docs/_ext", # contains scripts to be run by tests/test_crawler_subprocess.py::AsyncCrawlerProcessSubprocess *_py_files("tests/AsyncCrawlerProcess"), # contains scripts to be run by tests/test_crawler_subprocess.py::AsyncCrawlerRunnerSubprocess *_py_files("tests/AsyncCrawlerRunner"), # contains scripts to be run by tests/test_crawler_subprocess.py::CrawlerProcessSubprocess *_py_files("tests/CrawlerProcess"), # contains scripts to be run by tests/test_crawler_subprocess.py::CrawlerRunnerSubprocess *_py_files("tests/CrawlerRunner"), ] base_dir = Path(__file__).parent ignore_file_path = base_dir / "tests" / "ignores.txt" with ignore_file_path.open(encoding="utf-8") as reader: for line in reader: file_path = line.strip() if file_path and file_path[0] != "#": collect_ignore.append(file_path) if not H2_ENABLED: collect_ignore.extend( ( "scrapy/core/downloader/handlers/http2.py", *_py_files("scrapy/core/_http2"), ) ) if find_spec("httpx2") is None and find_spec("httpx") is None: collect_ignore.append("scrapy/core/downloader/handlers/_httpx.py") if find_spec("pytest_codspeed") is None: collect_ignore.append("tests/benchmarks") def pytest_addoption(parser, pluginmanager): if pluginmanager.hasplugin("twisted"): return # add the full choice set so that pytest doesn't complain about invalid choices in some cases parser.addoption( "--reactor", default="none", choices=["asyncio", "default", "none"], ) @pytest.fixture(autouse=True) def fast_engine_heartbeat(monkeypatch: pytest.MonkeyPatch) -> None: """Shorten the interval at which the engine checks for work while idle. Whenever nothing else wakes the engine up, e.g. while a component sleeps, it stays idle until its next heartbeat, so tests that go through that wait pay the whole interval. """ monkeypatch.setattr(ExecutionEngine, "_SLOT_HEARTBEAT_INTERVAL", 0.1) @pytest.fixture(scope="session") def mockserver() -> Generator[MockServer]: with MockServer() as mockserver: yield mockserver @pytest.fixture(scope="session") def _mitm_proxies() -> Generator[dict[str, tuple[MitmProxy, str]]]: proxies: dict[str, tuple[MitmProxy, str]] = {} try: yield proxies finally: for proxy, _url in proxies.values(): proxy.stop() @pytest.fixture # function scope because it modifies os.environ def proxy_server( request: pytest.FixtureRequest, monkeypatch: pytest.MonkeyPatch, _mitm_proxies: dict[str, tuple[MitmProxy, str]], ) -> str: kind: str = request.param if kind not in _mitm_proxies: proxy = MitmProxy(mode="socks5" if kind == "socks5" else None) _mitm_proxies[kind] = (proxy, proxy.start()) _, url = _mitm_proxies[kind] if kind == "https": url = url.replace("http://", "https://") monkeypatch.setenv("http_proxy", url) monkeypatch.setenv("https_proxy", url) return kind @pytest.fixture(scope="session") def reactor_pytest(request) -> str: return request.config.getoption("--reactor") def pytest_configure(config): if config.getoption("--reactor") in {"asyncio", "none"}: # Needed on Windows to switch from proactor to selector, which supports # add_reader/add_writer (required by the Twisted asyncio reactor, and by # tests that register their own readers) and is what Twisted expects. set_asyncio_event_loop_policy() if config.getoption("--reactor") == "none": install_reactor_import_hook() # Test modules made up mostly of tests that spawn a subprocess per test (see # tests/utils/cmdline.py). Collected in file order, they form one contiguous # block. pytest-xdist's initial scheduling (LoadScheduling.schedule() in # xdist/scheduler/load.py) hands each worker one contiguous slice of the # collection, so whichever worker's slice overlaps the block ends up running # most of it while workers with lighter slices drain them and sit idle for # the rest of the run. Spreading these tests evenly through the whole # collection instead means every slice gets a proportional share. _SUBPROCESS_HEAVY_SUFFIXES = ( "tests/test_cmdline/__init__.py", "tests/test_cmdline_crawl_with_pipeline/__init__.py", "tests/test_command_check.py", "tests/test_command_crawl.py", "tests/test_command_fetch.py", "tests/test_command_genspider.py", "tests/test_command_parse.py", "tests/test_command_runspider.py", "tests/test_command_shell.py", "tests/test_command_startproject.py", "tests/test_command_version.py", "tests/test_commands.py", "tests/test_crawler_subprocess.py", ) def _is_subprocess_heavy(item): return item.path.as_posix().endswith(_SUBPROCESS_HEAVY_SUFFIXES) def _interleave_evenly(items, is_heavy): """Merge *light* and *heavy* items so heavy ones land at roughly even intervals throughout the result, instead of clustered together.""" light = [item for item in items if not is_heavy(item)] heavy = [item for item in items if is_heavy(item)] result = [] light_i = heavy_i = 0 while light_i < len(light) or heavy_i < len(heavy): heavy_progress = heavy_i / len(heavy) if heavy else 1.0 light_progress = light_i / len(light) if light else 1.0 if heavy_i < len(heavy) and heavy_progress <= light_progress: result.append(heavy[heavy_i]) heavy_i += 1 else: result.append(light[light_i]) light_i += 1 return result def pytest_collection_modifyitems(items): items[:] = _interleave_evenly(items, _is_subprocess_heavy) for item in items: if item.get_closest_marker("requires_internet"): # Requests to real websites fail every now and then in CI for # reasons unrelated to the code under test. item.add_marker(pytest.mark.flaky(reruns=2, reruns_delay=5)) def pytest_runtest_setup(item): # Skip tests based on reactor markers reactor = item.config.getoption("--reactor") if item.get_closest_marker("requires_reactor") and reactor == "none": pytest.skip('This test is only run when the --reactor value is not "none"') if item.get_closest_marker("only_asyncio") and reactor not in {"asyncio", "none"}: pytest.skip( 'This test is only run when the --reactor value is "asyncio" (default) or "none"' ) if item.get_closest_marker("only_not_asyncio") and reactor in {"asyncio", "none"}: pytest.skip( 'This test is only run when the --reactor value is not "asyncio" (default) or "none"' ) # Skip tests requiring optional dependencies optional_deps = [ "uvloop", "botocore", "boto3", ] for module in optional_deps: if item.get_closest_marker(f"requires_{module}") and find_spec(module) is None: pytest.skip(f"{module} is not installed") if item.get_closest_marker("requires_mitmproxy") and mitmdump_cmd() is None: pytest.skip("mitmdump is not available") # Generate localhost certificate files, needed by some tests (but only once if xdist is used) if "PYTEST_XDIST_WORKER" not in os.environ: generate_keys() scrapy-scrapy-5569de3/docs/000077500000000000000000000000001525051012400156035ustar00rootroot00000000000000scrapy-scrapy-5569de3/docs/Makefile000066400000000000000000000011711525051012400172430ustar00rootroot00000000000000# Minimal makefile for Sphinx documentation # # You can set these variables from the command line, and also # from the environment for the first two. SPHINXOPTS ?= SPHINXBUILD ?= sphinx-build SOURCEDIR = . BUILDDIR = build # Put it first so that "make" without argument is like "make help". help: @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) .PHONY: help Makefile # Catch-all target: route all unknown targets to Sphinx using the new # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). %: Makefile @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) scrapy-scrapy-5569de3/docs/_ext/000077500000000000000000000000001525051012400165425ustar00rootroot00000000000000scrapy-scrapy-5569de3/docs/_ext/scrapydocs.py000066400000000000000000000124501525051012400212700ustar00rootroot00000000000000# pylint: disable=import-error from collections.abc import Sequence from operator import itemgetter from typing import Any, TypedDict from docutils import nodes from docutils.nodes import Element, General, Node, document from docutils.parsers.rst import Directive from sphinx.application import Sphinx from sphinx.util.nodes import make_refnode class SettingData(TypedDict): docname: str setting_name: str refid: str class SettingslistNode(General, Element): pass class SettingsListDirective(Directive): def run(self) -> Sequence[Node]: return [SettingslistNode()] def is_setting_index(node: Node) -> bool: if node.tagname == "index" and node["entries"]: # type: ignore[index,attr-defined] # index entries for setting directives look like: # [('pair', 'SETTING_NAME; setting', 'std:setting-SETTING_NAME', '')] entry_type, info, _ = node["entries"][0][:3] # type: ignore[index] return entry_type == "pair" and info.endswith("; setting") return False def get_setting_name_and_refid(node: Node) -> tuple[str, str]: """Extract setting name from directive index node""" _, info, refid = node["entries"][0][:3] # type: ignore[index] return info.replace("; setting", ""), refid def collect_scrapy_settings_refs(app: Sphinx, doctree: document) -> None: env = app.builder.env if not hasattr(env, "scrapy_all_settings"): emptyList: list[SettingData] = [] env.scrapy_all_settings = emptyList # type: ignore[attr-defined] for node in doctree.findall(is_setting_index): setting_name, refid = get_setting_name_and_refid(node) env.scrapy_all_settings.append( # type: ignore[attr-defined] SettingData( docname=env.docname, setting_name=setting_name, refid=refid, ) ) def make_setting_element( setting_data: SettingData, app: Sphinx, fromdocname: str ) -> Any: refnode = make_refnode( app.builder, fromdocname, todocname=setting_data["docname"], targetid=setting_data["refid"], child=nodes.Text(setting_data["setting_name"]), ) p = nodes.paragraph() p += refnode item = nodes.list_item() item += p return item def make_setting_markdown_item( setting_data: SettingData, app: Sphinx, fromdocname: str ) -> str: uri = app.builder.get_relative_uri(fromdocname, setting_data["docname"]) if uri.startswith("#"): target = f"#{setting_data['refid']}" else: target = f"{uri}#{setting_data['refid']}" return f"* [{setting_data['setting_name']}]({target})" def _iter_sorted_settings(env: Any, fromdocname: str) -> list[SettingData]: return [ d for d in sorted(env.scrapy_all_settings, key=itemgetter("setting_name")) # type: ignore[attr-defined] if fromdocname != d["docname"] ] def replace_settingslist_nodes( app: Sphinx, doctree: document, fromdocname: str ) -> None: env = app.builder.env for node in doctree.findall(SettingslistNode): settings_list = nodes.bullet_list() settings_list.extend( [ make_setting_element(d, app, fromdocname) for d in _iter_sorted_settings(env, fromdocname) ] ) node.replace_self(settings_list) def visit_settingslist_node_markdown(translator: Any, _node: Node) -> None: builder = translator.builder env = builder.env fromdocname = getattr(builder, "current_doc_name", env.docname) lines = [ make_setting_markdown_item(setting_data, builder.app, fromdocname) for setting_data in _iter_sorted_settings(env, fromdocname) ] if lines: translator.add("\n".join(lines), prefix_eol=2, suffix_eol=2) raise nodes.SkipNode def depart_settingslist_node_markdown(_translator: Any, _node: Node) -> None: return None def source_role( name, rawtext, text: str, lineno, inliner, options=None, content=None ) -> tuple[list[Any], list[Any]]: ref = "https://github.com/scrapy/scrapy/blob/master/" + text node = nodes.reference(rawtext, text, refuri=ref, **options) return [node], [] def commit_role( name, rawtext, text: str, lineno, inliner, options=None, content=None ) -> tuple[list[Any], list[Any]]: ref = "https://github.com/scrapy/scrapy/commit/" + text node = nodes.reference(rawtext, "commit " + text, refuri=ref) return [node], [] def rev_role( name, rawtext, text: str, lineno, inliner, options=None, content=None ) -> tuple[list[Any], list[Any]]: ref = "http://hg.scrapy.org/scrapy/changeset/" + text node = nodes.reference(rawtext, "r" + text, refuri=ref) return [node], [] def setup(app: Sphinx) -> dict[str, Any]: app.add_role("source", source_role) app.add_role("commit", commit_role) app.add_role("rev", rev_role) app.add_node( SettingslistNode, markdown=(visit_settingslist_node_markdown, depart_settingslist_node_markdown), singlemarkdown=( visit_settingslist_node_markdown, depart_settingslist_node_markdown, ), ) app.add_directive("settingslist", SettingsListDirective) app.connect("doctree-read", collect_scrapy_settings_refs) app.connect("doctree-resolved", replace_settingslist_nodes) return {"parallel_read_safe": True} scrapy-scrapy-5569de3/docs/_ext/scrapyfixautodoc.py000066400000000000000000000012071525051012400225030ustar00rootroot00000000000000""" Must be included after 'sphinx.ext.autodoc'. Fixes unwanted 'alias of' behavior. https://github.com/sphinx-doc/sphinx/issues/4422 """ from typing import Any # pylint: disable=import-error from sphinx.application import Sphinx def maybe_skip_member(app: Sphinx, what, name: str, obj, skip: bool, options) -> bool: if not skip: # autodoc was generating the text "alias of" for the following members return name in {"default_item_class", "default_selector_class"} return skip def setup(app: Sphinx) -> dict[str, Any]: app.connect("autodoc-skip-member", maybe_skip_member) return {"parallel_read_safe": True} scrapy-scrapy-5569de3/docs/_static/000077500000000000000000000000001525051012400172315ustar00rootroot00000000000000scrapy-scrapy-5569de3/docs/_static/custom.css000066400000000000000000000032271525051012400212610ustar00rootroot00000000000000/* Move lists closer to their introducing paragraph */ .rst-content .section ol p, .rst-content .section ul p { margin-bottom: 0px; } .rst-content p + ol, .rst-content p + ul { margin-top: -18px; /* Compensates margin-top: 24px of p */ } .rst-content dl p + ol, .rst-content dl p + ul { margin-top: -6px; /* Compensates margin-top: 12px of p */ } /*override some styles in sphinx-rtd-dark-mode/static/dark_mode_css/general.css*/ .theme-switcher { right: 0.4em !important; top: 0.6em !important; -webkit-box-shadow: 0px 3px 14px 4px rgba(0, 0, 0, 0.30) !important; box-shadow: 0px 3px 14px 4px rgba(0, 0, 0, 0.30) !important; height: 2em !important; width: 2em !important; } /*place the toggle button for dark mode at the bottom right corner on small screens*/ @media (max-width: 768px) { .theme-switcher { right: 0.4em !important; bottom: 2.6em !important; top: auto !important; } } /*persist blue color at the top left used in default rtd theme*/ html[data-theme="dark"] .wy-side-nav-search, html[data-theme="dark"] .wy-nav-top { background-color: #1d577d !important; } /*all the styles below used to present API objects nicely in dark mode*/ html[data-theme="dark"] .sig.sig-object { border-left-color: #3e4446 !important; background-color: #202325 !important } html[data-theme="dark"] .sig-name, html[data-theme="dark"] .sig-prename, html[data-theme="dark"] .property, html[data-theme="dark"] .sig-param, html[data-theme="dark"] .sig-paren, html[data-theme="dark"] .sig-return-icon, html[data-theme="dark"] .sig-return-typehint, html[data-theme="dark"] .optional { color: #e8e6e3 !important } scrapy-scrapy-5569de3/docs/_static/logo.svg000066400000000000000000000167371525051012400207300ustar00rootroot00000000000000 scrapy-scrapy-5569de3/docs/_static/selectors-sample1.html000066400000000000000000000012331525051012400234610ustar00rootroot00000000000000 Example website
Name: My image 1
image1
Name: My image 2
image2
Name: My image 3
image3
Name: My image 4
image4
Name: My image 5
image5
scrapy-scrapy-5569de3/docs/_templates/000077500000000000000000000000001525051012400177405ustar00rootroot00000000000000scrapy-scrapy-5569de3/docs/_templates/layout.html000066400000000000000000000015231525051012400221440ustar00rootroot00000000000000{% extends "!layout.html" %} {# Overridden to include a link to scrapy.org, not just to the docs root #} {%- block sidebartitle %} {# the logo helper function was removed in Sphinx 6 and deprecated since Sphinx 4 #} {# the master_doc variable was renamed to root_doc in Sphinx 4 (master_doc still exists in later Sphinx versions) #} {%- set _logo_url = logo_url|default(pathto('_static/' + (logo or ""), 1)) %} {%- set _root_doc = root_doc|default(master_doc) %} scrapy.org / docs {%- if READTHEDOCS or DEBUG %} {%- if theme_version_selector or theme_language_selector %}
{%- endif %} {%- endif %} {%- include "searchbox.html" %} {%- endblock %} scrapy-scrapy-5569de3/docs/_tests/000077500000000000000000000000001525051012400171045ustar00rootroot00000000000000scrapy-scrapy-5569de3/docs/_tests/quotes.html000066400000000000000000000241351525051012400213170ustar00rootroot00000000000000 Quotes to Scrape
“The world as we have created it is a process of our thinking. It cannot be changed without changing our thinking.� by (about)
“It is our choices, Harry, that show what we truly are, far more than our abilities.� by (about)
“There are only two ways to live your life. One is as though nothing is a miracle. The other is as though everything is a miracle.� by (about)
“The person, be it gentleman or lady, who has not pleasure in a good novel, must be intolerably stupid.� by (about)
“Imperfection is beauty, madness is genius and it's better to be absolutely ridiculous than absolutely boring.� by (about)
“Try not to become a man of success. Rather become a man of value.� by (about)
“It is better to be hated for what you are than to be loved for what you are not.� by (about)
Tags: life love
“I have not failed. I've just found 10,000 ways that won't work.� by (about)
“A woman is like a tea bag; you never know how strong it is until it's in hot water.� by (about)
“A day without sunshine is like, you know, night.� by (about)
scrapy-scrapy-5569de3/docs/_tests/quotes1.html000066400000000000000000000241351525051012400214000ustar00rootroot00000000000000 Quotes to Scrape
“The world as we have created it is a process of our thinking. It cannot be changed without changing our thinking.� by (about)
“It is our choices, Harry, that show what we truly are, far more than our abilities.� by (about)
“There are only two ways to live your life. One is as though nothing is a miracle. The other is as though everything is a miracle.� by (about)
“The person, be it gentleman or lady, who has not pleasure in a good novel, must be intolerably stupid.� by (about)
“Imperfection is beauty, madness is genius and it's better to be absolutely ridiculous than absolutely boring.� by (about)
“Try not to become a man of success. Rather become a man of value.� by (about)
“It is better to be hated for what you are than to be loved for what you are not.� by (about)
Tags: life love
“I have not failed. I've just found 10,000 ways that won't work.� by (about)
“A woman is like a tea bag; you never know how strong it is until it's in hot water.� by (about)
“A day without sunshine is like, you know, night.� by (about)
scrapy-scrapy-5569de3/docs/conf.py000066400000000000000000000156111525051012400171060ustar00rootroot00000000000000# Configuration file for the Sphinx documentation builder. # # For the full list of built-in configuration values, see the documentation: # https://www.sphinx-doc.org/en/master/usage/configuration.html import os import sys from collections.abc import Sequence from pathlib import Path # If your extensions are in another directory, add it here. If the directory # is relative to the documentation root, use Path.absolute to make it absolute. sys.path.append(str(Path(__file__).parent / "_ext")) sys.path.insert(0, str(Path(__file__).parent.parent)) # -- Project information ----------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information project = "Scrapy" project_copyright = "Scrapy developers" author = "Scrapy developers" # -- General configuration --------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration extensions = [ "notfound.extension", "scrapydocs", "sphinx_scrapy", "scrapyfixautodoc", # Must be after "sphinx.ext.autodoc" "sphinx.ext.coverage", "sphinx_reredirects", "sphinx_rtd_dark_mode", ] redirects = { "topics/broad-crawls": "optimize.html#broad-crawls", } templates_path = ["_templates"] exclude_patterns = ["build", "Thumbs.db", ".DS_Store"] # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the # built documents. # # The short X.Y version. try: import scrapy version = ".".join(map(str, scrapy.version_info[:2])) release = scrapy.__version__ except ImportError: version = "" release = "" suppress_warnings = ["epub.unknown_project_files"] # -- Options for HTML output ------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output html_theme = "sphinx_rtd_theme" html_static_path = ["_static"] html_last_updated_fmt = "%b %d, %Y" html_css_files = [ "custom.css", ] html_context = { "display_github": True, "github_user": "scrapy", "github_repo": "scrapy", "github_version": "master", "conf_py_path": "/docs/", } # Set canonical URL from the Read the Docs Domain html_baseurl = os.environ.get("READTHEDOCS_CANONICAL_URL", "") # -- Options for LaTeX output ------------------------------------------------ # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-latex-output # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, author, document class [howto/manual]). latex_documents = [ ("index", "Scrapy.tex", "Scrapy Documentation", "Scrapy developers", "manual"), ] # -- Options for the linkcheck builder --------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-the-linkcheck-builder linkcheck_ignore = [ r"http://localhost:\d+", "http://hg.scrapy.org", r"https://github.com/scrapy/scrapy/commit/\w+", r"https://github.com/scrapy/scrapy/issues/\d+", ] linkcheck_anchors_ignore_for_url = ["https://github.com/pyca/cryptography/issues/2692"] # -- Options for the Coverage extension -------------------------------------- # https://www.sphinx-doc.org/en/master/usage/extensions/coverage.html#configuration coverage_ignore_pyobjects = [ # Contract’s add_pre_hook and add_post_hook are not documented because # they should be transparent to contract developers, for whom pre_hook and # post_hook should be the actual concern. r"\bContract\.add_(pre|post)_hook$", # ContractsManager is an internal class, developers are not expected to # interact with it directly in any way. r"\bContractsManager\b$", # For default contracts we only want to document their general purpose in # their __init__ method, the methods they reimplement to achieve that purpose # should be irrelevant to developers using those contracts. r"\w+Contract\.(adjust_request_args|(pre|post)_process)$", # Methods of downloader middlewares are not documented, only the classes # themselves, since downloader middlewares are controlled through Scrapy # settings. r"^scrapy\.downloadermiddlewares\.\w*?\.(\w*?Middleware|DownloaderStats)\.", # Base classes of downloader middlewares are implementation details that # are not meant for users. r"^scrapy\.downloadermiddlewares\.\w*?\.Base\w*?Middleware", # The interface methods of duplicate request filtering classes are already # covered in the interface documentation part of the DUPEFILTER_CLASS # setting documentation. r"^scrapy\.dupefilters\.[A-Z]\w*?\.(from_crawler|request_seen|open|close|log)$", # Private exception used by the command-line interface implementation. r"^scrapy\.exceptions\.UsageError", # Methods of BaseItemExporter subclasses are only documented in # BaseItemExporter. r"^scrapy\.exporters\.(?!BaseItemExporter\b)\w*?\.", # Extension behavior is only modified through settings. Methods of # extension classes, as well as helper functions, are implementation # details that are not documented. r"^scrapy\.extensions\.[a-z]\w*?\.[A-Z]\w*?\.", # methods r"^scrapy\.extensions\.[a-z]\w*?\.[a-z]", # helper functions # Never documented before, and deprecated now. r"^scrapy\.linkextractors\.FilteringLinkExtractor$", # Implementation detail of LxmlLinkExtractor r"^scrapy\.linkextractors\.lxmlhtml\.LxmlParserLinkExtractor", ] # -- Options for the autodoc extension ---------------------------------------- autodoc_member_order = "bysource" # -- Options for the InterSphinx extension ----------------------------------- # https://www.sphinx-doc.org/en/master/usage/extensions/intersphinx.html#configuration intersphinx_disabled_reftypes: Sequence[str] = [] # sphinx-scrapy --------------------------------------------------------------- scrapy_intersphinx_enable = [ "attrs", "coverage", "cryptography", "cssselect", "form2request", "itemloaders", "parsel", "platformdirs", "pytest", "pypug", "scrapy-lint", "sphinx", "tox", "twisted", "twistedapi", "w3lib", ] # sphinx_llms_txt ------------------------------------------------------------- llms_txt_exclude = [ # Changelog, not useful for an LLM answering "how do I use Scrapy" # questions, and the largest single contributor to llms-full.txt size. "news.rst", "contributing.rst", ] def _exclude_llms_txt_docs_from_llms_full_txt(app): # llms-full.txt is the output of the singlemarkdown builder. if app.builder.name != "singlemarkdown": return app.config.exclude_patterns.extend(llms_txt_exclude) def setup(app): app.connect("builder-inited", _exclude_llms_txt_docs_from_llms_full_txt) # -- Other options ------------------------------------------------------------ default_dark_mode = False scrapy-scrapy-5569de3/docs/conftest.py000066400000000000000000000016311525051012400200030ustar00rootroot00000000000000from doctest import ELLIPSIS, NORMALIZE_WHITESPACE from pathlib import Path from sybil import Sybil from sybil.parsers.doctest import DocTestParser from sybil.parsers.skip import skip try: # >2.0.1 from sybil.parsers.codeblock import PythonCodeBlockParser except ImportError: from sybil.parsers.codeblock import CodeBlockParser as PythonCodeBlockParser from scrapy.http.response.html import HtmlResponse def load_response(url: str, filename: str) -> HtmlResponse: input_path = Path(__file__).parent / "_tests" / filename return HtmlResponse(url, body=input_path.read_bytes()) def setup(namespace): namespace["load_response"] = load_response pytest_collect_file = Sybil( parsers=[ DocTestParser(optionflags=ELLIPSIS | NORMALIZE_WHITESPACE), PythonCodeBlockParser(future_imports=["print_function"]), skip, ], pattern="*.rst", setup=setup, ).pytest() scrapy-scrapy-5569de3/docs/contributing.rst000066400000000000000000000416241525051012400210530ustar00rootroot00000000000000.. _topics-contributing: ====================== Contributing to Scrapy ====================== .. important:: Double check that you are reading the most recent version of this document at https://docs.scrapy.org/en/master/contributing.html By participating in this project you agree to abide by the terms of our `Code of Conduct `_. Please report unacceptable behavior to opensource@zyte.com. There are many ways to contribute to Scrapy. Here are some of them: * Report bugs and request features in the `issue tracker`_, trying to follow the guidelines detailed in `Reporting bugs`_ below. * Submit patches for new functionalities and/or bug fixes. Please read :ref:`writing-patches` and `Submitting patches`_ below for details on how to write and submit a patch. * Blog about Scrapy. Tell the world how you're using Scrapy. This will help newcomers with more examples and will help the Scrapy project to increase its visibility. * Join the `Scrapy subreddit`_ and share your ideas on how to improve Scrapy. We're always open to suggestions. * Answer Scrapy questions at `Stack Overflow `__. Reporting bugs ============== .. note:: Please report security issues **only** to scrapy-security@googlegroups.com. This is a private list only open to trusted Scrapy developers, and its archives are not public. Well-written bug reports are very helpful, so keep in mind the following guidelines when you're going to report a new bug. * check the :ref:`FAQ ` first to see if your issue is addressed in a well-known question * if you have a general question about Scrapy usage, please ask it at `Stack Overflow `__ (use "scrapy" tag). * check the `open issues`_ to see if the issue has already been reported. If it has, don't dismiss the report, but check the ticket history and comments. If you have additional useful information, please leave a comment, or consider :ref:`sending a pull request ` with a fix. * search the `scrapy-users`_ list and `Scrapy subreddit`_ to see if it has been discussed there, or if you're not sure if what you're seeing is a bug. You can also ask in the ``#scrapy`` IRC channel. * write **complete, reproducible, specific bug reports**. The smaller the test case, the better. Remember that other developers won't have your project to reproduce the bug, so please include all relevant files required to reproduce it. See for example StackOverflow's guide on creating a `Minimal, Complete, and Verifiable example`_ exhibiting the issue. * the most awesome way to provide a complete reproducible example is to send a pull request which adds a failing test case to the Scrapy testing suite (see :ref:`submitting-patches`). This is helpful even if you don't have an intention to fix the issue yourselves. * include the output of ``scrapy version -v`` so developers working on your bug know exactly which version and platform it occurred on, which is often very helpful for reproducing it, or knowing if it was already fixed. .. _Minimal, Complete, and Verifiable example: https://stackoverflow.com/help/mcve .. _find-work: Finding work ============ If you have decided to make a contribution to Scrapy, but you do not know what to contribute, you have a few options to find pending work: - Check out the `contribution GitHub page`_, which lists open issues tagged as **good first issue**. .. _contribution GitHub page: https://github.com/scrapy/scrapy/contribute There are also `help wanted issues`_ but mind that some may require familiarity with the Scrapy code base. You can also target any other issue provided it is not tagged as **discuss**. - If you enjoy writing documentation, there are `documentation issues`_ as well, but mind that some may require familiarity with the Scrapy code base as well. .. _documentation issues: https://github.com/scrapy/scrapy/issues?q=is%3Aissue+is%3Aopen+label%3Adocs+ - If you enjoy :ref:`writing automated tests `, you can work on increasing our `test coverage`_. - If you enjoy code cleanup, we welcome fixes for issues detected by our static analysis tools. See ``pyproject.toml`` for silenced issues that may need addressing. Mind that some issues we do not aim to address at all, and usually include a comment on them explaining the reason; not to confuse with comments that state what the issue is about, for non-descriptive issue codes. If you have found an issue, make sure you read the entire issue thread before you ask questions. That includes related issues and pull requests that show up in the issue thread when the issue is mentioned elsewhere. We do not assign issues, and you do not need to announce that you are going to start working on an issue either. If you want to work on an issue, just go ahead and :ref:`write a patch for it `. Do not discard an issue simply because there is an open pull request for it. Check if open pull requests are active first. And even if some are active, if you think you can build a better implementation, feel free to create a pull request with your approach. If you decide to work on something without an open issue, please: - Do not create an issue to work on code coverage or code cleanup, create a pull request directly. - Do not create both an issue and a pull request right away. Either open an issue first to get feedback on whether or not the issue is worth addressing, and create a pull request later only if the feedback from the team is positive, or create only a pull request, if you think a discussion will be easier over your code. - Do not add docstrings for the sake of adding docstrings, or only to address silenced Ruff issues. We expect docstrings to exist only when they add something significant to readers, such as explaining something that is not easier to understand from reading the corresponding code, summarizing a long, hard-to-read implementation, providing context about calling code, or indicating purposely uncaught exceptions from called code. - Do not add tests that use as much mocking as possible just to touch a given line of code and hence improve line coverage. While we do aim to maximize test coverage, tests should be written for real scenarios, with minimum mocking. We usually prefer end-to-end tests. .. _writing-patches: Writing patches =============== The better a patch is written, the higher the chances that it'll get accepted and the sooner it will be merged. Well-written patches should: * contain the minimum amount of code required for the specific change. Small patches are easier to review and merge. So, if you're doing more than one change (or bug fix), please consider submitting one patch per change. Do not collapse multiple changes into a single patch. For big changes consider using a patch queue. * pass all unit-tests. See `Running tests`_ below. * include one (or more) test cases that check the bug fixed or the new functionality added. See `Writing tests`_ below. * if you're adding or changing a public (documented) API, please include the documentation changes in the same patch. See `Documentation policies`_ below. * if you're adding a private API, please add a regular expression to the ``coverage_ignore_pyobjects`` variable of ``docs/conf.py`` to exclude the new private API from documentation coverage checks. To see if your private API is skipped properly, generate a documentation coverage report as follows:: tox -e docs-coverage * if you are removing deprecated code, first make sure that at least 1 year (12 months) has passed since the release that introduced the deprecation. See :ref:`deprecation-policy`. .. _submitting-patches: Submitting patches ================== The best way to submit a patch is to issue a `pull request`_ on GitHub, optionally creating a new issue first. Remember to explain what was fixed or the new functionality (what it is, why it's needed, etc). The more info you include, the easier will be for core developers to understand and accept your patch. If your pull request aims to resolve an open issue, `link it accordingly `__, e.g.: .. code-block:: none Resolves #123 You can also discuss the new functionality (or bug fix) before creating the patch, but it's always good to have a patch ready to illustrate your arguments and show that you have put some additional thought into the subject. A good starting point is to send a pull request on GitHub. It can be simple enough to illustrate your idea, and leave documentation/tests for later, after the idea has been validated and proven useful. Alternatively, you can start a conversation in the `Scrapy subreddit`_ to discuss your idea first. Sometimes there is an existing pull request for the problem you'd like to solve, which is stalled for some reason. Often the pull request is in a right direction, but changes are requested by Scrapy maintainers, and the original pull request author hasn't had time to address them. In this case consider picking up this pull request: open a new pull request with all commits from the original pull request, as well as additional changes to address the raised issues. Doing so helps a lot; it is not considered rude as long as the original author is acknowledged by keeping his/her commits. You can pull an existing pull request to a local branch by running ``git fetch upstream pull/$PR_NUMBER/head:$BRANCH_NAME_TO_CREATE`` (replace 'upstream' with a remote name for scrapy repository, ``$PR_NUMBER`` with an ID of the pull request, and ``$BRANCH_NAME_TO_CREATE`` with a name of the branch you want to create locally). See also: https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/reviewing-changes-in-pull-requests/checking-out-pull-requests-locally#modifying-an-inactive-pull-request-locally. When writing GitHub pull requests, try to keep titles short but descriptive. E.g. For bug #411: "Scrapy hangs if an exception raises in start_requests" prefer "Fix hanging when exception occurs in start_requests (#411)" instead of "Fix for #411". Complete titles make it easy to skim through the issue tracker. Finally, try to keep aesthetic changes (:pep:`8` compliance, unused imports removal, etc) in separate commits from functional changes. This will make pull requests easier to review and more likely to get merged. .. _coding-style: Coding style ============ Please follow these coding conventions when writing code for inclusion in Scrapy: * We use `Ruff `_ for code formatting. There is a hook in the pre-commit config that will automatically format your code before every commit. You can also run Ruff manually with ``tox -e pre-commit``. * Don't put your name in the code you contribute; git provides enough metadata to identify author of the code. See https://docs.github.com/en/get-started/git-basics/setting-your-username-in-git for setup instructions. .. _scrapy-pre-commit: Pre-commit ========== We use `pre-commit`_ to automatically address simple code issues before every commit. .. _pre-commit: https://pre-commit.com/ After your create a local clone of your fork of the Scrapy repository: #. `Install pre-commit `_. #. On the root of your local clone of the Scrapy repository, run the following command: .. code-block:: bash pre-commit install Now pre-commit will check your changes every time you create a Git commit. Upon finding issues, pre-commit aborts your commit, and either fixes those issues automatically, or only reports them to you. If it fixes those issues automatically, creating your commit again should succeed. Otherwise, you may need to address the corresponding issues manually first. .. _documentation-policies: Documentation policies ====================== For reference documentation of API members (classes, methods, etc.) use docstrings and make sure that the Sphinx documentation uses the :mod:`~sphinx.ext.autodoc` extension to pull the docstrings. API reference documentation should follow docstring conventions (`PEP 257`_) and be IDE-friendly: short, to the point, and it may provide short examples. Other types of documentation, such as tutorials or topics, should be covered in files within the ``docs/`` directory. This includes documentation that is specific to an API member, but goes beyond API reference documentation. In any case, if something is covered in a docstring, use the :mod:`~sphinx.ext.autodoc` extension to pull the docstring into the documentation instead of duplicating the docstring in files within the ``docs/`` directory. Documentation updates that cover new or modified features must use Sphinx’s :rst:dir:`versionadded` and :rst:dir:`versionchanged` directives. Use ``VERSION`` as version, we will replace it with the actual version right before the corresponding release. When we release a new major or minor version of Scrapy, we remove these directives if they are older than 3 years. Documentation about deprecated features must be removed as those features are deprecated, so that new readers do not run into it. New deprecations and deprecation removals are documented in the :ref:`release notes `. .. _write-tests: Tests ===== Tests are implemented using pytest_. Running tests requires :doc:`tox `. .. _pytest: https://pytest.org .. _running-tests: Running tests ------------- To run all tests:: tox To run a specific test (say ``tests/test_loader.py``) use: ``tox -- tests/test_loader.py`` To run the tests on a specific :doc:`tox ` environment, use ``-e `` with an environment name from ``tox.ini``. For example, to run the tests with Python 3.10 use:: tox -e py310 You can also specify a comma-separated list of environments, and use :ref:`tox’s parallel mode ` to run the tests on multiple environments in parallel:: tox -e py39,py310 -p auto To pass command-line options to :doc:`pytest `, add them after ``--`` in your call to :doc:`tox `. Using ``--`` overrides the default positional arguments defined in ``tox.ini``, so you must include those default positional arguments (``scrapy tests``) after ``--`` as well:: tox -- scrapy tests -x # stop after first failure You can also use the `pytest-xdist`_ plugin. For example, to run all tests on the Python 3.10 :doc:`tox ` environment using all your CPU cores:: tox -e py310 -- scrapy tests -n auto To see coverage report install :doc:`coverage ` (``pip install coverage``) and run: ``coverage report`` see output of ``coverage --help`` for more options like html or xml report. Some tests need a ``mitmdump`` executable (from mitmproxy_) to test against a fully featured proxy server; they are skipped when one cannot be found (``mitmproxy`` is intentionally not a test dependency that would be installed into test venvs, as that sometimes leads to various dependency conflicts). To run these tests, make ``mitmdump`` available in one of these ways: * install ``mitmproxy`` so that ``mitmdump`` is on your ``PATH``, e.g. with pipx_ (``pipx install mitmproxy``) or uv_ (``uv tool install mitmproxy``); * have uv_ installed, in which case the tests will run ``uvx --from mitmproxy mitmdump``; * set the ``MITMDUMP`` environment variable to the path of a ``mitmdump`` executable. Writing tests ------------- All functionality (including new features and bug fixes) must include a test case to check that it works as expected, so please include tests for your patches if you want them to get accepted sooner. Scrapy uses unit-tests, which are located in the `tests/`_ directory. Their module name typically resembles the full path of the module they're testing. For example, the item loaders code is in:: scrapy.loader And their unit-tests are in:: tests/test_loader.py .. _issue tracker: https://github.com/scrapy/scrapy/issues .. _scrapy-users: https://groups.google.com/forum/#!forum/scrapy-users .. _Scrapy subreddit: https://www.reddit.com/r/scrapy/ .. _tests/: https://github.com/scrapy/scrapy/tree/master/tests .. _open issues: https://github.com/scrapy/scrapy/issues .. _PEP 257: https://peps.python.org/pep-0257/ .. _pull request: https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/creating-a-pull-request .. _pytest-xdist: https://github.com/pytest-dev/pytest-xdist .. _help wanted issues: https://github.com/scrapy/scrapy/issues?q=is%3Aissue+is%3Aopen+label%3A%22help+wanted%22 .. _test coverage: https://app.codecov.io/gh/scrapy/scrapy .. _mitmproxy: https://mitmproxy.org/ .. _pipx: https://pipx.pypa.io/ .. _uv: https://docs.astral.sh/uv/ scrapy-scrapy-5569de3/docs/faq.rst000066400000000000000000000373141525051012400171140ustar00rootroot00000000000000.. _faq: Frequently Asked Questions ========================== .. _faq-scrapy-bs-cmp: How does Scrapy compare to BeautifulSoup or lxml? ------------------------------------------------- `BeautifulSoup`_ and `lxml`_ are libraries for parsing HTML and XML. Scrapy is an application framework for writing web spiders that crawl web sites and extract data from them. Scrapy provides a built-in mechanism for extracting data (called :ref:`selectors `) but you can easily use `BeautifulSoup`_ (or `lxml`_) instead, if you feel more comfortable working with them. After all, they're just parsing libraries which can be imported and used from any Python code. In other words, comparing `BeautifulSoup`_ (or `lxml`_) to Scrapy is like comparing `jinja2`_ to `Django`_. .. _BeautifulSoup: https://www.crummy.com/software/BeautifulSoup/ .. _lxml: https://lxml.de/ .. _jinja2: https://palletsprojects.com/projects/jinja/ .. _Django: https://www.djangoproject.com/ Can I use Scrapy with BeautifulSoup? ------------------------------------ Yes, you can. As mentioned :ref:`above `, `BeautifulSoup`_ can be used for parsing HTML responses in Scrapy callbacks. You just have to feed the response's body into a ``BeautifulSoup`` object and extract whatever data you need from it. Here's an example spider using BeautifulSoup API, with ``lxml`` as the HTML parser: .. skip: next .. code-block:: python from bs4 import BeautifulSoup import scrapy class ExampleSpider(scrapy.Spider): name = "example" allowed_domains = ["example.com"] start_urls = ("http://www.example.com/",) def parse(self, response): # use lxml to get decent HTML parsing speed soup = BeautifulSoup(response.text, "lxml") yield {"url": response.url, "title": soup.h1.string} .. note:: ``BeautifulSoup`` supports several HTML/XML parsers. See `BeautifulSoup's official documentation`_ on which ones are available. .. _BeautifulSoup's official documentation: https://www.crummy.com/software/BeautifulSoup/bs4/doc/#specifying-the-parser-to-use Did Scrapy "steal" X from Django? --------------------------------- Probably, but we don't like that word. We think Django_ is a great open source project and an example to follow, so we've used it as an inspiration for Scrapy. We believe that, if something is already done well, there's no need to reinvent it. This concept, besides being one of the foundations for open source and free software, not only applies to software but also to documentation, procedures, policies, etc. So, instead of going through each problem ourselves, we choose to copy ideas from those projects that have already solved them properly, and focus on the real problems we need to solve. We'd be proud if Scrapy serves as an inspiration for other projects. Feel free to steal from us! Does Scrapy work with HTTP proxies? ----------------------------------- Yes. Support for HTTP proxies is provided through the HTTP Proxy downloader middleware. See :class:`~scrapy.downloadermiddlewares.httpproxy.HttpProxyMiddleware`. Does Scrapy work with SOCKS proxies? ------------------------------------ Yes, when using :class:`~scrapy.core.downloader.handlers._httpx.HttpxDownloadHandler`. See :class:`~scrapy.downloadermiddlewares.httpproxy.HttpProxyMiddleware` and the handler documentation. How can I scrape an item with attributes in different pages? ------------------------------------------------------------ See :ref:`callback-data`. How can I simulate a user login in my spider? --------------------------------------------- See :ref:`topics-request-response-ref-request-userlogin`. .. _faq-bfo-dfo: Does Scrapy crawl in breadth-first or depth-first order? -------------------------------------------------------- :ref:`DFO by default, but other orders are possible `. My Scrapy crawler has memory leaks. What can I do? -------------------------------------------------- See :ref:`topics-leaks`. Also, Python has a builtin memory leak issue which is described in :ref:`topics-leaks-without-leaks`. How can I make Scrapy consume less memory? ------------------------------------------ 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.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 of 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 :gh:`1908`. See also `other suggestions at StackOverflow `__. .. note:: Remember to disable :class:`scrapy.downloadermiddlewares.offsite.OffsiteMiddleware` when you enable your custom implementation: .. code-block:: python DOWNLOADER_MIDDLEWARES = { "scrapy.downloadermiddlewares.offsite.OffsiteMiddleware": None, "myproject.middlewares.CustomOffsiteMiddleware": 50, } .. _pyre2: https://github.com/andreasvc/pyre2 .. _re: https://docs.python.org/3/library/re.html Can I use Basic HTTP Authentication in my spiders? -------------------------------------------------- Yes, see :class:`~scrapy.downloadermiddlewares.httpauth.HttpAuthMiddleware`. Why does Scrapy download pages in English instead of my native language? ------------------------------------------------------------------------ Try changing the default `Accept-Language`_ request header by overriding the :setting:`DEFAULT_REQUEST_HEADERS` setting. .. _Accept-Language: https://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.4 Where can I find some example Scrapy projects? ---------------------------------------------- See :ref:`intro-examples`. Can I run a spider without creating a project? ---------------------------------------------- Yes. You can use the :command:`runspider` command. For example, if you have a spider written in a ``my_spider.py`` file you can run it with:: scrapy runspider my_spider.py See :command:`runspider` command for more info. 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 :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? --------------------------------------------------------------------- See :ref:`topics-deploy`. Can I use JSON for large exports? --------------------------------- It'll depend on how large your output is. See :ref:`this warning ` in :class:`~scrapy.exporters.JsonItemExporter` documentation. Can I return (Twisted) deferreds from signal handlers? ------------------------------------------------------ Some signals support returning deferreds from their handlers, others don't. See the :ref:`topics-signals-ref` to know which ones. What does the response status code 999 mean? -------------------------------------------- 999 is a custom response status code used by some sites to throttle requests. Try slowing down the crawling speed by using a download delay of ``2`` (or higher) for the affected domains, with the :setting:`DOWNLOAD_SLOTS` setting: .. code-block:: python DOWNLOAD_SLOTS = { "example.com": {"delay": 2}, } Or by setting a global download delay in your project with the :setting:`DOWNLOAD_DELAY` setting. Can I call ``pdb.set_trace()`` from my spiders to debug them? ------------------------------------------------------------- Yes, but you can also use the Scrapy shell which allows you to quickly analyze (and even modify) the response being processed by your spider, which is, quite often, more useful than plain old ``pdb.set_trace()``. For more info see :ref:`topics-shell-inspect-response`. You can also :ref:`connect to live crawls `, which is the best way to debug running spiders without adding breakpoints to their code in advance. Simplest way to dump all my scraped items into a JSON/CSV/XML file? ------------------------------------------------------------------- To dump into a JSON file:: scrapy crawl myspider -O items.json To dump into a CSV file:: scrapy crawl myspider -O items.csv To dump into an XML file:: scrapy crawl myspider -O items.xml For more information see :ref:`topics-feed-exports` What's this huge cryptic ``__VIEWSTATE`` parameter used in some forms? ---------------------------------------------------------------------- The ``__VIEWSTATE`` parameter is used in sites built with ASP.NET/VB.NET. For more info on how it works see `this page`_. Also, here's an `example spider`_ which scrapes one of these sites. .. _this page: https://metacpan.org/release/ECARROLL/HTML-TreeBuilderX-ASP_NET-0.09/view/lib/HTML/TreeBuilderX/ASP_NET.pm .. _example spider: https://github.com/AmbientLighter/rpn-fas/blob/master/fas/spiders/rnp.py What's the best way to parse big XML/CSV data feeds? ---------------------------------------------------- Parsing big feeds with XPath selectors can be problematic since they need to 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 :func:`~scrapy.utils.iterators.xmliter_lxml` and :func:`~scrapy.utils.iterators.csviter` functions. In fact, this is what :class:`~scrapy.spiders.XMLFeedSpider` and :class:`~scrapy.spiders.CSVFeedSpider` use. .. autofunction:: scrapy.utils.iterators.xmliter_lxml .. autofunction:: scrapy.utils.iterators.csviter Does Scrapy manage cookies automatically? ----------------------------------------- Yes, Scrapy receives and keeps track of cookies sent by servers, and sends them back on subsequent requests, like any regular web browser does. For more info see :ref:`cookies`. How can I see the cookies being sent and received from Scrapy? -------------------------------------------------------------- Enable the :setting:`COOKIES_DEBUG` setting. How can I instruct a spider to stop itself? ------------------------------------------- Raise the :exc:`~scrapy.exceptions.CloseSpider` exception from a callback. For more info see: :exc:`~scrapy.exceptions.CloseSpider`. How can I prevent my Scrapy bot from getting banned? ---------------------------------------------------- See :ref:`bans`. Should I use spider arguments or settings to configure my spider? ----------------------------------------------------------------- Both :ref:`spider arguments ` and :ref:`settings ` can be used to configure your spider. There is no strict rule that mandates to use one or the other, but settings are more suited for parameters that, once set, don't change much, while spider arguments are meant to change more often, even on each spider run and sometimes are required for the spider to run at all (for example, to set the start url of a spider). To illustrate with an example, assuming you have a spider that needs to log into a site to scrape data, and you only want to scrape data from a certain section of the site (which varies each time). In that case, the credentials to log in would be settings, while the url of the section to scrape would be a spider argument. I'm scraping an XML document and my XPath selector doesn't return any items --------------------------------------------------------------------------- You may need to remove namespaces. See :ref:`removing-namespaces`. .. _faq-split-item: How to split an item into multiple items in an item pipeline? ------------------------------------------------------------- :ref:`Item pipelines ` cannot yield multiple items per input item. :ref:`Create a spider middleware ` instead, and use its :meth:`~scrapy.spidermiddlewares.SpiderMiddleware.process_spider_output` method for this purpose. For example: .. code-block:: python from copy import deepcopy from itemadapter import ItemAdapter from scrapy import Request class MultiplyItemsMiddleware: def process_spider_output(self, response, result): for item_or_request in result: if isinstance(item_or_request, Request): yield item_or_request continue adapter = ItemAdapter(item_or_request) for _ in range(adapter["multiply_by"]): yield deepcopy(item_or_request) Does Scrapy support IPv6 addresses? ----------------------------------- Yes, but when using :class:`~scrapy.core.downloader.handlers.http11.HTTP11DownloadHandler` or :class:`~scrapy.core.downloader.handlers.http2.H2DownloadHandler` you need to set :setting:`TWISTED_DNS_RESOLVER` to ``scrapy.resolver.CachingHostnameResolver``. Note that by doing so, you lose the ability to set a specific timeout for DNS requests (the value of the :setting:`DNS_TIMEOUT` setting is ignored). .. _faq-specific-reactor: How to deal with ``: filedescriptor out of range in select()`` exceptions? ---------------------------------------------------------------------------------------------- This issue `has been reported`_ to appear when running broad crawls in macOS, where the default Twisted reactor was :class:`twisted.internet.selectreactor.SelectReactor` at that time. If you have switched to this reactor using the :setting:`TWISTED_REACTOR` setting you can switch to a different one in the same way. .. _faq-stop-response-download: How can I cancel the download of a given response? -------------------------------------------------- In some situations, it might be useful to stop the download of a certain response. For instance, sometimes you can determine whether or not you need the full contents of a response by inspecting its headers or the first bytes of its body. In that case, you could save resources by attaching a handler to the :class:`~scrapy.signals.bytes_received` or :class:`~scrapy.signals.headers_received` signals and raising a :exc:`~scrapy.exceptions.StopDownload` exception. Please refer to the :ref:`topics-stop-response-download` topic for additional information and examples. .. _faq-blank-request: How can I make a blank request? ------------------------------- .. code-block:: python from scrapy import Request blank_request = Request("data:,") 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. Running ``runspider`` I get ``error: No spider found in file: `` -------------------------------------------------------------------------- This may happen if your Scrapy project has a spider module with a name that conflicts with the name of one of the `Python standard library modules`_, such as ``csv.py`` or ``os.py``, or any `Python package`_ that you have installed. See :gh:`2680`. .. _has been reported: https://github.com/scrapy/scrapy/issues/2905 .. _Python standard library modules: https://docs.python.org/3/py-modindex.html .. _Python package: https://pypi.org/ scrapy-scrapy-5569de3/docs/index.rst000066400000000000000000000162631525051012400174540ustar00rootroot00000000000000.. _topics-index: ============================== Scrapy |version| documentation ============================== Scrapy is a fast high-level `web crawling`_ and `web scraping`_ framework, used to crawl websites and extract structured data from their pages. It can be used for a wide range of purposes, from data mining to monitoring and automated testing. .. _web crawling: https://en.wikipedia.org/wiki/Web_crawler .. _web scraping: https://en.wikipedia.org/wiki/Web_scraping .. _getting-help: Getting help ============ Having trouble? We'd like to help! * Try the :doc:`FAQ ` -- it's got answers to some common questions. * Looking for specific information? Try the :ref:`genindex` or :ref:`modindex`. * Ask or search questions in `StackOverflow using the scrapy tag`_. * Ask or search questions in the `Scrapy subreddit`_. * Search for questions on the archives of the `scrapy-users mailing list`_. * Ask a question in the `#scrapy IRC channel`_. * Report bugs with Scrapy in our `issue tracker`_. * Join the Discord community `Scrapy Discord`_. .. _scrapy-users mailing list: https://groups.google.com/forum/#!forum/scrapy-users .. _Scrapy subreddit: https://www.reddit.com/r/scrapy/ .. _StackOverflow using the scrapy tag: https://stackoverflow.com/tags/scrapy .. _#scrapy IRC channel: irc://irc.freenode.net/scrapy .. _issue tracker: https://github.com/scrapy/scrapy/issues .. _Scrapy Discord: https://discord.com/invite/mv3yErfpvq First steps =========== .. toctree:: :caption: First steps :hidden: intro/overview intro/install intro/tutorial intro/examples :doc:`intro/overview` Understand what Scrapy is and how it can help you. :doc:`intro/install` Get Scrapy installed on your computer. :doc:`intro/tutorial` Write your first Scrapy project. :doc:`intro/examples` Learn more by playing with a pre-made Scrapy project. .. _section-basics: Basic concepts ============== .. toctree:: :caption: Basic concepts :hidden: topics/concepts topics/commands topics/spiders topics/selectors topics/items topics/loaders topics/shell topics/item-pipeline topics/feed-exports topics/request-response topics/cookies topics/link-extractors topics/settings topics/exceptions :doc:`topics/concepts` Get a quick map of Scrapy's main concepts and when to use each. :doc:`topics/commands` Learn about the command-line tool used to manage your Scrapy project. :doc:`topics/spiders` Write the rules to crawl your websites. :doc:`topics/selectors` Extract the data from web pages using XPath. :doc:`topics/items` Define the data you want to scrape. :doc:`topics/loaders` Populate your items with the extracted data. :doc:`topics/shell` Test your extraction code in an interactive environment. :doc:`topics/item-pipeline` Post-process and store your scraped data. :doc:`topics/feed-exports` Output your scraped data using different formats and storages. :doc:`topics/request-response` Understand the classes used to represent HTTP requests and responses. :doc:`topics/cookies` Send and receive cookies. :doc:`topics/link-extractors` Convenient classes to extract links to follow from pages. :doc:`topics/settings` Learn how to configure Scrapy and see all :ref:`available settings `. :doc:`topics/exceptions` See all available exceptions and their meaning. Built-in services ================= .. toctree:: :caption: Built-in services :hidden: topics/logging topics/stats topics/telnetconsole :doc:`topics/logging` Learn how to use Python's built-in logging on Scrapy. :doc:`topics/stats` Collect statistics about your scraping crawler. :doc:`topics/telnetconsole` Inspect a running crawler using a built-in Python console. Solving specific problems ========================= .. toctree:: :caption: Solving specific problems :hidden: faq topics/agents topics/debug topics/contracts topics/practices topics/security topics/optimize topics/developer-tools topics/dynamic-content topics/leaks topics/media-pipeline topics/deploy topics/autothrottle topics/benchmarking topics/jobs topics/coroutines topics/asyncio :doc:`faq` Get answers to most frequently asked questions. :doc:`topics/agents` Learn how to use Scrapy with coding agents efficiently. :doc:`topics/debug` Learn how to debug common problems of your Scrapy spider. :doc:`topics/contracts` Learn how to use contracts for testing your spiders. :doc:`topics/practices` Get familiar with some Scrapy common practices. :doc:`topics/security` Understand the security implications of Scrapy defaults and how to harden them. :doc:`topics/optimize` Find the bottleneck of your crawls and learn how to address it. :doc:`topics/developer-tools` Learn how to scrape with your browser's developer tools. :doc:`topics/dynamic-content` Read webpage data that is loaded dynamically. :doc:`topics/leaks` Learn how to find and get rid of memory leaks in your crawler. :doc:`topics/media-pipeline` Download files and/or images associated with your scraped items. :doc:`topics/deploy` Deploying your Scrapy spiders and run them in a remote server. :doc:`topics/autothrottle` Adjust crawl rate dynamically based on load. :doc:`topics/benchmarking` Check how Scrapy performs on your hardware. :doc:`topics/jobs` Learn how to pause and resume crawls for large spiders. :doc:`topics/coroutines` Use the :ref:`coroutine syntax `. :doc:`topics/asyncio` Use :mod:`asyncio` and :mod:`asyncio`-powered libraries. .. _extending-scrapy: Extending Scrapy ================ .. toctree:: :caption: Extending Scrapy :hidden: topics/architecture topics/addons topics/downloader-middleware topics/spider-middleware topics/extensions topics/signals topics/scheduler topics/exporters topics/download-handlers topics/components topics/api :doc:`topics/architecture` Understand the Scrapy architecture. :doc:`topics/addons` Enable and configure third-party extensions. :doc:`topics/downloader-middleware` Customize how pages get requested and downloaded. :doc:`topics/spider-middleware` Customize the input and output of your spiders. :doc:`topics/extensions` Extend Scrapy with your custom functionality :doc:`topics/signals` See all available signals and how to work with them. :doc:`topics/scheduler` Understand the scheduler component. :doc:`topics/exporters` Quickly export your scraped items to a file (XML, CSV, etc). :doc:`topics/download-handlers` Customize how requests are downloaded or add support for new URL schemes. :doc:`topics/components` Learn the common API and some good practices when building custom Scrapy components. :doc:`topics/api` Use it on extensions and middlewares to extend Scrapy functionality. All the rest ============ .. toctree:: :caption: All the rest :hidden: news contributing versioning :doc:`news` See what has changed in recent Scrapy versions. :doc:`contributing` Learn how to contribute to the Scrapy project. :doc:`versioning` Understand Scrapy versioning and API stability. scrapy-scrapy-5569de3/docs/intro/000077500000000000000000000000001525051012400167365ustar00rootroot00000000000000scrapy-scrapy-5569de3/docs/intro/examples.rst000066400000000000000000000013511525051012400213060ustar00rootroot00000000000000.. _intro-examples: ======== Examples ======== The best way to learn is with examples, and Scrapy is no exception. For this reason, there is an example Scrapy project named quotesbot_, that you can use to play and learn more about Scrapy. It contains two spiders for https://quotes.toscrape.com, one using CSS selectors and another one using XPath expressions. The quotesbot_ project is available at: https://github.com/scrapy/quotesbot. You can find more information about it in the project's README. If you're familiar with git, you can checkout the code. Otherwise you can download the project as a zip file by clicking `here `_. .. _quotesbot: https://github.com/scrapy/quotesbot scrapy-scrapy-5569de3/docs/intro/install.rst000066400000000000000000000262361525051012400211470ustar00rootroot00000000000000.. _intro-install: ================== Installation guide ================== .. _faq-python-versions: Supported Python versions ========================= Scrapy requires Python 3.10+, either the CPython implementation (default) or the PyPy implementation (see :ref:`python:implementations`). .. _intro-install-scrapy: Installing Scrapy ================= If you're using `Anaconda`_ or `Miniconda`_, you can install the package from the `conda-forge`_ channel, which has up-to-date packages for Linux, Windows and macOS. To install Scrapy using ``conda``, run:: conda install -c conda-forge scrapy Alternatively, if you’re already familiar with installation of Python packages, you can install Scrapy and its dependencies from PyPI with:: pip install Scrapy We strongly recommend that you install Scrapy in :ref:`a dedicated virtualenv `, to avoid conflicting with your system packages. 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-specific instructions, as well as troubleshooting information, read on. Things that are good to know ---------------------------- Scrapy is written in pure Python and depends on a few key Python packages (among others): * `lxml`_, an efficient XML and HTML parser * `parsel`_, an HTML/XML data extraction library written on top of lxml, * `w3lib`_, a multi-purpose helper for dealing with URLs and web page encodings * `twisted`_, an asynchronous networking framework * `cryptography`_ and `pyOpenSSL`_, to deal with various network-level security needs Some of these packages themselves depend on non-Python packages that might require additional installation steps depending on your platform. Please check :ref:`platform-specific guides below `. In case of any trouble related to these dependencies, please refer to their respective installation instructions: * `lxml installation`_ * :doc:`cryptography installation ` .. _lxml installation: https://lxml.de/installation.html .. _intro-using-virtualenv: Using a virtual environment (recommended) ----------------------------------------- TL;DR: We recommend installing Scrapy inside a virtual environment on all platforms. Python packages can be installed either globally (a.k.a system wide), or in user-space. We do not recommend installing Scrapy system wide. Instead, we recommend that you install Scrapy within a so-called "virtual environment" (:mod:`venv`). Virtual environments allow you to not conflict with already-installed Python system packages (which could break some of your system tools and scripts), and still install packages normally with ``pip`` (without ``sudo`` and the likes). See :ref:`tut-venv` on how to create your virtual environment. Once you have created a virtual environment, you can install Scrapy inside it with ``pip``, just like any other Python package. (See :ref:`platform-specific guides ` below for non-Python dependencies that you may need to install beforehand). .. _extras: Optional extras =============== Scrapy provides optional :ref:`extras ` that install additional dependencies to enable specific features. To install Scrapy with one or more extras, list them in square brackets: .. code-block:: console pip install scrapy[s3,images] The following extras are available: .. list-table:: :header-rows: 1 * - Extra - Provides * - ``bpython`` - :ref:`bpython shell ` * - ``color`` - :setting:`LOG_COLOR` * - ``gcs`` - :ref:`Google Cloud Storage ` for :ref:`feed exports ` and :ref:`media pipelines ` * - ``httpx`` - :ref:`httpx-handler`, including its HTTP/2 and SOCKS proxy support * - ``images`` - :ref:`Images pipeline ` * - ``ipython`` - :ref:`IPython shell ` * - ``ptpython`` - :ref:`ptpython shell ` * - ``robotparser`` - :ref:`Robotexclusionrulesparser robots.txt parsing ` * - ``s3`` - :ref:`Amazon S3 ` storage for :ref:`feed exports `, :ref:`media pipelines `, and :ref:`S3 downloads ` * - ``twisted-http2`` - :ref:`twisted-http2-handler` * - ``uvloop`` - `uvloop `_ event loop .. _intro-install-platform-notes: Platform specific installation notes ==================================== .. _intro-install-windows: Windows ------- Though it's possible to install Scrapy on Windows using pip, we recommend you 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:: conda install -c conda-forge scrapy To install Scrapy on Windows using ``pip``: .. warning:: This installation method requires “Microsoft Visual C++� for installing some Scrapy dependencies, which demands significantly more disk space than Anaconda. #. Download and execute `Microsoft C++ Build Tools`_ to install the Visual Studio Installer. #. Run the Visual Studio Installer. #. Under the Workloads section, select **C++ build tools**. #. Check the installation details and make sure following packages are selected as optional components: * **MSVC** (e.g MSVC v142 - VS 2019 C++ x64/x86 build tools (v14.23) ) * **Windows SDK** (e.g Windows 10 SDK (10.0.18362.0)) #. Install the Visual Studio Build Tools. Now, you should be able to :ref:`install Scrapy ` using ``pip``. .. _intro-install-ubuntu: Ubuntu 14.04 or above --------------------- Scrapy is currently tested with recent-enough versions of lxml, twisted and pyOpenSSL, and is compatible with recent Ubuntu distributions. 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 the latest Scrapy release. To install Scrapy on Ubuntu (or Ubuntu-based) systems, you need to install these dependencies:: sudo apt-get install python3 python3-dev python3-pip libxml2-dev libxslt1-dev zlib1g-dev libffi-dev libssl-dev - ``python3-dev``, ``zlib1g-dev``, ``libxml2-dev`` and ``libxslt1-dev`` are required for ``lxml`` - ``libssl-dev`` and ``libffi-dev`` are required for ``cryptography`` Inside a :ref:`virtualenv `, you can install Scrapy with ``pip`` after that:: pip install scrapy .. note:: The same non-Python dependencies can be used to install Scrapy in Debian Jessie (8.0) and above. .. _intro-install-macos: 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 window and run:: xcode-select --install There's a `known issue `_ that prevents ``pip`` from updating system packages. This has to be addressed to successfully install Scrapy and its dependencies. Here are some proposed solutions: * *(Recommended)* **Don't** use system Python. Install a new, updated version that doesn't conflict with the rest of your system. Here's how to do it using the `homebrew`_ package manager: * Install `homebrew`_ following the instructions in https://brew.sh/ * Update your ``PATH`` variable to state that homebrew packages should be used before system packages (Change ``.bashrc`` to ``.zshrc`` accordingly if you're using `zsh`_ as default shell):: echo "export PATH=/usr/local/bin:/usr/local/sbin:$PATH" >> ~/.bashrc * Reload ``.bashrc`` to ensure the changes have taken place:: source ~/.bashrc * Install python:: brew install python * *(Optional)* :ref:`Install Scrapy inside a Python virtual environment `. This method is a workaround for the above macOS issue, but it's an overall good practice for managing dependencies and can complement the first method. After any of these workarounds you should be able to install Scrapy:: pip install Scrapy PyPy ---- We recommend using the latest PyPy version. For PyPy3, only Linux installation was tested. Most Scrapy dependencies now have binary wheels for CPython, but not for PyPy. This means that these dependencies will be built during installation. On macOS, you are likely to face an issue with building the Cryptography dependency. The solution to this problem is described `here `_, that is to ``brew install openssl`` and then export the flags that this command recommends (only needed when installing Scrapy). Installing on Linux has no special issues besides installing build dependencies. Installing Scrapy with PyPy on Windows is not tested. You can check that Scrapy is installed correctly by running ``scrapy bench``. If this command gives errors such as ``TypeError: ... got 2 unexpected keyword arguments``, this means that the ``PyPyDispatcher`` dependency wasn't installed. To fix this issue, run ``pip install 'PyPyDispatcher>=2.1.0'``. .. _intro-install-troubleshooting: Troubleshooting =============== AttributeError: 'module' object has no attribute 'OP_NO_TLSv1_1' ---------------------------------------------------------------- After you install or upgrade Scrapy, Twisted or pyOpenSSL, you may get an exception with the following traceback:: […] File "[…]/site-packages/twisted/protocols/tls.py", line 63, in from twisted.internet._sslverify import _setAcceptableProtocols File "[…]/site-packages/twisted/internet/_sslverify.py", line 38, in TLSVersion.TLSv1_1: SSL.OP_NO_TLSv1_1, AttributeError: 'module' object has no attribute 'OP_NO_TLSv1_1' The reason you get this exception is that your system or virtual environment has a version of pyOpenSSL that your version of Twisted does not support. To install a version of pyOpenSSL that your version of Twisted supports, reinstall Twisted with the :code:`tls` extra option:: pip install twisted[tls] For details, see `Issue #2473 `_. .. _Python: https://www.python.org/ .. _lxml: https://lxml.de/index.html .. _parsel: https://pypi.org/project/parsel/ .. _w3lib: https://pypi.org/project/w3lib/ .. _twisted: https://twisted.org/ .. _cryptography: https://cryptography.io/en/latest/ .. _pyOpenSSL: https://pypi.org/project/pyOpenSSL/ .. _setuptools: https://pypi.org/pypi/setuptools .. _homebrew: https://brew.sh/ .. _zsh: https://www.zsh.org/ .. _Anaconda: https://www.anaconda.com/docs/main .. _Miniconda: https://docs.conda.io/projects/conda/en/latest/user-guide/install/index.html .. _Microsoft C++ Build Tools: https://visualstudio.microsoft.com/visual-cpp-build-tools/ .. _conda-forge: https://conda-forge.org/ scrapy-scrapy-5569de3/docs/intro/overview.rst000066400000000000000000000150541525051012400213430ustar00rootroot00000000000000.. _intro-overview: ================== Scrapy at a glance ================== Scrapy (/ˈskreɪpaɪ/) is an application framework for crawling web sites and extracting structured data which can be used for a wide range of useful applications, like data mining, information processing or historical archival. Even though Scrapy was originally designed for `web scraping`_, it can also be used to extract data using APIs (such as `Amazon Associates Web Services`_) or as a general purpose web crawler. Walk-through of an example spider ================================= In order to show you what Scrapy brings to the table, we'll walk you through an example of a Scrapy Spider using the simplest way to run a spider. Here's the code for a spider that scrapes famous quotes from website https://quotes.toscrape.com, following the pagination: .. code-block:: python import scrapy class QuotesSpider(scrapy.Spider): name = "quotes" start_urls = [ "https://quotes.toscrape.com/tag/humor/", ] def parse(self, response): for quote in response.css("div.quote"): yield { "author": quote.xpath("span/small/text()").get(), "text": quote.css("span.text::text").get(), } next_page = response.css('li.next a::attr("href")').get() if next_page is not None: yield response.follow(next_page, self.parse) 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 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"} {"author": "Garrison Keillor", "text": "\u201cAnyone who thinks sitting in church can make you a Christian must also think that sitting in a garage can make you a car.\u201d"} ... What just happened? ------------------- When you ran the command ``scrapy runspider quotes_spider.py``, Scrapy looked for a 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 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 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 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 the amount of concurrent requests per domain, and even :ref:`using an auto-throttling extension ` that tries to figure these settings out automatically. .. note:: This is using :ref:`feed exports ` to generate the JSON Lines file, you can easily change the export format (XML or CSV, for example) or the storage backend (FTP or `Amazon S3`_, for example). You can also write an :ref:`item pipeline ` to store the items in a database. .. _topics-whatelse: What else? ========== You've seen how to extract and store items from a website using Scrapy, but this is just the surface. Scrapy provides a lot of powerful features for making 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 for extraction using regular expressions. * An :ref:`interactive shell console ` (IPython aware) for trying 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 multiple formats (JSON, CSV, XML) and storing them in multiple backends (FTP, S3, local filesystem) * Robust encoding support and auto-detection, for dealing with foreign, non-standard and broken encoding declarations. * :ref:`Strong extensibility support `, allowing you to plug in your own functionality using :ref:`signals ` and a well-defined API (middlewares, :ref:`extensions `, and :ref:`pipelines `). * A wide range of built-in extensions and middlewares for handling: - cookies and session handling - HTTP features like compression, authentication, caching - user-agent spoofing - robots.txt - crawl depth restriction - and more * Several ways to :ref:`execute code inside a Scrapy process `, to introspect and debug it. * An official :ref:`plugin for coding agents `. * Plus other goodies like reusable spiders to crawl sites from `Sitemaps`_ and XML/CSV feeds, a media pipeline for :ref:`automatically downloading images ` (or any other media) associated with the scraped items, a caching DNS resolver, and much more! What's next? ============ The next steps for you are to :ref:`install Scrapy `, :ref:`follow through the tutorial ` to learn how to create a full-blown Scrapy project and `join the community`_. Thanks for your interest! .. _join the community: https://www.scrapy.org/community .. _web scraping: https://en.wikipedia.org/wiki/Web_scraping .. _Amazon Associates Web Services: https://affiliate-program.amazon.com/welcome/ecs .. _Amazon S3: https://aws.amazon.com/s3/ .. _Sitemaps: https://www.sitemaps.org/index.html scrapy-scrapy-5569de3/docs/intro/tutorial.rst000066400000000000000000000742541525051012400213470ustar00rootroot00000000000000.. _intro-tutorial: =============== Scrapy Tutorial =============== In this tutorial, we'll assume that Scrapy is already installed on your system. If that's not the case, see :ref:`intro-install`. We are going to scrape `quotes.toscrape.com `_, a website that lists quotes from famous authors. This tutorial will walk you through these tasks: 1. Creating a new Scrapy project 2. Writing a :ref:`spider ` to crawl a site and extract data 3. Exporting the scraped data using the command line 4. Changing spider to recursively follow links 5. Using spider arguments 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 new to programming and want to start with Python, the following books may be useful to you: * `Automate the Boring Stuff With Python`_ * `How To Think Like a Computer Scientist`_ * `Learn Python 3 The Hard Way`_ You can also take a look at `this list of Python resources for non-programmers`_, as well as the `suggested resources in the learnpython-subreddit`_. .. _Python: https://www.python.org/ .. _this list of Python resources for non-programmers: https://wiki.python.org/moin/BeginnersGuide/NonProgrammers .. _Python Tutorial: https://docs.python.org/3/tutorial .. _Automate the Boring Stuff With Python: https://automatetheboringstuff.com/ .. _How To Think Like a Computer Scientist: http://openbookproject.net/thinkcs/python/english3e/ .. _Learn Python 3 The Hard Way: https://learnpythonthehardway.org/python3/ .. _suggested resources in the learnpython-subreddit: https://www.reddit.com/r/learnpython/wiki/index#wiki_new_to_python.3F Creating a project ================== Before you start scraping, you will have to set up a new Scrapy project. Enter a directory where you'd like to store your code and run:: scrapy startproject tutorial This will create a ``tutorial`` directory with the following contents:: tutorial/ scrapy.cfg # deploy configuration file tutorial/ # project's Python module, you'll import your code from here __init__.py items.py # project items definition file middlewares.py # project middlewares file pipelines.py # project pipelines file settings.py # project settings file spiders/ # a directory where you'll later put your spiders __init__.py Before crawling anything, open ``settings.py`` and uncomment the :setting:`USER_AGENT` line to identify yourself, e.g. a project name plus a URL or an email address. Website owners who take issue with your crawler can then ask you to adjust it, rather than block it. 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 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 ``quotes_spider.py`` under the ``tutorial/spiders`` directory in your project: .. code-block:: python from pathlib import Path import scrapy class QuotesSpider(scrapy.Spider): name = "quotes" async def start(self): urls = [ "https://quotes.toscrape.com/page/1/", "https://quotes.toscrape.com/page/2/", ] for url in urls: yield scrapy.Request(url=url, callback=self.parse) def parse(self, response): page = response.url.split("/")[-2] filename = f"quotes-{page}.html" Path(filename).write_bytes(response.body) self.log(f"Saved file {filename}") As you can see, our Spider subclasses :class:`scrapy.Spider ` and defines some attributes and methods: * :attr:`~scrapy.Spider.name`: identifies the Spider. It must be unique within a project, that is, you can't set the same name for different Spiders. * :meth:`~scrapy.Spider.start`: must be an asynchronous generator that yields requests (and, optionally, items) for the spider to start crawling. Subsequent requests will be generated successively from these initial requests. * :meth:`~scrapy.Spider.parse`: a method that will be called to handle the response downloaded for each of the requests made. The response parameter is an instance of :class:`~scrapy.http.TextResponse` that holds the page content and has further helpful methods to handle it. The :meth:`~scrapy.Spider.parse` method usually parses the response, extracting the scraped data as dicts and also finding new URLs to follow and creating new requests (:class:`~scrapy.Request`) from them. How to run our spider --------------------- To put our spider to work, go to the project's top level directory and run:: scrapy crawl quotes 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:: ... (omitted for brevity) 2016-12-16 21:24:05 [scrapy.core.engine] INFO: Spider opened 2016-12-16 21:24:05 [scrapy.extensions.logstats] INFO: Crawled 0 pages (at 0 pages/min), scraped 0 items (at 0 items/min) 2016-12-16 21:24:05 [scrapy.extensions.telnet] DEBUG: Telnet console listening on 127.0.0.1:6023 2016-12-16 21:24:05 [scrapy.core.engine] DEBUG: Crawled (404) (referer: None) 2016-12-16 21:24:05 [scrapy.core.engine] DEBUG: Crawled (200) (referer: None) 2016-12-16 21:24:05 [scrapy.core.engine] DEBUG: Crawled (200) (referer: None) 2016-12-16 21:24:05 [quotes] DEBUG: Saved file quotes-1.html 2016-12-16 21:24:05 [quotes] DEBUG: Saved file quotes-2.html 2016-12-16 21:24:05 [scrapy.core.engine] INFO: Closing spider (finished) ... Now, check the files in the current directory. You should notice that two new files have been created: *quotes-1.html* and *quotes-2.html*, with the content for the respective URLs, as our ``parse`` method instructs. .. note:: If you are wondering why we haven't parsed the HTML yet, hold on, we will cover that soon. What just happened under the hood? ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Scrapy sends the first :class:`scrapy.Request ` objects yielded by the :meth:`~scrapy.Spider.start` spider method. Upon receiving a response for each one, Scrapy calls the callback method associated with the request (in this case, the ``parse`` method) with a :class:`~scrapy.http.Response` object. A shortcut to the ``start`` method ---------------------------------- Instead of implementing a :meth:`~scrapy.Spider.start` method that yields :class:`~scrapy.Request` objects from URLs, you can define a :attr:`~scrapy.Spider.start_urls` class attribute with a list of URLs. This list will then be used by the default implementation of :meth:`~scrapy.Spider.start` to create the initial requests for your spider. .. code-block:: python from pathlib import Path import scrapy class QuotesSpider(scrapy.Spider): name = "quotes" start_urls = [ "https://quotes.toscrape.com/page/1/", "https://quotes.toscrape.com/page/2/", ] def parse(self, response): page = response.url.split("/")[-2] filename = f"quotes-{page}.html" Path(filename).write_bytes(response.body) The :meth:`~scrapy.Spider.parse` method will be called to handle each of the requests for those URLs, even though we haven't explicitly told Scrapy to do so. This happens because :meth:`~scrapy.Spider.parse` is Scrapy's default callback method, which is called for requests without an explicitly assigned callback. Extracting data --------------- The best way to learn how to extract data with Scrapy is trying selectors using the :ref:`Scrapy shell `. Run:: scrapy shell 'https://quotes.toscrape.com/page/1/' .. note:: 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:: scrapy shell "https://quotes.toscrape.com/page/1/" You will see something like:: [ ... Scrapy log here ... ] 2016-09-19 12:09:27 [scrapy.core.engine] DEBUG: Crawled (200) (referer: None) [s] Available Scrapy objects: [s] scrapy scrapy module (contains scrapy.Request, scrapy.Selector, etc) [s] crawler [s] item {} [s] request [s] response <200 https://quotes.toscrape.com/page/1/> [s] settings [s] spider [s] Useful shortcuts: [s] shelp() Shell help (print this help) [s] fetch(req_or_url) Fetch request (or URL) and update local objects [s] view(response) View response in a browser Using the shell, you can try selecting elements using `CSS`_ with the response object: .. invisible-code-block: python response = load_response('https://quotes.toscrape.com/page/1/', 'quotes1.html') .. code-block:: pycon >>> response.css("title") [] 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 refine the selection or extract the data. To extract the text from the title above, you can do: .. code-block:: pycon >>> response.css("title::text").getall() ['Quotes to Scrape'] There are two things to note here: one is that we've added ``::text`` to the CSS query, to mean we want to select only the text elements directly inside ```` element. If we don't specify ``::text``, we'd get the full title element, including its tags: .. code-block:: pycon >>> response.css("title").getall() ['<title>Quotes to Scrape'] The other thing is that the result of calling ``.getall()`` is a list: it is possible that a selector returns more than one result, so we extract them all. When you know you just want the first result, as in this case, you can do: .. code-block:: pycon >>> response.css("title::text").get() 'Quotes to Scrape' As an alternative, you could've written: .. code-block:: pycon >>> response.css("title::text")[0].get() 'Quotes to Scrape' Accessing an index on a :class:`~scrapy.selector.SelectorList` instance will raise an :exc:`IndexError` exception if there are no results: .. code-block:: pycon >>> response.css("noelement")[0].get() Traceback (most recent call last): ... IndexError: list index out of range You might want to use ``.get()`` directly on the :class:`~scrapy.selector.SelectorList` instance instead, which returns ``None`` if there are no results: .. code-block:: pycon >>> response.css("noelement").get() There's a lesson here: for most scraping code, you want it to be resilient to errors due to things not being found on a page, so that even if some parts fail to be scraped, you can at least get **some** data. Besides the :meth:`~scrapy.selector.SelectorList.getall` and :meth:`~scrapy.selector.SelectorList.get` methods, you can also use the :meth:`~scrapy.selector.SelectorList.re` method to extract using :doc:`regular expressions `: .. code-block:: pycon >>> response.css("title::text").re(r"Quotes.*") ['Quotes to Scrape'] >>> response.css("title::text").re(r"Q\w+") ['Quotes'] >>> response.css("title::text").re(r"(\w+) to (\w+)") ['Quotes', 'Scrape'] In order to find the proper CSS selectors to use, you might find it useful to open the response page from the shell in your web browser using ``view(response)``. You can use your browser's developer tools to inspect the HTML and come up with a selector (see :ref:`topics-developer-tools`). `Selector Gadget`_ is also a nice tool to quickly find CSS selector for visually selected elements, which works in many browsers. .. _Selector Gadget: https://selectorgadget.com/ XPath: a brief intro ^^^^^^^^^^^^^^^^^^^^ Besides `CSS`_, Scrapy selectors also support using `XPath`_ expressions: .. code-block:: pycon >>> response.xpath("//title") [] >>> response.xpath("//title/text()").get() 'Quotes to Scrape' 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 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: *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. We won't cover much of XPath here, but you can read more about :ref:`using XPath with Scrapy Selectors here `. To learn more about XPath, we recommend `this tutorial to learn XPath through examples `_, and `this tutorial to learn "how to think in XPath" `_. .. _XPath: https://www.w3.org/TR/xpath-10/ .. _CSS: https://www.w3.org/TR/selectors Extracting quotes and authors ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Now that you know a bit about selection and extraction, let's complete our spider by writing the code to extract the quotes from the web page. Each quote in https://quotes.toscrape.com is represented by HTML elements that look like this: .. code-block:: html
“The world as we have created it is a process of our thinking. It cannot be changed without changing our thinking.� by Albert Einstein (about)
Let's open up scrapy shell and play a bit to find out how to extract the data we want:: scrapy shell 'https://quotes.toscrape.com' We get a list of selectors for the quote HTML elements with: .. code-block:: pycon >>> response.css("div.quote") [, , ...] Each of the selectors returned by the query above allows us to run further queries over their sub-elements. Let's assign the first selector to a variable, so that we can run our CSS selectors directly on a particular quote: .. code-block:: pycon >>> quote = response.css("div.quote")[0] Now, let's extract the ``text``, ``author`` and ``tags`` from that quote using the ``quote`` object we just created: .. code-block:: pycon >>> text = quote.css("span.text::text").get() >>> text '“The world as we have created it is a process of our thinking. It cannot be changed without changing our thinking.�' >>> author = quote.css("small.author::text").get() >>> author 'Albert Einstein' Given that the tags are a list of strings, we can use the ``.getall()`` method to get all of them: .. code-block:: pycon >>> tags = quote.css("div.tags a.tag::text").getall() >>> tags ['change', 'deep-thoughts', 'thinking', 'world'] .. invisible-code-block: python from sys import version_info Having figured out how to extract each bit, we can now iterate over all the quote elements and put them together into a Python dictionary: .. code-block:: pycon >>> for quote in response.css("div.quote"): ... text = quote.css("span.text::text").get() ... author = quote.css("small.author::text").get() ... tags = quote.css("div.tags a.tag::text").getall() ... print(dict(text=text, author=author, tags=tags)) ... {'text': '“The world as we have created it is a process of our thinking. It cannot be changed without changing our thinking.�', 'author': 'Albert Einstein', 'tags': ['change', 'deep-thoughts', 'thinking', 'world']} {'text': '“It is our choices, Harry, that show what we truly are, far more than our abilities.�', 'author': 'J.K. Rowling', 'tags': ['abilities', 'choices']} ... Extracting data in our spider ----------------------------- 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 extracted from the page. To do that, we use the ``yield`` Python keyword in the callback, as you can see below: .. code-block:: python import scrapy class QuotesSpider(scrapy.Spider): name = "quotes" start_urls = [ "https://quotes.toscrape.com/page/1/", "https://quotes.toscrape.com/page/2/", ] def parse(self, response): for quote in response.css("div.quote"): yield { "text": quote.css("span.text::text").get(), "author": quote.css("small.author::text").get(), "tags": quote.css("div.tags a.tag::text").getall(), } To run this spider, exit the scrapy shell by entering:: quit() Then, run:: scrapy crawl quotes Now, it should output the extracted data with the log:: 2016-09-19 18:57:19 [scrapy.core.scraper] DEBUG: Scraped from <200 https://quotes.toscrape.com/page/1/> {'tags': ['life', 'love'], 'author': 'André Gide', 'text': '“It is better to be hated for what you are than to be loved for what you are not.�'} 2016-09-19 18:57:19 [scrapy.core.scraper] DEBUG: Scraped from <200 https://quotes.toscrape.com/page/1/> {'tags': ['edison', 'failure', 'inspirational', 'paraphrased'], 'author': 'Thomas A. Edison', 'text': "“I have not failed. I've just found 10,000 ways that won't work.�"} .. _storing-data: Storing the scraped data ======================== The simplest way to store the scraped data is by using :ref:`Feed exports `, with the following command:: scrapy crawl quotes -O quotes.json That will generate a ``quotes.json`` file containing all scraped items, serialized in `JSON`_. The ``-O`` command-line switch overwrites any existing file; use ``-o`` instead to append new content to any existing file. However, appending to a JSON file makes the file contents invalid JSON. When appending to a file, consider 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, 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. In small projects (like the one in this tutorial), that should be enough. However, if you want to perform more complex things with the scraped items, you can write an :ref:`Item Pipeline `. A placeholder file for Item Pipelines has been set up for you when the project is created, in ``tutorial/pipelines.py``. Though you don't need to implement any item pipelines if you just want to store the scraped items. .. _JSON Lines: https://jsonlines.org .. _JQ: https://stedolan.github.io/jq Following links =============== Let's say, instead of just scraping the stuff from the first two pages from https://quotes.toscrape.com, you want quotes from all the pages in the website. Now that you know how to extract data from pages, let's see how to follow links from them. 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: .. code-block:: html We can try extracting it in the shell: >>> response.css('li.next a').get() 'Next ' This gets the anchor element, but we want the attribute ``href``. For that, Scrapy supports a CSS extension that lets you select the attribute contents, like this: .. code-block:: pycon >>> response.css("li.next a::attr(href)").get() '/page/2/' There is also an ``attrib`` property available (see :ref:`selecting-attributes` for more): .. code-block:: pycon >>> response.css("li.next a").attrib["href"] '/page/2/' Now let's see our spider, modified to recursively follow the link to the next page, extracting data from it: .. code-block:: python import scrapy class QuotesSpider(scrapy.Spider): name = "quotes" start_urls = [ "https://quotes.toscrape.com/page/1/", ] def parse(self, response): for quote in response.css("div.quote"): yield { "text": quote.css("span.text::text").get(), "author": quote.css("small.author::text").get(), "tags": quote.css("div.tags a.tag::text").getall(), } next_page = response.css("li.next a::attr(href)").get() if next_page is not None: next_page = response.urljoin(next_page) yield scrapy.Request(next_page, callback=self.parse) Now, after extracting the data, the ``parse()`` method looks for the link to the next page, builds a full absolute URL using the :meth:`~scrapy.http.Response.urljoin` method (since the links can be relative) and yields a new request to the next page, registering itself as callback to handle the data extraction for the next page and to keep the crawling going through all the pages. What you see here is Scrapy's mechanism of following links: when you yield a Request in a callback method, Scrapy will schedule that request to be sent and register a callback method to be executed when that request finishes. Using this, you can build complex crawlers that follow links according to rules you define, and extract different kinds of data depending on the page it's visiting. In our example, it creates a sort of loop, following all the links to the next page until it doesn't find one -- handy for crawling blogs, forums and other sites with pagination. .. _response-follow-example: A shortcut for creating Requests -------------------------------- As a shortcut for creating Request objects you can use :meth:`response.follow `: .. code-block:: python import scrapy class QuotesSpider(scrapy.Spider): name = "quotes" start_urls = [ "https://quotes.toscrape.com/page/1/", ] def parse(self, response): for quote in response.css("div.quote"): yield { "text": quote.css("span.text::text").get(), "author": quote.css("span small::text").get(), "tags": quote.css("div.tags a.tag::text").getall(), } next_page = response.css("li.next a::attr(href)").get() if next_page is not None: yield response.follow(next_page, callback=self.parse) Unlike scrapy.Request, ``response.follow`` supports relative URLs directly - no need to call urljoin. Note that ``response.follow`` just returns a Request instance; you still have to yield this Request. .. skip: start You can also pass a selector to ``response.follow`` instead of a string; this selector should extract necessary attributes: .. code-block:: python for href in response.css("ul.pager a::attr(href)"): yield response.follow(href, callback=self.parse) For ```` elements there is a shortcut: ``response.follow`` uses their href attribute automatically. So the code can be shortened further: .. code-block:: python for a in response.css("ul.pager a"): yield response.follow(a, callback=self.parse) To create multiple requests from an iterable, you can use :meth:`response.follow_all ` instead: .. code-block:: python anchors = response.css("ul.pager a") yield from response.follow_all(anchors, callback=self.parse) or, shortening it further: .. code-block:: python yield from response.follow_all(css="ul.pager a", callback=self.parse) .. skip: end More examples and patterns -------------------------- Here is another spider that illustrates callbacks and following links, this time for scraping author information: .. code-block:: python import scrapy class AuthorSpider(scrapy.Spider): name = "author" start_urls = ["https://quotes.toscrape.com/"] def parse(self, response): author_page_links = response.css(".author + a") yield from response.follow_all(author_page_links, self.parse_author) pagination_links = response.css("li.next a") yield from response.follow_all(pagination_links, self.parse) def parse_author(self, response): def extract_with_css(query): return response.css(query).get(default="").strip() yield { "name": extract_with_css("h3.author-title::text"), "birthdate": extract_with_css(".author-born-date::text"), "bio": extract_with_css(".author-description::text"), } This spider will start from the main page, it will follow all the links to the authors pages calling the ``parse_author`` callback for each of them, and also the pagination links with the ``parse`` callback as we saw before. Here we're passing callbacks to :meth:`response.follow_all ` as positional arguments to make the code shorter; it also works for :class:`~scrapy.Request`. The ``parse_author`` callback defines a helper function to extract and cleanup the data from a CSS query and yields the Python dict with the author data. 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 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. As yet another example spider that leverages the mechanism of following links, check out the :class:`~scrapy.spiders.CrawlSpider` class for a generic spider that implements a small rules engine that you can use to write your crawlers on top of it. Also, a common pattern is to build an item with data from more than one page, using a :ref:`trick to pass additional data to the callbacks `. Using spider arguments ====================== You can provide command line arguments to your spiders by using the ``-a`` option when running them:: scrapy crawl quotes -O quotes-humor.json -a tag=humor These arguments are passed to the Spider's ``__init__`` method and become spider attributes by default. In this example, the value provided for the ``tag`` argument will be available via ``self.tag``. You can use this to make your spider fetch only quotes with a specific tag, building the URL based on the argument: .. code-block:: python import scrapy class QuotesSpider(scrapy.Spider): name = "quotes" async def start(self): url = "https://quotes.toscrape.com/" tag = getattr(self, "tag", None) if tag is not None: url = url + "tag/" + tag yield scrapy.Request(url, self.parse) def parse(self, response): for quote in response.css("div.quote"): yield { "text": quote.css("span.text::text").get(), "author": quote.css("small.author::text").get(), } next_page = response.css("li.next a::attr(href)").get() if next_page is not None: yield response.follow(next_page, self.parse) If you pass the ``tag=humor`` argument to this spider, you'll notice that it will only visit URLs from the ``humor`` tag, such as ``https://quotes.toscrape.com/tag/humor``. You can :ref:`learn more about handling spider arguments here `. 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 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'd prefer to play with an example project, check the :ref:`intro-examples` section. If you are using a coding agent, see :ref:`agents`. .. _JSON: https://en.wikipedia.org/wiki/JSON scrapy-scrapy-5569de3/docs/news.rst000066400000000000000000014351111525051012400173170ustar00rootroot00000000000000.. _news: Release notes ============= .. _release-2.19.0: Scrapy 2.19.0 (2026-09-10) -------------------------- Highlights: - New ``RemoteControl`` extension which allows inspecting and controlling a running crawl over HTTP, used by the :ref:`Scrapy MCP server ` - Experimental ``aiohttp``-based download handler (now the default when running without a reactor) Modified requirements ~~~~~~~~~~~~~~~~~~~~~ - Added support for Python 3.15. (:gh:`7511`) - New dependencies: - aiohttp_ >= 3.13.3 - charset-normalizer_ >= 3.4.0 - platformdirs_ >= 2.0.0 (:gh:`7866`, :gh:`8054`) Backward-incompatible changes ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - When running :ref:`without a Twisted reactor `, i.e. with :setting:`TWISTED_REACTOR_ENABLED` set to ``False``, the default download handler for ``http`` and ``https`` is now :class:`~scrapy.core.downloader.handlers._aiohttp.AiohttpDownloadHandler` instead of :class:`~scrapy.core.downloader.handlers._httpx.HttpxDownloadHandler`. You can configure :class:`~scrapy.core.downloader.handlers._httpx.HttpxDownloadHandler` in the :setting:`DOWNLOAD_HANDLERS` setting if you want. (:gh:`8118`) - ``scrapy.http.cookies.WrappedRequest.is_unverifiable()`` now always returns ``False``, and the undocumented ``is_unverifiable`` request meta key that it used to read is ignored. (:gh:`7933`) Deprecations ~~~~~~~~~~~~ - The ``RANDOMIZE_DOWNLOAD_DELAY`` setting is deprecated. Use the new :setting:`DOWNLOAD_DELAY_JITTER` setting instead. Similarly, the ``randomize_delay`` key of the :setting:`DOWNLOAD_SLOTS` setting is deprecated in favor of a new ``jitter`` key, the ``randomize_delay`` attribute of :class:`scrapy.core.downloader.Slot` is deprecated in favor of its new ``jitter`` attribute, and the ``randomize_delay`` attribute of :class:`scrapy.core.downloader.Downloader` is deprecated in favor of the :setting:`DOWNLOAD_DELAY_JITTER` setting. (:gh:`7881`) - The ``install_root_handler`` parameter of :func:`~scrapy.utils.log.configure_logging`, :class:`~scrapy.crawler.CrawlerProcess` and :class:`~scrapy.crawler.AsyncCrawlerProcess` is deprecated. Use the new :setting:`LOG_INSTALL_ROOT_HANDLER` setting instead. (:gh:`4793`, :gh:`8041`) - The ``scrapy.dupefilters.RFPDupeFilter.fingerprints`` attribute is deprecated. Overriding :meth:`scrapy.dupefilters.RFPDupeFilter.request_fingerprint` is deprecated as well; set the :setting:`REQUEST_FINGERPRINTER_CLASS` setting instead. (:gh:`5517`, :gh:`7943`) - Overriding the ``add_pre_hook()`` or ``add_post_hook()`` methods of :class:`~scrapy.contracts.Contract` is deprecated. Define ``pre_process()`` or ``post_process()`` instead. (:gh:`6681`, :gh:`7886`) New features ~~~~~~~~~~~~ - Added a :class:`~scrapy.extensions.remote_control.RemoteControl` extension, enabled by default, which allows connecting to crawl processes via HTTP and running code inside them. (:gh:`7866`) - Added an experimental HTTP download handler based on aiohttp_, :class:`~scrapy.core.downloader.handlers._aiohttp.AiohttpDownloadHandler`. (:gh:`8118`) - Added SQLite-backed scheduler queues: :class:`~scrapy.squeues.PickleFifoSQLiteQueue`, :class:`~scrapy.squeues.PickleLifoSQLiteQueue`, :class:`~scrapy.squeues.MarshalFifoSQLiteQueue` and :class:`~scrapy.squeues.MarshalLifoSQLiteQueue`. They write each request within its own transaction, so that an unclean shutdown cannot corrupt the on-disk queue, at the cost of slower scheduling. (:gh:`845`, :gh:`7877`) - Added a :setting:`DOWNLOAD_DELAY_JITTER` setting, and a ``jitter`` key for :setting:`DOWNLOAD_SLOTS`, which set the magnitude of the random variation applied to :setting:`DOWNLOAD_DELAY`. They replace the ``RANDOMIZE_DOWNLOAD_DELAY`` setting and the ``randomize_delay`` key, which could only toggle a fixed ±50%. (:gh:`7881`) - Added a :setting:`LOG_COLOR` setting, which colorizes log output by log level when logging to a terminal. It needs the new :ref:`color ` extra. (:gh:`8091`) - The encoding of a response that doesn't declare one is now detected with charset-normalizer_ when the response body is neither ASCII nor UTF-8, instead of always falling back to ``cp1252``. (:gh:`3135`, :gh:`8054`) - Added a :setting:`LOG_INSTALL_ROOT_HANDLER` setting, which replaces the deprecated ``install_root_handler`` parameter and, unlike it, can also be set from a :file:`settings.py` module or from the command line. (:gh:`4793`, :gh:`8041`) - :ref:`Contracts ` now support callbacks defined with ``async def``, including asynchronous generators. (:gh:`6681`, :gh:`7886`) - Added the :class:`~scrapy.contracts.default.MethodContract` (``@method``), :class:`~scrapy.contracts.default.BodyContract` (``@body``), :class:`~scrapy.contracts.default.HeaderContract` (``@header``) and :class:`~scrapy.contracts.default.CookieContract` (``@cookie``) contracts, which set the corresponding attributes of the sample request, and an ``-a`` option for the :command:`check` command, to set spider arguments as in the :command:`crawl` command. (:gh:`1918`, :gh:`8053`) - Added :meth:`Response.to_dict() `, :meth:`Response.from_dict() ` and :func:`~scrapy.utils.response.response_from_dict`, and used them in the built-in :ref:`HTTP cache storages `, which now restore cached responses of any response class, including those of third-party plugins, with all their attributes. (:gh:`1450`, :gh:`7908`) - Cached responses now indicate when they were stored, through the new :reqmeta:`cache_timestamp` request meta key. (:gh:`2221`, :gh:`8034`) - The ``format`` key of :setting:`FEEDS` is now inferred from the file extension of the feed URI when not set, e.g. ``json`` for a URI ending in :file:`.json`. (:gh:`1158`, :gh:`8031`) - Added :func:`~scrapy.utils.project.find_projects`, which yields the root directory of every Scrapy project in a directory tree. (:gh:`8024`) - Added a public :attr:`~scrapy.core.engine.ExecutionEngine.scheduler` attribute to :class:`~scrapy.core.engine.ExecutionEngine`. (:gh:`8099`) - :class:`~scrapy.statscollectors.StatsCollector` objects now implement ``__str__()``, which returns the pretty-printed stats. (:gh:`2746`) - :func:`~scrapy.utils.response.open_in_browser` now also supports responses that are neither HTML nor plain text, picking a file extension based on the ``Content-Type`` header. (:gh:`3902`, :gh:`8067`) - The ``-t``/``--template`` option of the :command:`genspider` command now also takes a path to a :file:`.tmpl` file, so that a custom template can be used without setting :setting:`TEMPLATES_DIR`. (:gh:`8071`) - The ``--pdb`` command-line option now uses ipdb_ instead of :mod:`pdb` when ipdb_ is installed. (:gh:`4284`, :gh:`8052`) - Log records about item processing, i.e. about items scraped, dropped or raising an exception, now carry the item in their ``extra`` dict, under the ``item`` key, so that custom logging handlers can read it. (:gh:`8085`) Improvements ~~~~~~~~~~~~ - :class:`~scrapy.dupefilters.RFPDupeFilter` now keeps request fingerprints as :class:`bytes`, to reduce memory usage. The :file:`requests.seen` file that it writes in the :ref:`job directory ` is now a binary file. (:gh:`5517`, :gh:`7943`) - The ``--pdb`` command-line option now starts a post-mortem debugging session on every logged error that comes with a traceback, instead of on every :class:`~twisted.python.failure.Failure` object built anywhere in the process. (:gh:`3552`, :gh:`8037`) - The Scrapy :command:`shell` no longer prints the ``DEBUG`` messages of parso_, an indirect dependency of IPython, when :setting:`LOG_LEVEL` is ``DEBUG``. (:gh:`8060`) - Other code refactoring and improvements. (:gh:`8123`) Bug fixes ~~~~~~~~~ - :class:`~scrapy.core.downloader.handlers.http11.HTTP11DownloadHandler` now sets :attr:`~scrapy.http.Response.certificate` and :attr:`~scrapy.http.Response.ip_address` attributes even for responses without a body. (:gh:`4466`, :gh:`8048`) - HTTP connection pool keys in :class:`~scrapy.core.downloader.handlers.http11.HTTP11DownloadHandler` and :class:`~scrapy.core.downloader.handlers.http2.H2DownloadHandler` now include the request bind address (see :setting:`DOWNLOAD_BIND_ADDRESS`), so that requests bound to different addresses no longer reuse each other's pooled connections. (:gh:`3565`, :gh:`8081`) - :class:`~scrapy.downloadermiddlewares.httpcache.HttpCacheMiddleware` now updates the headers of a cached response, and stores it again, when a revalidation request gets a 304 response. (:gh:`3778`, :gh:`8068`) - An exception raised in a spider callback that no :meth:`~scrapy.spidermiddlewares.SpiderMiddleware.process_spider_exception` method handles is now offered to each of those methods only once. (:gh:`4729`, :gh:`7996`) - Defining :attr:`~scrapy.Spider.allowed_domains` as a property no longer breaks the commands that match a URL to a spider, i.e. :command:`shell`, :command:`fetch` and :command:`parse`. A property cannot be evaluated on a spider class, so it is now ignored, with a warning, instead of raising :exc:`TypeError`. (:gh:`3119`, :gh:`8094`) - :func:`~scrapy.utils.project.get_project_settings` now adds the project directory to :data:`sys.path` also when the ``SCRAPY_SETTINGS_MODULE`` environment variable is set, so that the settings module that the variable points to can be imported. (:gh:`4780`, :gh:`8042`) - :class:`scrapy.utils.datatypes.LocalCache` no longer evicts the oldest item when updating an existing one. (:gh:`8113`) Documentation ~~~~~~~~~~~~~ - Added a page :ref:`about using Scrapy with coding agents `, covering the official agent plugin and the Scrapy MCP server. (:gh:`8120`) - Documented the request metadata keys that were missing from the :ref:`list of special keys `, and documented that keys whose name starts with an underscore are internal. (:gh:`3585`, :gh:`5564`, :gh:`7933`) - Documented :ref:`how to write custom Scrapy commands `, :ref:`how to write custom spider templates `, :ref:`how to access the response of a failed media download ` and :ref:`how to set request headers for media pipeline requests `. (:gh:`2046`, :gh:`2504`, :gh:`3056`, :gh:`6844`, :gh:`6904`, :gh:`8049`, :gh:`8056`, :gh:`8063`) - Documented how to subclass :class:`~scrapy.downloadermiddlewares.redirect.RedirectMiddleware` to allow or deny redirects based on the target URL, and how to set the priority of the requests that :class:`~scrapy.spiders.CrawlSpider` generates from its rules. (:gh:`3613`, :gh:`4009`, :gh:`8061`, :gh:`8070`) - Documented that :func:`~scrapy.utils.project.get_project_settings` returns project settings only, that spiders :ref:`running concurrently in the same process ` get independent crawlers, middlewares and settings, and that Scrapy sets the ``request`` attribute of the :class:`~twisted.python.failure.Failure` objects that it passes to errbacks. (:gh:`2378`, :gh:`4253`, :gh:`6408`, :gh:`7901`, :gh:`8051`, :gh:`8055`) - Documented :class:`~scrapy.commands.ScrapyCommand` and :class:`~scrapy.statscollectors.StatsCollector`. (:gh:`6844`, :gh:`6904`, :gh:`8072`) - Improved the contents of ``llms-full.txt`` and Markdown versions of documentation pages. (:gh:`8083`, :gh:`8087`, :gh:`8088`, :gh:`8089`, :gh:`8117`, :gh:`8119`) Quality assurance ~~~~~~~~~~~~~~~~~ - Reached 100% test coverage. (:gh:`8021`) - Improved and fixed type hints. (:gh:`8017`, :gh:`8077`, :gh:`8102`) - Improved CodSpeed benchmarks. (:gh:`8030`, :gh:`8080`) - CI and test improvements and fixes. (:gh:`8027`, :gh:`8039`, :gh:`8059`, :gh:`8064`, :gh:`8073`, :gh:`8075`, :gh:`8076`, :gh:`8079`, :gh:`8092`, :gh:`8103`, :gh:`8115`) .. _release-2.18.0: Scrapy 2.18.0 (2026-08-20) -------------------------- Highlights: - ``HttpxDownloadHandler`` now uses `httpx2 `__ - The Twisted-based HTTP/2 download handler is no longer experimental - ``brotli`` and Zstandard support are now always available, and :ref:`optional extras ` cover the rest of the optional features - Late :class:`~scrapy.crawler.Crawler` attributes, such as :attr:`~scrapy.crawler.Crawler.stats`, now raise :exc:`RuntimeError` instead of being ``None`` before the crawl starts - Item exporters now export fields in declaration order - New :class:`~scrapy.spidermiddlewares.metacopy.MetaCopyDetectionMiddleware` - New :ref:`optimization ` page and :ref:`built-in stats reference ` Modified requirements ~~~~~~~~~~~~~~~~~~~~~ - ``brotli`` (``brotlicffi`` on PyPy) and Zstandard support (the standard library :mod:`compression.zstd` module on Python 3.14 and higher, the ``backports.zstd`` package on earlier versions) are now required, so ``br`` and ``zstd`` are always included in the ``Accept-Encoding`` header of requests, and Brotli- and Zstandard-compressed responses are always decoded. Websites may now serve such responses to crawls that previously did not advertise support for them. The minimum required versions are ``brotli`` 1.2.0, ``brotlicffi`` 1.2.0.0 and ``backports.zstd`` 1.3.0. (:gh:`4698`, :gh:`6978`, :gh:`7083`, :gh:`7929`, :gh:`8009`) - Increased the minimum versions of the following dependencies: - cryptography_: 37.0.0 → 41.0.5 - pyOpenSSL_: 22.0.0 → 24.3.0 - queuelib_: 1.4.2 → 1.6.1 - service_identity_: 23.1.0 → 24.2.0 - w3lib_: 1.17.0 → 2.1.1 (:gh:`7841`, :gh:`7874`, :gh:`7879`, :gh:`8001`) - The IPython :ref:`shell ` requires IPython 8.15.0 or higher. Install the :ref:`ipython extra ` to get a compatible version. (:gh:`5447`, :gh:`7596`, :gh:`7816`) Backward-incompatible changes ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - The following runtime usage of zope.interface_ interfaces is removed: - :class:`~scrapy.spiderloader.SpiderLoader` and :class:`~scrapy.spiderloader.DummySpiderLoader` are no longer marked as implementing the ``ISpiderLoader`` interface. - :func:`~scrapy.spiderloader.get_spider_loader` no longer checks that the configured spider loader implements the ``ISpiderLoader`` interface. - :class:`~scrapy.extensions.feedexport.BlockingFeedStorage`, :class:`~scrapy.extensions.feedexport.FileFeedStorage` and :class:`~scrapy.extensions.feedexport.StdoutFeedStorage` are no longer marked as implementing the ``IFeedStorage`` interface. - :class:`~scrapy.core.downloader.handlers.http2.H2DownloadHandler` no longer checks that the ``DOWNLOADER_CLIENTCONTEXTFACTORY`` class implements the ``IPolicyForHTTPS`` interface. (:gh:`6585`, :gh:`7731`) - The :attr:`~scrapy.crawler.Crawler.engine`, :attr:`~scrapy.crawler.Crawler.extensions`, :attr:`~scrapy.crawler.Crawler.logformatter`, :attr:`~scrapy.crawler.Crawler.request_fingerprinter` and :attr:`~scrapy.crawler.Crawler.stats` attributes of :class:`~scrapy.crawler.Crawler` raise :exc:`RuntimeError` when read before the crawl starts, instead of being ``None`` until then. Code that reads them from the :signal:`spider_opened` signal handler onwards is unaffected, and no longer needs to narrow their type. Code that checked whether they were set, e.g. ``if crawler.stats:``, must be updated, since reading them now raises instead of returning ``None``. (:gh:`6136`, :gh:`7882`) - :ref:`Item exporters ` now export the fields of an item in declaration order, i.e. the order in which they are defined in the :ref:`item class `, instead of the order in which they were populated, as :class:`~scrapy.exporters.CsvItemExporter` already did. :class:`dict` items, which have no declared fields, keep using the key order of each item. (:gh:`6662`, :gh:`6854`, :gh:`7824`) - ``scrapy.utils.serialize.ScrapyJSONEncoder``, used by :ref:`JSON feed exports `, the :ref:`telnet console ` and the :class:`~scrapy.extensions.periodic_log.PeriodicLog` extension, now serializes :class:`~datetime.datetime`, :class:`~datetime.date` and :class:`~datetime.time` objects in ISO 8601 format, e.g. ``2023-08-03T23:24:57.148903+00:00`` instead of ``2023-08-03 23:24:57``, keeping microseconds and time zone information. Its ``DATE_FORMAT`` and ``TIME_FORMAT`` attributes are removed. (:gh:`2087`, :gh:`7918`) - ``scrapy.utils.trackref.live_refs`` is now a :class:`~weakref.WeakKeyDictionary` instead of a :class:`collections.defaultdict`, so that classes defined at run time are released once they are no longer used. Reading the entry of a class with no tracked instances now raises :exc:`KeyError` instead of creating and returning an empty mapping. (:gh:`5995`, :gh:`7922`) - The ``MEMDEBUG_NOTIFY`` setting is removed. It had no effect, but code reading it now gets ``None`` instead of its default value, which was an empty list. (:gh:`7737`) - ``scrapy.utils.log.logformatter_adapter()`` no longer passes the whole :class:`dict` returned by a :ref:`log formatter ` method as logging arguments when that ``dict`` has no ``args`` key, or its ``args`` are empty, and its ``msg`` has no ``%(name)s`` placeholders. Such messages are now logged verbatim, so a literal ``%`` in them no longer breaks logging. An ``args`` :class:`tuple` is now expanded into one logging argument per item, so that ``%``-style placeholders work with it as they do with a ``dict``. (:gh:`5570`, :gh:`5572`, :gh:`7936`) - :setting:`FEEDS` keys and ``FEED_URI`` values that are :class:`pathlib.Path` objects are now used as paths, instead of being converted into ``file://`` URIs. This makes them keep working when they contain :ref:`URI parameters ` or characters that URI conversion would percent-encode. (:gh:`5794`, :gh:`6425`, :gh:`6611`, :gh:`7674`) - :class:`~scrapy.Selector` and :attr:`TextResponse.selector ` no longer force the ``html`` selector type for responses that are neither :class:`~scrapy.http.HtmlResponse` nor :class:`~scrapy.http.XmlResponse` objects. A :class:`~scrapy.http.JsonResponse` gets the ``json`` type, and for any other response ``parsel`` determines the type from the body. The response class, and hence the selector type, comes from the content type that the website reports. When a website reports the wrong content type, recast the response, e.g. ``response.replace(cls=HtmlResponse)``. (:gh:`4627`, :gh:`5291`, :gh:`6025`, :gh:`7924`, :gh:`7972`) - :ref:`AutoThrottle ` no longer sets the ``download_delay`` attribute of the running spider to define the starting delay of download slots. The starting delay is still applied, but code that reads that attribute at run time no longer sees it. (:gh:`7167`, :gh:`7175`, :gh:`7833`) - The :command:`check` command now ignores :setting:`ITEM_PIPELINES` and :setting:`FEEDS`, since contracts check the output of callbacks instead of sending it to item processing, so a check run no longer triggers their side effects, e.g. writing an empty output file. Use the ``-s`` command-line option to set them back for a check run. (:gh:`3385`, :gh:`7957`) - :class:`~scrapy.spiders.XMLFeedSpider` and :class:`~scrapy.spiders.CSVFeedSpider` no longer raise :exc:`~scrapy.exceptions.NotConfigured` when ``parse_node()`` or ``parse_row()`` is not defined; the resulting :exc:`AttributeError` is reported instead. (:gh:`7768`) Deprecation removals ~~~~~~~~~~~~~~~~~~~~ - ``scrapy.utils.misc.md5sum()``, deprecated since Scrapy 2.12.0, is removed. (:gh:`6264`, :gh:`8023`) - ``scrapy.utils.iterators.xmliter()``, deprecated since Scrapy 2.11.1 because it is vulnerable to ReDoS attacks, is removed. Use :func:`~scrapy.utils.iterators.xmliter_lxml` instead. (:gh:`7765`) - ``scrapy.utils.datatypes.CaselessDict``, deprecated since Scrapy 2.10.0, is removed. Use :class:`~scrapy.utils.datatypes.CaseInsensitiveDict` instead. (:gh:`5146`, :gh:`8023`) Deprecations ~~~~~~~~~~~~ - The ``download_delay`` spider attribute is deprecated. Use the :setting:`DOWNLOAD_DELAY` setting, or :setting:`DOWNLOAD_SLOTS` to set a delay for specific domains, instead. The ``max_concurrent_requests`` spider attribute, deprecated since Scrapy 2.13.0, now sets the :setting:`CONCURRENT_REQUESTS_PER_DOMAIN` setting, which is what it always mapped to, and warns accordingly. Both attributes are ignored, with a different warning, when the corresponding setting is already set at the ``spider`` priority or higher. (:gh:`7167`, :gh:`7175`, :gh:`7833`) - The ``Spider.log()`` method is deprecated. Use the methods of :attr:`Spider.logger ` instead. (:gh:`7739`) - The ``scrapy.interfaces`` module and its ``ISpiderLoader`` interface are deprecated. Custom spider loaders only need to follow :class:`~scrapy.spiderloader.SpiderLoaderProtocol`. (:gh:`6585`, :gh:`7731`) - ``scrapy.extensions.feedexport.IFeedStorage`` is deprecated. Custom feed storages only need to follow ``scrapy.extensions.feedexport.FeedStorageProtocol``. (:gh:`6585`, :gh:`7731`) - ``scrapy.utils.python.re_rsearch()`` is deprecated. (:gh:`7765`) - Importing ``FileException`` from ``scrapy.pipelines.files`` is deprecated. Import it from ``scrapy.pipelines.media`` instead. (:gh:`7544`, :gh:`7673`, :gh:`7973`) - Setting ``request.meta["is_secure"]`` to ``False`` to send an ``s3://`` request over plaintext HTTP is deprecated. The flag will be ignored in a future Scrapy version. (:gh:`7738`) - The unused ``multiplier`` attribute of :class:`~scrapy.extensions.periodic_log.PeriodicLog` is deprecated. (:gh:`7809`, :gh:`7982`) - Returning, from a :ref:`log formatter ` method, a ``msg`` with ``%(name)s`` placeholders and no ``args`` is deprecated. Those placeholders are still interpolated with the returned :class:`dict`, but in a future Scrapy version the message will be logged verbatim. Return those values under ``args`` instead. (:gh:`5570`, :gh:`7971`) New features ~~~~~~~~~~~~ - Added :ref:`optional extras ` for every optional dependency of Scrapy: ``bpython``, ``gcs``, ``httpx``, ``images``, ``ipython``, ``ptpython``, ``robotparser``, ``s3``, ``twisted-http2`` and ``uvloop``. For example, ``pip install scrapy[s3,images]``. (:gh:`7596`) - :class:`~scrapy.core.downloader.handlers._httpx.HttpxDownloadHandler` now uses `httpx2 `__, the successor of ``httpx``, which the new :ref:`httpx extra ` installs together with its HTTP/2 and SOCKS proxy support. ``httpx`` is still used when ``httpx2`` is not installed, but it is no longer tested. (:gh:`7762`) - Added a :signal:`robots_parsed` signal, sent by :class:`~scrapy.downloadermiddlewares.robotstxt.RobotsTxtMiddleware` after it parses a :file:`robots.txt` file. It supports :ref:`asynchronous handlers `. Added a :meth:`~scrapy.robotstxt.RobotParser.crawl_delay` method to :class:`~scrapy.robotstxt.RobotParser`, implemented by all built-in :ref:`robots.txt parsers `. (:gh:`7830`) - Added a :meth:`Request.to_curl() ` method, the inverse of :meth:`~scrapy.Request.from_curl`. (:gh:`7743`, :gh:`7746`, :gh:`7802`) - Added a :reqmeta:`depth_reset` request meta key that gives a request depth 0 instead of the depth of its source response plus 1. (:gh:`891`, :gh:`7913`) - Added :class:`~scrapy.spidermiddlewares.metacopy.MetaCopyDetectionMiddleware`, enabled by default, which warns once per crawl when a spider yields a request carrying internal :attr:`~scrapy.Request.meta` keys that were likely copied from ``response.meta``, and a :setting:`META_COPY_WARN_SKIP_KEYS` setting to exclude keys from that check. (:gh:`7588`) - Added an :setting:`AWS_MAX_POOL_CONNECTIONS` setting, which defines the connection pool size of the AWS clients of the :ref:`S3 feed storage backend ` and the :ref:`S3 media pipeline storage backend `, and defaults to :setting:`REACTOR_THREADPOOL_MAXSIZE`. It is also exposed as a ``max_pool_connections`` parameter of ``S3FeedStorage`` and as an ``AWS_MAX_POOL_CONNECTIONS`` attribute of ``S3FilesStore``. (:gh:`4985`, :gh:`7794`) - Added a :func:`scrapy.utils.asyncio.sleep` function, which works both with and without a Twisted reactor. (:gh:`7843`) - :setting:`CONCURRENT_REQUESTS` can now be set to ``0`` for no limit. (:gh:`7840`) - :class:`~scrapy.core.downloader.handlers.http2.H2DownloadHandler` is no longer experimental, and it now sends the :signal:`bytes_received` and :signal:`headers_received` signals and supports :exc:`~scrapy.exceptions.StopDownload`. (:gh:`5046`, :gh:`5047`, :gh:`5055`, :gh:`7896`, :gh:`7986`) - An exception raised by :meth:`Spider.start() ` is now reported through the :signal:`spider_error` signal and the :stat:`spider_exceptions/count` and :stat:`spider_exceptions/{exception}` stats, and closes the spider with the new ``start_error`` :stat:`finish_reason` instead of ``finished``. See :ref:`start-error`. :exc:`~scrapy.exceptions.CloseSpider` raised from :meth:`Spider.start() ` now closes the spider with the given reason, instead of being reported as a start error. (:gh:`3463`, :gh:`4058`, :gh:`4182`, :gh:`6148`, :gh:`7884`) - :exc:`~scrapy.exceptions.CloseSpider` can now also be raised while the spider is starting, e.g. from a :signal:`spider_opened` signal handler or from the ``open_spider()`` method of an :ref:`item pipeline `, to close the spider before it starts crawling. Every component still gets started, and stopped, before the spider is closed with the given reason. (:gh:`3435`, :gh:`7905`) - Added an :ref:`FTPS feed storage backend `, i.e. support for the ``ftps`` URI scheme in :setting:`FEEDS`, which uploads the feed over a TLS connection, verifying the certificate of the server. (:gh:`4180`, :gh:`7953`) - Changes to :attr:`Spider.allowed_domains ` during a crawl are now taken into account by :class:`~scrapy.downloadermiddlewares.offsite.OffsiteMiddleware`, whose :meth:`~scrapy.downloadermiddlewares.offsite.OffsiteMiddleware.should_follow` method is now documented as the way to implement a different offsite policy. (:gh:`3257`, :gh:`3412`, :gh:`7903`, :gh:`7912`) - :class:`~scrapy.settings.BaseSettings` methods that take settings, such as :meth:`~scrapy.settings.BaseSettings.update` and the ``settings`` parameter of crawler classes, now also accept an iterable of ``(name, value)`` tuples. (:gh:`7759`, :gh:`7763`) - The ``cookies`` parameter of :class:`~scrapy.Request` now also accepts :class:`bool`, :class:`float` and :class:`int` values, and the ``formdata`` parameter of :class:`~scrapy.FormRequest` now accepts any mapping or iterable of key-value pairs. (:gh:`7858`, :gh:`7864`) - Added a ``scrapy.utils.reactorless.uninstall_reactor_import_hook()`` function, which :meth:`AsyncCrawlerProcess.start() ` now uses to uninstall the :mod:`twisted.internet.reactor` import hook when it exits. (:gh:`7747`) - Added the :stat:`depth/request_ignored_count` and :stat:`httpcache/retrieve_error` stats. (:gh:`1308`, :gh:`2222`, :gh:`7805`, :gh:`7916`) - The :meth:`~scrapy.exporters.BaseItemExporter.get_serialized_fields` method of :ref:`item exporters `, previously named ``_get_serialized_fields()``, is now public and documented, for :ref:`custom item exporters ` to use. (:gh:`5706`, :gh:`7931`) - Scrapy now writes the session keys of its HTTPS connections to the file that the ``SSLKEYLOGFILE`` environment variable points to, so that traffic analysis tools such as Wireshark can decrypt them. See :ref:`debug-tls`. (:gh:`4368`, :gh:`7948`) - Added an :setting:`HTTP2_MAX_FRAME_SIZE` setting, which allows raising the maximum HTTP/2 frame size that servers may send, previously fixed at 16384, above which connections failed. (:gh:`5050`, :gh:`7988`) - The :command:`crawl`, :command:`parse` and :command:`runspider` commands now warn when :setting:`FEEDS` is set, e.g. through ``-o`` or ``-O``, but the :class:`~scrapy.extensions.feedexport.FeedExporter` extension is disabled, so that no item is exported. (:gh:`5970`, :gh:`6082`, :gh:`6373`, :gh:`7902`) - Log formatters (:setting:`LOG_FORMATTER`), item processors (:setting:`ITEM_PROCESSOR`) and :ref:`robots.txt parsers ` (:setting:`ROBOTSTXT_PARSER`) are now built as :ref:`components `, so they no longer need a ``from_crawler()`` method. (:gh:`7808`) Bug fixes ~~~~~~~~~ - :class:`~scrapy.downloadermiddlewares.httpcache.HttpCacheMiddleware` now logs a warning and handles the request as a cache miss when reading a cache entry raises an exception, e.g. because the entry is corrupted, instead of letting the exception propagate. It also counts those entries in the new :stat:`httpcache/retrieve_error` stat. (:gh:`2222`, :gh:`7805`) - :ref:`Feed URIs ` now only expand ``%(...)s`` parameters, keeping any other percent character as is, so that percent-encoded URIs, e.g. one with ``%20`` in a path or with percent-encoded FTP credentials, are no longer misinterpreted as printf-style formatting directives. (:gh:`5794`, :gh:`6425`, :gh:`7674`) - :ref:`Feed exports ` now start storing a :setting:`FEED_EXPORT_BATCH_ITEM_COUNT` batch as soon as it is complete, instead of waiting until the spider closes. (:gh:`7730`, :gh:`7733`) - :class:`~scrapy.exporters.CsvItemExporter` now warns when the fields that it took from the first item do not cover the fields of a later item, i.e. when it silently drops data. (:gh:`4002`, :gh:`4053`, :gh:`7613`, :gh:`7651`) - ``GCSFeedStorage`` no longer requires the ``storage.buckets.get`` permission. (:gh:`5475`, :gh:`7945`) - :ref:`Media pipelines ` now log media requests that were filtered out, e.g. as offsite requests, at the ``DEBUG`` level and without a traceback, instead of reporting them as download errors. (:gh:`7544`, :gh:`7673`) - :class:`~scrapy.downloadermiddlewares.offsite.OffsiteMiddleware` now raises :exc:`~scrapy.exceptions.IgnoreRequest` with a message, e.g. ``Filtered offsite request to 'offsite.example'``, which errbacks and log messages that report that exception now include. (:gh:`7544`, :gh:`7673`) - :class:`~scrapy.core.downloader.handlers.http11.HTTP11DownloadHandler` now skips response header lines that have no colon, logging them at the ``DEBUG`` level, as web browsers do, instead of being unable to download such a response at all. (:gh:`210`, :gh:`7806`) - :class:`~scrapy.downloadermiddlewares.cookies.CookiesMiddleware` now sends domain cookies to hosts without a dot in their name and to hosts given as an IP address. (:gh:`6410`, :gh:`7900`) - :meth:`TextResponse.json() ` now decodes bodies that are not valid UTF-8, UTF-16 or UTF-32 using :attr:`TextResponse.encoding `, instead of raising :exc:`UnicodeDecodeError`. (:gh:`6456`, :gh:`7897`) - ``scrapy.resolver.CachingHostnameResolver`` now caches addresses without a port, and sets the requested port on cache hits, so that a cached address no longer carries the port of the request that populated the cache. (:gh:`6442`, :gh:`7772`) - :class:`~scrapy.pqueues.DownloaderAwarePriorityQueue` now removes the directory of a download slot from the :setting:`JOBDIR` directory once that slot is drained. (:gh:`5275`, :gh:`7955`) - :class:`~scrapy.extensions.telnet.TelnetConsole` no longer raises an exception on shutdown when it could not listen on any of the :setting:`TELNETCONSOLE_PORT` ports. (:gh:`2702`, :gh:`7910`) - The :setting:`DOWNLOAD_WARNSIZE` warning is no longer logged twice for a response whose ``Content-Length`` header already exceeded the limit. (:gh:`2476`, :gh:`7963`) - :class:`HttpCompressionMiddleware ` now logs a warning when it drops a response for exceeding :setting:`DOWNLOAD_MAXSIZE` during decompression. (:gh:`6616`, :gh:`7742`) - :class:`~scrapy.spidermiddlewares.depth.DepthMiddleware` now logs only the first request ignored for exceeding :setting:`DEPTH_LIMIT`, and counts them all in the new :stat:`depth/request_ignored_count` stat. (:gh:`1308`, :gh:`7916`) - :command:`parse` now sets the callback it uses on the request of the response it passes to that callback. (:gh:`3095`, :gh:`3124`, :gh:`7803`) - The IPython :ref:`shell ` now works when an asyncio event loop is already running in the same thread, e.g. when calling ``scrapy.shell.inspect_response()`` from a callback while using the asyncio reactor. (:gh:`5447`, :gh:`7816`) - :meth:`Request.from_curl() ` now merges repeated ``-d``, ``--data`` and ``--data-raw`` options into a single body joined with ``&``, as curl does, instead of keeping only the last one. (:gh:`7728`) - The ``copy()`` method and the ``|=`` operator of ``scrapy.utils.datatypes.CaseInsensitiveDict`` no longer leave the internal mapping of original key spellings shared or out of date. (:gh:`7783`) - :meth:`ExecutionEngine.download_async() ` no longer recurses once per returned request, e.g. once per redirect. (:gh:`7544`, :gh:`7673`) - :class:`LinkExtractor ` now canonicalizes each extracted URL once instead of twice when ``canonicalize`` is ``True``. (:gh:`7961`) - Items yielded from :meth:`Spider.start() ` now keep the spider busy until the :ref:`item pipelines ` are done with them, so that a spider that only yields items from :meth:`~scrapy.Spider.start` no longer closes before processing them. (:gh:`7029`, :gh:`7891`) - :func:`~scrapy.utils.response.open_in_browser` now also adds its ``base`` tag to HTML responses that have no ``head`` element, and it now overrides a ``base`` tag already present in the response, so that relative URLs resolve against the response URL in every case. (:gh:`6550`, :gh:`7879`) - :meth:`Spider.start() ` implementations that are not asynchronous generators now raise :exc:`TypeError` with a message that says so, instead of failing in a way that does not point at the cause. (:gh:`5426`, :gh:`7946`) - The :command:`check`, :command:`fetch` and :command:`parse` commands now return the exit code 1 when a component fails to initialize, as :command:`crawl` and :command:`runspider` already did. (:gh:`4292`, :gh:`7920`) - :class:`HttpCompressionMiddleware ` no longer hangs on a ``deflate`` response body followed by extra bytes. (:gh:`7841`) - :func:`scrapy.utils.python.get_func_args` now reports the parameters that a :class:`functools.partial` object binds by position, instead of an empty list. (:gh:`7841`) - Fixed :exc:`NameError` exceptions on Python 3.14, where :pep:`649` made annotation evaluation lazy, when inspecting the signature of a callable with annotations imported only for type checking. (:gh:`7796`, :gh:`7818`) - ``scrapy.utils.decorators.deprecated`` can now be used both as ``@deprecated`` and as ``@deprecated(...)`` without confusing type checkers. (:gh:`7797`) - The default download handlers can now download from domains with emoji characters or underscores, which were previously rejected. (:gh:`3321`, :gh:`4330`, :gh:`7846`) - Callbacks and media pipeline results no longer wait 100 ms before proceeding. (:gh:`8019`) - Shutting down a crawl no longer risks raising an unhandled :exc:`RuntimeError` if the code interrupted by the shutdown signal was itself writing to the log. (:gh:`8022`) - Nested selectors, e.g. the result of calling :meth:`~scrapy.Selector.jmespath` on a selector, now let ``parsel`` determine their type instead of forcing the ``html`` type, so that they no longer return the wrong type or value. (:gh:`8038`, :gh:`8040`) Documentation ~~~~~~~~~~~~~ - Added a :ref:`built-in stats reference `, covering every stat that Scrapy sets. (:gh:`6351`, :gh:`7814`) - Replaced the broad crawls page with a new :ref:`optimization ` page, about finding the bottleneck of a crawl before changing any setting, which covers :ref:`broad crawls ` as one of its sections. (:gh:`4737`, :gh:`7938`) - Added a :ref:`concepts ` page, a quick map of Scrapy's main building blocks, what problem each one solves, and when to reach for it. (:gh:`1569`, :gh:`8025`) - Added a :ref:`cookies ` page, which gathers what used to be spread across the request and downloader middleware pages. (:gh:`7947`) - Added :ref:`callbacks ` and :ref:`errbacks ` sections to the request and response page, covering :ref:`callback assignment `, :ref:`how to write a callback ` and :ref:`supported callback output `. (:gh:`5054`, :gh:`6437`, :gh:`7821`, :gh:`7898`) - Documented the :setting:`ITEM_PROCESSOR` setting and the :class:`~scrapy.pipelines.ItemProcessorProtocol` protocol that its value must implement. (:gh:`7983`) - Documented :ref:`how to write an item exporter `, :ref:`how to test an item pipeline `, :ref:`how to download a request from a downloader middleware `, :ref:`how to name media files after the response `, :ref:`how to add objects to the shell ` and :ref:`how to run spiders inside an existing application ` or :ref:`in a Jupyter notebook `. (:gh:`915`, :gh:`1199`, :gh:`2594`, :gh:`5706`, :gh:`6554`, :gh:`6594`, :gh:`7751`, :gh:`7872`, :gh:`7876`, :gh:`7889`, :gh:`7909`, :gh:`7931`) - Added an :ref:`inspecting live traffic ` section to the debugging page, covering Wireshark and mitmproxy. (:gh:`5222`, :gh:`8007`) - Documented how :class:`~scrapy.http.TextResponse` resolves the response encoding, and how to resolve it differently, e.g. to give the encoding declared in the response body precedence over the ``Content-Type`` header. (:gh:`4933`, :gh:`7977`) - Documented the :ref:`memory use of response parsing ` and the :ref:`parser limits ` that Scrapy lifts, in the security page. (:gh:`5700`, :gh:`7930`) - Documented that :ref:`signal handlers run in an undefined order `, that :signal:`scheduler_empty` must only be awaited from :meth:`~scrapy.Spider.start`, that concurrency and politeness settings apply per crawler when :ref:`running multiple spiders in the same process `, and that a :setting:`JOBDIR` directory cannot be shared across Scrapy versions. (:gh:`3191`, :gh:`5330`, :gh:`5522`, :gh:`7861`, :gh:`7883`, :gh:`7907`, :gh:`7941`) - Documented that the ``html`` iterator of :class:`~scrapy.spiders.XMLFeedSpider` can silently mangle tags that HTML treats as void elements, e.g. ````, dropping their content and closing tag. (:gh:`4675`, :gh:`8045`) - Documented that the ``keep_fragments`` parameter of :func:`~scrapy.utils.request.fingerprint` is not a substitute for rendering JavaScript to reach content that a headless browser loads based on the URL fragment. (:gh:`4789`, :gh:`8033`) - Documented :ref:`how to derive the job directory from the spider name `. (:gh:`4748`, :gh:`8035`) - Documented that the project name from :file:`scrapy.cfg` also appears elsewhere by default, and which of those uses actually require it to match. (:gh:`2484`, :gh:`8044`) - Many other corrections and improvements. (:gh:`4589`, :gh:`4796`, :gh:`5532`, :gh:`5548`, :gh:`6053`, :gh:`6184`, :gh:`6627`, :gh:`6787`, :gh:`6943`, :gh:`6989`, :gh:`7710`, :gh:`7725`, :gh:`7737`, :gh:`7767`, :gh:`7769`, :gh:`7771`, :gh:`7774`, :gh:`7775`, :gh:`7777`, :gh:`7779`, :gh:`7780`, :gh:`7817`, :gh:`7832`, :gh:`7835`, :gh:`7862`, :gh:`7871`, :gh:`7875`, :gh:`7880`, :gh:`7890`, :gh:`7903`, :gh:`7913`, :gh:`7917`, :gh:`7939`, :gh:`7940`, :gh:`7962`, :gh:`7965`) Quality assurance ~~~~~~~~~~~~~~~~~ - Improved and fixed type hints. (:gh:`7712`, :gh:`7785`, :gh:`7858`, :gh:`7864`, :gh:`7865`, :gh:`7867`) - Added CPU benchmarks, tracked on CodSpeed, so that performance regressions are caught before they are merged and performance work can be measured. (:gh:`7831`, :gh:`7839`, :gh:`7870`, :gh:`7887`, :gh:`7914`, :gh:`7954`) - Added a nightly job that runs the test suite against the development branches of dependencies, so that incompatibilities are found before those dependencies are released. (:gh:`5291`, :gh:`6025`, :gh:`7924`, :gh:`7960`) - CI and test improvements and fixes. (:gh:`5049`, :gh:`5620`, :gh:`5837`, :gh:`6478`, :gh:`6794`, :gh:`7262`, :gh:`7437`, :gh:`7702`, :gh:`7720`, :gh:`7724`, :gh:`7727`, :gh:`7736`, :gh:`7741`, :gh:`7749`, :gh:`7753`, :gh:`7755`, :gh:`7768`, :gh:`7778`, :gh:`7782`, :gh:`7792`, :gh:`7793`, :gh:`7795`, :gh:`7797`, :gh:`7798`, :gh:`7809`, :gh:`7829`, :gh:`7834`, :gh:`7836`, :gh:`7838`, :gh:`7841`, :gh:`7844`, :gh:`7848`, :gh:`7853`, :gh:`7854`, :gh:`7857`, :gh:`7859`, :gh:`7863`, :gh:`7895`, :gh:`7906`, :gh:`7928`, :gh:`7935`, :gh:`7966`, :gh:`7968`, :gh:`7974`, :gh:`7979`, :gh:`7985`, :gh:`7990`, :gh:`7993`, :gh:`7995`, :gh:`8000`, :gh:`8001`, :gh:`8002`) .. _release-2.17.0: Scrapy 2.17.0 (2026-07-07) -------------------------- Highlights: - Security bug fixes - HTTP/2 and SOCKS proxy support for ``HttpxDownloadHandler`` - Improved settings for changing allowed TLS versions Security bug fixes ~~~~~~~~~~~~~~~~~~ - ``s3://`` requests now use HTTPS by default, instead of plaintext HTTP. Previously, :class:`~scrapy.core.downloader.handlers.s3.S3DownloadHandler` sent signed S3 requests over plaintext HTTP unless ``request.meta["is_secure"]`` was set to a true value, exposing the request path, the AWS ``Authorization`` header, the ``X-Amz-Security-Token`` header (when using temporary credentials), and the response contents to network attackers, who could also tamper with responses. See the `76g3-c3x4-crvx`_ security advisory for details. To restore the previous behavior for a given request, set ``request.meta["is_secure"]`` to ``False``. .. _76g3-c3x4-crvx: https://github.com/scrapy/scrapy/security/advisories/GHSA-76g3-c3x4-crvx Deprecations ~~~~~~~~~~~~ - The ``DOWNLOADER_CLIENT_TLS_METHOD`` setting is deprecated. You should use the :setting:`DOWNLOAD_TLS_MIN_VERSION` and/or :setting:`DOWNLOAD_TLS_MAX_VERSION` settings instead if you want to change the TLS method selection. (:gh:`3288`, :gh:`6546`) - The following spider attributes are deprecated in favor of settings: - ``http_user`` (use :setting:`HTTPAUTH_USER`) - ``http_pass`` (use :setting:`HTTPAUTH_PASS`) - ``http_auth_domain`` (use :setting:`HTTPAUTH_DOMAIN`) (:gh:`7590`) - The ``scrapy.commands.ScrapyCommand.help()`` method is deprecated. It was never called by Scrapy. (:gh:`7626`, :gh:`7633`) - The following TLS-related functions and constants, intended for internal use, are deprecated: - ``scrapy.core.downloader.tls.METHOD_TLS`` - ``scrapy.core.downloader.tls.METHOD_TLSv10`` - ``scrapy.core.downloader.tls.METHOD_TLSv11`` - ``scrapy.core.downloader.tls.METHOD_TLSv12`` - ``scrapy.core.downloader.tls.openssl_methods`` - ``scrapy.core.downloader.tls.DEFAULT_CIPHERS`` - ``scrapy.utils.ssl.ffi_buf_to_string()`` - ``scrapy.utils.ssl.get_temp_key_info()`` - ``scrapy.utils.ssl.x509name_to_string()`` (:gh:`6546`, :gh:`7619`, :gh:`7665`) - The ``CRAWLSPIDER_FOLLOW_LINKS`` setting is deprecated. You can set ``follow=False`` in your rules to achieve the same effect. (:gh:`7592`) - Instantiating :class:`~scrapy.downloadermiddlewares.httpcompression.HttpCompressionMiddleware` without a ``crawler`` argument is deprecated. (:gh:`7655`) - Instantiating :class:`~scrapy.spidermiddlewares.referer.RefererMiddleware` without a ``settings`` argument is deprecated. (:gh:`7664`) New features ~~~~~~~~~~~~ - Added support for HTTP/2 requests to :class:`~scrapy.core.downloader.handlers._httpx.HttpxDownloadHandler`. It requires setting the new :setting:`HTTPX_HTTP2_ENABLED` setting to ``True``. (:gh:`7575`) - Added support for SOCKS proxies to :class:`~scrapy.core.downloader.handlers._httpx.HttpxDownloadHandler`. (:gh:`747`, :gh:`7575`) - Added :setting:`DOWNLOAD_TLS_MIN_VERSION` and :setting:`DOWNLOAD_TLS_MAX_VERSION` settings as replacements for the ``DOWNLOADER_CLIENT_TLS_METHOD`` setting (which is now deprecated). Compared to the old setting, they support specifying a range of allowed versions and support newer TLS versions. (:gh:`4821`, :gh:`6546`) - Added :setting:`HTTPAUTH_USER`, :setting:`HTTPAUTH_PASS` and :setting:`HTTPAUTH_DOMAIN` settings and :reqmeta:`http_user`, :reqmeta:`http_pass` and :reqmeta:`http_auth_domain` meta keys as more flexible ways to set HTTP authentication data. (:gh:`7590`) - Added a :reqmeta:`verbatim_url` meta key that can be set to ``True`` to skip request URL canonicalization. (:gh:`7473`) - Added ``deny_tags`` and ``deny_attrs`` arguments to :class:`LinkExtractor `. (:gh:`6321`, :gh:`7679`) - :attr:`scrapy.Item.fields` now returns the fields in the definition order instead of the alphabetical one. (:gh:`7015`, :gh:`7694`) - Added a :setting:`RETRY_GIVE_UP_LOG_LEVEL` setting, a :reqmeta:`give_up_log_level` meta key and a ``give_up_log_level`` argument of the :func:`~scrapy.downloadermiddlewares.retry.get_retry_request` function that allow changing the log level of the message logged when the retry limit has been reached. (:gh:`4622`, :gh:`5297`, :gh:`7567`) - It's now possible to set :setting:`DOWNLOADER_CLIENT_TLS_CIPHERS` to ``None`` to use the default ciphers of the underlying TLS implementation. (:gh:`7499`, :gh:`7665`) Improvements ~~~~~~~~~~~~ - :class:`~scrapy.FormRequest` is no longer deprecated, only its ``from_response()`` method is still deprecated. (:gh:`7561`, :gh:`7671`) - Switched the item definition in the default project template from a :class:`scrapy.item.Item` to a dataclass. (:gh:`7493`, :gh:`7513`) - Fixed deprecation warnings with pyOpenSSL 26.3.0. (:gh:`7619`) - Removed the runtime warnings for :attr:`Spider.allowed_domains ` containing URLs or domains with ports instead of just domains and for spider classes having a ``start_url`` attribute instead of :class:`~scrapy.spiders.Spider.start_urls`. Please use :doc:`scrapy-lint ` to find mistakes in your spider code instead. (:gh:`4421`, :gh:`7627`) - :func:`scrapy.utils.test.get_crawler` now disables :setting:`TELNETCONSOLE_ENABLED` by default. (:gh:`7644`) - Other code refactoring and improvements. (:gh:`7409`, :gh:`7593`, :gh:`7594`, :gh:`7611`, :gh:`7649`) Bug fixes ~~~~~~~~~ - :class:`~scrapy.core.downloader.handlers._httpx.HttpxDownloadHandler` no longer ignores proxy credentials for redirected or retried requests. (:gh:`7601`, :gh:`7630`) - :class:`~scrapy.extensions.feedexport.GCSFeedStorage` now closes the temporary file after the upload. (:gh:`7546`) - Fixed ``scrapy shell `` running a full spider crawl when there is a spider for the requested URL. This bug was introduced in Scrapy 2.13.0. (:gh:`7552`, :gh:`7557`) - The :setting:`IMAGES_STORE_S3_ACL` and :setting:`IMAGES_STORE_GCS_ACL` settings are no longer ignored. This bug was introduced in Scrapy 2.12.0. (:gh:`7597`, :gh:`7614`) - :class:`~scrapy.core.downloader.handlers.ftp.FTPDownloadHandler` now closes the connection after making the request. (:gh:`7602`, :gh:`7667`) - Removed the deprecated ``spider`` argument from the pipeline defined in the default project template. (:gh:`7676`) - Fixed ``scrapy genspider --edit`` not working. (:gh:`7260`, :gh:`7683`) - When a :class:`~scrapy.crawler.Crawler` instance is passed to :meth:`AsyncCrawlerRunner.create_crawler() ` or :meth:`CrawlerRunner.create_crawler() `, settings from both classes are now merged, previously only the settings from the :class:`~scrapy.crawler.Crawler` instance were used. (:gh:`1280`, :gh:`7647`) - Fixed several issues with cookie handling in :func:`scrapy.utils.request.request_to_curl`. (:gh:`7603`, :gh:`7675`, :gh:`7684`) - Fixed :class:`scrapy.resolver.CachingThreadedResolver` not disabling the cache when :setting:`DNSCACHE_ENABLED` is set to ``False``. (:gh:`7663`) - Fixed :func:`scrapy.utils.response.open_in_browser` not removing comments when looking for the ```` tag. (:gh:`7506`) - Fixed checking for deprecated methods in custom :setting:`ITEM_PROCESSOR` implementations. (:gh:`7589`) - Fixed :func:`scrapy.utils.url.strip_url` corrupting some URLs with credentials. (:gh:`7604`, :gh:`7605`) - :func:`scrapy.utils.misc.rel_has_nofollow` now ignores the case when looking for "nofollow" strings. (:gh:`7632`) - Fixed an exception in :class:`scrapy.utils.sitemap.Sitemap` when parsing some malformed sitemaps. (:gh:`7686`, :gh:`7687`) Documentation ~~~~~~~~~~~~~ - Mentioned :doc:`scrapy-lint ` in the docs. (:gh:`4421`, :gh:`7627`) - Added the docs about :ref:`security considerations `. (:gh:`7389`, :gh:`7678`) - Improved the :ref:`item pipeline docs `. (:gh:`2350`, :gh:`7676`) - Documented which stats are collected by :class:`~scrapy.extensions.corestats.CoreStats`. (:gh:`7421`) - Switched documentation examples from using :class:`scrapy.item.Item` to using dataclasses. (:gh:`7493`, :gh:`7513`) - Added feature comparison tables to the :ref:`download handler ` docs. (:gh:`7575`) - Improved the docs for :ref:`logging settings `. (:gh:`6909`, :gh:`7668`) - Documented a way to :ref:`improve startup time and memory usage ` by using :setting:`SPIDER_MODULES`. (:gh:`7576`, :gh:`7600`) - Clarified handling of the ``type`` argument of :class:`~scrapy.Selector`. (:gh:`7704`) - Other documentation improvements and fixes. (:gh:`4954`, :gh:`6120`, :gh:`7286`, :gh:`7564`, :gh:`7573`, :gh:`7598`, :gh:`7599`, :gh:`7698`) Quality assurance ~~~~~~~~~~~~~~~~~ - Fixed deprecation warnings with pytest 9.1.0. (:gh:`7621`) - Type hints improvements and fixes. (:gh:`6958`, :gh:`7586`) - CI and test improvements and fixes. (:gh:`5954`, :gh:`7002`, :gh:`7017`, :gh:`7247`, :gh:`7508`, :gh:`7545`, :gh:`7566`, :gh:`7574`, :gh:`7585`, :gh:`7595`, :gh:`7608`, :gh:`7610`, :gh:`7612`, :gh:`7616`, :gh:`7625`, :gh:`7637`, :gh:`7639`, :gh:`7640`, :gh:`7641`, :gh:`7642`, :gh:`7643`, :gh:`7644`, :gh:`7645`, :gh:`7646`, :gh:`7654`, :gh:`7655`, :gh:`7664`, :gh:`7672`, :gh:`7677`, :gh:`7680`, :gh:`7682`, :gh:`7692`) .. _release-2.16.0: Scrapy 2.16.0 (2026-05-19) -------------------------- Highlights: - Official support for Python 3.14 - Support for Twisted 26.4.0+ Modified requirements ~~~~~~~~~~~~~~~~~~~~~ - Increased the minimum versions of the following dependencies: - service_identity_: 18.1.0 → 23.1.0 (:gh:`7347`) - Added support for Twisted 26.4.0+. (:gh:`7347`, :gh:`7505`, :gh:`7520`) - Added support for Python 3.14. (:gh:`6604`, :gh:`7460`) Backward-incompatible changes ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - The following classes and functions, intended for internal use by :class:`~scrapy.core.downloader.handlers.http11.HTTP11DownloadHandler` and :class:`~scrapy.core.downloader.handlers.http2.H2DownloadHandler`, have been made private: - ``scrapy.core.downloader.handlers.http11.ScrapyAgent`` - ``scrapy.core.downloader.handlers.http11.ScrapyProxyAgent`` - ``scrapy.core.downloader.handlers.http11.TunnelingAgent`` - ``scrapy.core.downloader.handlers.http11.TunnelingTCP4ClientEndpoint`` - ``scrapy.core.downloader.handlers.http11.tunnel_request_data()`` - ``scrapy.core.downloader.handlers.http2.ScrapyH2Agent`` (:gh:`7496`, :gh:`7510`) Deprecations ~~~~~~~~~~~~ - ``scrapy.FormRequest`` is deprecated. You can use the :doc:`form2request ` library instead, see :ref:`form`. (:gh:`6438`) - ``scrapy.utils.python.MutableChain`` is deprecated. (:gh:`7504`) Deprecation removals ~~~~~~~~~~~~~~~~~~~~ - The ``start_requests()`` method of :class:`~scrapy.Spider`, deprecated in 2.13.0, is removed and no longer called. Use :meth:`~scrapy.Spider.start` instead, or both to maintain support for lower Scrapy versions. (:gh:`7490`) - Support for ``process_start_requests()`` methods of :ref:`spider middlewares `, deprecated in 2.13.0, is removed. Use :meth:`~scrapy.spidermiddlewares.SpiderMiddleware.process_start` instead, or both to maintain support for lower Scrapy versions. (:gh:`7490`) - Support for synchronous ``process_spider_output()`` methods of spider middlewares, deprecated in Scrapy 2.13.0, is removed. You should upgrade the affected middlewares to have asynchronous ``process_spider_output()`` methods. (:gh:`7504`) - The ``spider`` arguments of the following methods of :class:`~scrapy.core.scraper.Scraper`, deprecated in Scrapy 2.13.0, are removed: - ``close_spider()`` - ``enqueue_scrape()`` - ``handle_spider_error()`` - ``handle_spider_output()`` (:gh:`7487`) - HTTP/1.0 support code, deprecated in Scrapy 2.13.0, is removed. This includes: - ``scrapy.core.downloader.handlers.http10.HTTP10DownloadHandler`` - The ``scrapy.core.downloader.webclient`` module. - The ``DOWNLOADER_HTTPCLIENTFACTORY`` setting. (:gh:`7486`) - The following functions, deprecated in Scrapy 2.13.0, are removed, you should import them from :mod:`w3lib.url` directly instead: - ``scrapy.utils.url.add_or_replace_parameter()`` - ``scrapy.utils.url.add_or_replace_parameters()`` - ``scrapy.utils.url.any_to_uri()`` - ``scrapy.utils.url.canonicalize_url()`` - ``scrapy.utils.url.file_uri_to_path()`` - ``scrapy.utils.url.is_url()`` - ``scrapy.utils.url.parse_data_uri()`` - ``scrapy.utils.url.parse_url()`` - ``scrapy.utils.url.path_to_file_uri()`` - ``scrapy.utils.url.safe_download_url()`` - ``scrapy.utils.url.safe_url_string()`` - ``scrapy.utils.url.url_query_cleaner()`` - ``scrapy.utils.url.url_query_parameter()`` (:gh:`7487`) - The following test-related code, deprecated in Scrapy 2.13.0, is removed: - the ``scrapy.utils.testproc`` module - the ``scrapy.utils.testsite`` module - ``scrapy.utils.test.assert_gcs_environ()`` - ``scrapy.utils.test.get_ftp_content_and_delete()`` - ``scrapy.utils.test.get_gcs_content_and_delete()`` - ``scrapy.utils.test.mock_google_cloud_storage()`` - ``scrapy.utils.test.skip_if_no_boto()`` - ``scrapy.utils.test.TestSpider`` (:gh:`7487`) - ``scrapy.utils.versions.scrapy_components_versions()``, deprecated in Scrapy 2.13.0, is removed, you can use :func:`scrapy.utils.versions.get_versions` instead. (:gh:`7487`) - ``scrapy.downloadermiddlewares.ajaxcrawl.AjaxCrawlMiddleware`` and ``scrapy.utils.url.escape_ajax()``, deprecated in Scrapy 2.13.0, are removed. (:gh:`7487`) - The ``__init__()`` method of priority queue classes (see :setting:`SCHEDULER_PRIORITY_QUEUE`) now needs to support a keyword-only ``start_queue_cls`` parameter, not supporting it was deprecated in Scrapy 2.13.0. (:gh:`7487`) - ``scrapy.spiders.init.InitSpider``, deprecated in Scrapy 2.13.0, is removed. (:gh:`7487`) New features ~~~~~~~~~~~~ - New features and improvements for :class:`~scrapy.core.downloader.handlers._httpx.HttpxDownloadHandler`: - Support for proxies. - Support for the :reqmeta:`download_latency` meta key. - Support for :attr:`Response.certificate `. - Default headers set by the ``httpx`` library are no longer added to requests. (:gh:`7441`, :gh:`7524`) - :class:`~scrapy.core.downloader.handlers.http11.HTTP11DownloadHandler` now skips HTTPS proxy certificate verification when the :setting:`DOWNLOAD_VERIFY_CERTIFICATES` setting is set to ``False``. (:gh:`7496`) Improvements ~~~~~~~~~~~~ - :func:`time.monotonic` is used instead of :func:`time.time` to calculate elapsed time in various places. (:gh:`7377`) - Improved extraction of the file extension from the URL in :class:`~scrapy.pipelines.files.FilesPipeline`. (:gh:`4225`, :gh:`7414`) - Other code refactoring and improvements. (:gh:`7401`) Bug fixes ~~~~~~~~~ - :class:`~scrapy.core.downloader.handlers.http11.HTTP11DownloadHandler` now raises an exception when a request has an ``https://`` destination and an ``https://`` proxy, which is not supported by this handler. Previously it tried to connect to the proxy via HTTP in this case. (:gh:`7496`) - :class:`~scrapy.core.downloader.handlers.http2.H2DownloadHandler` now raises an exception for requests with ``http://`` URLs instead of trying to connect, which is not supported by this handler. (:gh:`7496`) - :class:`~scrapy.core.downloader.handlers.http2.H2DownloadHandler` no longer adds the ``:status`` pseudo-header to :attr:`Response.headers `. (:gh:`7441`) - Fixed :func:`scrapy.utils.response.open_in_browser` removing the ```` tag when adding the ```` tag. (:gh:`7459`) Documentation ~~~~~~~~~~~~~ - Documented that :class:`~scrapy.core.downloader.handlers.http11.HTTP11DownloadHandler` doesn't support HTTPS proxies for HTTPS destinations and that :class:`~scrapy.core.downloader.handlers.http2.H2DownloadHandler` doesn't support proxies at all. (:gh:`7496`) - Added an example of using :class:`logging.handlers.TimedRotatingFileHandler` to rotate Scrapy logs. (:gh:`3628`, :gh:`7501`) - Added a ``CITATION.cff`` file. (:gh:`7502`, :gh:`7519`) - Mentioned ``DOWNLOADER_CLIENT_TLS_METHOD`` in :ref:`bans`. (:gh:`5232`, :gh:`7518`) - Other documentation improvements and fixes. (:gh:`7417`, :gh:`7463`, :gh:`7472`, :gh:`7480`, :gh:`7489`, :gh:`7503`, :gh:`7507`) Quality assurance ~~~~~~~~~~~~~~~~~ - Added tests that connect to https://books.toscrape.com/ to test the behavior with a real website. These tests are marked with the ``requires_internet`` pytest mark and can be skipped with e.g. ``-m 'not requires_internet'`` if you cannot or don't want to run them. (:gh:`7520`) - Type hints improvements and fixes. (:gh:`7492`, :gh:`7532`) - CI and test improvements and fixes. (:gh:`7441`, :gh:`7466`, :gh:`7491`, :gh:`7496`) .. _release-2.15.2: Scrapy 2.15.2 (2026-04-28) -------------------------- Bug fixes ~~~~~~~~~ - Fixed links in https://docs.scrapy.org/llms.txt (:gh:`7467`) .. _release-2.15.1: Scrapy 2.15.1 (2026-04-23) -------------------------- Bug fixes ~~~~~~~~~ - Sharing of the SSL context between multiple connections, introduced in Scrapy 2.15.0, is reverted as it caused problems and wasn't actually needed. (:gh:`7445`, :gh:`7450`) - Fixed :meth:`scrapy.settings.BaseSettings.getwithbase` failing on keys with dots that aren't import names. It now works the way it worked before Scrapy 2.15.0, without trying to match class objects and import path. A separate method, :func:`~scrapy.settings.BaseSettings.get_component_priority_dict_with_base`, was added that does that, and it is now used for :ref:`component priority dictionaries `. (:gh:`7426`, :gh:`7449`) - Documentation rendering improvements. (:gh:`7452`, :gh:`7454`) .. _release-2.15.0: Scrapy 2.15.0 (2026-04-09) -------------------------- Highlights: - Experimental support for running without a Twisted reactor - Experimental ``httpx``-based download handler Backward-incompatible changes ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - The built-in HTTP :ref:`download handlers ` now raise Scrapy-specific exceptions instead of implementation-specific ones, see :ref:`download-handlers-exceptions`. This can affect user code that handles downloader exceptions, such as ``process_exception()`` methods of custom :ref:`downloader middlewares `. (:gh:`7208`) - In order to fix a long-standing bug with handling of asynchronous storages, the following changes were made to media pipeline classes, which can impact some of the user code that subclasses them or calls their methods directly: - overrides of :meth:`scrapy.pipelines.media.MediaPipeline.media_downloaded` and :meth:`~scrapy.pipelines.files.FilesPipeline.file_downloaded` can now return coroutines - :meth:`~scrapy.pipelines.files.FilesPipeline.media_downloaded`, :meth:`~scrapy.pipelines.files.FilesPipeline.file_downloaded` and :meth:`~scrapy.pipelines.images.ImagesPipeline.image_downloaded` now return coroutines (:gh:`2183`, :gh:`6369`, :gh:`7182`) - ``Request`` and ``Response`` objects: ``__slots__`` and setter changes: - :class:`scrapy.http.Request` and :class:`scrapy.http.Response` now define ``__slots__``. Assigning arbitrary attributes to instances (for example, ``response.foo = 1``) will raise ``AttributeError``. Store per-request/response data in the request/response ``meta`` mapping instead of attaching new attributes to the objects. - If you maintain custom ``Request`` or ``Response`` subclasses that relied on dynamic instance attributes, either add ``'__dict__'`` to your subclass ``__slots__`` to allow dynamic attributes, or migrate per-instance state to ``meta`` or explicit documented attributes. - The setters for ``headers``, ``flags`` and ``cookies`` no longer coerce falsy values into ``None``. For example, ``request.headers = {}`` now stores an empty :class:`scrapy.http.headers.Headers` instance (not ``None``), and ``request.flags = []`` remains an empty list instead of being set to ``None``. Update code that relied on ``is None`` checks or the previous coercion behaviour. (:gh:`7036`, :gh:`7367`, :gh:`7374`) Deprecation removals ~~~~~~~~~~~~~~~~~~~~ - The context factory class set as the value of the ``DOWNLOADER_CLIENTCONTEXTFACTORY`` setting is now required to support the ``method`` argument of ``__init__()``, recommended since Scrapy 1.2.0. (:gh:`7353`) Deprecations ~~~~~~~~~~~~ - ``scrapy.mail.MailSender`` is deprecated. Please use :mod:`smtplib`, :mod:`twisted.mail.smtp` or other 3rd party email libraries. (:gh:`7249`, :gh:`7263`) - The ``scrapy.extensions.statsmailer.StatsMailer`` extension is deprecated. You can instead implement your own notifications by handling the :signal:`spider_closed` signal. (:gh:`7249`, :gh:`7263`) - The ``MEMUSAGE_NOTIFY_MAIL`` setting is deprecated. You can instead implement your own notifications by handling the :signal:`memusage_warning_reached` and :signal:`spider_closed` signals. (:gh:`7249`, :gh:`7263`) - The ``DNS_RESOLVER`` setting was renamed to :setting:`TWISTED_DNS_RESOLVER` and the old name is deprecated. (:gh:`7350`, :gh:`7361`) - The ``DOWNLOADER_CLIENTCONTEXTFACTORY`` setting is deprecated. If you were using it to switch to ``scrapy.core.downloader.contextfactory.BrowserLikeContextFactory``, please use the new :setting:`DOWNLOAD_VERIFY_CERTIFICATES` setting instead. If you cannot use the default context factory for some other reason, please subclass the :ref:`download handler ` instead. (:gh:`7352`, :gh:`7379`) - ``scrapy.core.downloader.contextfactory.BrowserLikeContextFactory`` is deprecated. You can set the new :setting:`DOWNLOAD_VERIFY_CERTIFICATES` setting to ``True`` instead. (:gh:`7379`) - The following implementation details of the context factory handling code are deprecated: - ``scrapy.core.downloader.contextfactory.AcceptableProtocolsContextFactory`` - ``scrapy.core.downloader.contextfactory.load_context_factory_from_settings()`` - ``scrapy.core.downloader.contextfactory.ScrapyClientContextFactory`` - ``scrapy.core.downloader.tls.ScrapyClientTLSOptions`` (:gh:`7353`, :gh:`7391`) - Passing :class:`str` instead of :class:`bytes` to :class:`scrapy.utils.sitemap.Sitemap` and :func:`scrapy.utils.sitemap.sitemap_urls_from_robots` is deprecated. (:gh:`7007`) - ``scrapy.utils.misc.walk_modules()`` is deprecated. You can use :func:`scrapy.utils.misc.walk_modules_iter` instead. (:gh:`7388`) - ``scrapy.shell.Shell.inthread`` is deprecated. You can use :attr:`scrapy.shell.Shell.fetch_available` instead to check if :func:`~scrapy.shell.Shell.fetch` can be used. (:gh:`7395`) - ``scrapy.commands.ScrapyCommand.set_crawler()`` is deprecated. (:gh:`7276`) New features ~~~~~~~~~~~~ - Added an *experimental* mode for running Scrapy without installing a Twisted reactor: set :setting:`TWISTED_REACTOR_ENABLED` to ``False`` to enable it. This mode has limitations, refer to :ref:`its documentation ` for details. As long as it's experimental, its behavior and related features and APIs may change in future Scrapy releases in a breaking way. (:gh:`6219`, :gh:`7185`, :gh:`7186`, :gh:`7187`, :gh:`7188`, :gh:`7190`, :gh:`7197`, :gh:`7199`, :gh:`7209`, :gh:`7228`, :gh:`7355`, :gh:`7366`, :gh:`7385`, :gh:`7395`) - Added the :func:`scrapy.utils.reactorless.is_reactorless` function that checks if there is a running asyncio event loop but no Twisted reactor. (:gh:`7185`, :gh:`7199`) - Changed :func:`scrapy.utils.asyncio.is_asyncio_available` to return ``True`` if there is a running asyncio loop, even if no Twisted reactor is installed. (:gh:`7185`, :gh:`7199`) - Added an *experimental* download handler that uses the httpx_ library and doesn't require a Twisted reactor: :class:`~scrapy.core.downloader.handlers._httpx.HttpxDownloadHandler`. As long as it's experimental, its behavior may change in future Scrapy releases in a breaking way. (:gh:`6805`, :gh:`7239`, :gh:`7368`, :gh:`7384`) .. _httpx: https://www.python-httpx.org/ - Added the :setting:`DOWNLOAD_BIND_ADDRESS` setting as a global counterpart to the per-request :reqmeta:`bindaddress` meta key. (:gh:`7266`, :gh:`7283`) - Added the :setting:`DOWNLOAD_VERIFY_CERTIFICATES` setting that can be set to ``True`` to make Scrapy abort HTTPS requests when the server certificate is invalid or doesn't match the domain. (:gh:`7379`) - The built-in HTTP :ref:`download handlers ` now raise Scrapy-specific exceptions instead of implementation-specific ones, to allow unified handling of similar problems caused by different implementations. The default value of the :setting:`RETRY_EXCEPTIONS` setting was updated replacing Twisted-specific exceptions with these new ones. The exceptions: - :exc:`~scrapy.exceptions.CannotResolveHostError` - :exc:`~scrapy.exceptions.DownloadCancelledError` - :exc:`~scrapy.exceptions.DownloadConnectionRefusedError` - :exc:`~scrapy.exceptions.DownloadFailedError` - :exc:`~scrapy.exceptions.DownloadTimeoutError` - :exc:`~scrapy.exceptions.ResponseDataLossError` - :exc:`~scrapy.exceptions.UnsupportedURLSchemeError` (:gh:`7208`) - Added the :signal:`memusage_warning_reached` signal emitted by the :class:`~scrapy.extensions.memusage.MemoryUsage` extension when the memory usage reaches :setting:`MEMUSAGE_WARNING_MB`. (:gh:`7249`, :gh:`7263`) - Added :meth:`Headers.to_tuple_list() ` that returns headers as a list of ``(key, value)`` tuples. (:gh:`7239`) - :class:`~scrapy.core.downloader.handlers.s3.S3DownloadHandler` now uses the download handler configured for the ``"https"`` scheme to make requests instead of always using :class:`~scrapy.core.downloader.handlers.http11.HTTP11DownloadHandler`. (:gh:`7369`, :gh:`7370`) - Added :func:`scrapy.utils.misc.walk_modules_iter` as a replacement for ``scrapy.utils.misc.walk_modules()`` that returns an iterable instead of a list. (:gh:`7388`) Improvements ~~~~~~~~~~~~ - :func:`asyncio.to_thread` is now used instead of :func:`twisted.internet.threads.deferToThread` in the built-in feed storages, media pipeline storages and the :func:`scrapy.utils.decorators.inthread` decorator when available. (:gh:`7183`, :gh:`7184`, :gh:`7349`) - Improved memory footprint of :class:`~scrapy.Request` and :class:`~scrapy.http.Response` objects by adding ``__slots__`` and omitting empty lists and dicts in some internal attributes. (:gh:`7036`, :gh:`7367`, :gh:`7374`) - :class:`~scrapy.core.downloader.contextfactory._ScrapyClientContextFactory` no longer mutates the SSL context, to avoid the behavior that was deprecated in pyOpenSSL 25.1.0. (:gh:`6859`, :gh:`7353`) - Improved memory usage of :class:`~scrapy.spiders.sitemap.SitemapSpider` and :class:`scrapy.utils.sitemap.Sitemap`. (:gh:`3529`, :gh:`7007`) - Improved the scheduling behavior of :class:`~scrapy.pqueues.DownloaderAwarePriorityQueue` when crawling multiple domains. (:gh:`7293`, :gh:`7351`) - :class:`~scrapy.core.downloader.handlers.http11.HTTP11DownloadHandler` and :class:`~scrapy.core.downloader.handlers.http2.H2DownloadHandler` now handle TLS verbose logging (see :setting:`DOWNLOADER_CLIENT_TLS_VERBOSE_LOGGING`) directly instead of relying on :class:`~scrapy.core.downloader.contextfactory._ScrapyClientContextFactory`. (:gh:`7387`) - The server certificate verification code now correctly handles certificates with IP addresses in ``subjectAltName``. (:gh:`7353`) - Improved reliability of :func:`scrapy.utils.trackref.get_oldest`. (:gh:`1758`, :gh:`7375`) - Other code refactoring and improvements. (:gh:`7210`, :gh:`7238`, :gh:`7376`, :gh:`7386`, :gh:`7395`, :gh:`7405`, :gh:`7410`) Bug fixes ~~~~~~~~~ - :ref:`Media pipelines ` should now wait for uploads to asynchronous storages (e.g. :class:`~scrapy.pipelines.files.S3FilesStore`) to complete. (:gh:`2183`, :gh:`6369`, :gh:`7182`) - Fixed merging ``*_BASE`` settings (e.g. merging :setting:`DOWNLOADER_MIDDLEWARES` with :setting:`DOWNLOADER_MIDDLEWARES_BASE`) when a component is referred to by a class object in one setting and by a string import path in the other one. (:gh:`6912`, :gh:`6993`) - ``scrapy runspider`` and ``scrapy crawl`` now set the exit code to 1 if an exception happened early (this was broken since Scrapy 2.13.0). (:gh:`6820`, :gh:`7255`) - Fixed repeated warnings about data loss (see :setting:`DOWNLOAD_FAIL_ON_DATALOSS`) not being suppressed in :class:`~scrapy.core.downloader.handlers.http11.HTTP11DownloadHandler`. (:gh:`7222`) - Improved FTP connection management in :class:`scrapy.pipelines.files.FTPFilesStore`. (:gh:`7256`) - Fixed the ``spider`` variable in the :ref:`shell `, which wasn't available since Scrapy 2.13.0. (:gh:`7395`) Documentation ~~~~~~~~~~~~~ - The ``llms.txt`` and ``llms-full.txt`` files and Markdown versions of pages are now generated when the HTML documentation is built. (:gh:`7380`) - Added a "Copy as Markdown" button to the HTML documentation. (:gh:`7380`) - Added :ref:`docs for using Pydantic models as items `. (:gh:`6955`, :gh:`6966`) - Documented :ref:`job directory contents `. (:gh:`4842`, :gh:`5260`) - Improved docs for :attr:`~scrapy.Request.dont_filter`. (:gh:`6398`, :gh:`7245`) - Clarified that settings related to :setting:`TWISTED_DNS_RESOLVER` are only taken into account if the selected resolver supports them. (:gh:`7385`) - Other documentation improvements and fixes. (:gh:`7248`, :gh:`7274`, :gh:`7406`, :gh:`7408`) Quality assurance ~~~~~~~~~~~~~~~~~ - Added the ``no-reactor`` test environment that doesn't install a Twisted reactor and uses ``pytest-asyncio`` instead of ``pytest-twisted`` to run asynchronous test functions. (:gh:`6952`, :gh:`7189`, :gh:`7233`, :gh:`7234`, :gh:`7254`, :gh:`7259`) - Fixed running tests with ``pytest-xdist``. (:gh:`7216`, :gh:`7257`) - Type hints improvements and fixes. (:gh:`7300`, :gh:`7331`) - CI and test improvements and fixes. (:gh:`7060`, :gh:`7223`, :gh:`7232`, :gh:`7241`, :gh:`7250`, :gh:`7256`, :gh:`7276`, :gh:`7277`, :gh:`7279`, :gh:`7329`, :gh:`7363`, :gh:`7381`, :gh:`7402`) .. _release-2.14.2: Scrapy 2.14.2 (2026-03-12) -------------------------- Security bug fixes ~~~~~~~~~~~~~~~~~~ - Values from the ``Referrer-Policy`` header of HTTP responses are no longer executed as Python callables. See the `cwxj-rr6w-m6w7`_ security advisory for details. .. _cwxj-rr6w-m6w7: https://github.com/scrapy/scrapy/security/advisories/GHSA-cwxj-rr6w-m6w7 - In line with the `standard `__, 301 redirects of ``POST`` requests are converted into ``GET`` requests. Converting to a ``GET`` request implies not only a method change, but also omitting the body and ``Content-*`` headers in the redirect request. On cross-origin redirects (for example, cross-domain redirects), this is effectively a security bug fix for scenarios where the body contains secrets. Deprecations ~~~~~~~~~~~~ - Passing a response URL string as the first positional argument to :meth:`scrapy.spidermiddlewares.referer.RefererMiddleware.policy` is deprecated. Pass a :class:`~scrapy.http.Response` instead. The parameter has also been renamed to ``response`` to reflect this change. The old parameter name (``resp_or_url``) is deprecated. New features ~~~~~~~~~~~~ - Added a new setting, :setting:`REFERRER_POLICIES`, to allow customizing supported referrer policies. Bug fixes ~~~~~~~~~ - Made additional redirect scenarios convert to ``GET`` in line with the `standard `__: - Only ``POST`` 302 redirects are converted into ``GET`` requests; other methods are preserved. - ``HEAD`` 303 redirects are not converted into ``GET`` requests. - ``GET`` 303 redirects do not have their body or standard ``Content-*`` headers removed. - Redirects where the original request body is dropped now also have their ``Content-Encoding``, ``Content-Language`` and ``Content-Location`` headers removed, in addition to the ``Content-Type`` and ``Content-Length`` headers that were already being removed. - Redirects now preserve the source URL fragment if the redirect URL does not include one. This is useful when using browser-based download handlers, such as `scrapy-playwright`_ or `scrapy-zyte-api`_, while letting Scrapy handle redirects. .. _scrapy-playwright: https://github.com/scrapy-plugins/scrapy-playwright .. _scrapy-zyte-api: https://scrapy-zyte-api.readthedocs.io/en/latest/ - The ``Referer`` header is now removed on redirect if :class:`~scrapy.spidermiddlewares.referer.RefererMiddleware` is disabled. - The handling of the ``Referer`` header on redirects now takes into account the ``Referer-Policy`` header of the response that triggers the redirect. .. _release-2.14.1: Scrapy 2.14.1 (2026-01-12) -------------------------- Deprecations ~~~~~~~~~~~~ - ``scrapy.utils.defer.maybeDeferred_coro()`` is deprecated. (:gh:`7212`) Bug fixes ~~~~~~~~~ - Fixed custom stats collectors that require a ``spider`` argument in their ``open_spider()`` and ``close_spider()`` methods not receiving the argument when called by the engine. Note, however, that the ``spider`` argument is now deprecated and will stop being passed in a future version of Scrapy. (:gh:`7213`) Quality assurance ~~~~~~~~~~~~~~~~~ - Replaced deprecated ``codecov/test-results-action@v1`` GitHub Action with ``codecov/codecov-action@v5``. (:gh:`7180`, :gh:`7215`) .. _release-2.14.0: Scrapy 2.14.0 (2026-01-05) -------------------------- Highlights: - More coroutine-based replacements for Deferred-based APIs - The default priority queue is now ``DownloaderAwarePriorityQueue`` - Dropped support for Python 3.9 and PyPy 3.10 - Improved and documented the API for custom download handlers Modified requirements ~~~~~~~~~~~~~~~~~~~~~ - Dropped support for Python 3.9. (:gh:`7121`) - Dropped support for PyPy 3.10. (:gh:`7050`) - Increased the minimum versions of the following dependencies: - lxml_: 4.6.0 → 4.6.4 - Pillow_ (optional dependency): 8.0.0 → 8.3.2 - botocore_ (optional dependency): 1.4.87 → 1.13.45 - Restored support for ``brotlicffi`` dropped in Scrapy 2.13.4. Its minimum supported version is now ``1.2.0.0``. (:gh:`7160`) Backward-incompatible changes ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - If you set the :setting:`TWISTED_REACTOR` setting to a :ref:`non-asyncio value ` at the :ref:`spider level `, you may now need to set the :setting:`FORCE_CRAWLER_PROCESS` setting to ``True`` when running Scrapy via :ref:`its command-line tool ` to avoid a reactor mismatch exception. (:gh:`6845`) - The ``log_count/*`` stats no longer count some of the early messages that they counted before. While the earliest log messages, emitted before the counter is initialized, were never counted, the counter initialization now happens later than in previous Scrapy versions. You may need to adjust expected values if you retrieve and compare values of these stats in your code. (:gh:`7046`) - The classes listed below are now :term:`abstract base classes `. They cannot be instantiated directly and their subclasses need to override the abstract methods listed below to be able to be instantiated. If you previously instantiated these classes directly, you will now need to subclass them and provide trivial (e.g. empty) implementations for the abstract methods. - :class:`scrapy.commands.ScrapyCommand` - :meth:`~scrapy.commands.ScrapyCommand.run` - :meth:`~scrapy.commands.ScrapyCommand.short_desc` - :class:`scrapy.exporters.BaseItemExporter` - :meth:`~scrapy.exporters.BaseItemExporter.export_item` - :class:`scrapy.extensions.feedexport.BlockingFeedStorage` - :meth:`~scrapy.extensions.feedexport.BlockingFeedStorage._store_in_thread` - :class:`scrapy.middleware.MiddlewareManager` - :meth:`~scrapy.middleware.MiddlewareManager._get_mwlist_from_settings` - :class:`scrapy.spidermiddlewares.referer.ReferrerPolicy` - :meth:`~scrapy.spidermiddlewares.referer.ReferrerPolicy.referrer` (:gh:`6930`) - Scrapy no longer passes a ``spider`` argument to any methods of the :setting:`stats collector `. It wasn't passed in many of the calls even in older Scrapy versions, so we don't expect existing custom stats collector implementations to require a ``spider`` argument. If your implementation needs a :class:`~scrapy.Spider` instance, you can get it from the :class:`~scrapy.crawler.Crawler` instance passed to the constructor. (:gh:`7011`) - :class:`scrapy.middleware.MiddlewareManager` no longer includes code for handling ``open_spider()`` and ``close_spider()`` component methods. As this code was only used for pipelines it was moved into :class:`scrapy.pipelines.ItemPipelineManager`. This change should only affect custom subclasses of :class:`~scrapy.middleware.MiddlewareManager`. The following code was moved: - ``scrapy.middleware.MiddlewareManager.open_spider()`` - ``scrapy.middleware.MiddlewareManager.close_spider()`` - Code in ``scrapy.middleware.MiddlewareManager._add_middleware()`` that processes ``open_spider()`` and ``close_spider()`` component methods. (:gh:`7006`) - :meth:`scrapy.downloadermiddlewares.robotstxt.RobotsTxtMiddleware.process_request` now returns a coroutine, previously it returned a :class:`~twisted.internet.defer.Deferred` object or ``None``. The ``robot_parser()`` method was also changed to return a coroutine. This change only impacts code that subclasses :class:`~scrapy.downloadermiddlewares.robotstxt.RobotsTxtMiddleware` or calls its methods directly. (:gh:`6802`) - The built-in :ref:`download handlers ` have been refactored, changing the signatures of their methods. This change should only affect user code that subclasses any of these handlers or calls their methods directly. (:gh:`6778`, :gh:`7164`) - :meth:`scrapy.pipelines.media.MediaPipeline.process_item` now returns a coroutine, previously it returned a :class:`~twisted.internet.defer.Deferred` object. This change only impacts code that calls this method directly. (:gh:`7177`) Deprecation removals ~~~~~~~~~~~~~~~~~~~~ - The ``from_settings()`` method of the following components, deprecated in Scrapy 2.12.0, is removed. You should use ``from_crawler()`` instead. - :class:`scrapy.dupefilters.RFPDupeFilter` - :class:`scrapy.mail.MailSender` - :class:`scrapy.middleware.MiddlewareManager` - :class:`scrapy.core.downloader.contextfactory.ScrapyClientContextFactory` - :class:`scrapy.pipelines.files.FilesPipeline` - :class:`scrapy.pipelines.images.ImagesPipeline` (:gh:`7126`) - Scrapy no longer calls ``from_settings()`` methods of 3rd-party :ref:`components `, deprecated in Scrapy 2.12.0. You should define a ``from_crawler()`` method instead. (:gh:`7126`) - The initialization flow of :class:`scrapy.pipelines.media.MediaPipeline` and its subclasses was simplified, it now mandates ``from_crawler()`` methods and ``crawler`` arguments of ``__init__()`` methods. Not using these was deprecated in Scrapy 2.12.0. (:gh:`7126`) - The ``REQUEST_FINGERPRINTER_IMPLEMENTATION`` setting, deprecated in Scrapy 2.12.0, is removed. (:gh:`7126`) - The ``scrapy.utils.misc.create_instance()`` function, deprecated in Scrapy 2.12.0, is removed. Use :func:`scrapy.utils.misc.build_from_crawler` instead. (:gh:`7126`) - The ``scrapy.core.downloader.Downloader._get_slot_key()`` function, deprecated in Scrapy 2.12.0, is removed. Use :meth:`scrapy.core.downloader.Downloader.get_slot_key` instead. (:gh:`7126`) - The ``scrapy.twisted_version`` attribute, deprecated in Scrapy 2.12.0, is removed. You should instead use the :attr:`twisted.version` attribute directly. (:gh:`7126`) - The following utility functions, deprecated in Scrapy 2.12.0, are removed: - ``scrapy.utils.defer.process_chain_both()`` - ``scrapy.utils.python.equal_attributes()`` - ``scrapy.utils.python.flatten()`` - ``scrapy.utils.python.iflatten()`` - ``scrapy.utils.request.request_authenticate()`` - ``scrapy.utils.test.assert_samelines()`` (:gh:`7126`) - ``scrapy.utils.serialize.ScrapyJSONDecoder``, deprecated in Scrapy 2.12.0, is removed. (:gh:`7126`) - The ``scrapy.extensions.feedexport.build_storage()`` function, deprecated in Scrapy 2.12.0, is removed, you can instead call the builder callable directly. (:gh:`7126`) - ``scrapy.spidermiddlewares.offsite.OffsiteMiddleware``, deprecated in Scrapy 2.11.2, is removed. :class:`scrapy.downloadermiddlewares.offsite.OffsiteMiddleware` should be used instead. (:gh:`6926`) Deprecations ~~~~~~~~~~~~ - The following methods that return a :class:`~twisted.internet.defer.Deferred` are deprecated in favor of their coroutine-based replacements: - :class:`scrapy.core.downloader.handlers.DownloadHandlers` - ``download_request()`` (use :meth:`~scrapy.core.downloader.handlers.DownloadHandlers.download_request_async`) - :class:`scrapy.core.downloader.middleware.DownloaderMiddlewareManager` - ``download()`` (use :meth:`~scrapy.core.downloader.middleware.DownloaderMiddlewareManager.download_async`) - :class:`scrapy.core.engine.ExecutionEngine` - ``start()`` (use :meth:`~scrapy.core.engine.ExecutionEngine.start_async`) - ``stop()`` (use :meth:`~scrapy.core.engine.ExecutionEngine.stop_async`) - ``close()`` (use :meth:`~scrapy.core.engine.ExecutionEngine.close_async`) - ``open_spider()`` (use :meth:`~scrapy.core.engine.ExecutionEngine.open_spider_async`) - ``close_spider()`` (use :meth:`~scrapy.core.engine.ExecutionEngine.close_spider_async`) - ``download()`` (use :meth:`~scrapy.core.engine.ExecutionEngine.download_async`) - :class:`scrapy.core.scraper.Scraper` - ``open_spider()`` (use :meth:`~scrapy.core.scraper.Scraper.open_spider_async`) - ``call_spider()`` (use :meth:`~scrapy.core.scraper.Scraper.call_spider_async`) - ``close_spider()`` (use :meth:`~scrapy.core.scraper.Scraper.close_spider_async`) - ``handle_spider_output()`` (use :meth:`~scrapy.core.scraper.Scraper.handle_spider_output_async`) - ``start_itemproc()`` (use :meth:`~scrapy.core.scraper.Scraper.start_itemproc_async`) - :class:`scrapy.core.spidermw.SpiderMiddlewareManager` - ``scrape_response()`` (use :meth:`~scrapy.core.spidermw.SpiderMiddlewareManager.scrape_response_async`) - :class:`scrapy.crawler.Crawler` - ``stop()`` (use :meth:`~scrapy.crawler.Crawler.stop_async`) - :class:`scrapy.pipelines.ItemPipelineManager` - ``process_item()`` (use :meth:`~scrapy.pipelines.ItemPipelineManager.process_item_async`) - ``open_spider()`` (use :meth:`~scrapy.pipelines.ItemPipelineManager.open_spider_async`) - ``close_spider()`` (use :meth:`~scrapy.pipelines.ItemPipelineManager.close_spider_async`) - :class:`scrapy.signalmanager.SignalManager` - ``send_catch_log_deferred()`` (use :meth:`~scrapy.signalmanager.SignalManager.send_catch_log_async`) - ``scrapy.utils.signal.send_catch_log_deferred()`` (use :func:`scrapy.utils.signal.send_catch_log_async`) (:gh:`6791`, :gh:`6842`, :gh:`6979`, :gh:`6997`, :gh:`6999`, :gh:`7005`, :gh:`7043`, :gh:`7069`, :gh:`7161`, :gh:`7164`) - The following spider attributes are deprecated in favor of settings: - ``download_maxsize`` (use :setting:`DOWNLOAD_MAXSIZE`) - ``download_timeout`` (use :setting:`DOWNLOAD_TIMEOUT`) - ``download_warnsize`` (use :setting:`DOWNLOAD_WARNSIZE`) - ``max_concurrent_requests`` (use :setting:`CONCURRENT_REQUESTS_PER_DOMAIN`) - ``user_agent`` (use :setting:`USER_AGENT`) (:gh:`6988`, :gh:`6994`, :gh:`7038`, :gh:`7039`, :gh:`7117`, :gh:`7176`) - Returning a :class:`~twisted.internet.defer.Deferred` from the following user-defined functions is deprecated in favor of defining them as coroutine functions: - spider callbacks and errbacks (which was never officially supported and may work incorrectly) - the ``process_request()``, ``process_response()`` and ``process_exception()`` methods of custom downloader middlewares - the ``process_item()``, ``open_spider()`` and ``close_spider()`` methods of custom pipelines - signal handlers - the ``download_request()`` and ``close()`` methods of custom download handlers (:gh:`6718`, :gh:`6778`, :gh:`7069`, :gh:`7147`, :gh:`7148`, :gh:`7149`, :gh:`7150`, :gh:`7151`, :gh:`7161`, :gh:`7164`, :gh:`7179`) - Passing a ``spider`` argument to the following methods is deprecated: - :meth:`scrapy.core.spidermw.SpiderMiddlewareManager.process_start` - :meth:`scrapy.core.downloader.Downloader.fetch` - :meth:`scrapy.core.downloader.Downloader._get_slot` - :meth:`scrapy.core.downloader.handlers.DownloadHandlers.download_request` - all public methods of :class:`scrapy.statscollectors.StatsCollector` - :meth:`scrapy.spidermiddlewares.base.BaseSpiderMiddleware.process_spider_output` - :meth:`scrapy.spidermiddlewares.base.BaseSpiderMiddleware.process_spider_output_async` - all ``process_*()`` methods of built-in downloader middlewares - all ``process_*()`` methods of built-in spider middlewares - :meth:`scrapy.pipelines.media.MediaPipeline.open_spider` - :meth:`scrapy.pipelines.media.MediaPipeline.process_item` (:gh:`6750`, :gh:`6927`, :gh:`6984`, :gh:`7006`, :gh:`7011`, :gh:`7033`, :gh:`7037`, :gh:`7045`, :gh:`7178`) - Instantiating subclasses of :class:`scrapy.middleware.MiddlewareManager` without a :class:`~scrapy.crawler.Crawler` instance is deprecated. (:gh:`6984`) - For the following user-defined functions and methods requiring a ``spider`` argument is deprecated, if you need a :class:`~scrapy.Spider` instance inside them you should get it from the :class:`~scrapy.crawler.Crawler` instance (you may need to refactor your code to save that instance in e.g. the ``from_crawler()`` method): - the ``process_request()``, ``process_response()`` and ``process_exception()`` methods of custom downloader middlewares - the ``process_spider_input()``, ``process_spider_output()``, ``process_spider_output_async()`` and ``process_spider_exception()`` methods of custom spider middlewares - the ``process_item()`` method of custom pipelines - the ``fetch()`` method of a custom :setting:`DOWNLOADER` (:gh:`6927`, :gh:`6984`, :gh:`7006`, :gh:`7037`) - The following things in custom download handlers are deprecated: - not having a ``lazy`` attribute (you should define it as ``True`` if you want to keep the current behavior) - returning a :class:`~twisted.internet.defer.Deferred` from the ``download_request()`` method (you should refactor it to return a coroutine; you also need to remove the ``spider`` argument when doing this) - not having a ``close()`` method, having a synchronous one or one that returns a :class:`~twisted.internet.defer.Deferred` (you should refactor it to return a coroutine or add an empty one if you don't have it) (:gh:`6778`, :gh:`7164`) - Custom implementations of :setting:`ITEM_PROCESSOR` should now define ``process_item_async()``, ``open_spider_async()`` and ``close_spider_async()`` methods instead of, or in addition to, ``process_item()``, ``open_spider()`` and ``close_spider()``. (:gh:`7005`, :gh:`7043`) - The ``CONCURRENT_REQUESTS_PER_IP`` setting is deprecated, use :setting:`CONCURRENT_REQUESTS_PER_DOMAIN` instead. (:gh:`6917`, :gh:`6921`) - The ``scrapy.core.downloader.handlers.http`` module is deprecated. You should import :class:`scrapy.core.downloader.handlers.http11.HTTP11DownloadHandler` directly instead of importing the ``scrapy.core.downloader.handlers.http.HTTPDownloadHandler`` alias. (:gh:`7079`) - The ``scrapy.utils.decorators.defers()`` decorator is deprecated, you can use :func:`twisted.internet.defer.maybeDeferred` directly or reimplement this decorator in your code. (:gh:`7164`) - ``scrapy.spiders.CrawlSpider._parse_response()`` is deprecated, use :meth:`scrapy.spiders.CrawlSpider.parse_with_rules` instead. (:gh:`4463`, :gh:`6804`) - The functions that add a delay to a Deferred are deprecated, their underlying Twisted functions can be used instead, either directly if a delay isn't needed, or with some explicit way to add a delay if it's needed: - ``scrapy.utils.defer.mustbe_deferred()`` (you can use :func:`twisted.internet.defer.maybeDeferred`) - ``scrapy.utils.defer.defer_succeed()`` (you can use :func:`twisted.internet.defer.succeed`) - ``scrapy.utils.defer.defer_fail()`` (you can use :func:`twisted.internet.defer.fail`) - ``scrapy.utils.defer.defer_result()`` (you can use :func:`twisted.internet.defer.succeed` and :func:`twisted.internet.defer.fail`) (:gh:`6937`) New features ~~~~~~~~~~~~ - Added :class:`scrapy.crawler.AsyncCrawlerProcess` and :class:`scrapy.crawler.AsyncCrawlerRunner` as counterparts to :class:`~scrapy.crawler.CrawlerProcess` and :class:`~scrapy.crawler.CrawlerRunner` that offer coroutine-based APIs. (:gh:`6789`, :gh:`6790`, :gh:`6796`, :gh:`6817`, :gh:`6845`, :gh:`7034`) - Added coroutine counterparts to some of the Deferred-based APIs: - :class:`scrapy.core.downloader.handlers.DownloadHandlers` - :meth:`~scrapy.core.downloader.handlers.DownloadHandlers.download_request_async` (to ``download_request()``) - :class:`scrapy.core.downloader.middleware.DownloaderMiddlewareManager` - :meth:`~scrapy.core.downloader.middleware.DownloaderMiddlewareManager.download_async` (to ``download()``) - :class:`scrapy.core.engine.ExecutionEngine` - :meth:`~scrapy.core.engine.ExecutionEngine.start_async` (to ``start()``) - :meth:`~scrapy.core.engine.ExecutionEngine.stop_async` (to ``stop()``) - :meth:`~scrapy.core.engine.ExecutionEngine.close_async` (to ``close()``) - :meth:`~scrapy.core.engine.ExecutionEngine.open_spider_async` (to ``open_spider()``) - :meth:`~scrapy.core.engine.ExecutionEngine.close_spider_async` (to ``close_spider()``) - :meth:`~scrapy.core.engine.ExecutionEngine.download_async` (to ``download()``) - :class:`scrapy.core.scraper.Scraper` - :meth:`~scrapy.core.scraper.Scraper.open_spider_async` (to ``open_spider()``) - :meth:`~scrapy.core.scraper.Scraper.close_spider_async` (to ``close_spider()``) - :meth:`~scrapy.core.scraper.Scraper.start_itemproc_async` (to ``start_itemproc()``) - :class:`scrapy.crawler.Crawler` - :meth:`~scrapy.crawler.Crawler.crawl_async` (to ``crawl()``) - :meth:`~scrapy.crawler.Crawler.stop_async` (to ``stop()``) - :class:`scrapy.pipelines.ItemPipelineManager` - :meth:`~scrapy.pipelines.ItemPipelineManager.process_item_async` (to ``process_item()``) - :meth:`~scrapy.pipelines.ItemPipelineManager.open_spider_async` (to ``open_spider()``) - :meth:`~scrapy.pipelines.ItemPipelineManager.close_spider_async` (to ``close_spider()``) - :class:`scrapy.signalmanager.SignalManager` - :meth:`~scrapy.signalmanager.SignalManager.send_catch_log_async` (to ``send_catch_log_deferred()``) (:gh:`6781`, :gh:`6791`, :gh:`6792`, :gh:`6795`, :gh:`6801`, :gh:`6817`, :gh:`6842`, :gh:`6997`, :gh:`7005`, :gh:`7043`, :gh:`7069`,:gh:`7164`, :gh:`7202`) - The default value of the :setting:`SCHEDULER_PRIORITY_QUEUE` setting is now ``'scrapy.pqueues.DownloaderAwarePriorityQueue'``. (:gh:`6924`, :gh:`6940`) - Added :class:`scrapy.extensions.logcount.LogCount`, an enabled-by-default extension that is responsible for the ``log_count/*`` stats. Previously, this code was in :class:`scrapy.crawler.Crawler` and couldn't be disabled. (:gh:`7046`) - Added :meth:`scrapy.spiders.CrawlSpider.parse_with_rules` as a public replacement for ``_parse_response()``. (:gh:`4463`, :gh:`6804`) - Added :func:`scrapy.utils.asyncio.is_asyncio_available` as an alternative to :func:`scrapy.utils.reactor.is_asyncio_reactor_installed` with a future-proof name and semantics. (:gh:`6827`) - The API for :ref:`download handlers `, previously undocumented, has been modernized and documented. An optional base class, :class:`scrapy.core.downloader.handlers.base.BaseDownloadHandler`, has been added to simplify writing custom download handlers that conform to the current API. (:gh:`4944`, :gh:`6778`, :gh:`7164`) - Added :func:`scrapy.utils.defer.ensure_awaitable`, which can be helpful to call user-defined functions that can return coroutines, Deferreds or values directly. (:gh:`7005`) - The ``requests.seen`` file, written by :class:`~scrapy.dupefilters.RFPDupeFilter` when :ref:`job persistence ` is enabled, now uses line buffering to reduce data loss in spider crashes. (:gh:`6019`, :gh:`7094`) - Images downloaded by :class:`~scrapy.pipelines.images.ImagesPipeline` are now automatically transposed based on EXIF data. (:gh:`6525`, :gh:`6975`) Improvements ~~~~~~~~~~~~ - Refactored internal functions to use coroutines instead of Deferreds. (:gh:`6795`, :gh:`6852`, :gh:`6855`, :gh:`6858`, :gh:`7159`) - Commands that don't need a :class:`~scrapy.crawler.CrawlerProcess` instance no longer create it. (:gh:`6824`) - Improved :command:`shell` help formatting when using IPython 9+. (:gh:`6915`, :gh:`6980`) Bug fixes ~~~~~~~~~ - Setting :setting:`FILES_STORE` or :setting:`IMAGES_STORE` to ``None`` now correctly disables the respective pipeline. (:gh:`6964`, :gh:`6969`) - :class:`~scrapy.downloadermiddlewares.redirect.MetaRefreshMiddleware` now uses the URL set in the ```` tag as the base URL when redirecting to a relative URL. (:gh:`7042`, :gh:`7047`) - Passing ``None`` as a value of the :reqmeta:`download_slot` request meta key is now handled in the same way as not setting this meta key at all. (:gh:`7172`) - Fixed parsing of the first line of ``robots.txt`` files that have a BOM. (:gh:`6195`, :gh:`7095`) Documentation ~~~~~~~~~~~~~ - Added :ref:`documentation ` about download handlers, their API and built-in handlers. (:gh:`4944`, :gh:`7164`) - Added a section about the `scrapy-spider-metadata`_ library to the :ref:`spider argument docs `. (:gh:`6676`, :gh:`6957`, :gh:`7116`) .. _scrapy-spider-metadata: https://scrapy-spider-metadata.readthedocs.io/en/latest/ - Improved :ref:`the docs ` about coroutine-based and Deferred-based APIs. (:gh:`6800`, :gh:`7146`) - Other documentation improvements and fixes. (:gh:`7058`, :gh:`7076`, :gh:`7109`, :gh:`7195`, :gh:`7198`) Quality assurance ~~~~~~~~~~~~~~~~~ - Switched from ``twisted.trial`` to ``pytest-twisted`` and replaced remaining ``unittest`` and ``twisted.trial`` features with ``pytest`` ones. (:gh:`6658`, :gh:`6873`, :gh:`6884`, :gh:`6938`) - Enabled fancy ``pytest`` asserts. (:gh:`6888`) - Added `Sphinx Lint`_ to the ``pre-commit`` configuration. (:gh:`6920`) .. _Sphinx Lint: https://github.com/sphinx-contrib/sphinx-lint - CI and test improvements and fixes. (:gh:`6649`, :gh:`6769`, :gh:`6821`, :gh:`6835`, :gh:`6836`, :gh:`6846`, :gh:`6883`, :gh:`6885`, :gh:`6889`, :gh:`6905`, :gh:`6928`, :gh:`6933`, :gh:`6941`, :gh:`6942`, :gh:`6945`, :gh:`6947`, :gh:`6960`, :gh:`6968`, :gh:`6972`, :gh:`6974`, :gh:`6996`, :gh:`7003`, :gh:`7012`, :gh:`7013`, :gh:`7050`, :gh:`7059`, :gh:`7070`, :gh:`7073`, :gh:`7118`, :gh:`7127`, :gh:`7141`, :gh:`7143`, :gh:`7145`, :gh:`7173`) - Code cleanups. (:gh:`6803`, :gh:`6838`, :gh:`6849`, :gh:`6875`, :gh:`6876`, :gh:`6892`, :gh:`6930`, :gh:`6949`, :gh:`6970`, :gh:`6977`, :gh:`6986`, :gh:`7008`, :gh:`7177`) .. _release-2.13.4: Scrapy 2.13.4 (2025-11-17) -------------------------- Security bug fixes ~~~~~~~~~~~~~~~~~~ - Improved protection against decompression bombs in :class:`~scrapy.downloadermiddlewares.httpcompression.HttpCompressionMiddleware` for responses compressed using the ``br`` and ``deflate`` methods: if a single compressed chunk would be larger than the response size limit (see :setting:`DOWNLOAD_MAXSIZE`) when decompressed, decompression is no longer carried out. This is especially important for the ``br`` (Brotli) method that can provide a very high compression ratio. Please, see the `CVE-2025-6176`_ and `GHSA-2qfp-q593-8484`_ security advisories for more information. (:gh:`7134`) .. _CVE-2025-6176: https://nvd.nist.gov/vuln/detail/CVE-2025-6176 .. _GHSA-2qfp-q593-8484: https://github.com/advisories/GHSA-2qfp-q593-8484 Modified requirements ~~~~~~~~~~~~~~~~~~~~~ - The minimum supported version of the optional ``brotli`` package is now ``1.2.0``. (:gh:`7134`) - The ``brotlicffi`` and ``brotlipy`` packages can no longer be used to decompress Brotli-compressed responses. Please install the ``brotli`` package instead. (:gh:`7134`) Other changes ~~~~~~~~~~~~~ - Restricted the maximum supported Twisted version to ``25.5.0``, as Scrapy currently uses some private APIs changed in later Twisted versions. (:gh:`7142`) - Stopped setting the ``COVERAGE_CORE`` environment variable in tests, it didn't have an effect but caused the ``coverage`` module to produce a warning or an error. (:gh:`7137`) - Removed the documentation build dependency on the deprecated ``sphinx-hoverxref`` module. (:gh:`6786`, :gh:`6922`) .. _release-2.13.3: Scrapy 2.13.3 (2025-07-02) -------------------------- - Changed the values for :setting:`DOWNLOAD_DELAY` (from ``0`` to ``1``) and :setting:`CONCURRENT_REQUESTS_PER_DOMAIN` (from ``8`` to ``1``) in the default project template. (:gh:`6597`, :gh:`6918`, :gh:`6923`) - Improved :class:`scrapy.core.engine.ExecutionEngine` logic related to initialization and exception handling, fixing several cases where the spider would crash, hang or log an unhandled exception. (:gh:`6783`, :gh:`6784`, :gh:`6900`, :gh:`6908`, :gh:`6910`, :gh:`6911`) - Fixed a Windows issue with :ref:`feed exports ` using :class:`scrapy.extensions.feedexport.FileFeedStorage` that caused the file to be created on the wrong drive. (:gh:`6894`, :gh:`6897`) - Allowed running tests with Twisted 25.5.0+ again. Pytest 8.4.1+ is now required for running tests in non-pinned envs as support for the new Twisted version was added in that version. (:gh:`6893`) - Fixed running tests with lxml 6.0.0+. (:gh:`6919`) - Added a deprecation notice for ``scrapy.spidermiddlewares.offsite.OffsiteMiddleware`` to :ref:`the Scrapy 2.11.2 release notes `. (:gh:`6926`) - Updated :ref:`contribution docs ` to refer to ruff_ instead of black_. (:gh:`6903`) - Added ``.venv/`` and ``.vscode/`` to ``.gitignore``. (:gh:`6901`, :gh:`6907`) .. _release-2.13.2: Scrapy 2.13.2 (2025-06-09) -------------------------- - Fixed a bug introduced in Scrapy 2.13.0 that caused results of request errbacks to be ignored when the errback was called because of a downloader error. (:gh:`6861`, :gh:`6863`) - Added a note about the behavior change of :func:`scrapy.utils.reactor.is_asyncio_reactor_installed` to its docs and to the "Backward-incompatible changes" section of :ref:`the Scrapy 2.13.0 release notes `. (:gh:`6866`) - Improved the message in the exception raised by :func:`scrapy.utils.test.get_reactor_settings` when there is no reactor installed. (:gh:`6866`) - Updated the :class:`scrapy.crawler.CrawlerRunner` examples in :ref:`topics-practices` to install the reactor explicitly, to fix reactor-related errors with Scrapy 2.13.0 and later. (:gh:`6865`) - Fixed ``scrapy fetch`` not working with scrapy-poet_. (:gh:`6872`) - Fixed an exception produced by :class:`scrapy.core.engine.ExecutionEngine` when it's closed before being fully initialized. (:gh:`6857`, :gh:`6867`) - Improved the README, updated the Scrapy logo in it. (:gh:`6831`, :gh:`6833`, :gh:`6839`) - Restricted the Twisted version used in tests to below 25.5.0, as some tests fail with 25.5.0. (:gh:`6878`, :gh:`6882`) - Updated type hints for Twisted 25.5.0 changes. (:gh:`6882`) - Removed the old artwork. (:gh:`6874`) .. _release-2.13.1: Scrapy 2.13.1 (2025-05-28) -------------------------- - Give callback requests precedence over start requests when priority values are the same. This makes changes from 2.13.0 to start request handling more intuitive and backward compatible. For scenarios where all requests have the same priorities, in 2.13.0 all start requests were sent before the first callback request. In 2.13.1, same as in 2.12 and lower, start requests are only sent when there are not enough pending callback requests to reach concurrency limits. (:gh:`6828`) - Added a deepwiki_ badge to the README. (:gh:`6793`) .. _deepwiki: https://deepwiki.com/scrapy/scrapy - Fixed a typo in the code example of :ref:`start-requests-lazy`. (:gh:`6812`, :gh:`6815`) - Fixed a typo in the :ref:`coroutine-support` section of the documentation. (:gh:`6822`) - Made this page more prominently listed in PyPI project links. (:gh:`6826`) .. _release-2.13.0: Scrapy 2.13.0 (2025-05-08) -------------------------- Highlights: - The asyncio reactor is now enabled by default - Replaced ``start_requests()`` (sync) with :meth:`~scrapy.Spider.start` (async) and changed how it is iterated - Added the :reqmeta:`allow_offsite` request meta key - Spider middlewares that don't support asynchronous spider output are deprecated - Added a base class for :ref:`universal spider middlewares ` Modified requirements ~~~~~~~~~~~~~~~~~~~~~ - Dropped support for PyPy 3.9. (:gh:`6613`) - Added support for PyPy 3.11. (:gh:`6697`) Backward-incompatible changes ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - The default value of the :setting:`TWISTED_REACTOR` setting was changed from ``None`` to ``"twisted.internet.asyncioreactor.AsyncioSelectorReactor"``. This value was used in newly generated projects since Scrapy 2.7.0 but now existing projects that don't explicitly set this setting will also use the asyncio reactor. You can :ref:`change this setting in your project ` to use a different reactor. (:gh:`6659`, :gh:`6713`) - The iteration of start requests and items no longer stops once there are requests in the scheduler, and instead runs continuously until all start requests have been scheduled. To reproduce the previous behavior, see :ref:`start-requests-lazy`. (:gh:`6729`) - An unhandled exception from the :meth:`~scrapy.spidermiddlewares.SpiderMiddleware.open_spider` method of a :ref:`spider middleware ` no longer stops the crawl. (:gh:`6729`) - In ``scrapy.core.engine.ExecutionEngine``: - The second parameter of ``open_spider()``, ``start_requests``, has been removed. The start requests are determined by the ``spider`` parameter instead (see :meth:`~scrapy.Spider.start`). - The ``slot`` attribute has been renamed to ``_slot`` and should not be used. (:gh:`6729`) - In ``scrapy.core.engine``, the ``Slot`` class has been renamed to ``_Slot`` and should not be used. (:gh:`6729`) - The ``slot`` :ref:`telnet variable ` has been removed. (:gh:`6729`) - In ``scrapy.core.spidermw.SpiderMiddlewareManager``, ``process_start_requests()`` has been replaced by ``process_start()``. (:gh:`6729`) - The ``scrape_func`` callable passed to ``scrapy.core.spidermw.SpiderMiddlewareManager.scrape_response()`` is now called with 2 parameters, ``response`` and ``request``, instead of 3, and must return a :class:`~twisted.internet.defer.Deferred` instead of an iterable. (:gh:`6787`) - The now-deprecated ``start_requests()`` method, when it returns an iterable instead of being defined as a generator, is now executed *after* the :ref:`scheduler ` instance has been created. (:gh:`6729`) - When using :setting:`JOBDIR`, :ref:`start requests ` are now serialized into their own, ``s``-suffixed priority folders. You can set :setting:`SCHEDULER_START_DISK_QUEUE` to ``None`` or ``""`` to change that, but the side effects may be undesirable. See :setting:`SCHEDULER_START_DISK_QUEUE` for details. (:gh:`6729`) - The URL length limit, set by the :setting:`URLLENGTH_LIMIT` setting, is now also enforced for start requests. (:gh:`6777`) - Calling :func:`scrapy.utils.reactor.is_asyncio_reactor_installed` without an installed reactor now raises an exception instead of installing a reactor. This shouldn't affect normal Scrapy use cases, but it may affect 3rd-party test suites that use Scrapy internals such as :class:`~scrapy.crawler.Crawler` and don't install a reactor explicitly. If you are affected by this change, you most likely need to install the reactor before running Scrapy code that expects it to be installed. (:gh:`6732`, :gh:`6735`) - The ``from_settings()`` method of :class:`~scrapy.spidermiddlewares.urllength.UrlLengthMiddleware`, deprecated in Scrapy 2.12.0, is removed earlier than the usual deprecation period (this was needed because after the introduction of the :class:`~scrapy.spidermiddlewares.base.BaseSpiderMiddleware` base class and switching built-in spider middlewares to it those middlewares need the :class:`~scrapy.crawler.Crawler` instance at run time). Please use ``from_crawler()`` instead. (:gh:`6693`) - ``scrapy.utils.url.escape_ajax()`` is no longer called when a :class:`~scrapy.Request` instance is created. It was only useful for websites supporting the ``_escaped_fragment_`` feature which most modern websites don't support. If you still need this you can modify the URLs before passing them to :class:`~scrapy.Request`. (:gh:`6523`, :gh:`6651`) Deprecation removals ~~~~~~~~~~~~~~~~~~~~ - Removed old deprecated name aliases for some signals: - ``stats_spider_opened`` (use ``spider_opened`` instead) - ``stats_spider_closing`` and ``stats_spider_closed`` (use ``spider_closed`` instead) - ``item_passed`` (use ``item_scraped`` instead) - ``request_received`` (use ``request_scheduled`` instead) (:gh:`6654`, :gh:`6655`) Deprecations ~~~~~~~~~~~~ - The ``start_requests()`` method of :class:`~scrapy.Spider` is deprecated, use :meth:`~scrapy.Spider.start` instead, or both to maintain support for lower Scrapy versions. (:gh:`456`, :gh:`3477`, :gh:`4467`, :gh:`5627`, :gh:`6729`) - The ``process_start_requests()`` method of :ref:`spider middlewares ` is deprecated, use :meth:`~scrapy.spidermiddlewares.SpiderMiddleware.process_start` instead, or both to maintain support for lower Scrapy versions. (:gh:`456`, :gh:`3477`, :gh:`4467`, :gh:`5627`, :gh:`6729`) - The ``__init__`` method of priority queue classes (see :setting:`SCHEDULER_PRIORITY_QUEUE`) should now support a keyword-only ``start_queue_cls`` parameter. (:gh:`6752`) - Spider middlewares that don't support asynchronous spider output are deprecated. The async iterable downgrading feature, needed for using such middlewares with asynchronous callbacks and with other spider middlewares that produce asynchronous iterables, is also deprecated. Please update all such middlewares to support asynchronous spider output. (:gh:`6664`) - Functions that were imported from :mod:`w3lib.url` and re-exported in :mod:`scrapy.utils.url` are now deprecated, you should import them from :mod:`w3lib.url` directly. They are: - ``scrapy.utils.url.add_or_replace_parameter()`` - ``scrapy.utils.url.add_or_replace_parameters()`` - ``scrapy.utils.url.any_to_uri()`` - ``scrapy.utils.url.canonicalize_url()`` - ``scrapy.utils.url.file_uri_to_path()`` - ``scrapy.utils.url.is_url()`` - ``scrapy.utils.url.parse_data_uri()`` - ``scrapy.utils.url.parse_url()`` - ``scrapy.utils.url.path_to_file_uri()`` - ``scrapy.utils.url.safe_download_url()`` - ``scrapy.utils.url.safe_url_string()`` - ``scrapy.utils.url.url_query_cleaner()`` - ``scrapy.utils.url.url_query_parameter()`` (:gh:`4577`, :gh:`6583`, :gh:`6586`) - HTTP/1.0 support code is deprecated. It was disabled by default and couldn't be used together with HTTP/1.1. If you still need it, you should write your own download handler or copy the code from Scrapy. The deprecations include: - ``scrapy.core.downloader.handlers.http10.HTTP10DownloadHandler`` - ``scrapy.core.downloader.webclient.ScrapyHTTPClientFactory`` - ``scrapy.core.downloader.webclient.ScrapyHTTPPageGetter`` - Overriding ``scrapy.core.downloader.contextfactory.ScrapyClientContextFactory.getContext()`` (:gh:`6634`) - The following modules and functions used only in tests are deprecated: - the ``scrapy.utils.testproc`` module - the ``scrapy.utils.testsite`` module - ``scrapy.utils.test.assert_gcs_environ()`` - ``scrapy.utils.test.get_ftp_content_and_delete()`` - ``scrapy.utils.test.get_gcs_content_and_delete()`` - ``scrapy.utils.test.mock_google_cloud_storage()`` - ``scrapy.utils.test.skip_if_no_boto()`` If you need to use them in your tests or code, you can copy the code from Scrapy. (:gh:`6696`) - ``scrapy.utils.test.TestSpider`` is deprecated. If you need an empty spider class you can use :class:`scrapy.utils.spider.DefaultSpider` or create your own subclass of :class:`scrapy.Spider`. (:gh:`6678`) - ``scrapy.downloadermiddlewares.ajaxcrawl.AjaxCrawlMiddleware`` is deprecated. It was disabled by default and isn't useful for most of the existing websites. (:gh:`6523`, :gh:`6651`, :gh:`6656`) - ``scrapy.utils.url.escape_ajax()`` is deprecated. (:gh:`6523`, :gh:`6651`) - ``scrapy.spiders.init.InitSpider`` is deprecated. If you find it useful, you can copy its code from Scrapy. (:gh:`6708`, :gh:`6714`) - ``scrapy.utils.versions.scrapy_components_versions()`` is deprecated, use :func:`scrapy.utils.versions.get_versions` instead. (:gh:`6582`) - ``BaseDupeFilter.log()`` is deprecated. It does nothing and shouldn't be called. (:gh:`4151`) - Passing the ``spider`` argument to the following methods of :class:`~scrapy.core.scraper.Scraper` is deprecated: - ``close_spider()`` - ``enqueue_scrape()`` - ``handle_spider_error()`` - ``handle_spider_output()`` (:gh:`6764`) New features ~~~~~~~~~~~~ - You can now yield the start requests and items of a spider from the :meth:`~scrapy.Spider.start` spider method and from the :meth:`~scrapy.spidermiddlewares.SpiderMiddleware.process_start` spider middleware method, both :term:`asynchronous generators `. This makes it possible to use asynchronous code to generate those start requests and items, e.g. reading them from a queue service or database using an asynchronous client, without workarounds. (:gh:`456`, :gh:`3477`, :gh:`4467`, :gh:`5627`, :gh:`6729`) - Start requests are now :ref:`scheduled ` as soon as possible. As a result, their :attr:`~scrapy.Request.priority` is now taken into account as soon as :setting:`CONCURRENT_REQUESTS` is reached. (:gh:`456`, :gh:`3477`, :gh:`4467`, :gh:`5627`, :gh:`6729`) - :class:`Crawler.signals ` has a new :meth:`~scrapy.signalmanager.SignalManager.wait_for` method. (:gh:`6729`) - Added a new :signal:`scheduler_empty` signal. (:gh:`6729`) - Added new settings: :setting:`SCHEDULER_START_DISK_QUEUE` and :setting:`SCHEDULER_START_MEMORY_QUEUE`. (:gh:`6729`) - Added :class:`~scrapy.spidermiddlewares.start.StartSpiderMiddleware`, which sets :reqmeta:`is_start_request` to ``True`` on :ref:`start requests `. (:gh:`6729`) - Exposed a new method of :class:`Crawler.engine `: :meth:`~scrapy.core.engine.ExecutionEngine.needs_backout`. (:gh:`6729`) - Added the :reqmeta:`allow_offsite` request meta key that can be used instead of the more general :attr:`~scrapy.Request.dont_filter` request attribute to skip processing of the request by :class:`~scrapy.downloadermiddlewares.offsite.OffsiteMiddleware` (but not by other code that checks :attr:`~scrapy.Request.dont_filter`). (:gh:`3690`, :gh:`6151`, :gh:`6366`) - Added an optional base class for spider middlewares, :class:`~scrapy.spidermiddlewares.base.BaseSpiderMiddleware`, which can be helpful for writing :ref:`universal spider middlewares ` without boilerplate and code duplication. The built-in spider middlewares now inherit from this class. (:gh:`6693`, :gh:`6777`) - :ref:`Scrapy add-ons ` can now define a class method called ``update_pre_crawler_settings()`` to update :ref:`pre-crawler settings `. (:gh:`6544`, :gh:`6568`) - Added :ref:`helpers ` for modifying :ref:`component priority dictionary ` settings. (:gh:`6614`) - Responses that use an unknown/unsupported encoding now produce a warning. If Scrapy knows that installing an additional package (such as brotli_) will allow decoding the response, that will be mentioned in the warning. (:gh:`4697`, :gh:`6618`) - Added the ``spider_exceptions/count`` stat which tracks the total count of exceptions (tracked also by per-type ``spider_exceptions/*`` stats). (:gh:`6739`, :gh:`6740`) - Added the :setting:`DEFAULT_DROPITEM_LOG_LEVEL` setting and the :attr:`scrapy.exceptions.DropItem.log_level` attribute that allow customizing the log level of the message that is logged when an item is dropped. (:gh:`6603`, :gh:`6608`) - Added support for the ``-b, --cookie`` curl argument to :meth:`scrapy.Request.from_curl`. (:gh:`6684`) - Added the :setting:`LOG_VERSIONS` setting that allows customizing the list of software whose versions are logged when the spider starts. (:gh:`6582`) - Added the :setting:`WARN_ON_GENERATOR_RETURN_VALUE` setting that allows disabling run time analysis of callback code used to warn about incorrect ``return`` statements in generator-based callbacks. You may need to disable this setting if this analysis breaks on your callback code. (:gh:`6731`, :gh:`6738`) Improvements ~~~~~~~~~~~~ - Removed or postponed some calls of :func:`itemadapter.is_item` to increase performance. (:gh:`6719`) - Improved the error message when running a ``scrapy`` command that requires a project (such as ``scrapy crawl``) outside of a project directory. (:gh:`2349`, :gh:`3426`) - Added an empty :setting:`ADDONS` setting to the ``settings.py`` template for new projects. (:gh:`6587`) Bug fixes ~~~~~~~~~ - Yielding an item from :meth:`Spider.start ` or from :meth:`SpiderMiddleware.process_start ` no longer delays the next iteration of starting requests and items by up to 5 seconds. (:gh:`6729`) - Fixed calculation of ``items_per_minute`` and ``responses_per_minute`` stats. (:gh:`6599`) - Fixed an error initializing :class:`scrapy.extensions.feedexport.GCSFeedStorage`. (:gh:`6617`, :gh:`6628`) - Fixed an error running ``scrapy bench``. (:gh:`6632`, :gh:`6633`) - Fixed duplicated log messages about the reactor and the event loop. (:gh:`6636`, :gh:`6657`) - Fixed resolving type annotations of ``SitemapSpider._parse_sitemap()`` at run time, required by tools such as scrapy-poet_. (:gh:`6665`, :gh:`6671`) .. _scrapy-poet: https://github.com/scrapinghub/scrapy-poet - Calling :func:`scrapy.utils.reactor.is_asyncio_reactor_installed` without an installed reactor now raises an exception instead of installing a reactor. (:gh:`6732`, :gh:`6735`) - Restored support for the ``x-gzip`` content encoding. (:gh:`6618`) Documentation ~~~~~~~~~~~~~ - Documented the setting values set in the default project template. (:gh:`6762`, :gh:`6775`) - Improved the docs about asynchronous iterable support in spider middlewares. (:gh:`6688`) - Improved the :ref:`docs ` about using :class:`~twisted.internet.defer.Deferred`-based APIs in coroutine-based code and included a list of such APIs. (:gh:`6677`, :gh:`6734`, :gh:`6776`) - Improved the :ref:`contribution docs `. (:gh:`6561`, :gh:`6575`) - Removed the ``Splash`` recommendation from the :ref:`headless browser ` suggestion. We no longer recommend using ``Splash`` and recommend using other headless browser solutions instead. (:gh:`6642`, :gh:`6701`) - Added the dark mode to the HTML documentation. (:gh:`6653`) - Other documentation improvements and fixes. (:gh:`4151`, :gh:`6526`, :gh:`6620`, :gh:`6621`, :gh:`6622`, :gh:`6623`, :gh:`6624`, :gh:`6721`, :gh:`6723`, :gh:`6780`) Packaging ~~~~~~~~~ - Switched from ``setup.py`` to ``pyproject.toml``. (:gh:`6514`, :gh:`6547`) - Switched the build backend from setuptools_ to hatchling_. (:gh:`6771`) .. _hatchling: https://pypi.org/project/hatchling/ Quality assurance ~~~~~~~~~~~~~~~~~ - Replaced most linters with ruff_. (:gh:`6565`, :gh:`6576`, :gh:`6577`, :gh:`6581`, :gh:`6584`, :gh:`6595`, :gh:`6601`, :gh:`6631`) .. _ruff: https://docs.astral.sh/ruff/ - Improved accuracy and performance of collecting test coverage. (:gh:`6255`, :gh:`6610`) - Fixed an error that prevented running tests from directories other than the top level source directory. (:gh:`6567`) - Reduced the amount of ``mockserver`` calls in tests to improve the overall test run time. (:gh:`6637`, :gh:`6648`) - Fixed tests that were running the same test code more than once. (:gh:`6646`, :gh:`6647`, :gh:`6650`) - Refactored tests to use more ``pytest`` features instead of ``unittest`` ones where possible. (:gh:`6678`, :gh:`6680`, :gh:`6695`, :gh:`6699`, :gh:`6700`, :gh:`6702`, :gh:`6709`, :gh:`6710`, :gh:`6711`, :gh:`6712`, :gh:`6725`) - Type hints improvements and fixes. (:gh:`6578`, :gh:`6579`, :gh:`6593`, :gh:`6605`, :gh:`6694`) - CI and test improvements and fixes. (:gh:`5360`, :gh:`6271`, :gh:`6547`, :gh:`6560`, :gh:`6602`, :gh:`6607`, :gh:`6609`, :gh:`6613`, :gh:`6619`, :gh:`6626`, :gh:`6679`, :gh:`6703`, :gh:`6704`, :gh:`6716`, :gh:`6720`, :gh:`6722`, :gh:`6724`, :gh:`6741`, :gh:`6743`, :gh:`6766`, :gh:`6770`, :gh:`6772`, :gh:`6773`) - Code cleanups. (:gh:`6600`, :gh:`6606`, :gh:`6635`, :gh:`6764`) .. _release-2.12.0: Scrapy 2.12.0 (2024-11-18) -------------------------- Highlights: - Dropped support for Python 3.8, added support for Python 3.13 - ``scrapy.Spider.start_requests()`` can now yield items - Added :class:`~scrapy.http.JsonResponse` - Added :setting:`CLOSESPIDER_PAGECOUNT_NO_ITEM` Modified requirements ~~~~~~~~~~~~~~~~~~~~~ - Dropped support for Python 3.8. (:gh:`6466`, :gh:`6472`) - Added support for Python 3.13. (:gh:`6166`) - Minimum versions increased for these dependencies: - Twisted_: 18.9.0 → 21.7.0 - cryptography_: 36.0.0 → 37.0.0 - pyOpenSSL_: 21.0.0 → 22.0.0 - lxml_: 4.4.1 → 4.6.0 - Removed ``setuptools`` from the dependency list. (:gh:`6487`) Backward-incompatible changes ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - User-defined cookies for HTTPS requests will have the ``secure`` flag set to ``True`` unless it's set to ``False`` explicitly. This is important when these cookies are reused in HTTP requests, e.g. after a redirect to an HTTP URL. (:gh:`6357`) - The Reppy-based ``robots.txt`` parser, ``scrapy.robotstxt.ReppyRobotParser``, was removed, as it doesn't support Python 3.9+. (:gh:`5230`, :gh:`6099`, :gh:`6499`) - The initialization API of :class:`scrapy.pipelines.media.MediaPipeline` and its subclasses was improved and it's possible that some previously working usage scenarios will no longer work. It can only affect you if you define custom subclasses of ``MediaPipeline`` or create instances of these pipelines via ``from_settings()`` or ``__init__()`` calls instead of ``from_crawler()`` calls. Previously, ``MediaPipeline.from_crawler()`` called the ``from_settings()`` method if it existed or the ``__init__()`` method otherwise, and then did some additional initialization using the ``crawler`` instance. If the ``from_settings()`` method existed (like in ``FilesPipeline``) it called ``__init__()`` to create the instance. It wasn't possible to override ``from_crawler()`` without calling ``MediaPipeline.from_crawler()`` from it which, in turn, couldn't be called in some cases (including subclasses of ``FilesPipeline``). Now, in line with the general usage of ``from_crawler()`` and ``from_settings()`` and the deprecation of the latter the recommended initialization order is the following one: - All ``__init__()`` methods should take a ``crawler`` argument. If they also take a ``settings`` argument they should ignore it, using ``crawler.settings`` instead. When they call ``__init__()`` of the base class they should pass the ``crawler`` argument to it too. - A ``from_settings()`` method shouldn't be defined. Class-specific initialization code should go into either an overridden ``from_crawler()`` method or into ``__init__()``. - It's now possible to override ``from_crawler()`` and it's not necessary to call ``MediaPipeline.from_crawler()`` in it if other recommendations were followed. - If pipeline instances were created with ``from_settings()`` or ``__init__()`` calls (which wasn't supported even before, as it missed important initialization code), they should now be created with ``from_crawler()`` calls. (:gh:`6540`) - The ``response_body`` argument of :meth:`ImagesPipeline.convert_image ` is now positional-only, as it was changed from optional to required. (:gh:`6500`) - The ``convert`` argument of :func:`scrapy.utils.conf.build_component_list` is now positional-only, as the preceding argument (``custom``) was removed. (:gh:`6500`) - The ``overwrite_output`` argument of :func:`scrapy.utils.conf.feed_process_params_from_cli` is now positional-only, as the preceding argument (``output_format``) was removed. (:gh:`6500`) Deprecation removals ~~~~~~~~~~~~~~~~~~~~ - Removed the ``scrapy.utils.request.request_fingerprint()`` function, deprecated in Scrapy 2.7.0. (:gh:`6212`, :gh:`6213`) - Removed support for value ``"2.6"`` of setting ``REQUEST_FINGERPRINTER_IMPLEMENTATION``, deprecated in Scrapy 2.7.0. (:gh:`6212`, :gh:`6213`) - :class:`~scrapy.dupefilters.RFPDupeFilter` subclasses now require supporting the ``fingerprinter`` parameter in their ``__init__`` method, introduced in Scrapy 2.7.0. (:gh:`6102`, :gh:`6113`) - Removed the ``scrapy.downloadermiddlewares.decompression`` module, deprecated in Scrapy 2.7.0. (:gh:`6100`, :gh:`6113`) - Removed the ``scrapy.utils.response.response_httprepr()`` function, deprecated in Scrapy 2.6.0. (:gh:`6111`, :gh:`6116`) - Spiders with spider-level HTTP authentication, i.e. with the ``http_user`` or ``http_pass`` attributes, must now define ``http_auth_domain`` as well, which was introduced in Scrapy 2.5.1. (:gh:`6103`, :gh:`6113`) - :ref:`Media pipelines ` methods ``file_path()``, ``file_downloaded()``, ``get_images()``, ``image_downloaded()``, ``media_downloaded()``, ``media_to_download()``, and ``thumb_path()`` must now support an ``item`` parameter, added in Scrapy 2.4.0. (:gh:`6107`, :gh:`6113`) - The ``__init__()`` and ``from_crawler()`` methods of :ref:`feed storage backend classes ` must now support the keyword-only ``feed_options`` parameter, introduced in Scrapy 2.4.0. (:gh:`6105`, :gh:`6113`) - Removed the ``scrapy.loader.common`` and ``scrapy.loader.processors`` modules, deprecated in Scrapy 2.3.0. (:gh:`6106`, :gh:`6113`) - Removed the ``scrapy.utils.misc.extract_regex()`` function, deprecated in Scrapy 2.3.0. (:gh:`6106`, :gh:`6113`) - Removed the ``scrapy.http.JSONRequest`` class, replaced with ``JsonRequest`` in Scrapy 1.8.0. (:gh:`6110`, :gh:`6113`) - ``scrapy.utils.log.logformatter_adapter`` no longer supports missing ``args``, ``level``, or ``msg`` parameters, and no longer supports a ``format`` parameter, all scenarios that were deprecated in Scrapy 1.0.0. (:gh:`6109`, :gh:`6116`) - A custom class assigned to the :setting:`SPIDER_LOADER_CLASS` setting that does not implement the ``ISpiderLoader`` interface will now raise a :exc:`zope.interface.verify.DoesNotImplement` exception at run time. Non-compliant classes have been triggering a deprecation warning since Scrapy 1.0.0. (:gh:`6101`, :gh:`6113`) - Removed the ``--output-format``/``-t`` command line option, deprecated in Scrapy 2.1.0. ``-O :`` should be used instead. (:gh:`6500`) - Running :meth:`~scrapy.crawler.Crawler.crawl` more than once on the same :class:`~scrapy.crawler.Crawler` instance, deprecated in Scrapy 2.11.0, now raises an exception. (:gh:`6500`) - Subclassing :class:`~scrapy.downloadermiddlewares.httpcompression.HttpCompressionMiddleware` without support for the ``crawler`` argument in ``__init__()`` and without a custom ``from_crawler()`` method, deprecated in Scrapy 2.5.0, is no longer allowed. (:gh:`6500`) - Removed the ``EXCEPTIONS_TO_RETRY`` attribute of :class:`~scrapy.downloadermiddlewares.retry.RetryMiddleware`, deprecated in Scrapy 2.10.0. (:gh:`6500`) - Removed support for :ref:`S3 feed exports ` without the boto3_ package installed, deprecated in Scrapy 2.10.0. (:gh:`6500`) - Removed the ``scrapy.extensions.feedexport._FeedSlot`` class, deprecated in Scrapy 2.10.0. (:gh:`6500`) - Removed the ``scrapy.pipelines.images.NoimagesDrop`` exception, deprecated in Scrapy 2.8.0. (:gh:`6500`) - The ``response_body`` argument of :meth:`ImagesPipeline.convert_image ` is now required, not passing it was deprecated in Scrapy 2.8.0. (:gh:`6500`) - Removed the ``custom`` argument of :func:`scrapy.utils.conf.build_component_list`, deprecated in Scrapy 2.10.0. (:gh:`6500`) - Removed the ``scrapy.utils.reactor.get_asyncio_event_loop_policy()`` function, deprecated in Scrapy 2.9.0. Use :func:`asyncio.get_event_loop` and related standard library functions instead. (:gh:`6500`) Deprecations ~~~~~~~~~~~~ - The ``from_settings()`` methods of the :ref:`Scrapy components ` that have them are now deprecated. ``from_crawler()`` should now be used instead. Affected components: - :class:`scrapy.dupefilters.RFPDupeFilter` - :class:`scrapy.mail.MailSender` - :class:`scrapy.middleware.MiddlewareManager` - :class:`scrapy.core.downloader.contextfactory.ScrapyClientContextFactory` - :class:`scrapy.pipelines.files.FilesPipeline` - :class:`scrapy.pipelines.images.ImagesPipeline` - :class:`scrapy.spidermiddlewares.urllength.UrlLengthMiddleware` (:gh:`6540`) - It's now deprecated to have a ``from_settings()`` method but no ``from_crawler()`` method in 3rd-party :ref:`Scrapy components `. You can define a simple ``from_crawler()`` method that calls ``cls.from_settings(crawler.settings)`` to fix this if you don't want to refactor the code. Note that if you have a ``from_crawler()`` method Scrapy will not call the ``from_settings()`` method so the latter can be removed. (:gh:`6540`) - The initialization API of :class:`scrapy.pipelines.media.MediaPipeline` and its subclasses was improved and some old usage scenarios are now deprecated (see also the "Backward-incompatible changes" section). Specifically: - It's deprecated to define an ``__init__()`` method that doesn't take a ``crawler`` argument. - It's deprecated to call an ``__init__()`` method without passing a ``crawler`` argument. If it's passed, it's also deprecated to pass a ``settings`` argument, which will be ignored anyway. - Calling ``from_settings()`` is deprecated, use ``from_crawler()`` instead. - Overriding ``from_settings()`` is deprecated, override ``from_crawler()`` instead. (:gh:`6540`) - The ``REQUEST_FINGERPRINTER_IMPLEMENTATION`` setting is now deprecated. (:gh:`6212`, :gh:`6213`) - The ``scrapy.utils.misc.create_instance()`` function is now deprecated, use :func:`scrapy.utils.misc.build_from_crawler` instead. (:gh:`5523`, :gh:`5884`, :gh:`6162`, :gh:`6169`, :gh:`6540`) - ``scrapy.core.downloader.Downloader._get_slot_key()`` is deprecated, use :meth:`scrapy.core.downloader.Downloader.get_slot_key` instead. (:gh:`6340`, :gh:`6352`) - ``scrapy.utils.defer.process_chain_both()`` is now deprecated. (:gh:`6397`) - ``scrapy.twisted_version`` is now deprecated, you should instead use :attr:`twisted.version` directly (but note that it's an ``incremental.Version`` object, not a tuple). (:gh:`6509`, :gh:`6512`) - ``scrapy.utils.python.flatten()`` and ``scrapy.utils.python.iflatten()`` are now deprecated. (:gh:`6517`, :gh:`6519`) - ``scrapy.utils.python.equal_attributes()`` is now deprecated. (:gh:`6517`, :gh:`6519`) - ``scrapy.utils.request.request_authenticate()`` is now deprecated, you should instead just set the ``Authorization`` header directly. (:gh:`6517`, :gh:`6519`) - ``scrapy.utils.serialize.ScrapyJSONDecoder`` is now deprecated, it didn't contain any code since Scrapy 1.0.0. (:gh:`6517`, :gh:`6519`) - ``scrapy.utils.test.assert_samelines()`` is now deprecated. (:gh:`6517`, :gh:`6519`) - ``scrapy.extensions.feedexport.build_storage()`` is now deprecated. You can instead call the builder callable directly. (:gh:`6540`) - ``scrapy.utils.misc.md5sum()`` is now deprecated. (:gh:`6264`) New features ~~~~~~~~~~~~ - ``scrapy.Spider.start_requests()`` can now yield items. (:gh:`5289`, :gh:`6417`) .. note:: Some spider middlewares may need to be updated for Scrapy 2.12 support before you can use them in combination with the ability to yield items from ``start_requests()``. - Added a new :class:`~scrapy.http.Response` subclass, :class:`~scrapy.http.JsonResponse`, for responses with a `JSON MIME type `_. (:gh:`6069`, :gh:`6171`, :gh:`6174`) - The :class:`~scrapy.extensions.logstats.LogStats` extension now adds ``items_per_minute`` and ``responses_per_minute`` to the :ref:`stats ` when the spider closes. (:gh:`4110`, :gh:`4111`) - Added :setting:`CLOSESPIDER_PAGECOUNT_NO_ITEM` which allows closing the spider if no items were scraped in a set amount of time. (:gh:`6434`) - User-defined cookies can now include the ``secure`` field. (:gh:`6357`) - Added component getters to :class:`~scrapy.crawler.Crawler`: :meth:`~scrapy.crawler.Crawler.get_addon`, :meth:`~scrapy.crawler.Crawler.get_downloader_middleware`, :meth:`~scrapy.crawler.Crawler.get_extension`, :meth:`~scrapy.crawler.Crawler.get_item_pipeline`, :meth:`~scrapy.crawler.Crawler.get_spider_middleware`. (:gh:`6181`) - Slot delay updates by the :ref:`AutoThrottle extension ` based on response latencies can now be disabled for specific requests via the :reqmeta:`autothrottle_dont_adjust_delay` meta key. (:gh:`6246`, :gh:`6527`) - If :setting:`SPIDER_LOADER_WARN_ONLY` is set to ``True``, :class:`~scrapy.spiderloader.SpiderLoader` does not raise :exc:`SyntaxError` but emits a warning instead. (:gh:`6483`, :gh:`6484`) - Added support for multiple-compressed responses (ones with several encodings in the ``Content-Encoding`` header). (:gh:`5143`, :gh:`5964`, :gh:`6063`) - Added support for multiple standard values in :setting:`REFERRER_POLICY`. (:gh:`6381`) - Added support for brotlicffi_ (previously named brotlipy_). brotli_ is still recommended but only brotlicffi_ works on PyPy. (:gh:`6263`, :gh:`6269`) .. _brotlicffi: https://github.com/python-hyper/brotlicffi - Added :class:`~scrapy.contracts.default.MetadataContract` that sets the request meta. (:gh:`6468`, :gh:`6469`) Improvements ~~~~~~~~~~~~ - Extended the list of file extensions that :class:`LinkExtractor ` ignores by default. (:gh:`6074`, :gh:`6125`) - :func:`scrapy.utils.httpobj.urlparse_cached` is now used in more places instead of :func:`urllib.parse.urlparse`. (:gh:`6228`, :gh:`6229`) Bug fixes ~~~~~~~~~ - :class:`~scrapy.pipelines.media.MediaPipeline` is now an abstract class and its methods that were expected to be overridden in subclasses are now abstract methods. (:gh:`6365`, :gh:`6368`) - Fixed handling of invalid ``@``-prefixed lines in contract extraction. (:gh:`6383`, :gh:`6388`) - Importing ``scrapy.extensions.telnet`` no longer installs the default reactor. (:gh:`6432`) - Reduced log verbosity for dropped requests that was increased in 2.11.2. (:gh:`6433`, :gh:`6475`) Documentation ~~~~~~~~~~~~~ - Added ``SECURITY.md`` that documents the security policy. (:gh:`5364`, :gh:`6051`) - Example code for :ref:`running Scrapy from a script ` no longer imports ``twisted.internet.reactor`` at the top level, which caused problems with non-default reactors when this code was used unmodified. (:gh:`6361`, :gh:`6374`) - Documented the :class:`~scrapy.extensions.spiderstate.SpiderState` extension. (:gh:`6278`, :gh:`6522`) - Other documentation improvements and fixes. (:gh:`5920`, :gh:`6094`, :gh:`6177`, :gh:`6200`, :gh:`6207`, :gh:`6216`, :gh:`6223`, :gh:`6317`, :gh:`6328`, :gh:`6389`, :gh:`6394`, :gh:`6402`, :gh:`6411`, :gh:`6427`, :gh:`6429`, :gh:`6440`, :gh:`6448`, :gh:`6449`, :gh:`6462`, :gh:`6497`, :gh:`6506`, :gh:`6507`, :gh:`6524`) Quality assurance ~~~~~~~~~~~~~~~~~ - Added ``py.typed``, in line with `PEP 561 `_. (:gh:`6058`, :gh:`6059`) - Fully covered the code with type hints (except for the most complicated parts, mostly related to ``twisted.web.http`` and other Twisted parts without type hints). (:gh:`5989`, :gh:`6097`, :gh:`6127`, :gh:`6129`, :gh:`6130`, :gh:`6133`, :gh:`6143`, :gh:`6191`, :gh:`6268`, :gh:`6274`, :gh:`6275`, :gh:`6276`, :gh:`6279`, :gh:`6325`, :gh:`6326`, :gh:`6333`, :gh:`6335`, :gh:`6336`, :gh:`6337`, :gh:`6341`, :gh:`6353`, :gh:`6356`, :gh:`6370`, :gh:`6371`, :gh:`6384`, :gh:`6385`, :gh:`6387`, :gh:`6391`, :gh:`6395`, :gh:`6414`, :gh:`6422`, :gh:`6460`, :gh:`6466`, :gh:`6472`, :gh:`6494`, :gh:`6498`, :gh:`6516`) - Improved Bandit_ checks. (:gh:`6260`, :gh:`6264`, :gh:`6265`) - Added pyupgrade_ to the ``pre-commit`` configuration. (:gh:`6392`) .. _pyupgrade: https://github.com/asottile/pyupgrade - Added ``flake8-bugbear``, ``flake8-comprehensions``, ``flake8-debugger``, ``flake8-docstrings``, ``flake8-string-format`` and ``flake8-type-checking`` to the ``pre-commit`` configuration. (:gh:`6406`, :gh:`6413`) - CI and test improvements and fixes. (:gh:`5285`, :gh:`5454`, :gh:`5997`, :gh:`6078`, :gh:`6084`, :gh:`6087`, :gh:`6132`, :gh:`6153`, :gh:`6154`, :gh:`6201`, :gh:`6231`, :gh:`6232`, :gh:`6235`, :gh:`6236`, :gh:`6242`, :gh:`6245`, :gh:`6253`, :gh:`6258`, :gh:`6259`, :gh:`6270`, :gh:`6272`, :gh:`6286`, :gh:`6290`, :gh:`6296` :gh:`6367`, :gh:`6372`, :gh:`6403`, :gh:`6416`, :gh:`6435`, :gh:`6489`, :gh:`6501`, :gh:`6504`, :gh:`6511`, :gh:`6543`, :gh:`6545`) - Code cleanups. (:gh:`6196`, :gh:`6197`, :gh:`6198`, :gh:`6199`, :gh:`6254`, :gh:`6257`, :gh:`6285`, :gh:`6305`, :gh:`6343`, :gh:`6349`, :gh:`6386`, :gh:`6415`, :gh:`6463`, :gh:`6470`, :gh:`6499`, :gh:`6505`, :gh:`6510`, :gh:`6531`, :gh:`6542`) Other ~~~~~ - Issue tracker improvements. (:gh:`6066`) .. _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. (:gh:`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. (:gh:`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. (:gh:`1042`, :gh:`2241`, :gh:`6358`) - :func:`~scrapy.utils.iterators.xmliter_lxml` no longer resolves XML entities. (:gh:`6265`) - defusedxml_ is now used to make :class:`scrapy.http.request.rpc.XmlRpcRequest` more secure. (:gh:`6250`, :gh:`6251`) .. _defusedxml: https://github.com/tiran/defusedxml Deprecations ~~~~~~~~~~~~ - ``scrapy.spidermiddlewares.offsite.OffsiteMiddleware`` (a spider middleware) is now deprecated and not enabled by default. The new downloader middleware with the same functionality, :class:`scrapy.downloadermiddlewares.offsite.OffsiteMiddleware`, is enabled instead. (:gh:`2241`, :gh:`6358`) Bug fixes ~~~~~~~~~ - Restored support for brotlipy_, which had been dropped in Scrapy 2.11.1 in favor of brotli_. (:gh:`6261`) .. 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. (:gh:`6342`, :gh:`6347`) - During :ref:`feed export `, do not close the underlying file from :ref:`built-in post-processing plugins `. (:gh:`5932`, :gh:`6178`, :gh:`6239`) - :class:`LinkExtractor ` now properly applies the ``unique`` and ``canonicalize`` parameters. (:gh:`3273`, :gh:`6221`) - Do not initialize the scheduler disk queue if :setting:`JOBDIR` is an empty string. (:gh:`6121`, :gh:`6124`) - Fix :attr:`Spider.logger ` not logging custom extra information. (:gh:`6323`, :gh:`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. (:gh:`6292`, :gh:`6298`) - :meth:`scrapy.http.cookies.WrappedRequest.get_header` no longer raises an exception if ``default`` is ``None``. (:gh:`6308`, :gh:`6310`) - :class:`~scrapy.Selector` now uses :func:`scrapy.utils.response.get_base_url` to determine the base URL of a given :class:`~scrapy.http.Response`. (:gh:`6265`) - The :meth:`media_to_download` method of :ref:`media pipelines ` now logs exceptions before stripping them. (:gh:`5067`, :gh:`5068`) - When passing a callback to the :command:`parse` command, build the callback callable with the right signature. (:gh:`6182`) Documentation ~~~~~~~~~~~~~ - Add a FAQ entry about :ref:`creating blank requests `. (:gh:`6203`, :gh:`6208`) - Document that :attr:`scrapy.Selector.type` can be ``"json"``. (:gh:`6328`, :gh:`6334`) Quality assurance ~~~~~~~~~~~~~~~~~ - Make builds reproducible. (:gh:`5019`, :gh:`6322`) - Packaging and test fixes. (:gh:`6286`, :gh:`6290`, :gh:`6312`, :gh:`6316`, :gh:`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. (:gh:`6024`, :gh:`6064`, :gh:`6142`) Bug fixes ~~~~~~~~~ - The OS signal handling code was refactored to no longer use private Twisted functions. (:gh:`6024`, :gh:`6064`, :gh:`6112`) Documentation ~~~~~~~~~~~~~ - Improved documentation for :class:`~scrapy.crawler.Crawler` initialization changes made in the 2.11.0 release. (:gh:`6057`, :gh:`6147`) - Extended documentation for :attr:`.Request.meta`. (:gh:`5565`) - Fixed the :reqmeta:`dont_merge_cookies` documentation. (:gh:`5936`, :gh:`6077`) - Added a link to Zyte's export guides to the :ref:`feed exports ` documentation. (:gh:`6183`) - Added a missing note about backward-incompatible changes in :class:`~scrapy.exporters.PythonItemExporter` to the 2.11.0 release notes. (:gh:`6060`, :gh:`6081`) - Added a missing note about removing the deprecated ``scrapy.utils.boto.is_botocore()`` function to the 2.8.0 release notes. (:gh:`6056`, :gh:`6061`) - Other documentation improvements. (:gh:`6128`, :gh:`6144`, :gh:`6163`, :gh:`6190`, :gh:`6192`) Quality assurance ~~~~~~~~~~~~~~~~~ - Added Python 3.12 to the CI configuration, re-enabled tests that were disabled when the pre-release support was added. (:gh:`5985`, :gh:`6083`, :gh:`6098`) - Fixed a test issue on PyPy 7.3.14. (:gh:`6204`, :gh:`6205`) .. _release-2.11.0: Scrapy 2.11.0 (2023-09-18) -------------------------- Highlights: - Spiders can now modify :ref:`settings ` in their :meth:`~scrapy.Spider.from_crawler` methods, e.g. based on :ref:`spider arguments `. - Periodic logging of stats. Backward-incompatible changes ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - Most of the initialization of :class:`scrapy.crawler.Crawler` instances is now done in :meth:`~scrapy.crawler.Crawler.crawl`, so the state of instances before that method is called is now different compared to older Scrapy versions. We do not recommend using the :class:`~scrapy.crawler.Crawler` instances before :meth:`~scrapy.crawler.Crawler.crawl` is called. (:gh:`6038`) - :meth:`scrapy.Spider.from_crawler` is now called before the initialization of various components previously initialized in :meth:`scrapy.crawler.Crawler.__init__` and before the settings are finalized and frozen. This change was needed to allow changing the settings in :meth:`scrapy.Spider.from_crawler`. If you want to access the final setting values and the initialized :class:`~scrapy.crawler.Crawler` attributes in the spider code as early as possible you can do this in ``scrapy.Spider.start_requests()`` or in a handler of the :signal:`engine_started` signal. (:gh:`6038`) - The :meth:`TextResponse.json ` method now requires the response to be in a valid JSON encoding (UTF-8, UTF-16, or UTF-32). If you need to deal with JSON documents in an invalid encoding, use ``json.loads(response.text)`` instead. (:gh:`6016`) - :class:`~scrapy.exporters.PythonItemExporter` used the binary output by default but it no longer does. (:gh:`6006`, :gh:`6007`) Deprecation removals ~~~~~~~~~~~~~~~~~~~~ - Removed the binary export mode of :class:`~scrapy.exporters.PythonItemExporter`, deprecated in Scrapy 1.1.0. (:gh:`6006`, :gh:`6007`) .. note:: If you are using this Scrapy version on Scrapy Cloud with a stack that includes an older Scrapy version and get a "TypeError: Unexpected options: binary" error, you may need to add ``scrapinghub-entrypoint-scrapy >= 0.14.1`` to your project requirements or switch to a stack that includes Scrapy 2.11. - Removed the ``CrawlerRunner.spiders`` attribute, deprecated in Scrapy 1.0.0, use :attr:`CrawlerRunner.spider_loader ` instead. (:gh:`6010`) - The :func:`scrapy.utils.response.response_httprepr` function, deprecated in Scrapy 2.6.0, has now been removed. (:gh:`6111`) Deprecations ~~~~~~~~~~~~ - Running :meth:`~scrapy.crawler.Crawler.crawl` more than once on the same :class:`scrapy.crawler.Crawler` instance is now deprecated. (:gh:`1587`, :gh:`6040`) New features ~~~~~~~~~~~~ - Spiders can now modify settings in their :meth:`~scrapy.Spider.from_crawler` method, e.g. based on :ref:`spider arguments `. (:gh:`1305`, :gh:`1580`, :gh:`2392`, :gh:`3663`, :gh:`6038`) - Added the :class:`~scrapy.extensions.periodic_log.PeriodicLog` extension which can be enabled to log stats and/or their differences periodically. (:gh:`5926`) - Optimized the memory usage in :meth:`TextResponse.json ` by removing unnecessary body decoding. (:gh:`5968`, :gh:`6016`) - Links to ``.webp`` files are now ignored by :ref:`link extractors `. (:gh:`6021`) Bug fixes ~~~~~~~~~ - Fixed logging enabled add-ons. (:gh:`6036`) - Fixed :class:`~scrapy.mail.MailSender` producing invalid message bodies when the ``charset`` argument is passed to :meth:`~scrapy.mail.MailSender.send`. (:gh:`5096`, :gh:`5118`) - Fixed an exception when accessing ``self.EXCEPTIONS_TO_RETRY`` from a subclass of :class:`~scrapy.downloadermiddlewares.retry.RetryMiddleware`. (:gh:`6049`, :gh:`6050`) - :meth:`scrapy.settings.BaseSettings.getdictorlist`, used to parse :setting:`FEED_EXPORT_FIELDS`, now handles tuple values. (:gh:`6011`, :gh:`6013`) - Calls to ``datetime.utcnow()``, no longer recommended to be used, have been replaced with calls to ``datetime.now()`` with a timezone. (:gh:`6014`) Documentation ~~~~~~~~~~~~~ - Updated a deprecated function call in a pipeline example. (:gh:`6008`, :gh:`6009`) Quality assurance ~~~~~~~~~~~~~~~~~ - Extended typing hints. (:gh:`6003`, :gh:`6005`, :gh:`6031`, :gh:`6034`) - Pinned brotli_ to 1.0.9 for the PyPy tests as 1.1.0 breaks them. (:gh:`6044`, :gh:`6045`) - Other CI and pre-commit improvements. (:gh:`6002`, :gh:`6013`, :gh:`6046`) .. _release-2.10.1: Scrapy 2.10.1 (2023-08-30) -------------------------- Marked ``Twisted >= 23.8.0`` as unsupported. (:gh:`6024`, :gh:`6026`) .. _release-2.10.0: Scrapy 2.10.0 (2023-08-04) -------------------------- Highlights: - Added Python 3.12 support, dropped Python 3.7 support. - The new add-ons framework simplifies configuring 3rd-party components that support it. - Exceptions to retry can now be configured. - Many fixes and improvements for feed exports. Modified requirements ~~~~~~~~~~~~~~~~~~~~~ - Dropped support for Python 3.7. (:gh:`5953`) - Added support for the upcoming Python 3.12. (:gh:`5984`) - Minimum versions increased for these dependencies: - lxml_: 4.3.0 → 4.4.1 - cryptography_: 3.4.6 → 36.0.0 - ``pkg_resources`` is no longer used. (:gh:`5956`, :gh:`5958`) - boto3_ is now recommended instead of botocore_ for exporting to S3. (:gh:`5833`). Backward-incompatible changes ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - The value of the :setting:`FEED_STORE_EMPTY` setting is now ``True`` instead of ``False``. In earlier Scrapy versions empty files were created even when this setting was ``False`` (which was a bug that is now fixed), so the new default should keep the old behavior. (:gh:`872`, :gh:`5847`) Deprecation removals ~~~~~~~~~~~~~~~~~~~~ - When a function is assigned to the :setting:`FEED_URI_PARAMS` setting, returning ``None`` or modifying the ``params`` input parameter, deprecated in Scrapy 2.6, is no longer supported. (:gh:`5994`, :gh:`5996`) - The ``scrapy.utils.reqser`` module, deprecated in Scrapy 2.6, is removed. (:gh:`5994`, :gh:`5996`) - The ``scrapy.squeues`` classes ``PickleFifoDiskQueueNonRequest``, ``PickleLifoDiskQueueNonRequest``, ``MarshalFifoDiskQueueNonRequest``, and ``MarshalLifoDiskQueueNonRequest``, deprecated in Scrapy 2.6, are removed. (:gh:`5994`, :gh:`5996`) - The property ``open_spiders`` and the methods ``has_capacity`` and ``schedule`` of :class:`scrapy.core.engine.ExecutionEngine`, deprecated in Scrapy 2.6, are removed. (:gh:`5994`, :gh:`5998`) - Passing a ``spider`` argument to the :meth:`~scrapy.core.engine.ExecutionEngine.spider_is_idle`, :meth:`~scrapy.core.engine.ExecutionEngine.crawl` and :meth:`~scrapy.core.engine.ExecutionEngine.download` methods of :class:`scrapy.core.engine.ExecutionEngine`, deprecated in Scrapy 2.6, is no longer supported. (:gh:`5994`, :gh:`5998`) Deprecations ~~~~~~~~~~~~ - :class:`scrapy.utils.datatypes.CaselessDict` is deprecated, use :class:`scrapy.utils.datatypes.CaseInsensitiveDict` instead. (:gh:`5146`) - Passing the ``custom`` argument to :func:`scrapy.utils.conf.build_component_list` is deprecated, it was used in the past to merge ``FOO`` and ``FOO_BASE`` setting values but now Scrapy uses :func:`scrapy.settings.BaseSettings.getwithbase` to do the same. Code that uses this argument and cannot be switched to ``getwithbase()`` can be switched to merging the values explicitly. (:gh:`5726`, :gh:`5923`) New features ~~~~~~~~~~~~ - Added support for :ref:`Scrapy add-ons `. (:gh:`5950`) - Added the :setting:`RETRY_EXCEPTIONS` setting that configures which exceptions will be retried by :class:`~scrapy.downloadermiddlewares.retry.RetryMiddleware`. (:gh:`2701`, :gh:`5929`) - Added the possiiblity to close the spider if no items were produced in the specified time, configured by :setting:`CLOSESPIDER_TIMEOUT_NO_ITEM`. (:gh:`5979`) - Added support for the :setting:`AWS_REGION_NAME` setting to feed exports. (:gh:`5980`) - Added support for using :class:`pathlib.Path` objects that refer to absolute Windows paths in the :setting:`FEEDS` setting. (:gh:`5939`) Bug fixes ~~~~~~~~~ - Fixed creating empty feeds even with ``FEED_STORE_EMPTY=False``. (:gh:`872`, :gh:`5847`) - Fixed using absolute Windows paths when specifying output files. (:gh:`5969`, :gh:`5971`) - Fixed problems with uploading large files to S3 by switching to multipart uploads (requires boto3_). (:gh:`960`, :gh:`5735`, :gh:`5833`) - Fixed the JSON exporter writing extra commas when some exceptions occur. (:gh:`3090`, :gh:`5952`) - Fixed the "read of closed file" error in the CSV exporter. (:gh:`5043`, :gh:`5705`) - Fixed an error when a component added by the class object throws :exc:`~scrapy.exceptions.NotConfigured` with a message. (:gh:`5950`, :gh:`5992`) - Added the missing :meth:`scrapy.settings.BaseSettings.pop` method. (:gh:`5959`, :gh:`5960`, :gh:`5963`) - Added :class:`~scrapy.utils.datatypes.CaseInsensitiveDict` as a replacement for :class:`~scrapy.utils.datatypes.CaselessDict` that fixes some API inconsistencies. (:gh:`5146`) Documentation ~~~~~~~~~~~~~ - Documented :meth:`scrapy.Spider.update_settings`. (:gh:`5745`, :gh:`5846`) - Documented possible problems with early Twisted reactor installation and their solutions. (:gh:`5981`, :gh:`6000`) - Added examples of making additional requests in callbacks. (:gh:`5927`) - Improved the feed export docs. (:gh:`5579`, :gh:`5931`) - Clarified the docs about request objects on redirection. (:gh:`5707`, :gh:`5937`) Quality assurance ~~~~~~~~~~~~~~~~~ - Added support for running tests against the installed Scrapy version. (:gh:`4914`, :gh:`5949`) - Extended typing hints. (:gh:`5925`, :gh:`5977`) - Fixed the ``test_utils_asyncio.AsyncioTest.test_set_asyncio_event_loop`` test. (:gh:`5951`) - Fixed the ``test_feedexport.BatchDeliveriesTest.test_batch_path_differ`` test on Windows. (:gh:`5847`) - Enabled CI runs for Python 3.11 on Windows. (:gh:`5999`) - Simplified skipping tests that depend on ``uvloop``. (:gh:`5984`) - Fixed the ``extra-deps-pinned`` tox env. (:gh:`5948`) - Implemented cleanups. (:gh:`5965`, :gh:`5986`) .. _release-2.9.0: Scrapy 2.9.0 (2023-05-08) ------------------------- Highlights: - Per-domain download settings. - Compatibility with new cryptography_ and new parsel_. - JMESPath selectors from the new parsel_. - Bug fixes. Deprecations ~~~~~~~~~~~~ - :class:`scrapy.extensions.feedexport._FeedSlot` is renamed to :class:`scrapy.extensions.feedexport.FeedSlot` and the old name is deprecated. (:gh:`5876`) New features ~~~~~~~~~~~~ - Settings corresponding to :setting:`DOWNLOAD_DELAY`, :setting:`CONCURRENT_REQUESTS_PER_DOMAIN` and ``RANDOMIZE_DOWNLOAD_DELAY`` can now be set on a per-domain basis via the new :setting:`DOWNLOAD_SLOTS` setting. (:gh:`5328`) - Added :meth:`.TextResponse.jmespath`, a shortcut for JMESPath selectors available since parsel_ 1.8.1. (:gh:`5894`, :gh:`5915`) - Added :signal:`feed_slot_closed` and :signal:`feed_exporter_closed` signals. (:gh:`5876`) - Added :func:`scrapy.utils.request.request_to_curl`, a function to produce a curl command from a :class:`~scrapy.Request` object. (:gh:`5892`) - Values of :setting:`FILES_STORE` and :setting:`IMAGES_STORE` can now be :class:`pathlib.Path` instances. (:gh:`5801`) Bug fixes ~~~~~~~~~ - Fixed a warning with Parsel 1.8.1+. (:gh:`5903`, :gh:`5918`) - Fixed an error when using feed postprocessing with S3 storage. (:gh:`5500`, :gh:`5581`) - Added the missing :meth:`scrapy.settings.BaseSettings.setdefault` method. (:gh:`5811`, :gh:`5821`) - Fixed an error when using cryptography_ 40.0.0+ and :setting:`DOWNLOADER_CLIENT_TLS_VERBOSE_LOGGING` is enabled. (:gh:`5857`, :gh:`5858`) - The checksums returned by :class:`~scrapy.pipelines.files.FilesPipeline` for files on Google Cloud Storage are no longer Base64-encoded. (:gh:`5874`, :gh:`5891`) - :func:`scrapy.utils.request.request_from_curl` now supports $-prefixed string values for the curl ``--data-raw`` argument, which are produced by browsers for data that includes certain symbols. (:gh:`5899`, :gh:`5901`) - The :command:`parse` command now also works with async generator callbacks. (:gh:`5819`, :gh:`5824`) - The :command:`genspider` command now properly works with HTTPS URLs. (:gh:`3553`, :gh:`5808`) - Improved handling of asyncio loops. (:gh:`5831`, :gh:`5832`) - :class:`LinkExtractor ` now skips certain malformed URLs instead of raising an exception. (:gh:`5881`) - :func:`scrapy.utils.python.get_func_args` now supports more types of callables. (:gh:`5872`, :gh:`5885`) - Fixed an error when processing non-UTF8 values of ``Content-Type`` headers. (:gh:`5914`, :gh:`5917`) - Fixed an error breaking user handling of send failures in :meth:`scrapy.mail.MailSender.send`. (:gh:`1611`, :gh:`5880`) Documentation ~~~~~~~~~~~~~ - Expanded contributing docs. (:gh:`5109`, :gh:`5851`) - Added blacken-docs_ to pre-commit and reformatted the docs with it. (:gh:`5813`, :gh:`5816`) - Fixed a JS issue. (:gh:`5875`, :gh:`5877`) - Fixed ``make htmlview``. (:gh:`5878`, :gh:`5879`) - Fixed typos and other small errors. (:gh:`5827`, :gh:`5839`, :gh:`5883`, :gh:`5890`, :gh:`5895`, :gh:`5904`) Quality assurance ~~~~~~~~~~~~~~~~~ - Extended typing hints. (:gh:`5805`, :gh:`5889`, :gh:`5896`) - Tests for most of the examples in the docs are now run as a part of CI, found problems were fixed. (:gh:`5816`, :gh:`5826`, :gh:`5919`) - Removed usage of deprecated Python classes. (:gh:`5849`) - Silenced ``include-ignored`` warnings from coverage. (:gh:`5820`) - Fixed a random failure of the ``test_feedexport.test_batch_path_differ`` test. (:gh:`5855`, :gh:`5898`) - Updated docstrings to match output produced by parsel_ 1.8.1 so that they don't cause test failures. (:gh:`5902`, :gh:`5919`) - Other CI and pre-commit improvements. (:gh:`5802`, :gh:`5823`, :gh:`5908`) .. _blacken-docs: https://github.com/adamchainz/blacken-docs .. _release-2.8.0: Scrapy 2.8.0 (2023-02-02) ------------------------- This is a maintenance release, with minor features, bug fixes, and cleanups. Deprecation removals ~~~~~~~~~~~~~~~~~~~~ - The ``scrapy.utils.gz.read1`` function, deprecated in Scrapy 2.0, has now been removed. Use the :meth:`~io.BufferedIOBase.read1` method of :class:`~gzip.GzipFile` instead. (:gh:`5719`) - The ``scrapy.utils.python.to_native_str`` function, deprecated in Scrapy 2.0, has now been removed. Use :func:`scrapy.utils.python.to_unicode` instead. (:gh:`5719`) - The ``scrapy.utils.python.MutableChain.next`` method, deprecated in Scrapy 2.0, has now been removed. Use :meth:`~scrapy.utils.python.MutableChain.__next__` instead. (:gh:`5719`) - The ``scrapy.linkextractors.FilteringLinkExtractor`` class, deprecated in Scrapy 2.0, has now been removed. Use :class:`LinkExtractor ` instead. (:gh:`5720`) - Support for using environment variables prefixed with ``SCRAPY_`` to override settings, deprecated in Scrapy 2.0, has now been removed. (:gh:`5724`) - Support for the ``noconnect`` query string argument in proxy URLs, deprecated in Scrapy 2.0, has now been removed. We expect proxies that used to need it to work fine without it. (:gh:`5731`) - The ``scrapy.utils.python.retry_on_eintr`` function, deprecated in Scrapy 2.3, has now been removed. (:gh:`5719`) - The ``scrapy.utils.python.WeakKeyCache`` class, deprecated in Scrapy 2.4, has now been removed. (:gh:`5719`) - The ``scrapy.utils.boto.is_botocore()`` function, deprecated in Scrapy 2.4, has now been removed. (:gh:`5719`) Deprecations ~~~~~~~~~~~~ - :exc:`scrapy.pipelines.images.NoimagesDrop` is now deprecated. (:gh:`5368`, :gh:`5489`) - :meth:`ImagesPipeline.convert_image ` must now accept a ``response_body`` parameter. (:gh:`3055`, :gh:`3689`, :gh:`4753`) New features ~~~~~~~~~~~~ - Applied black_ coding style to files generated with the :command:`genspider` and :command:`startproject` commands. (:gh:`5809`, :gh:`5814`) .. _black: https://black.readthedocs.io/en/stable/ - :setting:`FEED_EXPORT_ENCODING` is now set to ``"utf-8"`` in the ``settings.py`` file that the :command:`startproject` command generates. With this value, JSON exports won’t force the use of escape sequences for non-ASCII characters. (:gh:`5797`, :gh:`5800`) - The :class:`~scrapy.extensions.memusage.MemoryUsage` extension now logs the peak memory usage during checks, and the binary unit MiB is now used to avoid confusion. (:gh:`5717`, :gh:`5722`, :gh:`5727`) - The ``callback`` parameter of :class:`~scrapy.Request` can now be set to :func:`scrapy.http.request.NO_CALLBACK`, to distinguish it from ``None``, as the latter indicates that the default spider callback (:meth:`~scrapy.Spider.parse`) is to be used. (:gh:`5798`) Bug fixes ~~~~~~~~~ - Enabled unsafe legacy SSL renegotiation to fix access to some outdated websites. (:gh:`5491`, :gh:`5790`) - Fixed STARTTLS-based email delivery not working with Twisted 21.2.0 and better. (:gh:`5386`, :gh:`5406`) - Fixed the :meth:`finish_exporting` method of :ref:`item exporters ` not being called for empty files. (:gh:`5537`, :gh:`5758`) - Fixed HTTP/2 responses getting only the last value for a header when multiple headers with the same name are received. (:gh:`5777`) - Fixed an exception raised by the :command:`shell` command on some cases when :ref:`using asyncio `. (:gh:`5740`, :gh:`5742`, :gh:`5748`, :gh:`5759`, :gh:`5760`, :gh:`5771`) - When using :class:`~scrapy.spiders.CrawlSpider`, callback keyword arguments (``cb_kwargs``) added to a request in the ``process_request`` callback of a :class:`~scrapy.spiders.Rule` will no longer be ignored. (:gh:`5699`) - The :ref:`images pipeline ` no longer re-encodes JPEG files. (:gh:`3055`, :gh:`3689`, :gh:`4753`) - Fixed the handling of transparent WebP images by the :ref:`images pipeline `. (:gh:`3072`, :gh:`5766`, :gh:`5767`) - :func:`scrapy.shell.inspect_response` no longer inhibits ``SIGINT`` (Ctrl+C). (:gh:`2918`) - :class:`LinkExtractor ` with ``unique=False`` no longer filters out links that have identical URL *and* text. (:gh:`3798`, :gh:`3799`, :gh:`4695`, :gh:`5458`) - :class:`~scrapy.downloadermiddlewares.robotstxt.RobotsTxtMiddleware` now ignores URL protocols that do not support ``robots.txt`` (``data://``, ``file://``). (:gh:`5807`) - Silenced the ``filelock`` debug log messages introduced in Scrapy 2.6. (:gh:`5753`, :gh:`5754`) - Fixed the output of ``scrapy -h`` showing an unintended ``**commands**`` line. (:gh:`5709`, :gh:`5711`, :gh:`5712`) - Made the active project indication in the output of :ref:`commands ` more clear. (:gh:`5715`) Documentation ~~~~~~~~~~~~~ - Documented how to :ref:`debug spiders from Visual Studio Code `. (:gh:`5721`) - Documented how :setting:`DOWNLOAD_DELAY` affects per-domain concurrency. (:gh:`5083`, :gh:`5540`) - Improved consistency. (:gh:`5761`) - Fixed typos. (:gh:`5714`, :gh:`5744`, :gh:`5764`) Quality assurance ~~~~~~~~~~~~~~~~~ - Applied :ref:`black coding style `, sorted import statements, and introduced :ref:`pre-commit `. (:gh:`4654`, :gh:`4658`, :gh:`5734`, :gh:`5737`, :gh:`5806`, :gh:`5810`) - Switched from :mod:`os.path` to :mod:`pathlib`. (:gh:`4916`, :gh:`4497`, :gh:`5682`) - Addressed many issues reported by Pylint. (:gh:`5677`) - Improved code readability. (:gh:`5736`) - Improved package metadata. (:gh:`5768`) - Removed direct invocations of ``setup.py``. (:gh:`5774`, :gh:`5776`) - Removed unnecessary :class:`~collections.OrderedDict` usages. (:gh:`5795`) - Removed unnecessary ``__str__`` definitions. (:gh:`5150`) - Removed obsolete code and comments. (:gh:`5725`, :gh:`5729`, :gh:`5730`, :gh:`5732`) - Fixed test and CI issues. (:gh:`5749`, :gh:`5750`, :gh:`5756`, :gh:`5762`, :gh:`5765`, :gh:`5780`, :gh:`5781`, :gh:`5782`, :gh:`5783`, :gh:`5785`, :gh:`5786`) .. _release-2.7.1: Scrapy 2.7.1 (2022-11-02) ------------------------- New features ~~~~~~~~~~~~ - Relaxed the restriction introduced in 2.6.2 so that the ``Proxy-Authorization`` header can again be set explicitly, as long as the proxy URL in the :reqmeta:`proxy` metadata has no other credentials, and for as long as that proxy URL remains the same; this restores compatibility with scrapy-zyte-smartproxy 2.1.0 and older (:gh:`5626`). Bug fixes ~~~~~~~~~ - Using ``-O``/``--overwrite-output`` and ``-t``/``--output-format`` options together now produces an error instead of ignoring the former option (:gh:`5516`, :gh:`5605`). - Replaced deprecated :mod:`asyncio` APIs that implicitly use the current event loop with code that explicitly requests a loop from the event loop policy (:gh:`5685`, :gh:`5689`). - Fixed uses of deprecated Scrapy APIs in Scrapy itself (:gh:`5588`, :gh:`5589`). - Fixed uses of a deprecated Pillow API (:gh:`5684`, :gh:`5692`). - Improved code that checks if generators return values, so that it no longer fails on decorated methods and partial methods (:gh:`5323`, :gh:`5592`, :gh:`5599`, :gh:`5691`). Documentation ~~~~~~~~~~~~~ - Upgraded the Code of Conduct to Contributor Covenant v2.1 (:gh:`5698`). - Fixed typos (:gh:`5681`, :gh:`5694`). Quality assurance ~~~~~~~~~~~~~~~~~ - Re-enabled some erroneously disabled flake8 checks (:gh:`5688`). - Ignored harmless deprecation warnings from :mod:`typing` in tests (:gh:`5686`, :gh:`5697`). - Modernized our CI configuration (:gh:`5695`, :gh:`5696`). .. _release-2.7.0: Scrapy 2.7.0 (2022-10-17) ----------------------------- Highlights: - Added Python 3.11 support, dropped Python 3.6 support - Improved support for :ref:`asynchronous callbacks ` - :ref:`Asyncio support ` is enabled by default on new projects - Output names of item fields can now be arbitrary strings - Centralized :ref:`request fingerprinting ` configuration is now possible Modified requirements ~~~~~~~~~~~~~~~~~~~~~ Python 3.7 or greater is now required; support for Python 3.6 has been dropped. Support for the upcoming Python 3.11 has been added. The minimum required version of some dependencies has changed as well: - lxml_: 3.5.0 → 4.3.0 - Pillow_ (:ref:`images pipeline `): 4.0.0 → 7.1.0 - zope.interface_: 5.0.0 → 5.1.0 (:gh:`5512`, :gh:`5514`, :gh:`5524`, :gh:`5563`, :gh:`5664`, :gh:`5670`, :gh:`5678`) Deprecations ~~~~~~~~~~~~ - :meth:`ImagesPipeline.thumb_path ` must now accept an ``item`` parameter (:gh:`5504`, :gh:`5508`). - The ``scrapy.downloadermiddlewares.decompression`` module is now deprecated (:gh:`5546`, :gh:`5547`). New features ~~~~~~~~~~~~ - The :meth:`~scrapy.spidermiddlewares.SpiderMiddleware.process_spider_output` method of :ref:`spider middlewares ` can now be defined as an :term:`asynchronous generator` (:gh:`4978`). - The output of :class:`~scrapy.Request` callbacks defined as :ref:`coroutines ` is now processed asynchronously (:gh:`4978`). - :class:`~scrapy.spiders.crawl.CrawlSpider` now supports :ref:`asynchronous callbacks ` (:gh:`5657`). - New projects created with the :command:`startproject` command have :ref:`asyncio support ` enabled by default (:gh:`5590`, :gh:`5679`). - The :setting:`FEED_EXPORT_FIELDS` setting can now be defined as a dictionary to customize the output name of item fields, lifting the restriction that required output names to be valid Python identifiers, e.g. preventing them to have whitespace (:gh:`1008`, :gh:`3266`, :gh:`3696`). - You can now customize :ref:`request fingerprinting ` through the new :setting:`REQUEST_FINGERPRINTER_CLASS` setting, instead of having to change it on every Scrapy component that relies on request fingerprinting (:gh:`900`, :gh:`3420`, :gh:`4113`, :gh:`4762`, :gh:`4524`). - ``jsonl`` is now supported and encouraged as a file extension for `JSON Lines`_ files (:gh:`4848`). .. _JSON Lines: https://jsonlines.org/ - :meth:`ImagesPipeline.thumb_path ` now receives the source :ref:`item ` (:gh:`5504`, :gh:`5508`). Bug fixes ~~~~~~~~~ - When using Google Cloud Storage with a :ref:`media pipeline `, :setting:`FILES_EXPIRES` now also works when :setting:`FILES_STORE` does not point at the root of your Google Cloud Storage bucket (:gh:`5317`, :gh:`5318`). - The :command:`parse` command now supports :ref:`asynchronous callbacks ` (:gh:`5424`, :gh:`5577`). - When using the :command:`parse` command with a URL for which there is no available spider, an exception is no longer raised (:gh:`3264`, :gh:`3265`, :gh:`5375`, :gh:`5376`, :gh:`5497`). - :class:`~scrapy.http.TextResponse` now gives higher priority to the `byte order mark`_ when determining the text encoding of the response body, following the `HTML living standard`_ (:gh:`5601`, :gh:`5611`). .. _byte order mark: https://en.wikipedia.org/wiki/Byte_order_mark .. _HTML living standard: https://html.spec.whatwg.org/multipage/parsing.html#determining-the-character-encoding - MIME sniffing takes the response body into account in FTP and HTTP/1.0 requests, as well as in cached requests (:gh:`4873`). - MIME sniffing now detects valid HTML 5 documents even if the ``html`` tag is missing (:gh:`4873`). - An exception is now raised if :setting:`ASYNCIO_EVENT_LOOP` has a value that does not match the asyncio event loop actually installed (:gh:`5529`). - Fixed :meth:`Headers.getlist() ` returning only the last header (:gh:`5515`, :gh:`5526`). - Fixed :class:`LinkExtractor ` not ignoring the ``tar.gz`` file extension by default (:gh:`1837`, :gh:`2067`, :gh:`4066`) Documentation ~~~~~~~~~~~~~ - Clarified the return type of :meth:`Spider.parse ` (:gh:`5602`, :gh:`5608`). - To enable :class:`~scrapy.downloadermiddlewares.httpcompression.HttpCompressionMiddleware` to do `brotli compression`_, installing brotli_ is now recommended instead of installing brotlipy_, as the former provides a more recent version of brotli. .. _brotli: https://github.com/google/brotli .. _brotli compression: https://www.ietf.org/rfc/rfc7932.txt - :ref:`Signal documentation ` now mentions :ref:`coroutine support ` and uses it in code examples (:gh:`4852`, :gh:`5358`). - :ref:`bans` now recommends `Common Crawl`_ instead of `Google cache`_ (:gh:`3582`, :gh:`5432`). .. _Common Crawl: https://commoncrawl.org/ .. _Google cache: https://www.googleguide.com/cached_pages.html - The new :ref:`topics-components` topic covers enforcing requirements on Scrapy components, like :ref:`downloader middlewares `, :ref:`extensions `, :ref:`item pipelines `, :ref:`spider middlewares `, and more; :ref:`enforce-asyncio-requirement` has also been added (:gh:`4978`). - :ref:`topics-settings` now indicates that setting values must be :ref:`picklable ` (:gh:`5607`, :gh:`5629`). - Removed outdated documentation (:gh:`5446`, :gh:`5373`, :gh:`5369`, :gh:`5370`, :gh:`5554`). - Fixed typos (:gh:`5442`, :gh:`5455`, :gh:`5457`, :gh:`5461`, :gh:`5538`, :gh:`5553`, :gh:`5558`, :gh:`5624`, :gh:`5631`). - Fixed other issues (:gh:`5283`, :gh:`5284`, :gh:`5559`, :gh:`5567`, :gh:`5648`, :gh:`5659`, :gh:`5665`). Quality assurance ~~~~~~~~~~~~~~~~~ - Added a continuous integration job to run `twine check`_ (:gh:`5655`, :gh:`5656`). .. _twine check: https://twine.readthedocs.io/en/stable/#twine-check - Addressed test issues and warnings (:gh:`5560`, :gh:`5561`, :gh:`5612`, :gh:`5617`, :gh:`5639`, :gh:`5645`, :gh:`5662`, :gh:`5671`, :gh:`5675`). - Cleaned up code (:gh:`4991`, :gh:`4995`, :gh:`5451`, :gh:`5487`, :gh:`5542`, :gh:`5667`, :gh:`5668`, :gh:`5672`). - Applied minor code improvements (:gh:`5661`). .. _release-2.6.3: Scrapy 2.6.3 (2022-09-27) ------------------------- - Added support for pyOpenSSL_ 22.1.0, removing support for SSLv3 (:gh:`5634`, :gh:`5635`, :gh:`5636`). - Upgraded the minimum versions of the following dependencies: - cryptography_: 2.0 → 3.3 - pyOpenSSL_: 16.2.0 → 21.0.0 - service_identity_: 16.0.0 → 18.1.0 - Twisted_: 17.9.0 → 18.9.0 - zope.interface_: 4.1.3 → 5.0.0 (:gh:`5621`, :gh:`5632`) - Fixes test and documentation issues (:gh:`5612`, :gh:`5617`, :gh:`5631`). .. _release-2.6.2: Scrapy 2.6.2 (2022-07-25) ------------------------- **Security bug fix:** - When :class:`~scrapy.downloadermiddlewares.httpproxy.HttpProxyMiddleware` processes a request with :reqmeta:`proxy` metadata, and that :reqmeta:`proxy` metadata includes proxy credentials, :class:`~scrapy.downloadermiddlewares.httpproxy.HttpProxyMiddleware` sets the ``Proxy-Authorization`` header, but only if that header is not already set. There are third-party proxy-rotation downloader middlewares that set different :reqmeta:`proxy` metadata every time they process a request. Because of request retries and redirects, the same request can be processed by downloader middlewares more than once, including both :class:`~scrapy.downloadermiddlewares.httpproxy.HttpProxyMiddleware` and any third-party proxy-rotation downloader middleware. These third-party proxy-rotation downloader middlewares could change the :reqmeta:`proxy` metadata of a request to a new value, but fail to remove the ``Proxy-Authorization`` header from the previous value of the :reqmeta:`proxy` metadata, causing the credentials of one proxy to be sent to a different proxy. To prevent the unintended leaking of proxy credentials, the behavior of :class:`~scrapy.downloadermiddlewares.httpproxy.HttpProxyMiddleware` is now as follows when processing a request: - If the request being processed defines :reqmeta:`proxy` metadata that includes credentials, the ``Proxy-Authorization`` header is always updated to feature those credentials. - If the request being processed defines :reqmeta:`proxy` metadata without credentials, the ``Proxy-Authorization`` header is removed *unless* it was originally defined for the same proxy URL. To remove proxy credentials while keeping the same proxy URL, remove the ``Proxy-Authorization`` header. - If the request has no :reqmeta:`proxy` metadata, or that metadata is a falsy value (e.g. ``None``), the ``Proxy-Authorization`` header is removed. It is no longer possible to set a proxy URL through the :reqmeta:`proxy` metadata but set the credentials through the ``Proxy-Authorization`` header. Set proxy credentials through the :reqmeta:`proxy` metadata instead. Also fixes the following regressions introduced in 2.6.0: - :class:`~scrapy.crawler.CrawlerProcess` supports again crawling multiple spiders (:gh:`5435`, :gh:`5436`) - Installing a Twisted reactor before Scrapy does (e.g. importing :mod:`twisted.internet.reactor` somewhere at the module level) no longer prevents Scrapy from starting, as long as a different reactor is not specified in :setting:`TWISTED_REACTOR` (:gh:`5525`, :gh:`5528`) - Fixed an exception that was being logged after the spider finished under certain conditions (:gh:`5437`, :gh:`5440`) - The ``--output``/``-o`` command-line parameter supports again a value starting with a hyphen (:gh:`5444`, :gh:`5445`) - The ``scrapy parse -h`` command no longer throws an error (:gh:`5481`, :gh:`5482`) .. _release-2.6.1: Scrapy 2.6.1 (2022-03-01) ------------------------- Fixes a regression introduced in 2.6.0 that would unset the request method when following redirects. .. _release-2.6.0: Scrapy 2.6.0 (2022-03-01) ------------------------- Highlights: * :ref:`Security fixes for cookie handling <2.6-security-fixes>` * Python 3.10 support * :ref:`asyncio support ` is no longer considered experimental, and works out-of-the-box on Windows regardless of your Python version * Feed exports now support :class:`pathlib.Path` output paths and per-feed :ref:`item filtering ` and :ref:`post-processing ` .. _2.6-security-fixes: Security bug fixes ~~~~~~~~~~~~~~~~~~ - When a :class:`~scrapy.Request` object with cookies defined gets a redirect response causing a new :class:`~scrapy.Request` object to be scheduled, the cookies defined in the original :class:`~scrapy.Request` object are no longer copied into the new :class:`~scrapy.Request` object. If you manually set the ``Cookie`` header on a :class:`~scrapy.Request` object and the domain name of the redirect URL is not an exact match for the domain of the URL of the original :class:`~scrapy.Request` object, your ``Cookie`` header is now dropped from the new :class:`~scrapy.Request` object. The old behavior could be exploited by an attacker to gain access to your cookies. Please, see the `cjvr-mfj7-j4j8 security advisory`_ for more information. .. _cjvr-mfj7-j4j8 security advisory: https://github.com/scrapy/scrapy/security/advisories/GHSA-cjvr-mfj7-j4j8 .. note:: It is still possible to enable the sharing of cookies between different domains with a shared domain suffix (e.g. ``example.com`` and any subdomain) by defining the shared domain suffix (e.g. ``example.com``) as the cookie domain when defining your cookies. See the documentation of the :class:`~scrapy.Request` class for more information. - When the domain of a cookie, either received in the ``Set-Cookie`` header of a response or defined in a :class:`~scrapy.Request` object, is set to a `public suffix `_, the cookie is now ignored unless the cookie domain is the same as the request domain. The old behavior could be exploited by an attacker to inject cookies from a controlled domain into your cookiejar that could be sent to other domains not controlled by the attacker. Please, see the `mfjm-vh54-3f96 security advisory`_ for more information. .. _mfjm-vh54-3f96 security advisory: https://github.com/scrapy/scrapy/security/advisories/GHSA-mfjm-vh54-3f96 Modified requirements ~~~~~~~~~~~~~~~~~~~~~ - The h2_ dependency is now optional, only needed to :ref:`enable HTTP/2 support `. (:gh:`5113`) .. _h2: https://pypi.org/project/h2/ Backward-incompatible changes ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - The ``formdata`` parameter of :class:`~scrapy.FormRequest`, if specified for a non-POST request, now overrides the URL query string, instead of being appended to it. (:gh:`2919`, :gh:`3579`) - When a function is assigned to the :setting:`FEED_URI_PARAMS` setting, now the return value of that function, and not the ``params`` input parameter, will determine the feed URI parameters, unless that return value is ``None``. (:gh:`4962`, :gh:`4966`) - In :class:`scrapy.core.engine.ExecutionEngine`, methods :meth:`~scrapy.core.engine.ExecutionEngine.crawl`, :meth:`~scrapy.core.engine.ExecutionEngine.download`, :meth:`~scrapy.core.engine.ExecutionEngine.schedule`, and :meth:`~scrapy.core.engine.ExecutionEngine.spider_is_idle` now raise :exc:`RuntimeError` if called before :meth:`~scrapy.core.engine.ExecutionEngine.open_spider`. (:gh:`5090`) These methods used to assume that :attr:`ExecutionEngine.slot ` had been defined by a prior call to :meth:`~scrapy.core.engine.ExecutionEngine.open_spider`, so they were raising :exc:`AttributeError` instead. - If the API of the configured :ref:`scheduler ` does not meet expectations, :exc:`TypeError` is now raised at startup time. Before, other exceptions would be raised at run time. (:gh:`3559`) - The ``_encoding`` field of serialized :class:`~scrapy.Request` objects is now named ``encoding``, in line with all other fields (:gh:`5130`) Deprecation removals ~~~~~~~~~~~~~~~~~~~~ - ``scrapy.http.TextResponse.body_as_unicode``, deprecated in Scrapy 2.2, has now been removed. (:gh:`5393`) - ``scrapy.item.BaseItem``, deprecated in Scrapy 2.2, has now been removed. (:gh:`5398`) - ``scrapy.item.DictItem``, deprecated in Scrapy 1.8, has now been removed. (:gh:`5398`) - ``scrapy.Spider.make_requests_from_url``, deprecated in Scrapy 1.4, has now been removed. (:gh:`4178`, :gh:`4356`) Deprecations ~~~~~~~~~~~~ - When a function is assigned to the :setting:`FEED_URI_PARAMS` setting, returning ``None`` or modifying the ``params`` input parameter is now deprecated. Return a new dictionary instead. (:gh:`4962`, :gh:`4966`) - :mod:`scrapy.utils.reqser` is deprecated. (:gh:`5130`) - Instead of :func:`~scrapy.utils.reqser.request_to_dict`, use the new :meth:`.Request.to_dict` method. - Instead of :func:`~scrapy.utils.reqser.request_from_dict`, use the new :func:`scrapy.utils.request.request_from_dict` function. - In :mod:`scrapy.squeues`, the following queue classes are deprecated: :class:`~scrapy.squeues.PickleFifoDiskQueueNonRequest`, :class:`~scrapy.squeues.PickleLifoDiskQueueNonRequest`, :class:`~scrapy.squeues.MarshalFifoDiskQueueNonRequest`, and :class:`~scrapy.squeues.MarshalLifoDiskQueueNonRequest`. You should instead use: :class:`~scrapy.squeues.PickleFifoDiskQueue`, :class:`~scrapy.squeues.PickleLifoDiskQueue`, :class:`~scrapy.squeues.MarshalFifoDiskQueue`, and :class:`~scrapy.squeues.MarshalLifoDiskQueue`. (:gh:`5117`) - Many aspects of :class:`scrapy.core.engine.ExecutionEngine` that come from a time when this class could handle multiple :class:`~scrapy.Spider` objects at a time have been deprecated. (:gh:`5090`) - The :meth:`~scrapy.core.engine.ExecutionEngine.has_capacity` method is deprecated. - The :meth:`~scrapy.core.engine.ExecutionEngine.schedule` method is deprecated, use :meth:`~scrapy.core.engine.ExecutionEngine.crawl` or :meth:`~scrapy.core.engine.ExecutionEngine.download` instead. - The :attr:`~scrapy.core.engine.ExecutionEngine.open_spiders` attribute is deprecated, use :attr:`~scrapy.core.engine.ExecutionEngine.spider` instead. - The ``spider`` parameter is deprecated for the following methods: - :meth:`~scrapy.core.engine.ExecutionEngine.spider_is_idle` - :meth:`~scrapy.core.engine.ExecutionEngine.crawl` - :meth:`~scrapy.core.engine.ExecutionEngine.download` 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. (:gh:`4972`) New features ~~~~~~~~~~~~ - You can now use :ref:`item filtering ` to control which items are exported to each output feed. (:gh:`4575`, :gh:`5178`, :gh:`5161`, :gh:`5203`) - You can now apply :ref:`post-processing ` to feeds, and :ref:`built-in post-processing plugins ` are provided for output file compression. (:gh:`2174`, :gh:`5168`, :gh:`5190`) - The :setting:`FEEDS` setting now supports :class:`pathlib.Path` objects as keys. (:gh:`5383`, :gh:`5384`) - Enabling :ref:`asyncio ` while using Windows and Python 3.8 or later will automatically switch the asyncio event loop to one that allows Scrapy to work. See :ref:`asyncio-windows`. (:gh:`4976`, :gh:`5315`) - The :command:`genspider` command now supports a start URL instead of a domain name. (:gh:`4439`) - :mod:`scrapy.utils.defer` gained 2 new functions, :func:`~scrapy.utils.defer.deferred_to_future` and :func:`~scrapy.utils.defer.maybe_deferred_to_future`, to help :ref:`await on Deferreds when using the asyncio reactor `. (:gh:`5288`) - :ref:`Amazon S3 feed export storage ` gained support for `temporary security credentials`_ (:setting:`AWS_SESSION_TOKEN`) and endpoint customization (:setting:`AWS_ENDPOINT_URL`). (:gh:`4998`, :gh:`5210`) .. _temporary security credentials: https://docs.aws.amazon.com/IAM/latest/UserGuide/security-creds.html - New :setting:`LOG_FILE_APPEND` setting to allow truncating the log file. (:gh:`5279`) - :attr:`Request.cookies ` values that are :class:`bool`, :class:`float` or :class:`int` are cast to :class:`str`. (:gh:`5252`, :gh:`5253`) - You may now raise :exc:`~scrapy.exceptions.CloseSpider` from a handler of the :signal:`spider_idle` signal to customize the reason why the spider is stopping. (:gh:`5191`) - When using :class:`~scrapy.downloadermiddlewares.httpproxy.HttpProxyMiddleware`, the proxy URL for non-HTTPS HTTP/1.1 requests no longer needs to include a URL scheme. (:gh:`4505`, :gh:`4649`) - All built-in queues now expose a ``peek`` method that returns the next queue object (like ``pop``) but does not remove the returned object from the queue. (:gh:`5112`) If the underlying queue does not support peeking (e.g. because you are not using ``queuelib`` 1.6.1 or later), the ``peek`` method raises :exc:`NotImplementedError`. - :class:`~scrapy.Request` and :class:`~scrapy.http.Response` now have an ``attributes`` attribute that makes subclassing easier. For :class:`~scrapy.Request`, it also allows subclasses to work with :func:`scrapy.utils.request.request_from_dict`. (:gh:`1877`, :gh:`5130`, :gh:`5218`) - The :meth:`~scrapy.core.scheduler.BaseScheduler.open` and :meth:`~scrapy.core.scheduler.BaseScheduler.close` methods of the :ref:`scheduler ` are now optional. (:gh:`3559`) - HTTP/1.1 :exc:`~scrapy.core.downloader.handlers.http11.TunnelError` exceptions now only truncate response bodies longer than 1000 characters, instead of those longer than 32 characters, making it easier to debug such errors. (:gh:`4881`, :gh:`5007`) - :class:`~scrapy.loader.ItemLoader` now supports non-text responses. (:gh:`5145`, :gh:`5269`) Bug fixes ~~~~~~~~~ - The :setting:`TWISTED_REACTOR` and :setting:`ASYNCIO_EVENT_LOOP` settings are no longer ignored if defined in :attr:`~scrapy.Spider.custom_settings`. (:gh:`4485`, :gh:`5352`) - Removed a module-level Twisted reactor import that could prevent :ref:`using the asyncio reactor `. (:gh:`5357`) - The :command:`startproject` command works with existing folders again. (:gh:`4665`, :gh:`4676`) - The :setting:`FEED_URI_PARAMS` setting now behaves as documented. (:gh:`4962`, :gh:`4966`) - :attr:`Request.cb_kwargs ` once again allows the ``callback`` keyword. (:gh:`5237`, :gh:`5251`, :gh:`5264`) - Made :func:`scrapy.utils.response.open_in_browser` support more complex HTML. (:gh:`5319`, :gh:`5320`) - Fixed :attr:`CSVFeedSpider.quotechar ` being interpreted as the CSV file encoding. (:gh:`5391`, :gh:`5394`) - Added missing setuptools_ to the list of dependencies. (:gh:`5122`) .. _setuptools: https://pypi.org/project/setuptools/ - :class:`LinkExtractor ` now also works as expected with links that have comma-separated ``rel`` attribute values including ``nofollow``. (:gh:`5225`) - Fixed a :exc:`TypeError` that could be raised during :ref:`feed export ` parameter parsing. (:gh:`5359`) Documentation ~~~~~~~~~~~~~ - :ref:`asyncio support ` is no longer considered experimental. (:gh:`5332`) - Included :ref:`Windows-specific help for asyncio usage `. (:gh:`4976`, :gh:`5315`) - Rewrote :ref:`topics-headless-browsing` with up-to-date best practices. (:gh:`4484`, :gh:`4613`) - Documented :ref:`local file naming in media pipelines `. (:gh:`5069`, :gh:`5152`) - :ref:`faq` now covers spider file name collision issues. (:gh:`2680`, :gh:`3669`) - Provided better context and instructions to disable the :setting:`URLLENGTH_LIMIT` setting. (:gh:`5135`, :gh:`5250`) - Documented that Reppy parser does not support Python 3.9+. (:gh:`5226`, :gh:`5231`) - Documented :ref:`the scheduler component `. (:gh:`3537`, :gh:`3559`) - Documented the method used by :ref:`media pipelines ` to :ref:`determine if a file has expired `. (:gh:`5120`, :gh:`5254`) - :ref:`run-multiple-spiders` now features :func:`scrapy.utils.project.get_project_settings` usage. (:gh:`5070`) - :ref:`run-multiple-spiders` now covers what happens when you define different per-spider values for some settings that cannot differ at run time. (:gh:`4485`, :gh:`5352`) - Extended the documentation of the :class:`~scrapy.extensions.statsmailer.StatsMailer` extension. (:gh:`5199`, :gh:`5217`) - Added :setting:`JOBDIR` to :ref:`topics-settings`. (:gh:`5173`, :gh:`5224`) - Documented :attr:`Spider.attribute `. (:gh:`5174`, :gh:`5244`) - Documented :attr:`TextResponse.urljoin `. (:gh:`1582`) - Added the ``body_length`` parameter to the documented signature of the :signal:`headers_received` signal. (:gh:`5270`) - Clarified :meth:`SelectorList.get ` usage in the :ref:`tutorial `. (:gh:`5256`) - The documentation now features the shortest import path of classes with multiple import paths. (:gh:`2733`, :gh:`5099`) - ``quotes.toscrape.com`` references now use HTTPS instead of HTTP. (:gh:`5395`, :gh:`5396`) - Added a link to `our Discord server `_ to :ref:`getting-help`. (:gh:`5421`, :gh:`5422`) - The pronunciation of the project name is now :ref:`officially ` /ˈskreɪpaɪ/. (:gh:`5280`, :gh:`5281`) - Added the Scrapy logo to the README. (:gh:`5255`, :gh:`5258`) - Fixed issues and implemented minor improvements. (:gh:`3155`, :gh:`4335`, :gh:`5074`, :gh:`5098`, :gh:`5134`, :gh:`5180`, :gh:`5194`, :gh:`5239`, :gh:`5266`, :gh:`5271`, :gh:`5273`, :gh:`5274`, :gh:`5276`, :gh:`5347`, :gh:`5356`, :gh:`5414`, :gh:`5415`, :gh:`5416`, :gh:`5419`, :gh:`5420`) Quality Assurance ~~~~~~~~~~~~~~~~~ - Added support for Python 3.10. (:gh:`5212`, :gh:`5221`, :gh:`5265`) - Significantly reduced memory usage by :func:`scrapy.utils.response.response_httprepr`, used by the :class:`~scrapy.downloadermiddlewares.stats.DownloaderStats` downloader middleware, which is enabled by default. (:gh:`4964`, :gh:`4972`) - Removed uses of the deprecated :mod:`optparse` module. (:gh:`5366`, :gh:`5374`) - Extended typing hints. (:gh:`5077`, :gh:`5090`, :gh:`5100`, :gh:`5108`, :gh:`5171`, :gh:`5215`, :gh:`5334`) - Improved tests, fixed CI issues, removed unused code. (:gh:`5094`, :gh:`5157`, :gh:`5162`, :gh:`5198`, :gh:`5207`, :gh:`5208`, :gh:`5229`, :gh:`5298`, :gh:`5299`, :gh:`5310`, :gh:`5316`, :gh:`5333`, :gh:`5388`, :gh:`5389`, :gh:`5400`, :gh:`5401`, :gh:`5404`, :gh:`5405`, :gh:`5407`, :gh:`5410`, :gh:`5412`, :gh:`5425`, :gh:`5427`) - Implemented improvements for contributors. (:gh:`5080`, :gh:`5082`, :gh:`5177`, :gh:`5200`) - Implemented cleanups. (:gh:`5095`, :gh:`5106`, :gh:`5209`, :gh:`5228`, :gh:`5235`, :gh:`5245`, :gh:`5246`, :gh:`5292`, :gh:`5314`, :gh:`5322`) .. _release-2.5.1: Scrapy 2.5.1 (2021-10-05) ------------------------- * **Security bug fix:** If you use :class:`~scrapy.downloadermiddlewares.httpauth.HttpAuthMiddleware` (i.e. the ``http_user`` and ``http_pass`` spider attributes) for HTTP authentication, any request exposes your credentials to the request target. To prevent unintended exposure of authentication credentials to unintended domains, you must now additionally set a new, additional spider attribute, ``http_auth_domain``, and point it to the specific domain to which the authentication credentials must be sent. If the ``http_auth_domain`` spider attribute is not set, the domain of the first request will be considered the HTTP authentication target, and authentication credentials will only be sent in requests targeting that domain. If you need to send the same HTTP authentication credentials to multiple domains, you can use :func:`w3lib.http.basic_auth_header` instead to set the value of the ``Authorization`` header of your requests. If you *really* want your spider to send the same HTTP authentication credentials to any domain, set the ``http_auth_domain`` spider attribute to ``None``. Finally, if you are a user of `scrapy-splash`_, know that this version of Scrapy breaks compatibility with scrapy-splash 0.7.2 and earlier. You will need to upgrade scrapy-splash to a greater version for it to continue to work. .. _release-2.5.0: Scrapy 2.5.0 (2021-04-06) ------------------------- Highlights: - Official Python 3.9 support - Experimental :ref:`HTTP/2 support ` - New :func:`~scrapy.downloadermiddlewares.retry.get_retry_request` function to retry requests from spider callbacks - New :class:`~scrapy.signals.headers_received` signal that allows stopping downloads early - New :class:`Response.protocol ` attribute Deprecation removals ~~~~~~~~~~~~~~~~~~~~ - Removed all code that :ref:`was deprecated in 1.7.0 <1.7-deprecations>` and had not :ref:`already been removed in 2.4.0 <2.4-deprecation-removals>`. (:gh:`4901`) - Removed support for the ``SCRAPY_PICKLED_SETTINGS_TO_OVERRIDE`` environment variable, :ref:`deprecated in 1.8.0 <1.8-deprecations>`. (:gh:`4912`) Deprecations ~~~~~~~~~~~~ - The :mod:`scrapy.utils.py36` module is now deprecated in favor of :mod:`scrapy.utils.asyncgen`. (:gh:`4900`) New features ~~~~~~~~~~~~ - Experimental :ref:`HTTP/2 support ` through a new download handler that can be assigned to the ``https`` protocol in the :setting:`DOWNLOAD_HANDLERS` setting. (:gh:`1854`, :gh:`4769`, :gh:`5058`, :gh:`5059`, :gh:`5066`) - The new :func:`scrapy.downloadermiddlewares.retry.get_retry_request` function may be used from spider callbacks or middlewares to handle the retrying of a request beyond the scenarios that :class:`~scrapy.downloadermiddlewares.retry.RetryMiddleware` supports. (:gh:`3590`, :gh:`3685`, :gh:`4902`) - The new :class:`~scrapy.signals.headers_received` signal gives early access to response headers and allows :ref:`stopping downloads `. (:gh:`1772`, :gh:`4897`) - The new :attr:`Response.protocol ` attribute gives access to the string that identifies the protocol used to download a response. (:gh:`4878`) - :ref:`Stats ` now include the following entries that indicate the number of successes and failures in storing :ref:`feeds `:: feedexport/success_count/ feedexport/failed_count/ Where ```` is the feed storage backend class name, such as :class:`~scrapy.extensions.feedexport.FileFeedStorage` or :class:`~scrapy.extensions.feedexport.FTPFeedStorage`. (:gh:`3947`, :gh:`4850`) - The :class:`~scrapy.spidermiddlewares.urllength.UrlLengthMiddleware` spider middleware now logs ignored URLs with ``INFO`` :ref:`logging level ` instead of ``DEBUG``, and it now includes the following entry into :ref:`stats ` to keep track of the number of ignored URLs:: urllength/request_ignored_count (:gh:`5036`) - The :class:`~scrapy.downloadermiddlewares.httpcompression.HttpCompressionMiddleware` downloader middleware now logs the number of decompressed responses and the total count of resulting bytes:: httpcompression/response_bytes httpcompression/response_count (:gh:`4797`, :gh:`4799`) Bug fixes ~~~~~~~~~ - Fixed installation on PyPy installing PyDispatcher in addition to PyPyDispatcher, which could prevent Scrapy from working depending on which package got imported. (:gh:`4710`, :gh:`4814`) - When inspecting a callback to check if it is a generator that also returns a value, an exception is no longer raised if the callback has a docstring with lower indentation than the following code. (:gh:`4477`, :gh:`4935`) - The `Content-Length `_ header is no longer omitted from responses when using the default, HTTP/1.1 download handler (see :setting:`DOWNLOAD_HANDLERS`). (:gh:`5009`, :gh:`5034`, :gh:`5045`, :gh:`5057`, :gh:`5062`) - Setting the :reqmeta:`handle_httpstatus_all` request meta key to ``False`` now has the same effect as not setting it at all, instead of having the same effect as setting it to ``True``. (:gh:`3851`, :gh:`4694`) Documentation ~~~~~~~~~~~~~ - Added instructions to :ref:`install Scrapy in Windows using pip `. (:gh:`4715`, :gh:`4736`) - Logging documentation now includes :ref:`additional ways to filter logs `. (:gh:`4216`, :gh:`4257`, :gh:`4965`) - Covered how to deal with long lists of allowed domains in the :ref:`FAQ `. (:gh:`2263`, :gh:`3667`) - Covered scrapy-bench_ in :ref:`benchmarking`. (:gh:`4996`, :gh:`5016`) - Clarified that one :ref:`extension ` instance is created per crawler. (:gh:`5014`) - Fixed some errors in examples. (:gh:`4829`, :gh:`4830`, :gh:`4907`, :gh:`4909`, :gh:`5008`) - Fixed some external links, typos, and so on. (:gh:`4892`, :gh:`4899`, :gh:`4936`, :gh:`4942`, :gh:`5005`, :gh:`5063`) - The :ref:`list of Request.meta keys ` is now sorted alphabetically. (:gh:`5061`, :gh:`5065`) - Updated references to Scrapinghub, which is now called Zyte. (:gh:`4973`, :gh:`5072`) - Added a mention to contributors in the README. (:gh:`4956`) - Reduced the top margin of lists. (:gh:`4974`) Quality Assurance ~~~~~~~~~~~~~~~~~ - Made Python 3.9 support official (:gh:`4757`, :gh:`4759`) - Extended typing hints (:gh:`4895`) - Fixed deprecated uses of the Twisted API. (:gh:`4940`, :gh:`4950`, :gh:`5073`) - Made our tests run with the new pip resolver. (:gh:`4710`, :gh:`4814`) - Added tests to ensure that :ref:`coroutine support ` is tested. (:gh:`4987`) - Migrated from Travis CI to GitHub Actions. (:gh:`4924`) - Fixed CI issues. (:gh:`4986`, :gh:`5020`, :gh:`5022`, :gh:`5027`, :gh:`5052`, :gh:`5053`) - Implemented code refactorings, style fixes and cleanups. (:gh:`4911`, :gh:`4982`, :gh:`5001`, :gh:`5002`, :gh:`5076`) .. _release-2.4.1: Scrapy 2.4.1 (2020-11-17) ------------------------- - Fixed :ref:`feed exports ` overwrite support (:gh:`4845`, :gh:`4857`, :gh:`4859`) - Fixed the AsyncIO event loop handling, which could make code hang (:gh:`4855`, :gh:`4872`) - Fixed the IPv6-capable DNS resolver :class:`~scrapy.resolver.CachingHostnameResolver` for download handlers that call :meth:`reactor.resolve ` (:gh:`4802`, :gh:`4803`) - Fixed the output of the :command:`genspider` command showing placeholders instead of the import path of the generated spider module (:gh:`4874`) - Migrated Windows CI from Azure Pipelines to GitHub Actions (:gh:`4869`, :gh:`4876`) .. _release-2.4.0: Scrapy 2.4.0 (2020-10-11) ------------------------- Highlights: * Python 3.5 support has been dropped. * The ``file_path`` method of :ref:`media pipelines ` can now access the source :ref:`item `. This allows you to set a download file path based on item data. * The new ``item_export_kwargs`` key of the :setting:`FEEDS` setting allows to define keyword parameters to pass to :ref:`item exporter classes ` * You can now choose whether :ref:`feed exports ` overwrite or append to the output file. For example, when using the :command:`crawl` or :command:`runspider` commands, you can use the ``-O`` option instead of ``-o`` to overwrite the output file. * Zstd-compressed responses are now supported if zstandard_ is installed. * In settings, where the import path of a class is required, it is now possible to pass a class object instead. Modified requirements ~~~~~~~~~~~~~~~~~~~~~ * Python 3.6 or greater is now required; support for Python 3.5 has been dropped As a result: - When using PyPy, PyPy 7.2.0 or greater :ref:`is now required ` - For Amazon S3 storage support in :ref:`feed exports ` or :ref:`media pipelines `, botocore_ 1.4.87 or greater is now required - To use the :ref:`images pipeline `, Pillow_ 4.0.0 or greater is now required (:gh:`4718`, :gh:`4732`, :gh:`4733`, :gh:`4742`, :gh:`4743`, :gh:`4764`) Backward-incompatible changes ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ * :class:`~scrapy.downloadermiddlewares.cookies.CookiesMiddleware` once again discards cookies defined in :attr:`.Request.headers`. We decided to revert this bug fix, introduced in Scrapy 2.2.0, because it was reported that the current implementation could break existing code. If you need to set cookies for a request, use the :class:`Request.cookies ` parameter. A future version of Scrapy will include a new, better implementation of the reverted bug fix. (:gh:`4717`, :gh:`4823`) .. _2.4-deprecation-removals: Deprecation removals ~~~~~~~~~~~~~~~~~~~~ * :class:`scrapy.extensions.feedexport.S3FeedStorage` no longer reads the values of ``access_key`` and ``secret_key`` from the running project settings when they are not passed to its ``__init__`` method; you must either pass those parameters to its ``__init__`` method or use :class:`S3FeedStorage.from_crawler ` (:gh:`4356`, :gh:`4411`, :gh:`4688`) * :attr:`Rule.process_request ` no longer admits callables which expect a single ``request`` parameter, rather than both ``request`` and ``response`` (:gh:`4818`) Deprecations ~~~~~~~~~~~~ * In custom :ref:`media pipelines `, signatures that do not accept a keyword-only ``item`` parameter in any of the methods that :ref:`now support this parameter ` are now deprecated (:gh:`4628`, :gh:`4686`) * In custom :ref:`feed storage backend classes `, ``__init__`` method signatures that do not accept a keyword-only ``feed_options`` parameter are now deprecated (:gh:`547`, :gh:`716`, :gh:`4512`) * The :class:`scrapy.utils.python.WeakKeyCache` class is now deprecated (:gh:`4684`, :gh:`4701`) * The :func:`scrapy.utils.boto.is_botocore` function is now deprecated, use :func:`scrapy.utils.boto.is_botocore_available` instead (:gh:`4734`, :gh:`4776`) New features ~~~~~~~~~~~~ .. _media-pipeline-item-parameter: * The following methods of :ref:`media pipelines ` now accept an ``item`` keyword-only parameter containing the source :ref:`item `: - In :class:`scrapy.pipelines.files.FilesPipeline`: - :meth:`~scrapy.pipelines.files.FilesPipeline.file_downloaded` - :meth:`~scrapy.pipelines.files.FilesPipeline.file_path` - :meth:`~scrapy.pipelines.files.FilesPipeline.media_downloaded` - :meth:`~scrapy.pipelines.files.FilesPipeline.media_to_download` - In :class:`scrapy.pipelines.images.ImagesPipeline`: - :meth:`~scrapy.pipelines.images.ImagesPipeline.file_downloaded` - :meth:`~scrapy.pipelines.images.ImagesPipeline.file_path` - :meth:`~scrapy.pipelines.images.ImagesPipeline.get_images` - :meth:`~scrapy.pipelines.images.ImagesPipeline.image_downloaded` - :meth:`~scrapy.pipelines.images.ImagesPipeline.media_downloaded` - :meth:`~scrapy.pipelines.images.ImagesPipeline.media_to_download` (:gh:`4628`, :gh:`4686`) * The new ``item_export_kwargs`` key of the :setting:`FEEDS` setting allows to define keyword parameters to pass to :ref:`item exporter classes ` (:gh:`4606`, :gh:`4768`) * :ref:`Feed exports ` gained overwrite support: * When using the :command:`crawl` or :command:`runspider` commands, you can use the ``-O`` option instead of ``-o`` to overwrite the output file * You can use the ``overwrite`` key in the :setting:`FEEDS` setting to configure whether to overwrite the output file (``True``) or append to its content (``False``) * The ``__init__`` and ``from_crawler`` methods of :ref:`feed storage backend classes ` now receive a new keyword-only parameter, ``feed_options``, which is a dictionary of :ref:`feed options ` (:gh:`547`, :gh:`716`, :gh:`4512`) * Zstd-compressed responses are now supported if zstandard_ is installed (:gh:`4831`) * In settings, where the import path of a class is required, it is now possible to pass a class object instead (:gh:`3870`, :gh:`3873`). This includes also settings where only part of its value is made of an import path, such as :setting:`DOWNLOADER_MIDDLEWARES` or :setting:`DOWNLOAD_HANDLERS`. * :ref:`Downloader middlewares ` can now override :class:`response.request `. If a :ref:`downloader middleware ` returns a :class:`~scrapy.http.Response` object from :meth:`~scrapy.downloadermiddlewares.DownloaderMiddleware.process_response` or :meth:`~scrapy.downloadermiddlewares.DownloaderMiddleware.process_exception` with a custom :class:`~scrapy.Request` object assigned to :class:`response.request `: - The response is handled by the callback of that custom :class:`~scrapy.Request` object, instead of being handled by the callback of the original :class:`~scrapy.Request` object - That custom :class:`~scrapy.Request` object is now sent as the ``request`` argument to the :signal:`response_received` signal, instead of the original :class:`~scrapy.Request` object (:gh:`4529`, :gh:`4632`) * When using the :ref:`FTP feed storage backend `: - It is now possible to set the new ``overwrite`` :ref:`feed option ` to ``False`` to append to an existing file instead of overwriting it - The FTP password can now be omitted if it is not necessary (:gh:`547`, :gh:`716`, :gh:`4512`) * The ``__init__`` method of :class:`~scrapy.exporters.CsvItemExporter` now supports an ``errors`` parameter to indicate how to handle encoding errors (:gh:`4755`) * When :ref:`using asyncio `, it is now possible to :ref:`set a custom asyncio loop ` (:gh:`4306`, :gh:`4414`) * Serialized requests (see :ref:`topics-jobs`) now support callbacks that are spider methods that delegate on other callable (:gh:`4756`) * When a response is larger than :setting:`DOWNLOAD_MAXSIZE`, the logged message is now a warning, instead of an error (:gh:`3874`, :gh:`3886`, :gh:`4752`) Bug fixes ~~~~~~~~~ * The :command:`genspider` command no longer overwrites existing files unless the ``--force`` option is used (:gh:`4561`, :gh:`4616`, :gh:`4623`) * Cookies with an empty value are no longer considered invalid cookies (:gh:`4772`) * The :command:`runspider` command now supports files with the ``.pyw`` file extension (:gh:`4643`, :gh:`4646`) * The :class:`~scrapy.downloadermiddlewares.httpproxy.HttpProxyMiddleware` middleware now simply ignores unsupported proxy values (:gh:`3331`, :gh:`4778`) * Checks for generator callbacks with a ``return`` statement no longer warn about ``return`` statements in nested functions (:gh:`4720`, :gh:`4721`) * The system file mode creation mask no longer affects the permissions of files generated using the :command:`startproject` command (:gh:`4722`) * ``scrapy.utils.iterators.xmliter`` now supports namespaced node names (:gh:`861`, :gh:`4746`) * :class:`~scrapy.Request` objects can now have ``about:`` URLs, which can work when using a headless browser (:gh:`4835`) Documentation ~~~~~~~~~~~~~ * The :setting:`FEED_URI_PARAMS` setting is now documented (:gh:`4671`, :gh:`4724`) * Improved the documentation of :ref:`link extractors ` with an usage example from a spider callback and reference documentation for the :class:`~scrapy.link.Link` class (:gh:`4751`, :gh:`4775`) * Clarified the impact of :setting:`CONCURRENT_REQUESTS` when using the :class:`~scrapy.extensions.closespider.CloseSpider` extension (:gh:`4836`) * Removed references to Python 2’s ``unicode`` type (:gh:`4547`, :gh:`4703`) * We now have an :ref:`official deprecation policy ` (:gh:`4705`) * Our :ref:`documentation policies ` now cover usage of Sphinx’s :rst:dir:`versionadded` and :rst:dir:`versionchanged` directives, and we have removed usages referencing Scrapy 1.4.0 and earlier versions (:gh:`3971`, :gh:`4310`) * Other documentation cleanups (:gh:`4090`, :gh:`4782`, :gh:`4800`, :gh:`4801`, :gh:`4809`, :gh:`4816`, :gh:`4825`) Quality assurance ~~~~~~~~~~~~~~~~~ * Extended typing hints (:gh:`4243`, :gh:`4691`) * Added tests for the :command:`check` command (:gh:`4663`) * Fixed test failures on Debian (:gh:`4726`, :gh:`4727`, :gh:`4735`) * Improved Windows test coverage (:gh:`4723`) * Switched to :ref:`formatted string literals ` where possible (:gh:`4307`, :gh:`4324`, :gh:`4672`) * Modernized :func:`super` usage (:gh:`4707`) * Other code and test cleanups (:gh:`1790`, :gh:`3288`, :gh:`4165`, :gh:`4564`, :gh:`4651`, :gh:`4714`, :gh:`4738`, :gh:`4745`, :gh:`4747`, :gh:`4761`, :gh:`4765`, :gh:`4804`, :gh:`4817`, :gh:`4820`, :gh:`4822`, :gh:`4839`) .. _release-2.3.0: Scrapy 2.3.0 (2020-08-04) ------------------------- Highlights: * :ref:`Feed exports ` now support :ref:`Google Cloud Storage ` as a storage backend * The new :setting:`FEED_EXPORT_BATCH_ITEM_COUNT` setting allows to deliver output items in batches of up to the specified number of items. It also serves as a workaround for :ref:`delayed file delivery `, which causes Scrapy to only start item delivery after the crawl has finished when using certain storage backends (:ref:`S3 `, :ref:`FTP `, and now :ref:`GCS `). * The base implementation of :ref:`item loaders ` has been moved into a separate library, :doc:`itemloaders `, allowing usage from outside Scrapy and a separate release schedule Deprecation removals ~~~~~~~~~~~~~~~~~~~~ * Removed the following classes and their parent modules from ``scrapy.linkextractors``: * ``htmlparser.HtmlParserLinkExtractor`` * ``regex.RegexLinkExtractor`` * ``sgml.BaseSgmlLinkExtractor`` * ``sgml.SgmlLinkExtractor`` Use :class:`LinkExtractor ` instead (:gh:`4356`, :gh:`4679`) Deprecations ~~~~~~~~~~~~ * The ``scrapy.utils.python.retry_on_eintr`` function is now deprecated (:gh:`4683`) New features ~~~~~~~~~~~~ * :ref:`Feed exports ` support :ref:`Google Cloud Storage ` (:gh:`685`, :gh:`3608`) * New :setting:`FEED_EXPORT_BATCH_ITEM_COUNT` setting for batch deliveries (:gh:`4250`, :gh:`4434`) * The :command:`parse` command now allows specifying an output file (:gh:`4317`, :gh:`4377`) * :meth:`.Request.from_curl` and :func:`~scrapy.utils.curl.curl_to_request_kwargs` now also support ``--data-raw`` (:gh:`4612`) * A ``parse`` callback may now be used in built-in spider subclasses, such as :class:`~scrapy.spiders.CrawlSpider` (:gh:`712`, :gh:`732`, :gh:`781`, :gh:`4254` ) Bug fixes ~~~~~~~~~ * Fixed the :ref:`CSV exporting ` of :ref:`dataclass items ` and :ref:`attr.s items ` (:gh:`4667`, :gh:`4668`) * :meth:`.Request.from_curl` and :func:`~scrapy.utils.curl.curl_to_request_kwargs` now set the request method to ``POST`` when a request body is specified and no request method is specified (:gh:`4612`) * The processing of ANSI escape sequences in enabled in Windows 10.0.14393 and later, where it is required for colored output (:gh:`4393`, :gh:`4403`) Documentation ~~~~~~~~~~~~~ * Updated the `OpenSSL cipher list format`_ link in the documentation about the :setting:`DOWNLOADER_CLIENT_TLS_CIPHERS` setting (:gh:`4653`) * Simplified the code example in :ref:`topics-loaders-dataclass` (:gh:`4652`) .. _OpenSSL cipher list format: https://docs.openssl.org/master/man1/openssl-ciphers/#cipher-list-format Quality assurance ~~~~~~~~~~~~~~~~~ * The base implementation of :ref:`item loaders ` has been moved into :doc:`itemloaders ` (:gh:`4005`, :gh:`4516`) * Fixed a silenced error in some scheduler tests (:gh:`4644`, :gh:`4645`) * Renewed the localhost certificate used for SSL tests (:gh:`4650`) * Removed cookie-handling code specific to Python 2 (:gh:`4682`) * Stopped using Python 2 unicode literal syntax (:gh:`4704`) * Stopped using a backlash for line continuation (:gh:`4673`) * Removed unneeded entries from the MyPy exception list (:gh:`4690`) * Automated tests now pass on Windows as part of our continuous integration system (:gh:`4458`) * Automated tests now pass on the latest PyPy version for supported Python versions in our continuous integration system (:gh:`4504`) .. _release-2.2.1: Scrapy 2.2.1 (2020-07-17) ------------------------- * The :command:`startproject` command no longer makes unintended changes to the permissions of files in the destination folder, such as removing execution permissions (:gh:`4662`, :gh:`4666`) .. _release-2.2.0: Scrapy 2.2.0 (2020-06-24) ------------------------- Highlights: * Python 3.5.2+ is required now * :ref:`dataclass objects ` and :ref:`attrs objects ` are now valid :ref:`item types ` * New :meth:`TextResponse.json ` method * New :signal:`bytes_received` signal that allows canceling response download * :class:`~scrapy.downloadermiddlewares.cookies.CookiesMiddleware` fixes Backward-incompatible changes ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ * Support for Python 3.5.0 and 3.5.1 has been dropped; Scrapy now refuses to run with a Python version lower than 3.5.2, which introduced :class:`typing.Type` (:gh:`4615`) Deprecations ~~~~~~~~~~~~ * ``TextResponse.body_as_unicode()`` is now deprecated, use :attr:`TextResponse.text ` instead (:gh:`4546`, :gh:`4555`, :gh:`4579`) * :class:`scrapy.item.BaseItem` is now deprecated, use :class:`scrapy.item.Item` instead (:gh:`4534`) New features ~~~~~~~~~~~~ * :ref:`dataclass objects ` and :ref:`attrs objects ` are now valid :ref:`item types `, and a new itemadapter_ library makes it easy to write code that :ref:`supports any item type ` (:gh:`2749`, :gh:`2807`, :gh:`3761`, :gh:`3881`, :gh:`4642`) * A new :meth:`TextResponse.json ` method allows to deserialize JSON responses (:gh:`2444`, :gh:`4460`, :gh:`4574`) * A new :signal:`bytes_received` signal allows monitoring response download progress and :ref:`stopping downloads ` (:gh:`4205`, :gh:`4559`) * The dictionaries in the result list of a :ref:`media pipeline ` now include a new key, ``status``, which indicates if the file was downloaded or, if the file was not downloaded, why it was not downloaded; see :meth:`FilesPipeline.get_media_requests ` for more information (:gh:`2893`, :gh:`4486`) * When using :ref:`Google Cloud Storage ` for a :ref:`media pipeline `, a warning is now logged if the configured credentials do not grant the required permissions (:gh:`4346`, :gh:`4508`) * :ref:`Link extractors ` are now serializable, as long as you do not use :ref:`lambdas ` for parameters; for example, you can now pass link extractors in :attr:`.Request.cb_kwargs` or :attr:`.Request.meta` when :ref:`persisting scheduled requests ` (:gh:`4554`) * Upgraded the :ref:`pickle protocol ` that Scrapy uses from protocol 2 to protocol 4, improving serialization capabilities and performance (:gh:`4135`, :gh:`4541`) * :func:`scrapy.utils.misc.create_instance` now raises a :exc:`TypeError` exception if the resulting instance is ``None`` (:gh:`4528`, :gh:`4532`) .. _itemadapter: https://github.com/scrapy/itemadapter Bug fixes ~~~~~~~~~ * :class:`~scrapy.downloadermiddlewares.cookies.CookiesMiddleware` no longer discards cookies defined in :attr:`Request.headers ` (:gh:`1992`, :gh:`2400`) * :class:`~scrapy.downloadermiddlewares.cookies.CookiesMiddleware` no longer re-encodes cookies defined as :class:`bytes` in the ``cookies`` parameter of the ``__init__`` method of :class:`~scrapy.Request` (:gh:`2400`, :gh:`3575`) * When :setting:`FEEDS` defines multiple URIs, :setting:`FEED_STORE_EMPTY` is ``False`` and the crawl yields no items, Scrapy no longer stops feed exports after the first URI (:gh:`4621`, :gh:`4626`) * :class:`~scrapy.spiders.Spider` callbacks defined using :doc:`coroutine syntax ` no longer need to return an iterable, and may instead return a :class:`~scrapy.Request` object, an :ref:`item `, or ``None`` (:gh:`4609`) * The :command:`startproject` command now ensures that the generated project folders and files have the right permissions (:gh:`4604`) * Fix a :exc:`KeyError` exception being sometimes raised from :class:`scrapy.utils.datatypes.LocalWeakReferencedCache` (:gh:`4597`, :gh:`4599`) * When :setting:`FEEDS` defines multiple URIs, log messages about items being stored now contain information from the corresponding feed, instead of always containing information about only one of the feeds (:gh:`4619`, :gh:`4629`) Documentation ~~~~~~~~~~~~~ * Added a new section about :ref:`accessing cb_kwargs from errbacks ` (:gh:`4598`, :gh:`4634`) * Covered chompjs_ in :ref:`topics-parsing-javascript` (:gh:`4556`, :gh:`4562`) * Removed from :doc:`topics/coroutines` the warning about the API being experimental (:gh:`4511`, :gh:`4513`) * Removed references to unsupported versions of :doc:`Twisted ` (:gh:`4533`) * Updated the description of the :ref:`screenshot pipeline example `, which now uses :doc:`coroutine syntax ` instead of returning a :class:`~twisted.internet.defer.Deferred` (:gh:`4514`, :gh:`4593`) * Removed a misleading import line from the :func:`scrapy.utils.log.configure_logging` code example (:gh:`4510`, :gh:`4587`) * The display-on-hover behavior of internal documentation references now also covers links to :ref:`commands `, :attr:`.Request.meta` keys, :ref:`settings ` and :ref:`signals ` (:gh:`4495`, :gh:`4563`) * It is again possible to download the documentation for offline reading (:gh:`4578`, :gh:`4585`) * Removed backslashes preceding ``*args`` and ``**kwargs`` in some function and method signatures (:gh:`4592`, :gh:`4596`) .. _chompjs: https://github.com/Nykakin/chompjs Quality assurance ~~~~~~~~~~~~~~~~~ * Adjusted the code base further to our :ref:`style guidelines ` (:gh:`4237`, :gh:`4525`, :gh:`4538`, :gh:`4539`, :gh:`4540`, :gh:`4542`, :gh:`4543`, :gh:`4544`, :gh:`4545`, :gh:`4557`, :gh:`4558`, :gh:`4566`, :gh:`4568`, :gh:`4572`) * Removed remnants of Python 2 support (:gh:`4550`, :gh:`4553`, :gh:`4568`) * Improved code sharing between the :command:`crawl` and :command:`runspider` commands (:gh:`4548`, :gh:`4552`) * Replaced ``chain(*iterable)`` with ``chain.from_iterable(iterable)`` (:gh:`4635`) * You may now run the :mod:`asyncio` tests with Tox on any Python version (:gh:`4521`) * Updated test requirements to reflect an incompatibility with pytest 5.4 and 5.4.1 (:gh:`4588`) * Improved :class:`~scrapy.spiderloader.SpiderLoader` test coverage for scenarios involving duplicate spider names (:gh:`4549`, :gh:`4560`) * Configured Travis CI to also run the tests with Python 3.5.2 (:gh:`4518`, :gh:`4615`) * Added a `Pylint `_ job to Travis CI (:gh:`3727`) * Added a `Mypy `_ job to Travis CI (:gh:`4637`) * Made use of set literals in tests (:gh:`4573`) * Cleaned up the Travis CI configuration (:gh:`4517`, :gh:`4519`, :gh:`4522`, :gh:`4537`) .. _release-2.1.0: Scrapy 2.1.0 (2020-04-24) ------------------------- Highlights: * New :setting:`FEEDS` setting to export to multiple feeds * New :attr:`Response.ip_address ` attribute Backward-incompatible changes ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ * :exc:`AssertionError` exceptions triggered by :ref:`assert ` statements have been replaced by new exception types, to support running Python in optimized mode (see :option:`-O`) without changing Scrapy’s behavior in any unexpected ways. If you catch an :exc:`AssertionError` exception from Scrapy, update your code to catch the corresponding new exception. (:gh:`4440`) Deprecation removals ~~~~~~~~~~~~~~~~~~~~ * The ``LOG_UNSERIALIZABLE_REQUESTS`` setting is no longer supported, use :setting:`SCHEDULER_DEBUG` instead (:gh:`4385`) * The ``REDIRECT_MAX_METAREFRESH_DELAY`` setting is no longer supported, use :setting:`METAREFRESH_MAXDELAY` instead (:gh:`4385`) * The :class:`~scrapy.downloadermiddlewares.chunked.ChunkedTransferMiddleware` middleware has been removed, including the entire :class:`scrapy.downloadermiddlewares.chunked` module; chunked transfers work out of the box (:gh:`4431`) * The ``spiders`` property has been removed from :class:`~scrapy.crawler.Crawler`, use :class:`CrawlerRunner.spider_loader ` or instantiate :setting:`SPIDER_LOADER_CLASS` with your settings instead (:gh:`4398`) * The ``MultiValueDict``, ``MultiValueDictKeyError``, and ``SiteNode`` classes have been removed from :mod:`scrapy.utils.datatypes` (:gh:`4400`) Deprecations ~~~~~~~~~~~~ * The ``FEED_FORMAT`` and ``FEED_URI`` settings have been deprecated in favor of the new :setting:`FEEDS` setting (:gh:`1336`, :gh:`3858`, :gh:`4507`) New features ~~~~~~~~~~~~ * A new setting, :setting:`FEEDS`, allows configuring multiple output feeds with different settings each (:gh:`1336`, :gh:`3858`, :gh:`4507`) * The :command:`crawl` and :command:`runspider` commands now support multiple ``-o`` parameters (:gh:`1336`, :gh:`3858`, :gh:`4507`) * The :command:`crawl` and :command:`runspider` commands now support specifying an output format by appending ``:`` to the output file (:gh:`1336`, :gh:`3858`, :gh:`4507`) * The new :attr:`Response.ip_address ` attribute gives access to the IP address that originated a response (:gh:`3903`, :gh:`3940`) * A warning is now issued when a value in :attr:`~scrapy.spiders.Spider.allowed_domains` includes a port (:gh:`50`, :gh:`3198`, :gh:`4413`) * Zsh completion now excludes used option aliases from the completion list (:gh:`4438`) Bug fixes ~~~~~~~~~ * :ref:`Request serialization ` no longer breaks for callbacks that are spider attributes which are assigned a function with a different name (:gh:`4500`) * ``None`` values in :attr:`~scrapy.spiders.Spider.allowed_domains` no longer cause a :exc:`TypeError` exception (:gh:`4410`) * Zsh completion no longer allows options after arguments (:gh:`4438`) * zope.interface 5.0.0 and later versions are now supported (:gh:`4447`, :gh:`4448`) * ``Spider.make_requests_from_url``, deprecated in Scrapy 1.4.0, now issues a warning when used (:gh:`4412`) Documentation ~~~~~~~~~~~~~ * Improved the documentation about signals that allow their handlers to return a :class:`~twisted.internet.defer.Deferred` (:gh:`4295`, :gh:`4390`) * Our PyPI entry now includes links for our documentation, our source code repository and our issue tracker (:gh:`4456`) * Covered the `curl2scrapy `_ service in the documentation (:gh:`4206`, :gh:`4455`) * Removed references to the Guppy library, which only works in Python 2 (:gh:`4285`, :gh:`4343`) * Extended use of InterSphinx to link to Python 3 documentation (:gh:`4444`, :gh:`4445`) * Added support for Sphinx 3.0 and later (:gh:`4475`, :gh:`4480`, :gh:`4496`, :gh:`4503`) Quality assurance ~~~~~~~~~~~~~~~~~ * Removed warnings about using old, removed settings (:gh:`4404`) * Removed a warning about importing :class:`~twisted.internet.testing.StringTransport` from ``twisted.test.proto_helpers`` in Twisted 19.7.0 or newer (:gh:`4409`) * Removed outdated Debian package build files (:gh:`4384`) * Removed :class:`object` usage as a base class (:gh:`4430`) * Removed code that added support for old versions of Twisted that we no longer support (:gh:`4472`) * Fixed code style issues (:gh:`4468`, :gh:`4469`, :gh:`4471`, :gh:`4481`) * Removed :func:`twisted.internet.defer.returnValue` calls (:gh:`4443`, :gh:`4446`, :gh:`4489`) .. _release-2.0.1: Scrapy 2.0.1 (2020-03-18) ------------------------- * :meth:`Response.follow_all ` now supports an empty URL iterable as input (:gh:`4408`, :gh:`4420`) * Removed top-level :mod:`~twisted.internet.reactor` imports to prevent errors about the wrong Twisted reactor being installed when setting a different Twisted reactor using :setting:`TWISTED_REACTOR` (:gh:`4401`, :gh:`4406`) * Fixed tests (:gh:`4422`) .. _release-2.0.0: Scrapy 2.0.0 (2020-03-03) ------------------------- Highlights: * Python 2 support has been removed * :doc:`Partial ` :ref:`coroutine syntax ` support and :doc:`experimental ` :mod:`asyncio` support * New :meth:`Response.follow_all ` method * :ref:`FTP support ` for media pipelines * New :attr:`Response.certificate ` attribute * IPv6 support through ``DNS_RESOLVER`` Backward-incompatible changes ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ * Python 2 support has been removed, following `Python 2 end-of-life on January 1, 2020`_ (:gh:`4091`, :gh:`4114`, :gh:`4115`, :gh:`4121`, :gh:`4138`, :gh:`4231`, :gh:`4242`, :gh:`4304`, :gh:`4309`, :gh:`4373`) * Retry gaveups (see :setting:`RETRY_TIMES`) are now logged as errors instead of as debug information (:gh:`3171`, :gh:`3566`) * File extensions that :class:`LinkExtractor ` ignores by default now also include ``7z``, ``7zip``, ``apk``, ``bz2``, ``cdr``, ``dmg``, ``ico``, ``iso``, ``tar``, ``tar.gz``, ``webm``, and ``xz`` (:gh:`1837`, :gh:`2067`, :gh:`4066`) * The :setting:`METAREFRESH_IGNORE_TAGS` setting is now an empty list by default, following web browser behavior (:gh:`3844`, :gh:`4311`) * The :class:`~scrapy.downloadermiddlewares.httpcompression.HttpCompressionMiddleware` now includes spaces after commas in the value of the ``Accept-Encoding`` header that it sets, following web browser behavior (:gh:`4293`) * The ``__init__`` method of custom download handlers (see :setting:`DOWNLOAD_HANDLERS`) or subclasses of the following downloader handlers no longer receives a ``settings`` parameter: * :class:`scrapy.core.downloader.handlers.datauri.DataURIDownloadHandler` * :class:`scrapy.core.downloader.handlers.file.FileDownloadHandler` Use the ``from_settings`` or ``from_crawler`` class methods to expose such a parameter to your custom download handlers. (:gh:`4126`) * We have refactored the :class:`scrapy.core.scheduler.Scheduler` class and related queue classes (see :setting:`SCHEDULER_PRIORITY_QUEUE`, :setting:`SCHEDULER_DISK_QUEUE` and :setting:`SCHEDULER_MEMORY_QUEUE`) to make it easier to implement custom scheduler queue classes. See :ref:`2-0-0-scheduler-queue-changes` below for details. * Overridden settings are now logged in a different format. This is more in line with similar information logged at startup (:gh:`4199`) .. _Python 2 end-of-life on January 1, 2020: https://www.python.org/doc/sunset-python-2/ Deprecation removals ~~~~~~~~~~~~~~~~~~~~ * The :ref:`Scrapy shell ` no longer provides a `sel` proxy object, use :meth:`response.selector ` instead (:gh:`4347`) * LevelDB support has been removed (:gh:`4112`) * The following functions have been removed from :mod:`scrapy.utils.python`: ``isbinarytext``, ``is_writable``, ``setattr_default``, ``stringify_dict`` (:gh:`4362`) Deprecations ~~~~~~~~~~~~ * Using environment variables prefixed with ``SCRAPY_`` to override settings is deprecated (:gh:`4300`, :gh:`4374`, :gh:`4375`) * :class:`scrapy.linkextractors.FilteringLinkExtractor` is deprecated, use :class:`scrapy.linkextractors.LinkExtractor ` instead (:gh:`4045`) * The ``noconnect`` query string argument of proxy URLs is deprecated and should be removed from proxy URLs (:gh:`4198`) * The :meth:`next ` method of :class:`scrapy.utils.python.MutableChain` is deprecated, use the global :func:`next` function or :meth:`MutableChain.__next__ ` instead (:gh:`4153`) New features ~~~~~~~~~~~~ * Added :doc:`partial support ` for Python’s :ref:`coroutine syntax ` and :doc:`experimental support ` for :mod:`asyncio` and :mod:`asyncio`-powered libraries (:gh:`4010`, :gh:`4259`, :gh:`4269`, :gh:`4270`, :gh:`4271`, :gh:`4316`, :gh:`4318`) * The new :meth:`Response.follow_all ` method offers the same functionality as :meth:`Response.follow ` but supports an iterable of URLs as input and returns an iterable of requests (:gh:`2582`, :gh:`4057`, :gh:`4286`) * :ref:`Media pipelines ` now support :ref:`FTP storage ` (:gh:`3928`, :gh:`3961`) * The new :attr:`Response.certificate ` attribute exposes the SSL certificate of the server as a :class:`twisted.internet.ssl.Certificate` object for HTTPS responses (:gh:`2726`, :gh:`4054`) * A new ``DNS_RESOLVER`` setting allows enabling IPv6 support (:gh:`1031`, :gh:`4227`) * A new :setting:`SCRAPER_SLOT_MAX_ACTIVE_SIZE` setting allows configuring the existing soft limit that pauses request downloads when the total response data being processed is too high (:gh:`1410`, :gh:`3551`) * A new :setting:`TWISTED_REACTOR` setting allows customizing the :mod:`~twisted.internet.reactor` that Scrapy uses, allowing to :doc:`enable asyncio support ` or deal with a :ref:`common macOS issue ` (:gh:`2905`, :gh:`4294`) * Scheduler disk and memory queues may now use the class methods ``from_crawler`` or ``from_settings`` (:gh:`3884`) * The new :attr:`Response.cb_kwargs ` attribute serves as a shortcut for :attr:`Response.request.cb_kwargs ` (:gh:`4331`) * :meth:`Response.follow ` now supports a ``flags`` parameter, for consistency with :class:`~scrapy.Request` (:gh:`4277`, :gh:`4279`) * :ref:`Item loader processors ` can now be regular functions, they no longer need to be methods (:gh:`3899`) * :class:`~scrapy.spiders.Rule` now accepts an ``errback`` parameter (:gh:`4000`) * :class:`~scrapy.Request` no longer requires a ``callback`` parameter when an ``errback`` parameter is specified (:gh:`3586`, :gh:`4008`) * :class:`~scrapy.logformatter.LogFormatter` now supports some additional methods: * :class:`~scrapy.logformatter.LogFormatter.download_error` for download errors * :class:`~scrapy.logformatter.LogFormatter.item_error` for exceptions raised during item processing by :ref:`item pipelines ` * :class:`~scrapy.logformatter.LogFormatter.spider_error` for exceptions raised from :ref:`spider callbacks ` (:gh:`374`, :gh:`3986`, :gh:`3989`, :gh:`4176`, :gh:`4188`) * The :setting:`FEED_URI` setting now supports :class:`pathlib.Path` values (:gh:`3731`, :gh:`4074`) * A new :signal:`request_left_downloader` signal is sent when a request leaves the downloader (:gh:`4303`) * Scrapy logs a warning when it detects a request callback or errback that uses ``yield`` but also returns a value, since the returned value would be lost (:gh:`3484`, :gh:`3869`) * :class:`~scrapy.spiders.Spider` objects now raise an :exc:`AttributeError` exception if they do not have a :class:`~scrapy.spiders.Spider.start_urls` attribute nor reimplement ``scrapy.spiders.Spider.start_requests()``, but have a ``start_url`` attribute (:gh:`4133`, :gh:`4170`) * :class:`~scrapy.exporters.BaseItemExporter` subclasses may now use ``super().__init__(**kwargs)`` instead of ``self._configure(kwargs)`` in their ``__init__`` method, passing ``dont_fail=True`` to the parent ``__init__`` method if needed, and accessing ``kwargs`` at ``self._kwargs`` after calling their parent ``__init__`` method (:gh:`4193`, :gh:`4370`) * A new ``keep_fragments`` parameter of ``scrapy.utils.request.request_fingerprint`` allows to generate different fingerprints for requests with different fragments in their URL (:gh:`4104`) * Download handlers (see :setting:`DOWNLOAD_HANDLERS`) may now use the ``from_settings`` and ``from_crawler`` class methods that other Scrapy components already supported (:gh:`4126`) * :class:`scrapy.utils.python.MutableChain.__iter__` now returns ``self``, allowing it to be used as a sequence. (:gh:`4153`) Bug fixes ~~~~~~~~~ * The :command:`crawl` command now also exits with exit code 1 when an exception happens before the crawling starts (:gh:`4175`, :gh:`4207`) * :class:`LinkExtractor.extract_links ` no longer re-encodes the query string or URLs from non-UTF-8 responses in UTF-8 (:gh:`998`, :gh:`1403`, :gh:`1949`, :gh:`4321`) * The first spider middleware (see :setting:`SPIDER_MIDDLEWARES`) now also processes exceptions raised from callbacks that are generators (:gh:`4260`, :gh:`4272`) * Redirects to URLs starting with 3 slashes (``///``) are now supported (:gh:`4032`, :gh:`4042`) * :class:`~scrapy.Request` no longer accepts strings as ``url`` simply because they have a colon (:gh:`2552`, :gh:`4094`) * The correct encoding is now used for attach names in :class:`~scrapy.mail.MailSender` (:gh:`4229`, :gh:`4239`) * :class:`~scrapy.dupefilters.RFPDupeFilter`, the default :setting:`DUPEFILTER_CLASS`, no longer writes an extra ``\r`` character on each line in Windows, which made the size of the ``requests.seen`` file unnecessarily large on that platform (:gh:`4283`) * Z shell auto-completion now looks for ``.html`` files, not ``.http`` files, and covers the ``-h`` command-line switch (:gh:`4122`, :gh:`4291`) * Adding items to a :class:`scrapy.utils.datatypes.LocalCache` object without a ``limit`` defined no longer raises a :exc:`TypeError` exception (:gh:`4123`) * Fixed a typo in the message of the :exc:`ValueError` exception raised when :func:`scrapy.utils.misc.create_instance` gets both ``settings`` and ``crawler`` set to ``None`` (:gh:`4128`) Documentation ~~~~~~~~~~~~~ * API documentation now links to an online, syntax-highlighted view of the corresponding source code (:gh:`4148`) * Links to unexisting documentation pages now allow access to the sidebar (:gh:`4152`, :gh:`4169`) * Cross-references within our documentation now display a tooltip when hovered (:gh:`4173`, :gh:`4183`) * Improved the documentation about :meth:`LinkExtractor.extract_links ` and simplified :ref:`topics-link-extractors` (:gh:`4045`) * Clarified how :class:`ItemLoader.item ` works (:gh:`3574`, :gh:`4099`) * Clarified that :func:`logging.basicConfig` should not be used when also using :class:`~scrapy.crawler.CrawlerProcess` (:gh:`2149`, :gh:`2352`, :gh:`3146`, :gh:`3960`) * Clarified the requirements for :class:`~scrapy.Request` objects :ref:`when using persistence ` (:gh:`4124`, :gh:`4139`) * Clarified how to install a :ref:`custom image pipeline ` (:gh:`4034`, :gh:`4252`) * Fixed the signatures of the ``file_path`` method in :ref:`media pipeline ` examples (:gh:`4290`) * Covered a backward-incompatible change in Scrapy 1.7.0 affecting custom :class:`scrapy.core.scheduler.Scheduler` subclasses (:gh:`4274`) * Improved the ``README.rst`` and ``CODE_OF_CONDUCT.md`` files (:gh:`4059`) * Documentation examples are now checked as part of our test suite and we have fixed some of the issues detected (:gh:`4142`, :gh:`4146`, :gh:`4171`, :gh:`4184`, :gh:`4190`) * Fixed logic issues, broken links and typos (:gh:`4247`, :gh:`4258`, :gh:`4282`, :gh:`4288`, :gh:`4305`, :gh:`4308`, :gh:`4323`, :gh:`4338`, :gh:`4359`, :gh:`4361`) * Improved consistency when referring to the ``__init__`` method of an object (:gh:`4086`, :gh:`4088`) * Fixed an inconsistency between code and output in :ref:`intro-overview` (:gh:`4213`) * Extended :mod:`~sphinx.ext.intersphinx` usage (:gh:`4147`, :gh:`4172`, :gh:`4185`, :gh:`4194`, :gh:`4197`) * We now use a recent version of Python to build the documentation (:gh:`4140`, :gh:`4249`) * Cleaned up documentation (:gh:`4143`, :gh:`4275`) Quality assurance ~~~~~~~~~~~~~~~~~ * Re-enabled proxy ``CONNECT`` tests (:gh:`2545`, :gh:`4114`) * Added Bandit_ security checks to our test suite (:gh:`4162`, :gh:`4181`) * Added Flake8_ style checks to our test suite and applied many of the corresponding changes (:gh:`3944`, :gh:`3945`, :gh:`4137`, :gh:`4157`, :gh:`4167`, :gh:`4174`, :gh:`4186`, :gh:`4195`, :gh:`4238`, :gh:`4246`, :gh:`4355`, :gh:`4360`, :gh:`4365`) * Improved test coverage (:gh:`4097`, :gh:`4218`, :gh:`4236`) * Started reporting slowest tests, and improved the performance of some of them (:gh:`4163`, :gh:`4164`) * Fixed broken tests and refactored some tests (:gh:`4014`, :gh:`4095`, :gh:`4244`, :gh:`4268`, :gh:`4372`) * Modified the :doc:`tox ` configuration to allow running tests with any Python version, run Bandit_ and Flake8_ tests by default, and enforce a minimum tox version programmatically (:gh:`4179`) * Cleaned up code (:gh:`3937`, :gh:`4208`, :gh:`4209`, :gh:`4210`, :gh:`4212`, :gh:`4369`, :gh:`4376`, :gh:`4378`) .. _Bandit: https://bandit.readthedocs.io/en/latest/ .. _Flake8: https://flake8.pycqa.org/en/latest/ .. _2-0-0-scheduler-queue-changes: Changes to scheduler queue classes ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ The following changes may impact any custom queue classes of all types: * The ``push`` method no longer receives a second positional parameter containing ``request.priority * -1``. If you need that value, get it from the first positional parameter, ``request``, instead, or use the new :meth:`~scrapy.core.scheduler.ScrapyPriorityQueue.priority` method in :class:`scrapy.core.scheduler.ScrapyPriorityQueue` subclasses. The following changes may impact custom priority queue classes: * In the ``__init__`` method or the ``from_crawler`` or ``from_settings`` class methods: * The parameter that used to contain a factory function, ``qfactory``, is now passed as a keyword parameter named ``downstream_queue_cls``. * A new keyword parameter has been added: ``key``. It is a string that is always an empty string for memory queues and indicates the :setting:`JOBDIR` value for disk queues. * The parameter for disk queues that contains data from the previous crawl, ``startprios`` or ``slot_startprios``, is now passed as a keyword parameter named ``startprios``. * The ``serialize`` parameter is no longer passed. The disk queue class must take care of request serialization on its own before writing to disk, using the :func:`~scrapy.utils.reqser.request_to_dict` and :func:`~scrapy.utils.reqser.request_from_dict` functions from the :mod:`scrapy.utils.reqser` module. The following changes may impact custom disk and memory queue classes: * The signature of the ``__init__`` method is now ``__init__(self, crawler, key)``. The following changes affect specifically the :class:`~scrapy.core.scheduler.ScrapyPriorityQueue` and :class:`~scrapy.core.scheduler.DownloaderAwarePriorityQueue` classes from :mod:`scrapy.core.scheduler` and may affect subclasses: * In the ``__init__`` method, most of the changes described above apply. ``__init__`` may still receive all parameters as positional parameters, however: * ``downstream_queue_cls``, which replaced ``qfactory``, must be instantiated differently. ``qfactory`` was instantiated with a priority value (integer). Instances of ``downstream_queue_cls`` should be created using the new :meth:`ScrapyPriorityQueue.qfactory ` or :meth:`DownloaderAwarePriorityQueue.pqfactory ` methods. * The new ``key`` parameter displaced the ``startprios`` parameter 1 position to the right. * The following class attributes have been added: * :attr:`~scrapy.core.scheduler.ScrapyPriorityQueue.crawler` * :attr:`~scrapy.core.scheduler.ScrapyPriorityQueue.downstream_queue_cls` (details above) * :attr:`~scrapy.core.scheduler.ScrapyPriorityQueue.key` (details above) * The ``serialize`` attribute has been removed (details above) The following changes affect specifically the :class:`~scrapy.core.scheduler.ScrapyPriorityQueue` class and may affect subclasses: * A new :meth:`~scrapy.core.scheduler.ScrapyPriorityQueue.priority` method has been added which, given a request, returns ``request.priority * -1``. It is used in :meth:`~scrapy.core.scheduler.ScrapyPriorityQueue.push` to make up for the removal of its ``priority`` parameter. * The ``spider`` attribute has been removed. Use :attr:`crawler.spider ` instead. The following changes affect specifically the :class:`~scrapy.core.scheduler.DownloaderAwarePriorityQueue` class and may affect subclasses: * A new :attr:`~scrapy.core.scheduler.DownloaderAwarePriorityQueue.pqueues` attribute offers a mapping of downloader slot names to the corresponding instances of :attr:`~scrapy.core.scheduler.DownloaderAwarePriorityQueue.downstream_queue_cls`. (:gh:`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: Scrapy 1.8.3 (2022-07-25) ------------------------- **Security bug fix:** - When :class:`~scrapy.downloadermiddlewares.httpproxy.HttpProxyMiddleware` processes a request with :reqmeta:`proxy` metadata, and that :reqmeta:`proxy` metadata includes proxy credentials, :class:`~scrapy.downloadermiddlewares.httpproxy.HttpProxyMiddleware` sets the ``Proxy-Authorization`` header, but only if that header is not already set. There are third-party proxy-rotation downloader middlewares that set different :reqmeta:`proxy` metadata every time they process a request. Because of request retries and redirects, the same request can be processed by downloader middlewares more than once, including both :class:`~scrapy.downloadermiddlewares.httpproxy.HttpProxyMiddleware` and any third-party proxy-rotation downloader middleware. These third-party proxy-rotation downloader middlewares could change the :reqmeta:`proxy` metadata of a request to a new value, but fail to remove the ``Proxy-Authorization`` header from the previous value of the :reqmeta:`proxy` metadata, causing the credentials of one proxy to be sent to a different proxy. To prevent the unintended leaking of proxy credentials, the behavior of :class:`~scrapy.downloadermiddlewares.httpproxy.HttpProxyMiddleware` is now as follows when processing a request: - If the request being processed defines :reqmeta:`proxy` metadata that includes credentials, the ``Proxy-Authorization`` header is always updated to feature those credentials. - If the request being processed defines :reqmeta:`proxy` metadata without credentials, the ``Proxy-Authorization`` header is removed *unless* it was originally defined for the same proxy URL. To remove proxy credentials while keeping the same proxy URL, remove the ``Proxy-Authorization`` header. - If the request has no :reqmeta:`proxy` metadata, or that metadata is a falsy value (e.g. ``None``), the ``Proxy-Authorization`` header is removed. It is no longer possible to set a proxy URL through the :reqmeta:`proxy` metadata but set the credentials through the ``Proxy-Authorization`` header. Set proxy credentials through the :reqmeta:`proxy` metadata instead. .. _release-1.8.2: Scrapy 1.8.2 (2022-03-01) ------------------------- **Security bug fixes:** - When a :class:`~scrapy.Request` object with cookies defined gets a redirect response causing a new :class:`~scrapy.Request` object to be scheduled, the cookies defined in the original :class:`~scrapy.Request` object are no longer copied into the new :class:`~scrapy.Request` object. If you manually set the ``Cookie`` header on a :class:`~scrapy.Request` object and the domain name of the redirect URL is not an exact match for the domain of the URL of the original :class:`~scrapy.Request` object, your ``Cookie`` header is now dropped from the new :class:`~scrapy.Request` object. The old behavior could be exploited by an attacker to gain access to your cookies. Please, see the `cjvr-mfj7-j4j8 security advisory`_ for more information. .. _cjvr-mfj7-j4j8 security advisory: https://github.com/scrapy/scrapy/security/advisories/GHSA-cjvr-mfj7-j4j8 .. note:: It is still possible to enable the sharing of cookies between different domains with a shared domain suffix (e.g. ``example.com`` and any subdomain) by defining the shared domain suffix (e.g. ``example.com``) as the cookie domain when defining your cookies. See the documentation of the :class:`~scrapy.Request` class for more information. - When the domain of a cookie, either received in the ``Set-Cookie`` header of a response or defined in a :class:`~scrapy.Request` object, is set to a `public suffix `_, the cookie is now ignored unless the cookie domain is the same as the request domain. The old behavior could be exploited by an attacker to inject cookies into your requests to some other domains. Please, see the `mfjm-vh54-3f96 security advisory`_ for more information. .. _mfjm-vh54-3f96 security advisory: https://github.com/scrapy/scrapy/security/advisories/GHSA-mfjm-vh54-3f96 .. _release-1.8.1: Scrapy 1.8.1 (2021-10-05) ------------------------- * **Security bug fix:** If you use :class:`~scrapy.downloadermiddlewares.httpauth.HttpAuthMiddleware` (i.e. the ``http_user`` and ``http_pass`` spider attributes) for HTTP authentication, any request exposes your credentials to the request target. To prevent unintended exposure of authentication credentials to unintended domains, you must now additionally set a new, additional spider attribute, ``http_auth_domain``, and point it to the specific domain to which the authentication credentials must be sent. If the ``http_auth_domain`` spider attribute is not set, the domain of the first request will be considered the HTTP authentication target, and authentication credentials will only be sent in requests targeting that domain. If you need to send the same HTTP authentication credentials to multiple domains, you can use :func:`w3lib.http.basic_auth_header` instead to set the value of the ``Authorization`` header of your requests. If you *really* want your spider to send the same HTTP authentication credentials to any domain, set the ``http_auth_domain`` spider attribute to ``None``. Finally, if you are a user of `scrapy-splash`_, know that this version of Scrapy breaks compatibility with scrapy-splash 0.7.2 and earlier. You will need to upgrade scrapy-splash to a greater version for it to continue to work. .. _scrapy-splash: https://github.com/scrapy-plugins/scrapy-splash .. _release-1.8.0: Scrapy 1.8.0 (2019-10-28) ------------------------- Highlights: * Dropped Python 3.4 support and updated minimum requirements; made Python 3.8 support official * New :meth:`.Request.from_curl` class method * New :setting:`ROBOTSTXT_PARSER` and :setting:`ROBOTSTXT_USER_AGENT` settings * New :setting:`DOWNLOADER_CLIENT_TLS_CIPHERS` and :setting:`DOWNLOADER_CLIENT_TLS_VERBOSE_LOGGING` settings Backward-incompatible changes ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ .. skip: start * Python 3.4 is no longer supported, and some of the minimum requirements of Scrapy have also changed: * :doc:`cssselect ` 0.9.1 * cryptography_ 2.0 * lxml_ 3.5.0 * pyOpenSSL_ 16.2.0 * queuelib_ 1.4.2 * service_identity_ 16.0.0 * six_ 1.10.0 * Twisted_ 17.9.0 (16.0.0 with Python 2) * zope.interface_ 4.1.3 (:gh:`3892`) * ``JSONRequest`` is now called :class:`~scrapy.http.JsonRequest` for consistency with similar classes (:gh:`3929`, :gh:`3982`) * If you are using a custom context factory (``DOWNLOADER_CLIENTCONTEXTFACTORY``), its ``__init__`` method must accept two new parameters: ``tls_verbose_logging`` and ``tls_ciphers`` (:gh:`2111`, :gh:`3392`, :gh:`3442`, :gh:`3450`) * :class:`~scrapy.loader.ItemLoader` now turns the values of its input item into lists: .. code-block:: pycon >>> item = MyItem() >>> item["field"] = "value1" >>> loader = ItemLoader(item=item) >>> item["field"] ['value1'] This is needed to allow adding values to existing fields (``loader.add_value('field', 'value2')``). (:gh:`3804`, :gh:`3819`, :gh:`3897`, :gh:`3976`, :gh:`3998`, :gh:`4036`) .. skip: end See also :ref:`1.8-deprecation-removals` below. New features ~~~~~~~~~~~~ * A new :meth:`Request.from_curl ` class method allows :ref:`creating a request from a cURL command ` (:gh:`2985`, :gh:`3862`) * 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), Reppy, and :ref:`Robotexclusionrulesparser `, and allows you to :ref:`implement support for additional parsers ` (:gh:`754`, :gh:`2669`, :gh:`3796`, :gh:`3935`, :gh:`3969`, :gh:`4006`) * A new :setting:`ROBOTSTXT_USER_AGENT` setting allows defining a separate user agent string to use for robots.txt_ parsing (:gh:`3931`, :gh:`3966`) * :class:`~scrapy.spiders.Rule` no longer requires a :class:`LinkExtractor ` parameter (:gh:`781`, :gh:`4016`) * Use the new :setting:`DOWNLOADER_CLIENT_TLS_CIPHERS` setting to customize the TLS/SSL ciphers used by the default HTTP/1.1 downloader (:gh:`3392`, :gh:`3442`) * Set the new :setting:`DOWNLOADER_CLIENT_TLS_VERBOSE_LOGGING` setting to ``True`` to enable debug-level messages about TLS connection parameters after establishing HTTPS connections (:gh:`2111`, :gh:`3450`) * Callbacks that receive keyword arguments (see :attr:`.Request.cb_kwargs`) can now be tested using the new :class:`@cb_kwargs ` :ref:`spider contract ` (:gh:`3985`, :gh:`3988`) * When a :class:`@scrapes ` spider contract fails, all missing fields are now reported (:gh:`766`, :gh:`3939`) * :ref:`Custom log formats ` can now drop messages by having the corresponding methods of the configured :setting:`LOG_FORMATTER` return ``None`` (:gh:`3984`, :gh:`3987`) * A much improved completion definition is now available for Zsh_ (:gh:`4069`) Bug fixes ~~~~~~~~~ * :meth:`ItemLoader.load_item() ` no longer makes later calls to :meth:`ItemLoader.get_output_value() ` or :meth:`ItemLoader.load_item() ` return empty data (:gh:`3804`, :gh:`3819`, :gh:`3897`, :gh:`3976`, :gh:`3998`, :gh:`4036`) * Fixed :class:`~scrapy.statscollectors.DummyStatsCollector` raising a :exc:`TypeError` exception (:gh:`4007`, :gh:`4052`) * :meth:`FilesPipeline.file_path ` and :meth:`ImagesPipeline.file_path ` no longer choose file extensions that are not `registered with IANA`_ (:gh:`1287`, :gh:`3953`, :gh:`3954`) * When using botocore_ to persist files in S3, all botocore-supported headers are properly mapped now (:gh:`3904`, :gh:`3905`) * FTP passwords in :setting:`FEED_URI` containing percent-escaped characters are now properly decoded (:gh:`3941`) * A memory-handling and error-handling issue in :func:`scrapy.utils.ssl.get_temp_key_info` has been fixed (:gh:`3920`) Documentation ~~~~~~~~~~~~~ * The documentation now covers how to define and configure a :ref:`custom log format ` (:gh:`3616`, :gh:`3660`) * API documentation added for :class:`~scrapy.exporters.MarshalItemExporter` and :class:`~scrapy.exporters.PythonItemExporter` (:gh:`3973`) * API documentation added for :class:`~scrapy.item.BaseItem` and :class:`~scrapy.item.ItemMeta` (:gh:`3999`) * Minor documentation fixes (:gh:`2998`, :gh:`3398`, :gh:`3597`, :gh:`3894`, :gh:`3934`, :gh:`3978`, :gh:`3993`, :gh:`4022`, :gh:`4028`, :gh:`4033`, :gh:`4046`, :gh:`4050`, :gh:`4055`, :gh:`4056`, :gh:`4061`, :gh:`4072`, :gh:`4071`, :gh:`4079`, :gh:`4081`, :gh:`4089`, :gh:`4093`) .. _1.8-deprecation-removals: Deprecation removals ~~~~~~~~~~~~~~~~~~~~ * ``scrapy.xlib`` has been removed (:gh:`4015`) .. _1.8-deprecations: Deprecations ~~~~~~~~~~~~ * The LevelDB_ storage backend (``scrapy.extensions.httpcache.LeveldbCacheStorage``) of :class:`~scrapy.downloadermiddlewares.httpcache.HttpCacheMiddleware` is deprecated (:gh:`4085`, :gh:`4092`) * Use of the undocumented ``SCRAPY_PICKLED_SETTINGS_TO_OVERRIDE`` environment variable is deprecated (:gh:`3910`) * ``scrapy.item.DictItem`` is deprecated, use :class:`~scrapy.item.Item` instead (:gh:`3999`) Other changes ~~~~~~~~~~~~~ * Minimum versions of optional Scrapy requirements that are covered by continuous integration tests have been updated: * botocore_ 1.3.23 * Pillow_ 3.4.2 Lower versions of these optional requirements may work, but it is not guaranteed (:gh:`3892`) * GitHub templates for bug reports and feature requests (:gh:`3126`, :gh:`3471`, :gh:`3749`, :gh:`3754`) * Continuous integration fixes (:gh:`3923`) * Code cleanup (:gh:`3391`, :gh:`3907`, :gh:`3946`, :gh:`3950`, :gh:`4023`, :gh:`4031`) .. _release-1.7.4: Scrapy 1.7.4 (2019-10-21) ------------------------- Revert the fix for :gh:`3804` (:gh:`3819`), which has a few undesired side effects (:gh:`3897`, :gh:`3976`). As a result, when an item loader is initialized with an item, :meth:`ItemLoader.load_item() ` once again makes later calls to :meth:`ItemLoader.get_output_value() ` or :meth:`ItemLoader.load_item() ` return empty data. .. _release-1.7.3: Scrapy 1.7.3 (2019-08-01) ------------------------- Enforce lxml 4.3.5 or lower for Python 3.4 (:gh:`3912`, :gh:`3918`). .. _release-1.7.2: Scrapy 1.7.2 (2019-07-23) ------------------------- Fix Python 2 support (:gh:`3889`, :gh:`3893`, :gh:`3896`). .. _release-1.7.1: Scrapy 1.7.1 (2019-07-18) ------------------------- Re-packaging of Scrapy 1.7.0, which was missing some changes in PyPI. .. _release-1.7.0: Scrapy 1.7.0 (2019-07-18) ------------------------- .. note:: Make sure you install Scrapy 1.7.1. The Scrapy 1.7.0 package in PyPI is the result of an erroneous commit tagging and does not include all the changes described below. Highlights: * Improvements for crawls targeting multiple domains * A cleaner way to pass arguments to callbacks * A new class for JSON requests * Improvements for rule-based spiders * New features for feed exports Backward-incompatible changes ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ * ``429`` is now part of the :setting:`RETRY_HTTP_CODES` setting by default This change is **backward incompatible**. If you don’t want to retry ``429``, you must override :setting:`RETRY_HTTP_CODES` accordingly. * :class:`~scrapy.crawler.Crawler`, :meth:`CrawlerRunner.crawl ` and :meth:`CrawlerRunner.create_crawler ` no longer accept a :class:`~scrapy.spiders.Spider` subclass instance, they only accept a :class:`~scrapy.spiders.Spider` subclass now. :class:`~scrapy.spiders.Spider` subclass instances were never meant to work, and they were not working as one would expect: instead of using the passed :class:`~scrapy.spiders.Spider` subclass instance, their :class:`~scrapy.spiders.Spider.from_crawler` method was called to generate a new instance. * Non-default values for the :setting:`SCHEDULER_PRIORITY_QUEUE` setting may stop working. Scheduler priority queue classes now need to handle :class:`~scrapy.Request` objects instead of arbitrary Python data structures. * An additional ``crawler`` parameter has been added to the ``__init__`` method of the :class:`~scrapy.core.scheduler.Scheduler` class. Custom scheduler subclasses which don't accept arbitrary parameters in their ``__init__`` method might break because of this change. For more information, see :setting:`SCHEDULER`. See also :ref:`1.7-deprecation-removals` below. New features ~~~~~~~~~~~~ * A new scheduler priority queue, ``scrapy.pqueues.DownloaderAwarePriorityQueue``, may be :ref:`enabled ` for a significant scheduling improvement on crawls targeting multiple web domains, at the cost of no ``CONCURRENT_REQUESTS_PER_IP`` support (:gh:`3520`) * A new :attr:`.Request.cb_kwargs` attribute provides a cleaner way to pass keyword arguments to callback methods (:gh:`1138`, :gh:`3563`) * A new :class:`JSONRequest ` class offers a more convenient way to build JSON requests (:gh:`3504`, :gh:`3505`) * A ``process_request`` callback passed to the :class:`~scrapy.spiders.Rule` ``__init__`` method now receives the :class:`~scrapy.http.Response` object that originated the request as its second argument (:gh:`3682`) * A new ``restrict_text`` parameter for the :attr:`LinkExtractor ` ``__init__`` method allows filtering links by linking text (:gh:`3622`, :gh:`3635`) * A new :setting:`FEED_STORAGE_S3_ACL` setting allows defining a custom ACL for feeds exported to Amazon S3 (:gh:`3607`) * A new :setting:`FEED_STORAGE_FTP_ACTIVE` setting allows using FTP’s active connection mode for feeds exported to FTP servers (:gh:`3829`) * A new :setting:`METAREFRESH_IGNORE_TAGS` setting allows overriding which HTML tags are ignored when searching a response for HTML meta tags that trigger a redirect (:gh:`1422`, :gh:`3768`) * A new :reqmeta:`redirect_reasons` request meta key exposes the reason (status code, meta refresh) behind every followed redirect (:gh:`3581`, :gh:`3687`) * The ``SCRAPY_CHECK`` variable is now set to the ``true`` string during runs of the :command:`check` command, which allows :ref:`detecting contract check runs from code ` (:gh:`3704`, :gh:`3739`) * A new :meth:`Item.deepcopy() ` method makes it easier to :ref:`deep-copy items ` (:gh:`1493`, :gh:`3671`) * :class:`~scrapy.extensions.corestats.CoreStats` also logs ``elapsed_time_seconds`` now (:gh:`3638`) * Exceptions from :class:`~scrapy.loader.ItemLoader` :ref:`input and output processors ` are now more verbose (:gh:`3836`, :gh:`3840`) * :class:`~scrapy.crawler.Crawler`, :class:`CrawlerRunner.crawl ` and :class:`CrawlerRunner.create_crawler ` now fail gracefully if they receive a :class:`~scrapy.spiders.Spider` subclass instance instead of the subclass itself (:gh:`2283`, :gh:`3610`, :gh:`3872`) Bug fixes ~~~~~~~~~ * :meth:`~scrapy.spidermiddlewares.SpiderMiddleware.process_spider_exception` is now also invoked for generators (:gh:`220`, :gh:`2061`) * System exceptions like KeyboardInterrupt_ are no longer caught (:gh:`3726`) * :meth:`ItemLoader.load_item() ` no longer makes later calls to :meth:`ItemLoader.get_output_value() ` or :meth:`ItemLoader.load_item() ` return empty data (:gh:`3804`, :gh:`3819`) * The images pipeline (:class:`~scrapy.pipelines.images.ImagesPipeline`) no longer ignores these Amazon S3 settings: :setting:`AWS_ENDPOINT_URL`, :setting:`AWS_REGION_NAME`, :setting:`AWS_USE_SSL`, :setting:`AWS_VERIFY` (:gh:`3625`) * Fixed a memory leak in ``scrapy.pipelines.media.MediaPipeline`` affecting, for example, non-200 responses and exceptions from custom middlewares (:gh:`3813`) * Requests with private callbacks are now correctly unserialized from disk (:gh:`3790`) * :meth:`.FormRequest.from_response` now handles invalid methods like major web browsers (:gh:`3777`, :gh:`3794`) Documentation ~~~~~~~~~~~~~ * A new topic, :ref:`topics-dynamic-content`, covers recommended approaches to read dynamically-loaded data (:gh:`3703`) * :ref:`topics-broad-crawls` now features information about memory usage (:gh:`1264`, :gh:`3866`) * The documentation of :class:`~scrapy.spiders.Rule` now covers how to access the text of a link when using :class:`~scrapy.spiders.CrawlSpider` (:gh:`3711`, :gh:`3712`) * A new section, :ref:`httpcache-storage-custom`, covers writing a custom cache storage backend for :class:`~scrapy.downloadermiddlewares.httpcache.HttpCacheMiddleware` (:gh:`3683`, :gh:`3692`) * A new :ref:`FAQ ` entry, :ref:`faq-split-item`, explains what to do when you want to split an item into multiple items from an item pipeline (:gh:`2240`, :gh:`3672`) * Updated the :ref:`FAQ entry about crawl order ` to explain why the first few requests rarely follow the desired order (:gh:`1739`, :gh:`3621`) * The :setting:`LOGSTATS_INTERVAL` setting (:gh:`3730`), the :meth:`FilesPipeline.file_path ` and :meth:`ImagesPipeline.file_path ` methods (:gh:`2253`, :gh:`3609`) and the :meth:`Crawler.stop() ` method (:gh:`3842`) are now documented * Some parts of the documentation that were confusing or misleading are now clearer (:gh:`1347`, :gh:`1789`, :gh:`2289`, :gh:`3069`, :gh:`3615`, :gh:`3626`, :gh:`3668`, :gh:`3670`, :gh:`3673`, :gh:`3728`, :gh:`3762`, :gh:`3861`, :gh:`3882`) * Minor documentation fixes (:gh:`3648`, :gh:`3649`, :gh:`3662`, :gh:`3674`, :gh:`3676`, :gh:`3694`, :gh:`3724`, :gh:`3764`, :gh:`3767`, :gh:`3791`, :gh:`3797`, :gh:`3806`, :gh:`3812`) .. _1.7-deprecation-removals: Deprecation removals ~~~~~~~~~~~~~~~~~~~~ The following deprecated APIs have been removed (:gh:`3578`): * ``scrapy.conf`` (use :attr:`Crawler.settings `) * From ``scrapy.core.downloader.handlers``: * ``http.HttpDownloadHandler`` (use ``http10.HTTP10DownloadHandler``) * ``scrapy.loader.ItemLoader._get_values`` (use ``_get_xpathvalues``) * ``scrapy.loader.XPathItemLoader`` (use :class:`~scrapy.loader.ItemLoader`) * ``scrapy.log`` (see :ref:`topics-logging`) * From ``scrapy.pipelines``: * ``files.FilesPipeline.file_key`` (use ``file_path``) * ``images.ImagesPipeline.file_key`` (use ``file_path``) * ``images.ImagesPipeline.image_key`` (use ``file_path``) * ``images.ImagesPipeline.thumb_key`` (use ``thumb_path``) * From both ``scrapy.selector`` and ``scrapy.selector.lxmlsel``: * ``HtmlXPathSelector`` (use :class:`~scrapy.Selector`) * ``XmlXPathSelector`` (use :class:`~scrapy.Selector`) * ``XPathSelector`` (use :class:`~scrapy.Selector`) * ``XPathSelectorList`` (use :class:`~scrapy.Selector`) * From ``scrapy.selector.csstranslator``: * ``ScrapyGenericTranslator`` (use parsel.csstranslator.GenericTranslator_) * ``ScrapyHTMLTranslator`` (use parsel.csstranslator.HTMLTranslator_) * ``ScrapyXPathExpr`` (use parsel.csstranslator.XPathExpr_) * From :class:`~scrapy.Selector`: * ``_root`` (both the ``__init__`` method argument and the object property, use ``root``) * ``extract_unquoted`` (use ``getall``) * ``select`` (use ``xpath``) * From :class:`~scrapy.selector.SelectorList`: * ``extract_unquoted`` (use ``getall``) * ``select`` (use ``xpath``) * ``x`` (use ``xpath``) * ``scrapy.spiders.BaseSpider`` (use :class:`~scrapy.spiders.Spider`) * From :class:`~scrapy.spiders.Spider` (and subclasses): * ``DOWNLOAD_DELAY`` (use :ref:`download_delay `) * ``set_crawler`` (use :meth:`~scrapy.spiders.Spider.from_crawler`) * ``scrapy.spiders.spiders`` (use :class:`~scrapy.spiderloader.SpiderLoader`) * ``scrapy.telnet`` (use :mod:`scrapy.extensions.telnet`) * From ``scrapy.utils.python``: * ``str_to_unicode`` (use ``to_unicode``) * ``unicode_to_str`` (use ``to_bytes``) * ``scrapy.utils.response.body_or_str`` The following deprecated settings have also been removed (:gh:`3578`): * ``SPIDER_MANAGER_CLASS`` (use :setting:`SPIDER_LOADER_CLASS`) .. _1.7-deprecations: Deprecations ~~~~~~~~~~~~ * The ``queuelib.PriorityQueue`` value for the :setting:`SCHEDULER_PRIORITY_QUEUE` setting is deprecated. Use ``scrapy.pqueues.ScrapyPriorityQueue`` instead. * ``process_request`` callbacks passed to :class:`~scrapy.spiders.Rule` that do not accept two arguments are deprecated. * The following modules are deprecated: * ``scrapy.utils.http`` (use `w3lib.http`_) * ``scrapy.utils.markup`` (use `w3lib.html`_) * ``scrapy.utils.multipart`` (use `urllib3`_) * The ``scrapy.utils.datatypes.MergeDict`` class is deprecated for Python 3 code bases. Use :class:`~collections.ChainMap` instead. (:gh:`3878`) * The ``scrapy.utils.gz.is_gzipped`` function is deprecated. Use ``scrapy.utils.gz.gzip_magic_number`` instead. .. _urllib3: https://urllib3.readthedocs.io/en/latest/index.html .. _w3lib.html: https://w3lib.readthedocs.io/en/latest/w3lib.html#module-w3lib.html .. _w3lib.http: https://w3lib.readthedocs.io/en/latest/w3lib.html#module-w3lib.http Other changes ~~~~~~~~~~~~~ * It is now possible to run all tests from the same tox_ environment in parallel; the documentation now covers :ref:`this and other ways to run tests ` (:gh:`3707`) * It is now possible to generate an API documentation coverage report (:gh:`3806`, :gh:`3810`, :gh:`3860`) * The :ref:`documentation policies ` now require docstrings_ (:gh:`3701`) that follow `PEP 257`_ (:gh:`3748`) * Internal fixes and cleanup (:gh:`3629`, :gh:`3643`, :gh:`3684`, :gh:`3698`, :gh:`3734`, :gh:`3735`, :gh:`3736`, :gh:`3737`, :gh:`3809`, :gh:`3821`, :gh:`3825`, :gh:`3827`, :gh:`3833`, :gh:`3857`, :gh:`3877`) .. _release-1.6.0: Scrapy 1.6.0 (2019-01-30) ------------------------- Highlights: * better Windows support; * Python 3.7 compatibility; * big documentation improvements, including a switch from ``.extract_first()`` + ``.extract()`` API to ``.get()`` + ``.getall()`` API; * feed exports, FilePipeline and MediaPipeline improvements; * better extensibility: :signal:`item_error` and :signal:`request_reached_downloader` signals; ``from_crawler`` support for feed exporters, feed storages and dupefilters. * ``scrapy.contracts`` fixes and new features; * telnet console security improvements, first released as a backport in :ref:`release-1.5.2`; * clean-up of the deprecated code; * various bug fixes, small new features and usability improvements across the codebase. Selector API changes ~~~~~~~~~~~~~~~~~~~~ While these are not changes in Scrapy itself, but rather in the parsel_ library which Scrapy uses for xpath/css selectors, these changes are worth mentioning here. Scrapy now depends on parsel >= 1.5, and Scrapy documentation is updated to follow recent ``parsel`` API conventions. Most visible change is that ``.get()`` and ``.getall()`` selector methods are now preferred over ``.extract_first()`` and ``.extract()``. We feel that these new methods result in a more concise and readable code. See :ref:`old-extraction-api` for more details. .. note:: There are currently **no plans** to deprecate ``.extract()`` and ``.extract_first()`` methods. Another useful new feature is the introduction of ``Selector.attrib`` and ``SelectorList.attrib`` properties, which make it easier to get attributes of HTML elements. See :ref:`selecting-attributes`. CSS selectors are cached in parsel >= 1.5, which makes them faster when the same CSS path is used many times. This is very common in case of Scrapy spiders: callbacks are usually called several times, on different pages. If you're using custom ``Selector`` or ``SelectorList`` subclasses, a **backward incompatible** change in parsel may affect your code. See `parsel changelog`_ for a detailed description, as well as for the full list of improvements. .. _parsel changelog: https://parsel.readthedocs.io/en/latest/history.html Telnet console ~~~~~~~~~~~~~~ **Backward incompatible**: Scrapy's telnet console now requires username and password. See :ref:`topics-telnetconsole` for more details. This change fixes a **security issue**; see :ref:`release-1.5.2` release notes for details. New extensibility features ~~~~~~~~~~~~~~~~~~~~~~~~~~ * ``from_crawler`` support is added to feed exporters and feed storages. This, among other things, allows to access Scrapy settings from custom feed storages and exporters (:gh:`1605`, :gh:`3348`). * ``from_crawler`` support is added to dupefilters (:gh:`2956`); this allows to access e.g. settings or a spider from a dupefilter. * :signal:`item_error` is fired when an error happens in a pipeline (:gh:`3256`); * :signal:`request_reached_downloader` is fired when Downloader gets a new Request; this signal can be useful e.g. for custom Schedulers (:gh:`3393`). * new SitemapSpider :meth:`~.SitemapSpider.sitemap_filter` method which allows to select sitemap entries based on their attributes in SitemapSpider subclasses (:gh:`3512`). * Lazy loading of Downloader Handlers is now optional; this enables better initialization error handling in custom Downloader Handlers (:gh:`3394`). New FilePipeline and MediaPipeline features ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ * Expose more options for S3FilesStore: :setting:`AWS_ENDPOINT_URL`, :setting:`AWS_USE_SSL`, :setting:`AWS_VERIFY`, :setting:`AWS_REGION_NAME`. For example, this allows to use alternative or self-hosted AWS-compatible providers (:gh:`2609`, :gh:`3548`). * ACL support for Google Cloud Storage: :setting:`FILES_STORE_GCS_ACL` and :setting:`IMAGES_STORE_GCS_ACL` (:gh:`3199`). ``scrapy.contracts`` improvements ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ * Exceptions in contracts code are handled better (:gh:`3377`); * ``dont_filter=True`` is used for contract requests, which allows to test different callbacks with the same URL (:gh:`3381`); * ``request_cls`` attribute in Contract subclasses allow to use different Request classes in contracts, for example FormRequest (:gh:`3383`). * Fixed errback handling in contracts, e.g. for cases where a contract is executed for URL which returns non-200 response (:gh:`3371`). Usability improvements ~~~~~~~~~~~~~~~~~~~~~~ * more stats for RobotsTxtMiddleware (:gh:`3100`) * INFO log level is used to show telnet host/port (:gh:`3115`) * a message is added to IgnoreRequest in RobotsTxtMiddleware (:gh:`3113`) * better validation of ``url`` argument in ``Response.follow`` (:gh:`3131`) * non-zero exit code is returned from Scrapy commands when error happens on spider initialization (:gh:`3226`) * Link extraction improvements: "ftp" is added to scheme list (:gh:`3152`); "flv" is added to common video extensions (:gh:`3165`) * better error message when an exporter is disabled (:gh:`3358`); * ``scrapy shell --help`` mentions syntax required for local files (``./file.html``) - :gh:`3496`. * Referer header value is added to RFPDupeFilter log messages (:gh:`3588`) Bug fixes ~~~~~~~~~ * fixed issue with extra blank lines in .csv exports under Windows (:gh:`3039`); * proper handling of pickling errors in Python 3 when serializing objects for disk queues (:gh:`3082`) * flags are now preserved when copying Requests (:gh:`3342`); * FormRequest.from_response clickdata shouldn't ignore elements with ``input[type=image]`` (:gh:`3153`). * FormRequest.from_response should preserve duplicate keys (:gh:`3247`) Documentation improvements ~~~~~~~~~~~~~~~~~~~~~~~~~~ * Docs are re-written to suggest .get/.getall API instead of .extract/.extract_first. Also, :ref:`topics-selectors` docs are updated and re-structured to match latest parsel docs; they now contain more topics, such as :ref:`selecting-attributes` or :ref:`topics-selectors-css-extensions` (:gh:`3390`). * :ref:`topics-developer-tools` is a new tutorial which replaces old Firefox and Firebug tutorials (:gh:`3400`). * SCRAPY_PROJECT environment variable is documented (:gh:`3518`); * troubleshooting section is added to install instructions (:gh:`3517`); * improved links to beginner resources in the tutorial (:gh:`3367`, :gh:`3468`); * fixed :setting:`RETRY_HTTP_CODES` default values in docs (:gh:`3335`); * remove unused ``DEPTH_STATS`` option from docs (:gh:`3245`); * other cleanups (:gh:`3347`, :gh:`3350`, :gh:`3445`, :gh:`3544`, :gh:`3605`). Deprecation removals ~~~~~~~~~~~~~~~~~~~~ Compatibility shims for pre-1.0 Scrapy module names are removed (:gh:`3318`): * ``scrapy.command`` * ``scrapy.contrib`` (with all submodules) * ``scrapy.contrib_exp`` (with all submodules) * ``scrapy.dupefilter`` * ``scrapy.linkextractor`` * ``scrapy.project`` * ``scrapy.spider`` * ``scrapy.spidermanager`` * ``scrapy.squeue`` * ``scrapy.stats`` * ``scrapy.statscol`` * ``scrapy.utils.decorator`` See :ref:`module-relocations` for more information, or use suggestions from Scrapy 1.5.x deprecation warnings to update your code. Other deprecation removals: * Deprecated scrapy.interfaces.ISpiderManager is removed; please use scrapy.interfaces.ISpiderLoader. * Deprecated ``CrawlerSettings`` class is removed (:gh:`3327`). * Deprecated ``Settings.overrides`` and ``Settings.defaults`` attributes are removed (:gh:`3327`, :gh:`3359`). Other improvements, cleanups ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ * All Scrapy tests now pass on Windows; Scrapy testing suite is executed in a Windows environment on CI (:gh:`3315`). * Python 3.7 support (:gh:`3326`, :gh:`3150`, :gh:`3547`). * Testing and CI fixes (:gh:`3526`, :gh:`3538`, :gh:`3308`, :gh:`3311`, :gh:`3309`, :gh:`3305`, :gh:`3210`, :gh:`3299`) * ``scrapy.http.cookies.CookieJar.clear`` accepts "domain", "path" and "name" optional arguments (:gh:`3231`). * additional files are included to sdist (:gh:`3495`); * code style fixes (:gh:`3405`, :gh:`3304`); * unneeded .strip() call is removed (:gh:`3519`); * collections.deque is used to store MiddlewareManager methods instead of a list (:gh:`3476`) .. _release-1.5.2: Scrapy 1.5.2 (2019-01-22) ------------------------- * *Security bugfix*: Telnet console extension can be easily exploited by rogue websites POSTing content to http://localhost:6023, we haven't found a way to exploit it from Scrapy, but it is very easy to trick a browser to do so and elevates the risk for local development environment. *The fix is backward incompatible*, it enables telnet user-password authentication by default with a random generated password. If you can't upgrade right away, please consider setting :setting:`TELNETCONSOLE_PORT` out of its default value. See :ref:`telnet console ` documentation for more info * Backport CI build failure under GCE environment due to boto import error. .. _release-1.5.1: Scrapy 1.5.1 (2018-07-12) ------------------------- This is a maintenance release with important bug fixes, but no new features: * ``O(N^2)`` gzip decompression issue which affected Python 3 and PyPy is fixed (:gh:`3281`); * skipping of TLS validation errors is improved (:gh:`3166`); * Ctrl-C handling is fixed in Python 3.5+ (:gh:`3096`); * testing fixes (:gh:`3092`, :gh:`3263`); * documentation improvements (:gh:`3058`, :gh:`3059`, :gh:`3089`, :gh:`3123`, :gh:`3127`, :gh:`3189`, :gh:`3224`, :gh:`3280`, :gh:`3279`, :gh:`3201`, :gh:`3260`, :gh:`3284`, :gh:`3298`, :gh:`3294`). .. _release-1.5.0: Scrapy 1.5.0 (2017-12-29) ------------------------- This release brings small new features and improvements across the codebase. Some highlights: * Google Cloud Storage is supported in FilesPipeline and ImagesPipeline. * Crawling with proxy servers becomes more efficient, as connections to proxies can be reused now. * Warnings, exception and logging messages are improved to make debugging easier. * ``scrapy parse`` command now allows to set custom request meta via ``--meta`` argument. * Compatibility with Python 3.6, PyPy and PyPy3 is improved; PyPy and PyPy3 are now supported officially, by running tests on CI. * Better default handling of HTTP 308, 522 and 524 status codes. * Documentation is improved, as usual. Backward Incompatible Changes ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ * Scrapy 1.5 drops support for Python 3.3. * Default Scrapy User-Agent now uses https link to scrapy.org (:gh:`2983`). **This is technically backward-incompatible**; override :setting:`USER_AGENT` if you relied on old value. * Logging of settings overridden by ``custom_settings`` is fixed; **this is technically backward-incompatible** because the logger changes from ``[scrapy.utils.log]`` to ``[scrapy.crawler]``. If you're parsing Scrapy logs, please update your log parsers (:gh:`1343`). * LinkExtractor now ignores ``m4v`` extension by default, this is change in behavior. * 522 and 524 status codes are added to ``RETRY_HTTP_CODES`` (:gh:`2851`) New features ~~~~~~~~~~~~ - Support ```` tags in ``Response.follow`` (:gh:`2785`) - Support for ``ptpython`` REPL (:gh:`2654`) - Google Cloud Storage support for FilesPipeline and ImagesPipeline (:gh:`2923`). - New ``--meta`` option of the "scrapy parse" command allows to pass additional request.meta (:gh:`2883`) - Populate spider variable when using ``shell.inspect_response`` (:gh:`2812`) - Handle HTTP 308 Permanent Redirect (:gh:`2844`) - Add 522 and 524 to ``RETRY_HTTP_CODES`` (:gh:`2851`) - Log versions information at startup (:gh:`2857`) - ``scrapy.mail.MailSender`` now works in Python 3 (it requires Twisted 17.9.0) - Connections to proxy servers are reused (:gh:`2743`) - Add template for a downloader middleware (:gh:`2755`) - Explicit message for NotImplementedError when parse callback not defined (:gh:`2831`) - CrawlerProcess got an option to disable installation of root log handler (:gh:`2921`) - LinkExtractor now ignores ``m4v`` extension by default - Better log messages for responses over :setting:`DOWNLOAD_WARNSIZE` and :setting:`DOWNLOAD_MAXSIZE` limits (:gh:`2927`) - Show warning when a URL is put to ``Spider.allowed_domains`` instead of a domain (:gh:`2250`). Bug fixes ~~~~~~~~~ - Fix logging of settings overridden by ``custom_settings``; **this is technically backward-incompatible** because the logger changes from ``[scrapy.utils.log]`` to ``[scrapy.crawler]``, so please update your log parsers if needed (:gh:`1343`) - Default Scrapy User-Agent now uses https link to scrapy.org (:gh:`2983`). **This is technically backward-incompatible**; override :setting:`USER_AGENT` if you relied on old value. - Fix PyPy and PyPy3 test failures, support them officially (:gh:`2793`, :gh:`2935`, :gh:`2990`, :gh:`3050`, :gh:`2213`, :gh:`3048`) - Fix DNS resolver when ``DNSCACHE_ENABLED=False`` (:gh:`2811`) - Add ``cryptography`` for Debian Jessie tox test env (:gh:`2848`) - Add verification to check if Request callback is callable (:gh:`2766`) - Port ``extras/qpsclient.py`` to Python 3 (:gh:`2849`) - Use getfullargspec under the scenes for Python 3 to stop DeprecationWarning (:gh:`2862`) - Update deprecated test aliases (:gh:`2876`) - Fix ``SitemapSpider`` support for alternate links (:gh:`2853`) Docs ~~~~ - Added missing bullet point for the ``AUTOTHROTTLE_TARGET_CONCURRENCY`` setting. (:gh:`2756`) - Update Contributing docs, document new support channels (:gh:`2762`, :gh:`3038`) - Include references to Scrapy subreddit in the docs - Fix broken links; use ``https://`` for external links (:gh:`2978`, :gh:`2982`, :gh:`2958`) - Document CloseSpider extension better (:gh:`2759`) - Use ``pymongo.collection.Collection.insert_one()`` in MongoDB example (:gh:`2781`) - Spelling mistake and typos (:gh:`2828`, :gh:`2837`, :gh:`2884`, :gh:`2924`) - Clarify ``CSVFeedSpider.headers`` documentation (:gh:`2826`) - Document ``DontCloseSpider`` exception and clarify ``spider_idle`` (:gh:`2791`) - Update "Releases" section in README (:gh:`2764`) - Fix rst syntax in ``DOWNLOAD_FAIL_ON_DATALOSS`` docs (:gh:`2763`) - Small fix in description of startproject arguments (:gh:`2866`) - Clarify data types in Response.body docs (:gh:`2922`) - Add a note about ``request.meta['depth']`` to DepthMiddleware docs (:gh:`2374`) - Add a note about ``request.meta['dont_merge_cookies']`` to CookiesMiddleware docs (:gh:`2999`) - Up-to-date example of project structure (:gh:`2964`, :gh:`2976`) - A better example of ItemExporters usage (:gh:`2989`) - Document ``from_crawler`` methods for spider and downloader middlewares (:gh:`3019`) .. _release-1.4.0: Scrapy 1.4.0 (2017-05-18) ------------------------- Scrapy 1.4 does not bring that many breathtaking new features but quite a few handy improvements nonetheless. Scrapy now supports anonymous FTP sessions with customizable user and password via the new :setting:`FTP_USER` and :setting:`FTP_PASSWORD` settings. And if you're using Twisted version 17.1.0 or above, FTP is now available with Python 3. There's a new :meth:`response.follow ` method for creating requests; **it is now a recommended way to create Requests in Scrapy spiders**. This method makes it easier to write correct spiders; ``response.follow`` has several advantages over creating ``scrapy.Request`` objects directly: * it handles relative URLs; * it works properly with non-ascii URLs on non-UTF8 pages; * in addition to absolute and relative URLs it supports Selectors; for ```` elements it can also extract their href values. For example, instead of this:: for href in response.css('li.page a::attr(href)').extract(): url = response.urljoin(href) yield scrapy.Request(url, self.parse, encoding=response.encoding) One can now write this:: for a in response.css('li.page a'): yield response.follow(a, self.parse) Link extractors are also improved. They work similarly to what a regular modern browser would do: leading and trailing whitespace are removed from attributes (think ``href=" http://example.com"``) when building ``Link`` objects. This whitespace-stripping also happens for ``action`` attributes with ``FormRequest``. **Please also note that link extractors do not canonicalize URLs by default anymore.** This was puzzling users every now and then, and it's not what browsers do in fact, so we removed that extra transformation on extracted links. For those of you wanting more control on the ``Referer:`` header that Scrapy sends when following links, you can set your own ``Referrer Policy``. Prior to Scrapy 1.4, the default ``RefererMiddleware`` would simply and blindly set it to the URL of the response that generated the HTTP request (which could leak information on your URL seeds). By default, Scrapy now behaves much like your regular browser does. And this policy is fully customizable with W3C standard values (or with something really custom of your own if you wish). See :setting:`REFERRER_POLICY` for details. To make Scrapy spiders easier to debug, Scrapy logs more stats by default in 1.4: memory usage stats, detailed retry stats, detailed HTTP error code stats. A similar change is that HTTP cache path is also visible in logs now. Last but not least, Scrapy now has the option to make JSON and XML items more human-readable, with newlines between items and even custom indenting offset, using the new :setting:`FEED_EXPORT_INDENT` setting. Enjoy! (Or read on for the rest of changes in this release.) Deprecations and Backward Incompatible Changes ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - Default to ``canonicalize=False`` in :class:`scrapy.linkextractors.LinkExtractor ` (:gh:`2537`, fixes :gh:`1941` and :gh:`1982`): **warning, this is technically backward-incompatible** - Enable memusage extension by default (:gh:`2539`, fixes :gh:`2187`); **this is technically backward-incompatible** so please check if you have any non-default ``MEMUSAGE_***`` options set. - ``EDITOR`` environment variable now takes precedence over ``EDITOR`` option defined in settings.py (:gh:`1829`); Scrapy default settings no longer depend on environment variables. **This is technically a backward incompatible change**. - ``Spider.make_requests_from_url`` is deprecated (:gh:`1728`, fixes :gh:`1495`). New Features ~~~~~~~~~~~~ - Accept proxy credentials in :reqmeta:`proxy` request meta key (:gh:`2526`) - Support `brotli-compressed`_ content; requires optional `brotlipy`_ (:gh:`2535`) - New :ref:`response.follow ` shortcut for creating requests (:gh:`1940`) - Added ``flags`` argument and attribute to :class:`~scrapy.Request` objects (:gh:`2047`) - Support Anonymous FTP (:gh:`2342`) - Added ``retry/count``, ``retry/max_reached`` and ``retry/reason_count/`` stats to :class:`RetryMiddleware ` (:gh:`2543`) - Added ``httperror/response_ignored_count`` and ``httperror/response_ignored_status_count/`` stats to :class:`HttpErrorMiddleware ` (:gh:`2566`) - Customizable :setting:`Referrer policy ` in :class:`RefererMiddleware ` (:gh:`2306`) - New ``data:`` URI download handler (:gh:`2334`, fixes :gh:`2156`) - Log cache directory when HTTP Cache is used (:gh:`2611`, fixes :gh:`2604`) - Warn users when project contains duplicate spider names (fixes :gh:`2181`) - ``scrapy.utils.datatypes.CaselessDict`` now accepts ``Mapping`` instances and not only dicts (:gh:`2646`) - :ref:`Media downloads `, with :class:`~scrapy.pipelines.files.FilesPipeline` or :class:`~scrapy.pipelines.images.ImagesPipeline`, can now optionally handle HTTP redirects using the new :setting:`MEDIA_ALLOW_REDIRECTS` setting (:gh:`2616`, fixes :gh:`2004`) - Accept non-complete responses from websites using a new :setting:`DOWNLOAD_FAIL_ON_DATALOSS` setting (:gh:`2590`, fixes :gh:`2586`) - Optional pretty-printing of JSON and XML items via :setting:`FEED_EXPORT_INDENT` setting (:gh:`2456`, fixes :gh:`1327`) - Allow dropping fields in ``FormRequest.from_response`` formdata when ``None`` value is passed (:gh:`667`) - Per-request retry times with the new :reqmeta:`max_retry_times` meta key (:gh:`2642`) - ``python -m scrapy`` as a more explicit alternative to ``scrapy`` command (:gh:`2740`) .. _brotli-compressed: https://www.ietf.org/rfc/rfc7932.txt .. _brotlipy: https://github.com/python-hyper/brotlipy/ Bug fixes ~~~~~~~~~ - LinkExtractor now strips leading and trailing whitespaces from attributes (:gh:`2547`, fixes :gh:`1614`) - Properly handle whitespaces in action attribute in :class:`~scrapy.FormRequest` (:gh:`2548`) - Buffer CONNECT response bytes from proxy until all HTTP headers are received (:gh:`2495`, fixes :gh:`2491`) - FTP downloader now works on Python 3, provided you use Twisted>=17.1 (:gh:`2599`) - Use body to choose response type after decompressing content (:gh:`2393`, fixes :gh:`2145`) - Always decompress ``Content-Encoding: gzip`` at :class:`HttpCompressionMiddleware ` stage (:gh:`2391`) - Respect custom log level in ``Spider.custom_settings`` (:gh:`2581`, fixes :gh:`1612`) - 'make htmlview' fix for macOS (:gh:`2661`) - Remove "commands" from the command list (:gh:`2695`) - Fix duplicate Content-Length header for POST requests with empty body (:gh:`2677`) - Properly cancel large downloads, i.e. above :setting:`DOWNLOAD_MAXSIZE` (:gh:`1616`) - ImagesPipeline: fixed processing of transparent PNG images with palette (:gh:`2675`) Cleanups & Refactoring ~~~~~~~~~~~~~~~~~~~~~~ - Tests: remove temp files and folders (:gh:`2570`), fixed ProjectUtilsTest on macOS (:gh:`2569`), use portable pypy for Linux on Travis CI (:gh:`2710`) - Separate building request from ``_requests_to_follow`` in CrawlSpider (:gh:`2562`) - Remove “Python 3 progress� badge (:gh:`2567`) - Add a couple more lines to ``.gitignore`` (:gh:`2557`) - Remove bumpversion prerelease configuration (:gh:`2159`) - Add codecov.yml file (:gh:`2750`) - Set context factory implementation based on Twisted version (:gh:`2577`, fixes :gh:`2560`) - Add omitted ``self`` arguments in default project middleware template (:gh:`2595`) - Remove redundant ``slot.add_request()`` call in ExecutionEngine (:gh:`2617`) - Catch more specific ``os.error`` exception in ``scrapy.pipelines.files.FSFilesStore`` (:gh:`2644`) - Change "localhost" test server certificate (:gh:`2720`) - Remove unused ``MEMUSAGE_REPORT`` setting (:gh:`2576`) Documentation ~~~~~~~~~~~~~ - Binary mode is required for exporters (:gh:`2564`, fixes :gh:`2553`) - Mention issue with :meth:`.FormRequest.from_response` due to bug in lxml (:gh:`2572`) - Use single quotes uniformly in templates (:gh:`2596`) - Document :reqmeta:`ftp_user` and :reqmeta:`ftp_password` meta keys (:gh:`2587`) - Removed section on deprecated ``contrib/`` (:gh:`2636`) - Recommend Anaconda when installing Scrapy on Windows (:gh:`2477`, fixes :gh:`2475`) - FAQ: rewrite note on Python 3 support on Windows (:gh:`2690`) - Rearrange selector sections (:gh:`2705`) - Remove ``__nonzero__`` from :class:`~scrapy.selector.SelectorList` docs (:gh:`2683`) - Mention how to disable request filtering in documentation of :setting:`DUPEFILTER_CLASS` setting (:gh:`2714`) - Add sphinx_rtd_theme to docs setup readme (:gh:`2668`) - Open file in text mode in JSON item writer example (:gh:`2729`) - Clarify ``allowed_domains`` example (:gh:`2670`) .. _release-1.3.3: Scrapy 1.3.3 (2017-03-10) ------------------------- Bug fixes ~~~~~~~~~ - Make ``SpiderLoader`` raise ``ImportError`` again by default for missing dependencies and wrong :setting:`SPIDER_MODULES`. These exceptions were silenced as warnings since 1.3.0. A new setting is introduced to toggle between warning or exception if needed ; see :setting:`SPIDER_LOADER_WARN_ONLY` for details. .. _release-1.3.2: Scrapy 1.3.2 (2017-02-13) ------------------------- Bug fixes ~~~~~~~~~ - Preserve request class when converting to/from dicts (utils.reqser) (:gh:`2510`). - Use consistent selectors for author field in tutorial (:gh:`2551`). - Fix TLS compatibility in Twisted 17+ (:gh:`2558`) .. _release-1.3.1: Scrapy 1.3.1 (2017-02-08) ------------------------- New features ~~~~~~~~~~~~ - Support ``'True'`` and ``'False'`` string values for boolean settings (:gh:`2519`); you can now do something like ``scrapy crawl myspider -s REDIRECT_ENABLED=False``. - Support kwargs with ``response.xpath()`` to use :ref:`XPath variables ` and ad-hoc namespaces declarations ; this requires at least Parsel v1.1 (:gh:`2457`). - Add support for Python 3.6 (:gh:`2485`). - Run tests on PyPy (warning: some tests still fail, so PyPy is not supported yet). Bug fixes ~~~~~~~~~ - Enforce ``DNS_TIMEOUT`` setting (:gh:`2496`). - Fix :command:`view` command ; it was a regression in v1.3.0 (:gh:`2503`). - Fix tests regarding ``*_EXPIRES settings`` with Files/Images pipelines (:gh:`2460`). - Fix name of generated pipeline class when using basic project template (:gh:`2466`). - Fix compatibility with Twisted 17+ (:gh:`2496`, :gh:`2528`). - Fix ``scrapy.Item`` inheritance on Python 3.6 (:gh:`2511`). - Enforce numeric values for components order in ``SPIDER_MIDDLEWARES``, ``DOWNLOADER_MIDDLEWARES``, ``EXTENSIONS`` and ``SPIDER_CONTRACTS`` (:gh:`2420`). Documentation ~~~~~~~~~~~~~ - Reword Code of Conduct section and upgrade to Contributor Covenant v1.4 (:gh:`2469`). - Clarify that passing spider arguments converts them to spider attributes (:gh:`2483`). - Document ``formid`` argument on ``FormRequest.from_response()`` (:gh:`2497`). - Add .rst extension to README files (:gh:`2507`). - Mention LevelDB cache storage backend (:gh:`2525`). - Use ``yield`` in sample callback code (:gh:`2533`). - Add note about HTML entities decoding with ``.re()/.re_first()`` (:gh:`1704`). - Typos (:gh:`2512`, :gh:`2534`, :gh:`2531`). Cleanups ~~~~~~~~ - Remove redundant check in ``MetaRefreshMiddleware`` (:gh:`2542`). - Faster checks in ``LinkExtractor`` for allow/deny patterns (:gh:`2538`). - Remove dead code supporting old Twisted versions (:gh:`2544`). .. _release-1.3.0: Scrapy 1.3.0 (2016-12-21) ------------------------- This release comes rather soon after 1.2.2 for one main reason: it was found out that releases since 0.18 up to 1.2.2 (included) use some backported code from Twisted (``scrapy.xlib.tx.*``), even if newer Twisted modules are available. Scrapy now uses ``twisted.web.client`` and ``twisted.internet.endpoints`` directly. (See also cleanups below.) As it is a major change, we wanted to get the bug fix out quickly while not breaking any projects using the 1.2 series. New Features ~~~~~~~~~~~~ - ``MailSender`` now accepts single strings as values for ``to`` and ``cc`` arguments (:gh:`2272`) - ``scrapy fetch url``, ``scrapy shell url`` and ``fetch(url)`` inside Scrapy shell now follow HTTP redirections by default (:gh:`2290`); See :command:`fetch` and :command:`shell` for details. - ``HttpErrorMiddleware`` now logs errors with ``INFO`` level instead of ``DEBUG``; this is technically **backward incompatible** so please check your log parsers. - By default, logger names now use a long-form path, e.g. ``[scrapy.extensions.logstats]``, instead of the shorter "top-level" variant of prior releases (e.g. ``[scrapy]``); this is **backward incompatible** if you have log parsers expecting the short logger name part. You can switch back to short logger names using :setting:`LOG_SHORT_NAMES` set to ``True``. Dependencies & Cleanups ~~~~~~~~~~~~~~~~~~~~~~~ - Scrapy now requires Twisted >= 13.1 which is the case for many Linux distributions already. - As a consequence, we got rid of ``scrapy.xlib.tx.*`` modules, which copied some of Twisted code for users stuck with an "old" Twisted version - ``ChunkedTransferMiddleware`` is deprecated and removed from the default downloader middlewares. .. _release-1.2.3: Scrapy 1.2.3 (2017-03-03) ------------------------- - Packaging fix: disallow unsupported Twisted versions in setup.py .. _release-1.2.2: Scrapy 1.2.2 (2016-12-06) ------------------------- Bug fixes ~~~~~~~~~ - Fix a cryptic traceback when a pipeline fails on ``open_spider()`` (:gh:`2011`) - Fix embedded IPython shell variables (fixing :gh:`396` that re-appeared in 1.2.0, fixed in :gh:`2418`) - A couple of patches when dealing with robots.txt: - handle (non-standard) relative sitemap URLs (:gh:`2390`) - handle non-ASCII URLs and User-Agents in Python 2 (:gh:`2373`) Documentation ~~~~~~~~~~~~~ - Document ``"download_latency"`` key in ``Request``'s ``meta`` dict (:gh:`2033`) - Remove page on (deprecated & unsupported) Ubuntu packages from ToC (:gh:`2335`) - A few fixed typos (:gh:`2346`, :gh:`2369`, :gh:`2369`, :gh:`2380`) and clarifications (:gh:`2354`, :gh:`2325`, :gh:`2414`) Other changes ~~~~~~~~~~~~~ - Advertize `conda-forge`_ as Scrapy's official conda channel (:gh:`2387`) - More helpful error messages when trying to use ``.css()`` or ``.xpath()`` on non-Text Responses (:gh:`2264`) - ``startproject`` command now generates a sample ``middlewares.py`` file (:gh:`2335`) - Add more dependencies' version info in ``scrapy version`` verbose output (:gh:`2404`) - Remove all ``*.pyc`` files from source distribution (:gh:`2386`) .. _conda-forge: https://anaconda.org/conda-forge/scrapy .. _release-1.2.1: Scrapy 1.2.1 (2016-10-21) ------------------------- Bug fixes ~~~~~~~~~ - Include OpenSSL's more permissive default ciphers when establishing TLS/SSL connections (:gh:`2314`). - Fix "Location" HTTP header decoding on non-ASCII URL redirects (:gh:`2321`). Documentation ~~~~~~~~~~~~~ - Fix JsonWriterPipeline example (:gh:`2302`). - Various notes: :gh:`2330` on spider names, :gh:`2329` on middleware methods processing order, :gh:`2327` on getting multi-valued HTTP headers as lists. Other changes ~~~~~~~~~~~~~ - Removed ``www.`` from ``start_urls`` in built-in spider templates (:gh:`2299`). .. _release-1.2.0: Scrapy 1.2.0 (2016-10-03) ------------------------- New Features ~~~~~~~~~~~~ - New :setting:`FEED_EXPORT_ENCODING` setting to customize the encoding used when writing items to a file. This can be used to turn off ``\uXXXX`` escapes in JSON output. This is also useful for those wanting something else than UTF-8 for XML or CSV output (:gh:`2034`). - ``startproject`` command now supports an optional destination directory to override the default one based on the project name (:gh:`2005`). - New :setting:`SCHEDULER_DEBUG` setting to log requests serialization failures (:gh:`1610`). - JSON encoder now supports serialization of ``set`` instances (:gh:`2058`). - Interpret ``application/json-amazonui-streaming`` as ``TextResponse`` (:gh:`1503`). - ``scrapy`` is imported by default when using shell tools (:command:`shell`, :ref:`inspect_response `) (:gh:`2248`). Bug fixes ~~~~~~~~~ - DefaultRequestHeaders middleware now runs before UserAgent middleware (:gh:`2088`). **Warning: this is technically backward incompatible**, though we consider this a bug fix. - HTTP cache extension and plugins that use the ``.scrapy`` data directory now work outside projects (:gh:`1581`). **Warning: this is technically backward incompatible**, though we consider this a bug fix. - ``Selector`` does not allow passing both ``response`` and ``text`` anymore (:gh:`2153`). - Fixed logging of wrong callback name with ``scrapy parse`` (:gh:`2169`). - Fix for an odd gzip decompression bug (:gh:`1606`). - Fix for selected callbacks when using ``CrawlSpider`` with :command:`scrapy parse ` (:gh:`2225`). - Fix for invalid JSON and XML files when spider yields no items (:gh:`872`). - Implement ``flush()`` for ``StreamLogger`` avoiding a warning in logs (:gh:`2125`). Refactoring ~~~~~~~~~~~ - ``canonicalize_url`` has been moved to `w3lib.url`_ (:gh:`2168`). .. _w3lib.url: https://w3lib.readthedocs.io/en/latest/w3lib.html#w3lib.url.canonicalize_url Tests & Requirements ~~~~~~~~~~~~~~~~~~~~ Scrapy's new requirements baseline is Debian 8 "Jessie". It was previously Ubuntu 12.04 Precise. What this means in practice is that we run continuous integration tests with these (main) packages versions at a minimum: Twisted 14.0, pyOpenSSL 0.14, lxml 3.4. Scrapy may very well work with older versions of these packages (the code base still has switches for older Twisted versions for example) but it is not guaranteed (because it's not tested anymore). Documentation ~~~~~~~~~~~~~ - Grammar fixes: :gh:`2128`, :gh:`1566`. - Download stats badge removed from README (:gh:`2160`). - New Scrapy :ref:`architecture diagram ` (:gh:`2165`). - Updated ``Response`` parameters documentation (:gh:`2197`). - Reworded misleading ``RANDOMIZE_DOWNLOAD_DELAY`` description (:gh:`2190`). - Add StackOverflow as a support channel (:gh:`2257`). .. _release-1.1.4: Scrapy 1.1.4 (2017-03-03) ------------------------- - Packaging fix: disallow unsupported Twisted versions in setup.py .. _release-1.1.3: Scrapy 1.1.3 (2016-09-22) ------------------------- Bug fixes ~~~~~~~~~ - Class attributes for subclasses of ``ImagesPipeline`` and ``FilesPipeline`` work as they did before 1.1.1 (:gh:`2243`, fixes :gh:`2198`) Documentation ~~~~~~~~~~~~~ - :ref:`Overview ` and :ref:`tutorial ` rewritten to use http://toscrape.com websites (:gh:`2236`, :gh:`2249`, :gh:`2252`). .. _release-1.1.2: Scrapy 1.1.2 (2016-08-18) ------------------------- Bug fixes ~~~~~~~~~ - Introduce a missing :setting:`IMAGES_STORE_S3_ACL` setting to override the default ACL policy in ``ImagesPipeline`` when uploading images to S3 (note that default ACL policy is "private" -- instead of "public-read" -- since Scrapy 1.1.0) - :setting:`IMAGES_EXPIRES` default value set back to 90 (the regression was introduced in 1.1.1) .. _release-1.1.1: Scrapy 1.1.1 (2016-07-13) ------------------------- Bug fixes ~~~~~~~~~ - Add "Host" header in CONNECT requests to HTTPS proxies (:gh:`2069`) - Use response ``body`` when choosing response class (:gh:`2001`, fixes :gh:`2000`) - Do not fail on canonicalizing URLs with wrong netlocs (:gh:`2038`, fixes :gh:`2010`) - a few fixes for ``HttpCompressionMiddleware`` (and ``SitemapSpider``): - Do not decode HEAD responses (:gh:`2008`, fixes :gh:`1899`) - Handle charset parameter in gzip Content-Type header (:gh:`2050`, fixes :gh:`2049`) - Do not decompress gzip octet-stream responses (:gh:`2065`, fixes :gh:`2063`) - Catch (and ignore with a warning) exception when verifying certificate against IP-address hosts (:gh:`2094`, fixes :gh:`2092`) - Make ``FilesPipeline`` and ``ImagesPipeline`` backward compatible again regarding the use of legacy class attributes for customization (:gh:`1989`, fixes :gh:`1985`) New features ~~~~~~~~~~~~ - Enable genspider command outside project folder (:gh:`2052`) - Retry HTTPS CONNECT ``TunnelError`` by default (:gh:`1974`) Documentation ~~~~~~~~~~~~~ - ``FEED_TEMPDIR`` setting at lexicographical position (:commit:`9b3c72c`) - Use idiomatic ``.extract_first()`` in overview (:gh:`1994`) - Update years in copyright notice (:commit:`c2c8036`) - Add information and example on errbacks (:gh:`1995`) - Use "url" variable in downloader middleware example (:gh:`2015`) - Grammar fixes (:gh:`2054`, :gh:`2120`) - New FAQ entry on using BeautifulSoup in spider callbacks (:gh:`2048`) - Add notes about Scrapy not working on Windows with Python 3 (:gh:`2060`) - Encourage complete titles in pull requests (:gh:`2026`) Tests ~~~~~ - Upgrade py.test requirement on Travis CI and Pin pytest-cov to 2.2.1 (:gh:`2095`) .. _release-1.1.0: Scrapy 1.1.0 (2016-05-11) ------------------------- This 1.1 release brings a lot of interesting features and bug fixes: - Scrapy 1.1 has beta Python 3 support (requires Twisted >= 15.5). See :ref:`news_betapy3` for more details and some limitations. - Hot new features: - Item loaders now support nested loaders (:gh:`1467`). - ``FormRequest.from_response`` improvements (:gh:`1382`, :gh:`1137`). - Added setting :setting:`AUTOTHROTTLE_TARGET_CONCURRENCY` and improved AutoThrottle docs (:gh:`1324`). - Added ``response.text`` to get body as unicode (:gh:`1730`). - Anonymous S3 connections (:gh:`1358`). - Deferreds in downloader middlewares (:gh:`1473`). This enables better robots.txt handling (:gh:`1471`). - HTTP caching now follows RFC2616 more closely, added settings :setting:`HTTPCACHE_ALWAYS_STORE` and :setting:`HTTPCACHE_IGNORE_RESPONSE_CACHE_CONTROLS` (:gh:`1151`). - Selectors were extracted to the parsel_ library (:gh:`1409`). This means you can use Scrapy Selectors without Scrapy and also upgrade the selectors engine without needing to upgrade Scrapy. - HTTPS downloader now does TLS protocol negotiation by default, instead of forcing TLS 1.0. You can also set the SSL/TLS method using the new ``DOWNLOADER_CLIENT_TLS_METHOD`` setting. - These bug fixes may require your attention: - Don't retry bad requests (HTTP 400) by default (:gh:`1289`). If you need the old behavior, add ``400`` to :setting:`RETRY_HTTP_CODES`. - Fix shell files argument handling (:gh:`1710`, :gh:`1550`). If you try ``scrapy shell index.html`` it will try to load the URL ``http://index.html``, use ``scrapy shell ./index.html`` to load a local file. - Robots.txt compliance is now enabled by default for newly-created projects (:gh:`1724`). Scrapy will also wait for robots.txt to be downloaded before proceeding with the crawl (:gh:`1735`). If you want to disable this behavior, update :setting:`ROBOTSTXT_OBEY` in ``settings.py`` file after creating a new project. - Exporters now work on unicode, instead of bytes by default (:gh:`1080`). If you use :class:`~scrapy.exporters.PythonItemExporter`, you may want to update your code to disable binary mode which is now deprecated. - Accept XML node names containing dots as valid (:gh:`1533`). - When uploading files or images to S3 (with ``FilesPipeline`` or ``ImagesPipeline``), the default ACL policy is now "private" instead of "public" **Warning: backward incompatible!**. You can use :setting:`FILES_STORE_S3_ACL` to change it. - We've reimplemented ``canonicalize_url()`` for more correct output, especially for URLs with non-ASCII characters (:gh:`1947`). This could change link extractors output compared to previous Scrapy versions. This may also invalidate some cache entries you could still have from pre-1.1 runs. **Warning: backward incompatible!**. Keep reading for more details on other improvements and bug fixes. .. _news_betapy3: Beta Python 3 Support ~~~~~~~~~~~~~~~~~~~~~ We have been hard at work to make Scrapy run on Python 3. As a result, now you can run spiders on Python 3.3, 3.4 and 3.5 (Twisted >= 15.5 required). Some features are still missing (and some may never be ported). Almost all builtin extensions/middlewares are expected to work. However, we are aware of some limitations in Python 3: - Scrapy does not work on Windows with Python 3 - Sending emails is not supported - FTP download handler is not supported - Telnet console is not supported Additional New Features and Enhancements ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - Scrapy now has a `Code of Conduct`_ (:gh:`1681`). - Command line tool now has completion for zsh (:gh:`934`). - Improvements to ``scrapy shell``: - Support for bpython and configure preferred Python shell via ``SCRAPY_PYTHON_SHELL`` (:gh:`1100`, :gh:`1444`). - Support URLs without scheme (:gh:`1498`) **Warning: backward incompatible!** - Bring back support for relative file path (:gh:`1710`, :gh:`1550`). - Added :setting:`MEMUSAGE_CHECK_INTERVAL_SECONDS` setting to change default check interval (:gh:`1282`). - Download handlers are now lazy-loaded on first request using their scheme (:gh:`1390`, :gh:`1421`). - HTTPS download handlers do not force TLS 1.0 anymore; instead, OpenSSL's ``SSLv23_method()/TLS_method()`` is used allowing to try negotiating with the remote hosts the highest TLS protocol version it can (:gh:`1794`, :gh:`1629`). - ``RedirectMiddleware`` now skips the status codes from ``handle_httpstatus_list`` on spider attribute or in ``Request``'s ``meta`` key (:gh:`1334`, :gh:`1364`, :gh:`1447`). - Form submission: - now works with ``
  • Olio

    Olio

    £23.88

    In stock

  • scrapy-scrapy-5569de3/tests/sample_data/compressed/000077500000000000000000000000001525051012400224335ustar00rootroot00000000000000scrapy-scrapy-5569de3/tests/sample_data/compressed/bomb-br.bin000066400000000000000000000000421525051012400244410ustar00rootroot00000000000000Ÿ;§¯ø�”nÞªVp SmoY2·å ï(ä)-д=_oscrapy-scrapy-5569de3/tests/sample_data/compressed/bomb-deflate.bin000066400000000000000000000665001525051012400254550ustar00rootroot00000000000000xœìÉÁ €0°¿SÜNÓ¬죞xûƒÈ/íš¿#Î\Ï;ªfÞÑsõ}kÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9÷±'‡@þ¿v„ê½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{ï½÷Þ{Ú“CAÿ_{ÃçœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ�.sáUøscrapy-scrapy-5569de3/tests/sample_data/compressed/bomb-gzip.bin000066400000000000000000000665241525051012400250300ustar00rootroot00000000000000‹Qí]ecompression-bomb.txtìÉÁ €0°¿SÜNÓ¬죞xûƒÈ/íš¿#Î\Ï;ªfÞÑsõ}kÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9÷µ'‡‚þ¿ö†*Î9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çœsÎ9çÜèÌ›ys<§¯scrapy-scrapy-5569de3/tests/sample_data/compressed/bomb-zstd.bin000066400000000000000000000021101525051012400250200ustar00rootroot00000000000000(µ/ý„X<§¯$àThis is a compression bomb. „ÿ>�L üÿ9Laüÿ9Lmüÿ9Lsüÿ9Lnüÿ9Lmüÿ9LTüÿ9L üÿ9Laüÿ9Lmüÿ9Lsüÿ9Lnüÿ9Lmüÿ9LTüÿ9L üÿ9Laüÿ9Lmüÿ9Lsüÿ9Lnüÿ9Lmüÿ9LTüÿ9L üÿ9Laüÿ9Lmüÿ9Lsüÿ9Lnüÿ9Lmüÿ9LTüÿ9L üÿ9Laüÿ9Lmüÿ9Lsüÿ9Lnüÿ9Lmüÿ9LTüÿ9L üÿ9Laüÿ9Lmüÿ9Lsüÿ9Lnüÿ9Lmüÿ9LTüÿ9L üÿ9Laüÿ9Lmüÿ9Lsüÿ9Lnüÿ9Lmüÿ9LTüÿ9L üÿ9Laüÿ9Lmüÿ9Lsüÿ9Lnüÿ9Lmüÿ9LTüÿ9L üÿ9Laüÿ9Lmüÿ9Lsüÿ9Lnüÿ9Lmüÿ9LTüÿ9DýÿŸO Laüÿ9Lmüÿ9Lsüÿ9Lnüÿ9Lmüÿ9LTüÿ9L üÿ9Laüÿ9Lmüÿ9Lsüÿ9Lnüÿ9Lmüÿ9LTüÿ9L üÿ9Laüÿ9Lmüÿ9Lsüÿ9Lnüÿ9Lmüÿ9LTüÿ9L üÿ9Laüÿ9Mm8§9‹D2'scrapy-scrapy-5569de3/tests/sample_data/compressed/feed-sample1.tar000066400000000000000000000500001525051012400254010ustar00rootroot00000000000000feed-sample1.xml0000644000175000017500000002333611061471701014564 0ustar elpolillaelpolilla 28.00 Beautiful, open edition giclee print from Agatha's original watercolours, reprinted onto watercolour paper, reproducing all the qualities of the original painting.

    The print is conservation mounted and individually signed by the artist.

    Picture can be personalised!

    An original and delightful gift, perfect as a bedroom or nursery decoration. (Especially pleasing for any cat lover!)

    Picture size: 17cm x 17cm Framed picture size: 30cm x 30cm (12" x 12")

    ]]>
    28.00 Beautiful, open edition giclee print from Agatha's original watercolours, reprinted onto watercolour paper, reproducing all the qualities of the original painting.

    The print is conservation mounted and individually signed by the artist.

    Picture can be personalised!

    An original and delightful gift, perfect as a bedroom or nursery decoration. (Especially pleasing for any cat lover!)

    Picture size: 17cm x 17cm Framed picture size: 30cm x 30cm (12" x 12")

    ]]>
    28.00 Beautiful, open edition giclee print from Agatha's original watercolours, reprinted onto watercolour paper, reproducing all the qualities of the original painting.

    The print is conservation mounted and individually signed by the artist.

    Picture can be personalised!

    An original and delightful gift, perfect as a bedroom or nursery decoration. (Especially pleasing for any cat lover!)

    Picture size: 17cm x 17cm Framed picture size: 30cm x 30cm (12" x 12")

    ]]>
    28.00 Beautiful, open edition giclee print from Agatha's original watercolours, reprinted onto watercolour paper, reproducing all the qualities of the original painting.

    The print is conservation mounted and individually signed by the artist.

    Picture can be personalised!

    An original and delightful gift, perfect as a bedroom or nursery decoration. (Especially pleasing for any cat lover!)

    Picture size: 17cm x 17cm Framed picture size: 30cm x 30cm (12" x 12")

    ]]>
    28.00 Beautiful, open edition giclee print from Agatha's original watercolours, reprinted onto watercolour paper, reproducing all the qualities of the original painting.

    The print is conservation mounted and individually signed by the artist.

    Picture can be personalised!

    An original and delightful gift, perfect as a bedroom or nursery decoration. (Especially pleasing for any cat lover!)

    Picture size: 17cm x 17cm Framed picture size: 30cm x 30cm (12" x 12")

    ]]>
    28.00 Beautiful, open edition giclee print from Agatha's original watercolours, reprinted onto watercolour paper, reproducing all the qualities of the original painting.

    The print is conservation mounted and individually signed by the artist.

    An original and delightful christening gift , perfect nursery decoration.

    FREE hand finished, "Quiet night" greeting card included!

    Picture can be personalised!

    Picture size: 17cm x 17cm Framed picture size: 30cm x 30cm (12" x 12").

    ]]>
    28.00 Beautiful, open edition giclee print from Agatha's original watercolours, reprinted onto watercolour paper, reproducing all the qualities of the original painting.

    The print is conservation mounted and individually signed by the artist.

    An original and delightful christening gift , perfect nursery decoration.

    FREE hand finished, "Sunshine lullaby" greeting card included!

    Picture can be personalised!

    Picture size: 17cm x 17cm Framed picture size: 30cm x 30cm (12" x 12").

    ]]>
    28.00 Beautiful, open edition giclee print from Agatha's original watercolours, reprinted onto watercolour paper, reproducing all the qualities of the original painting.

    The print is conservation mounted and individually signed by the artist.

    Picture can be personalised!

    An original and delightful gift, perfect as a bedroom or nursery decoration. (Especially pleasing for any cat lover!)

    Picture size: 17cm x 17cm Framed picture size: 30cm x 30cm (12" x 12")

    ]]>
    scrapy-scrapy-5569de3/tests/sample_data/compressed/feed-sample1.xml000066400000000000000000000233361525051012400254270ustar00rootroot00000000000000 28.00 Beautiful, open edition giclee print from Agatha's original watercolours, reprinted onto watercolour paper, reproducing all the qualities of the original painting.

    The print is conservation mounted and individually signed by the artist.

    Picture can be personalised!

    An original and delightful gift, perfect as a bedroom or nursery decoration. (Especially pleasing for any cat lover!)

    Picture size: 17cm x 17cm Framed picture size: 30cm x 30cm (12" x 12")

    ]]>
    28.00 Beautiful, open edition giclee print from Agatha's original watercolours, reprinted onto watercolour paper, reproducing all the qualities of the original painting.

    The print is conservation mounted and individually signed by the artist.

    Picture can be personalised!

    An original and delightful gift, perfect as a bedroom or nursery decoration. (Especially pleasing for any cat lover!)

    Picture size: 17cm x 17cm Framed picture size: 30cm x 30cm (12" x 12")

    ]]>
    28.00 Beautiful, open edition giclee print from Agatha's original watercolours, reprinted onto watercolour paper, reproducing all the qualities of the original painting.

    The print is conservation mounted and individually signed by the artist.

    Picture can be personalised!

    An original and delightful gift, perfect as a bedroom or nursery decoration. (Especially pleasing for any cat lover!)

    Picture size: 17cm x 17cm Framed picture size: 30cm x 30cm (12" x 12")

    ]]>
    28.00 Beautiful, open edition giclee print from Agatha's original watercolours, reprinted onto watercolour paper, reproducing all the qualities of the original painting.

    The print is conservation mounted and individually signed by the artist.

    Picture can be personalised!

    An original and delightful gift, perfect as a bedroom or nursery decoration. (Especially pleasing for any cat lover!)

    Picture size: 17cm x 17cm Framed picture size: 30cm x 30cm (12" x 12")

    ]]>
    28.00 Beautiful, open edition giclee print from Agatha's original watercolours, reprinted onto watercolour paper, reproducing all the qualities of the original painting.

    The print is conservation mounted and individually signed by the artist.

    Picture can be personalised!

    An original and delightful gift, perfect as a bedroom or nursery decoration. (Especially pleasing for any cat lover!)

    Picture size: 17cm x 17cm Framed picture size: 30cm x 30cm (12" x 12")

    ]]>
    28.00 Beautiful, open edition giclee print from Agatha's original watercolours, reprinted onto watercolour paper, reproducing all the qualities of the original painting.

    The print is conservation mounted and individually signed by the artist.

    An original and delightful christening gift , perfect nursery decoration.

    FREE hand finished, "Quiet night" greeting card included!

    Picture can be personalised!

    Picture size: 17cm x 17cm Framed picture size: 30cm x 30cm (12" x 12").

    ]]>
    28.00 Beautiful, open edition giclee print from Agatha's original watercolours, reprinted onto watercolour paper, reproducing all the qualities of the original painting.

    The print is conservation mounted and individually signed by the artist.

    An original and delightful christening gift , perfect nursery decoration.

    FREE hand finished, "Sunshine lullaby" greeting card included!

    Picture can be personalised!

    Picture size: 17cm x 17cm Framed picture size: 30cm x 30cm (12" x 12").

    ]]>
    28.00 Beautiful, open edition giclee print from Agatha's original watercolours, reprinted onto watercolour paper, reproducing all the qualities of the original painting.

    The print is conservation mounted and individually signed by the artist.

    Picture can be personalised!

    An original and delightful gift, perfect as a bedroom or nursery decoration. (Especially pleasing for any cat lover!)

    Picture size: 17cm x 17cm Framed picture size: 30cm x 30cm (12" x 12")

    ]]>
    scrapy-scrapy-5569de3/tests/sample_data/compressed/feed-sample1.xml.bz2000066400000000000000000000026261525051012400261220ustar00rootroot00000000000000BZh91AY&SYgç?±[߀qçûÿ¿ÿÿê¿ÿÿð`Ÿ Âiˆ` €F™2ahp # ¦!€@2adÉ„`!¡À0Œ&˜†È„i“&€†ƒU?I´ 4 ÈÀ0Œ&˜†È„i“&€†�RH ”ÐÓ@dS ú�›LD`ši¦’\‚vú’"ÉYÕÿG ~Ï7´;E  ƒrYah¥}Ù�¸[ÕîÎ׌Ú/zý ýŸÇÁ›Ž»=Vµõßš~óá6�õI€¼dÞ™¶x\1�%™&‚ñfiñ¹ QI5È[jS€`;:b2 cxÈmK pXX(áŒ?aChÔ4âÐjŸ-”!ððz!¿Ñòyü÷ÛäÓÑœèæÈ>`¤žús Žqš`棿;øÇ®ý{GŒ{ìyèÜmüW~÷ÝÑŽòæ4Ìv ƒÆæ?g©ô>Ÿ×fªÔ×ï²ï÷aŸÝÿ=WþTØ>Íßêýnú/ÏŒ1GÕùé’³ÂþNû²æçÞ!–­EM7öÜõV2IòˆT#¸CÞœÔ| ¨¡¨óuÿVûVÎ.ÀˆQ)GpXM~�„$bP0fDñªýð]v!˜l�ÉC�ˆð#ÃQ�^¸0œá äÐ|ÉxÀ=~?�ŸÈæÐj¡Ò¤ËhóàÉ€¼RbçOØ{C<¤ôÖÅÞo(Ú0Ôs�Xñɹ'ˆ(QAÈÄb;{úúª èàøZ6§`ÂFO¨}áBÛƒì1˜óAI°R�؃rᘡ)¨Y`)r™ ¥òÂʆáB… ± édÜ6 wòeÔ8E¬=£`°^s‰YuA€Ã€¡Bª®TØ»¬0O¤fœCõQ�¡˜¡aÎ0m^½Ì8ÓQÂÀ2&£+؉/�â… áp¾Ã€4àÄ[à’Öj »¯7ºÃØ9Çø÷Õ Ç ¡È‰�r î È(&г8ØX=‚‡3�`±gY8Êõéå߸>K\ºf=¡È(S„pm®¡Ò÷›Ã�áXŒDãœ<~0ò�(Òwœh±Î/h›VM\Ó>Þ!8†B_P\Ž1¢ñ{0¼h(2 øpK„Ù/Å ‡M…Ã@x窡.¤Ð^'¸&ÁeÀÄbâ‘�¡B… C¬uŒocˆ¸‹‰Ö'@´‰¦Œ—GéåãÚ7˜B((R…†cTÐs«!ëäèA�°Xf–âÑÔ.ŒR‡‰).˜\&ðâ#Ì;‘ÅJ”E ›Ðßrn‡Æ(tš dí ¢c6 ÔW(e0ÉÐlIHs9°†Ö(9Zã C“ß«¡.`y›r Gr\6 $›BÀØ%×&ápöŽÁó_a òaxð‹bÜômFcAe‹œ£É$ÆàÀKÜ>„¬ÆØëƒ•޽ ˜¡BÉuÎŒQl/ÄPÛ%×Õd³Ñ,P^*›Cj‡8ä›ã(:P¼gË6¸ ¤fÐçD_óWsú`å žõz\Ît1#¤\$Ò(^7Q7‹“§xž{÷w®jÁ¶ Dëð¶ÊðíãN!̓΋ Â(P¡f Å&ùÎ@ÿâîH§  üçö scrapy-scrapy-5569de3/tests/sample_data/compressed/feed-sample1.xml.gz000066400000000000000000000021531525051012400260400ustar00rootroot00000000000000‹ÁsÆHfeed-sample1.xmlíšmO9Çßó)†¼¸ëId“€ÚR.¤Ji(\yèA¸UÕÊØÎ®‹×6¶—°ýô7û�l@w§;%�¨º¼ ‰ÇÿùÏxüS¥ÿú>‘pÇ­Zí·zA·\QÍ„Šö[WãÃönëõ`Š¿¾±š¥Ô;ÀMÊíÝ;±ßн7{�Ît: ¦;�¶Qg»Ûíuþ8=¹¤1OH[(牢¼¿§ôI¸3„òrýDSâ‹ä!9*âãÐëÐQK îk ú›í6Œ�Ž/áôêr oFp9Ãáùü6<9~;ŸŸÁø~?¿xÇgðçùÕ¿A»=ØxäÃB_¾Ø}Þš†‹©•˜åãj ?.ÔÄïIb$¨N:$Bkä3qXÒ >‘’Û¬SÉvB?”‡Ä…IþøéÓ ßÉUë +¯“œêTyÎ`¨Z\bðAPŸZ¾ã\ †Nñ±P*öÖR"!ÿ‹éÔHM˜Ã¶Z¯ðœÿ¡ŠçÝn‡<,!øl¢"w�¬6`,F ¶wƒn·ß)ŸÔ‹äŽI®…>« *]¨=X¬÷¸Ô)¸­ãª(6Î#êMŒ;©ÅMâ:ø¥*Þ•õóèÔ “`­Ñ7ƒ7œ¤^LR¹Úpœ‰<"A%ç€+«(þè@[ E$L‰ç–j©Së¶Àò"OW+¯Áà|Û2"!¼r€nÁÇnS‚ýe'Å suCP CÀ¾Ðê8žù¨VŽÛ»âFARMÁ Љ;ÁPVfP´�ÁuVHã`çƒJ®šA DÁ5*ãÈhÌ+g›³”CUûÉÅ—"Š=¶ 4ñ[ù® Ç«FTaVc§´…-ÁÓÀ TÛÂdÏFÎp* cxÜùÁE0Á`¢2táAjäÓæOðÈ _ð¦ô^Òîˇê™;Ý" xxÖÛþá6Õþç|Gõo©;›�ù0Ô#‚x¤í ­/âç+‹ó$ºÍ©ð±X˜©^{É\•èaÔŒP3–<îYÛ+FM“¯{9)!f8)ºV]\ c¥üV´jteU »v}Ûì:Ò ¾± ”;‡m¨Æzý ÛY1ÈŒ$™ ÿZ$ûPé¯e³’–5,kXö´Þ‡½X1¾bâC�·÷f¹��GxJã\f�˜š[o8ÕpªáÔÓâÔ«•sʘ,Ä:–åÊÀñkåTe½áTé†SOŠS¯z+æÔm*¸U~xË‘ê×\Îr¡5²jÁ~C«ï’VÿB"[ ä*7šS j,ý†*ÁËÑâ\j"”p1g[P‚¢ñbØ*ˆD–ó¼8¤”Í=S™2¤äÿéªY|K0[õ—ô.U ðP¦¯l¶Ñ.+58)ÕÖˆµÇ…4lkØöuÙ6öjâÀ­p«þòÞ Æd6êÆ-ù³‰Zh�?š¨³6Dû.‰Ö|¶|B´š½ànå�ÒÞ&scrapy-scrapy-5569de3/tests/sample_data/compressed/feed-sample1.zip000066400000000000000000000023541525051012400254260ustar00rootroot00000000000000PK;P)9nå�ÒHÞ&feed-sample1.xmlUT ÁsÆHÕsÆHUxèèíšmO9Çßó)†¼¸ëId“€ÚR.¤Ji(\yèA¸UÕÊØÎ®‹×6¶—°ýô7û�l@w§;%�¨º¼ ‰ÇÿùÏxüS¥ÿú>‘pÇ­Zí·zA·\QÍ„Šö[WãÃönëõ`Š¿¾±š¥Ô;ÀMÊíÝ;±ßн7{�Ît: ¦;�¶Qg»Ûíuþ8=¹¤1OH[(牢¼¿§ôI¸3„òrýDSâ‹ä!9*âãÐëÐQK îk ú›í6Œ�Ž/áôêr oFp9Ãáùü6<9~;ŸŸÁø~?¿xÇgðçùÕ¿A»=ØxäÃB_¾Ø}Þš†‹©•˜åãj ?.ÔÄïIb$¨N:$Bkä3qXÒ >‘’Û¬SÉvB?”‡Ä…IþøéÓ ßÉUë +¯“œêTyÎ`¨Z\bðAPŸZ¾ã\ †Nñ±P*öÖR"!ÿ‹éÔHM˜Ã¶Z¯ðœÿ¡ŠçÝn‡<,!øl¢"w�¬6`,F ¶wƒn·ß)ŸÔ‹äŽI®…>« *]¨=X¬÷¸Ô)¸­ãª(6Î#êMŒ;©ÅMâ:ø¥*Þ•õóèÔ “`­Ñ7ƒ7œ¤^LR¹Úpœ‰<"A%ç€+«(þè@[ E$L‰ç–j©Së¶Àò"OW+¯Áà|Û2"!¼r€nÁÇnS‚ýe'Å suCP CÀ¾Ðê8žù¨VŽÛ»âFARMÁ Љ;ÁPVfP´�ÁuVHã`çƒJ®šA DÁ5*ãÈhÌ+g›³”CUûÉÅ—"Š=¶ 4ñ[ù® Ç«FTaVc§´…-ÁÓÀ TÛÂdÏFÎp* cxÜùÁE0Á`¢2táAjäÓæOðÈ _ð¦ô^Òîˇê™;Ý" xxÖÛþá6Õþç|Gõo©;›�ù0Ô#‚x¤í ­/âç+‹ó$ºÍ©ð±X˜©^{É\•èaÔŒP3–<îYÛ+FM“¯{9)!f8)ºV]\ c¥üV´jteU »v}Ûì:Ò ¾± ”;‡m¨Æzý ÛY1ÈŒ$™ ÿZ$ûPé¯e³’–5,kXö´Þ‡½X1¾bâC�·÷f¹��GxJã\f�˜š[o8ÕpªáÔÓâÔ«•sʘ,Ä:–åÊÀñkåTe½áTé†SOŠS¯z+æÔm*¸U~xË‘ê×\Îr¡5²jÁ~C«ï’VÿB"[ ä*7šS j,ý†*ÁËÑâ\j"”p1g[P‚¢ñbØ*ˆD–ó¼8¤”Í=S™2¤äÿéªY|K0[õ—ô.U ðP¦¯l¶Ñ.+58)ÕÖˆµÇ…4lkØöuÙ6öjâÀ­p«þòÞ Æd6êÆ-ù³‰Zh�?š¨³6Dû.‰Ö|¶|B´š½àPK;P)9nå�ÒHÞ& ¤�feed-sample1.xmlUTÁsÆHUxPKK‹scrapy-scrapy-5569de3/tests/sample_data/compressed/html-br.bin000066400000000000000000000076731525051012400245070ustar00rootroot00000000000000[T$1a‹X-¹‘;˜F%j™¼±¢YM¨ L~5?¶µ0ÐG/·œ*Í]ø&ò{u:­ ÑXšEX«Ø_8ç�NÃؼ áÒ "IR ¦Æmz^Ó,Åæb{4Œáýý˜«[²¶¸(©z]5ô/￉[BÔ®“Ì#•º„äüÿ;ÿ5*ʳ¶ÙSع셵¸3ÊûCóccV¸Ê8а<_-+¯.äÆ,øEZ1ËŒ åÆåˆ!T°¡A¶j°7eœûV븆ãDyß»[L¥@\�•-ÎHÃ�§‹y&‚E"ý¬œ®.xlù‡+‚[ïy¼ñrÙˆ …>—}‡6�º,vùhþã21#'©Ñ;0UÑp¦–¶ŸZ …Ô뙌j}Lî”+yÛG“ßuÖ:»gŠ,GÂßÀçòé)˜„øiäÊxüu ï”r<œG5â•U¤í4ÿnÜÉí!…sŽ€:N~Å“´gë˜xs…š­ñÞ/.¹_^ŽQlü,“A¥KVÎÅÉ>™ÖÚD’ÓW_4Û»ãÉ8”µÚØ–ºo?¢Ë¤šB, ©œ´S’à“ºÊCÒŽðÛqõ¨”zRÚÛYÌ<¶¾Ïf”‡’ÖPÄUÎXÚ™Ïm(�¬£³›Æº&ø¢ö£³LôxHÛ©§q[&¬ˆ†ÕôžMÏÖÛ6Ý•ÊÖ;@å�¦/ÌÃvέl› îÇû£âÙá¡ô‚§Îb#=ôÞkïQ™×&ôœ8j³%Þªø;E?!&*^JêÌÕÞ7Ž#ŸF�…+-í’¾J ´?ÂÍî éêýå:,1ùÂb ¥´3®[!ÇH˦§Þký•'Ú†G_ïkÿLcs(òa†±O–$äçæC{úâ¿aɉg?é‹WsOþbjñÝšÁŒ¾Û¿´fÍýÍýuãÞƒµ9úŒ“V6ØÆYVxެWÕŠB6ôÀ·m ÌßX~qËZ¹W Vm䣲ͽ‰5,+ë«ÊšØâíxßÌ;»˜3Ú$N©¥^éLLŸt�›sä¥F7£›Õ±sŸ�ç¥ql«xk:8•&:Òf¼$nvl«ß‹Oe”Ñ2?�cpcáb¹CpÖñþ[n²ا×Åå›Rê—j°V(ôNWRÄñŽa3m³XÂÆý?˜RÄëg¹[=Ý›‰ëR-•á¥îh"›xéŒxeKh½ÀŠíC�)^:¶õ¥ÀͰC­µ\Çã§a\i‹~_†-vpZ:§+Úq3¶\LëÕf‡�ÿ:¡ÜZJk¼­5N\’‹Y*8mÍ锆šnb÷¡ëÿ\ÿ/i†7¸°$mŽÅ�ÉŠ£u±r…v&œÿŒ·K7¦£v¼‚Q/µ†lÜ|:Ðq Ej¯_,1§Ô;Ú°½Ó2<äðÄ­}©fô¢s¸¹ºÑLëXr³)8 �’2†*^gLÌb½¯ƒjü‰J×j¦ʼná}1:‚cÀ åÊäus/Q¾³K'£K´£×²Ç›Ž£þõb ÇÆÔµ–ܸ5é2×"–a†R'Ú•d|=–³ æ®õíá¸e.¦wO³±e/5¸Œ)±þ——4“Ý÷¿m1Á©5«‘tãQÅehÂüä] †;XP1Ý:­™ž-ø›ÅÎûÿôVR6èìõÉZ1êè…D\v6Ê9mÀ=|LúFǃ©KÙ‰Û2èí]?üø)Öð„¶Þi­íJ'qXm±Zot8.l�È(–'^C[¼ñØ–P3ºÑ=N\šêÐÖ(U3V8]Ý“8†C§âáÄ6´¦x–4œ`ÉË#›˜–ØŽÖ8±4E+‡cqar;h�Ë·`‰u†/¸0ô¬³´g‹z*ùþÛ ¿–vuO2e¥Ù˜r1 ”½†‘ðfÊë\‹XÚii=ì°Š`Xcpi—®~šŠaÝBçY8ô"µŒÐ‰¶ö—nRònö<ñzÕ¬b:Ö1w”¯Ûà-±gÂҜ˞¡sÜ‚0=QsšÞÞ>ñZÇ–*¡ß""Ùž8ž;äl¡¦â&ŠJk�áÆÒ;îá[whƒ/¯ð#ý}Þ1ý"ŽQ›ØºƒÔ�‰ÿ1¬1˜º¥•qûÄå(YÙ%¼lˆûyµ”Ì QÚ"Ù½ÆpcÊt¼�c鯗´ö*‹{“­x5)=7÷˜‰g�A�×÷pZ‡PçâKÊw‘¥—Gò9rŽo<6qãõ-‚a�áÉKDl£gÙ±W ÜšèJÚ±Ý1˜W�BMp³ÀÔHëÄ-nιbÛ–ŒÏ"Çõ^}ÿ×È$¶ëÌüÐ!«é"[¯1xycÈnÜÆ8¦¸©ŠV½—Á+6†F‡¥‰æbkD΂JÎñøPb¹OX5N E«”Ý—\âvv(5'k ž„õŽwáÒ�âá¥ez–žg²®®t[ª˜¶'Ø—¦[¶á,¨µ§ÝÜŽvbpJÖvÙݸaÅrk:mï›=»6Ûh˜¸§É± L[¨ÃæM°J£ÐYÍ6´| ‰£sNëšÄá.w ¶Pƒ¦Mf³ÉÅvKÃj ÆK7¾ŒÍb·á‘KL»-Ô:ÇK•U&Xû Sw4,(_�iØfgÁ*Îÿ鎳Ïè3ÚKm|ð6É*k7pcè°ƒê@i®&V“З´ãòÓt í¸uJÖú‘|tô¢¹-ˆMVÓm¶¹Yì6nLݱîÍœ!–²ÄÞ«,\šÕö8Ò´ÞæfA©MÉ0^¯œÈÓƒy/ ²ŽÛç0¥C[«ñò‰¦ëÿöˆ�)X·„Ä åwtµÑ³ìŒŸ®^þ-ÛÜ,èh­{‡�ã~l�hVapcê¶Îj§`¬-8)gfA¥s\ÍÜ�cÅÍ9åÓÄWJ��¾–çb» ìÛõáf��õH>‡¯CWWqÔ%ËZ¤é�®;X:hÁ)Õ#ý}Ðhv?´ÖLÜ–x­Jl³ŠCGÓšÚ¡ƒÖéšö›6¬qù~t¨5*Û.Ñ*ƒõë÷ÿÀŽã£E�ŒÚª3‹Ý„Gïø:`-b»KhZeáÂÒ³ìŽËO ]Ô”ß,080ö«C§CÓ�î/¶§ÝÑI©³ãm%Ðz$ß^n\šÓìS”ÛøY8»ULíJp½éN�¼ú0³ÀÆ�òÕë¥Ã°E´[ú½hëîᄵS_�jÖ4‹ÝÂ�[W"Þêu�«LLÍI]P~wHèÌ g� CãÃnsß¶´W—ëýilãÌ×üZÞàÒDW†­Nè tHßxü´è`iRÒì7¥åÁ~áÂ¥‰F1ljdþ©`è�œB¸OˆY» û–šNô|¸¦§®F5äýL|—vua¹›9«’r¾)·îêœLNX/Ù.Æ[ì/Üî®. )Œü/toX/°é¦ @°KÒzͳ«Ø÷»v 8º§z/'9¼Ö¬$•-¦6›"›'^ÂÔ]÷qv³‡¥àÿKµ¹~¹^’qµ´Éž8î+N\•ˆF¿u{ŒË9'¾¾oýŸmì¾s=%g+7>W³^1‹yEƨÛm’½w½J^U:/^Œ×âeó¿Ùÿ‘gkE.§ž�ék£’90]Ü>ñÉì®-ùKŽw6ç9w¹S‰|p[¶é>vÈÛ.¶aP=g©¬)ÜÓZ¶wM�y,ŒÚ±-Ëe±ž³›éJç‚a†q¤s]mÃîÿáå}ð«ßKw¥2a¹7óœ^ÆÕÒ6\—÷ØuWV>¼áØsx쓾»ïÝÚ–íöØÇR]›Ü0LËb?g.åönèwÍÇ4¬cW];Ùp{X}cé´·×g»,ÃvK»qÐV¿³Œc!ìí²gsæ@�Þ±!÷éÿ¤™�b^XngrÚSeq/Û6,�&¯Ý5JÎÎr úlU©ó¬Yõ^¬=›3›˜}Äm–ÐwÏÜåƒéa÷9Ô¹þSe»¶èž³,mnyª$nŒÎ x]¦œ5îšJÊÞ›î=—”òV�zç$L¯árUcÔ–mL-ßN'–ú¥¦w§ˆóþ_1ç´ã­¢´·Ç¶ |ž;» ÂN]f™³‰Û=«2©Û˜\SKÛ5��‡œqU�´“ýîÍ´‡¼gÔtÂí­³=æ«ÓÙ7UôT2î^ÔyÆSi/Æ&?L8³ô­£y`x\R¶¬’™µ)ðŸÝºoæ®í©ä^³ªÕ;³yá™Å™�˨¢«¶l·Ë¥D¹÷j*%;×9ÏôÖd÷Vœ¼wÎ6æå›Íí©Z³²ã¡÷<–”+v�ÓÞ^ÇöªsÖ´¬ªöê|Ût kVQz¿(¿g[ª¸î{¦Q—¹–\`}xLJýÓ½Ñäî£ÿK“¬Y]ÛðZ—Sµ×ÚD¢O4/cSËOÕH™³jÛã6-—]Õ†k­³`K¹GÌ;î%'׬ʶ\.ÃeU•íZcn¸„¨é¼<㪂”1«e}c8.âV‹Ëµ†hôD›iMôô\͆ÎéÜ<5g{=†m:Uã1×’¶:—չ׋>ïÿSçìêÚn˰,�åuU*/LU†UY±]rÒåÍêØ.Ã0Ü–ayÝkÁ?£ÿÛ±rÓÉ<²ï™rž[mÛñºÛå5L{Mùðz–ò5¯Ëàô‹áZ¥mÛ¦jÏù¶61Ù»Tç ¼ž¦ýšÛtܶÇ\…m™–”¯e©wvráõÍ×L·Çešì§r¶a¸]†m†±–H|ú΃œÏ »õº^ªdÍêÚ¯g­x>–åx=•Ê‚ÇãRrÎôÚêÌ[Û–s¯×vÙnÓ^sn¸Ý–Çe¸M—kµmsÖvSßkòöìï*K•p;+Ùp™¶Û혆׽*Ûp¹¬*°zckŒƒ¾7£òþ„jÞÊkx¥Œ]-›é9ËeqúœmV*/<Ža¶ã6ŒUØö¼ÛÔ=óUóTn/ô缜~óâü¸¸=¦cY—§V¶%gÄk˜¶akÉÓmÏS¿®ÁîsRά®í²\¶å²¼f¡Î3·©;s3 Óc«C÷ž«¦£¶ï¹ä”äÝ5ç‚c¸,Ë1,o­lìŸóÿxg*BÏ™¶±Ö™ð8nUæz—:Ϯʶ½.ÃíñfegÁ=ï|\6§'.×*mË®é][›öv:¹aº]Ž]ÕÇ^-Û5‹3/ÓrÛs Ù0L·&ãLË-Ãöá9Ûš+ÿæ ûnA$scrapy-scrapy-5569de3/tests/sample_data/compressed/html-gzip-deflate-gzip.bin000066400000000000000000000175161525051012400274230ustar00rootroot00000000000000‹7Èàxœ5Uu\“ÿ÷U�f„ 1iP@ ‰Ñ)0Jº‡tw éîî!Ý%!ƒ�ÒHHHǾãó{ýþ}Îsî¹qî}ÓFa?1yÃöá zWŽAÕw·Ðãv"ÃaçÉI×)rc @B¢ cŠ®bÍ2ƒÄ[6dÁ¢�w€_;Ö–)£XŒ— Í÷m6§ºh†Kû”×”�Øí ¸ßÝﯣ€Ë‹cΤ;rkpðaq?åe‰4oòv híYv0:2<ää¼Z€¬D!þ>Lí¬\˜x]ýº9‰dæÌ¥}؉Òp¸O}@n^ÞC�[Ûƒö;;HØÝÅݾ©G‹w“ªG{ÏŠÁšõÑñ•Xœöˆ\ëPs|öy° à@°p ~¸;êßø¾&ªè1Ù2r…¼ýÚ£¿B]³VÕݹLkh´\[/q ¤%@.!Èó›ûËßwbwçHD?Ïñꚟ÷bí�Õšïí‰ìxÍÈè÷ñíBÖì*­Ã † 4z¸ÙÞwLËÝtì2ºsuéþôi›võpÍÿäþ±MûÖþø v|UËy¹plßÓ:Bëçtµqú÷ˆY † „ÝA¡ÖÞ~V+ÀWO|‚–Z`' ¹dw{Åc:\Œ: k¯¬œ%<,¢ýµ¹ÖØÓZu{A›ã�ëÖt¤*~÷ËC¹|áð¤ì÷žnîV€¿o}ÑȰj«žEÑ™e“"rï6NÕè¸�!¯´Ì+Õ�w±xóa½Ð�¤}$*T9»[cÅaqO2;7{µ¨é4[5èæô#dùÅu¦ôhÐmâ«MÖÈÁýÍuϦ-Ÿ ˜¸Z]š(’ˆœ„MÌ š›ÝWˆÄ–©–'¾+Í4d0Ѩ�xÏrÝKUd/¿—äH6ÑP…ñ²Ò‡óµ¬6BdC”»]Hô‹î‹ÃWÿì‡[’”öx4Ó�Ç–©IóQ€ÂõAÏœj•µØ·LŸ/7²ÊGÆãÚ߬fDbCd±)™Aسsv‹6°\Ò°·VÆ] )±@Êõ|£ ÿáùˆaVèMÓÏH®÷…™†j]^(…ÅÊ0º7í{ez…Έ‹R¾·­*Hº¶{ˆ!ñŠzw¹ö5£��Izâ¹jI¼âG©ͼTOxºº”Õxÿg>¥Ú�¡'I«UëŘtëÓT›)s¯¢•»äeTÖ³¶Î’ôŸ�¬ÊüÉÌ€XØËAÒS¿Ѥ+S’çˆÊj!>P½D æç¤¹éŽkºÒ˜†k{ë9{ëÒž}á ⺺Dà@TóÈ”Ríj1fIMt DmÚ¸¨×D•¹wåh;ÛU)©)À̱ƒ¯hòðáªÅuØÞîYÂ$C÷®äØutöÁm»“�Ö°àÅ¡ó"ºÃt‹¯ˆ+D[#gî•cÔóý»¢‘«Ñxf¬kÆ Ë ˆ€4‰År¡!‹KÒÜ«oÅ¡å—6õPÖ$§  k 0Ä¢õͤ’ˆ¡Z`#zq·p—h|UBñi™RTóÂÕ!1-2bŠû–ÅĨÕþt„îgÜLO8ä"‹CGŸ,e@(æì‰Ž ^j”*š{õ ΖX=iÊ#” ì|¼2!}:Ú€Èwïo¬8"͘)W«äf³³-‚ [‘(gtÃ[{Hk˹5¶MT!qøËŠ.óÐ`Ky@ ±ƒa×PJËh -ëÉ G¿Þ!féÁÜ¢¦¯’ÚRmµ~"Ovh”<äÑ›€‰Sʰ!ó-Ï É·&Tè $`ȇD 45žÆœ#f¥ ¹pfV`&Å€1»x¢•ªé׊1ëî,Û‡O‚ t1^ƒ0`R”=>ú +d*è›q¶…aר€tÒ{ˆâ¥ ååçR Ò[áÌlo«ž`蓪¾@T”°ä`Ö„ @¢Žœ™¡©“Jw›µ–¼‚�ð4}¤uχ¯²3rX´¤Ì;ãD%@ŽUÅD^­fYF1Ÿ ÖM“ ªÌ&ùb¬€$Œƒ¡‹€ÒH†·I@ I”[`$õ½ŠGBÓgæ\Þ¤À•a {¶±Ê&ElÅްÄfñ¡ÕË(Àcû7^AGË“.Ò¨ü/¥ð›Ö�†Ê v¦âaZ�¼ºW\)¸KÁl->6O™L�µ@}©ûã�2`d´_JìÎ…³`VV®\!CÛÚ(ôN%±M-‡™”×U©F±,˜ŸŸÈú=ø©†õ¿r+k=&t»”WŸj²|‹Ÿ‘#ÔÂÁÊ+ÒZˆSâp̾ǯâ0=|NÉ÷ β2Õ@<[«N²K”èÆ!€úm›�TõoÎï%0l™ä+2ù—�›æM]&Bž’™U‡“—n\­lë'?âÐ'~‚eGg¥Ó§º®ãvZĘkBi¤,o�v1dŠB˜–µˆuL¾"z¶Ì>‚ '(Yì¬~Ь5 þ~1AûàéD «Ž;)La¯Ø3W$_D0=@A-f§’“(ƒ5vµé«Öb´-“)cßïÔª–3Kj·É£3O&ø†œ9ßgšaÉ÷Ѧš ,Lb/ÍwøŽ|šUŸ cà•UéBîà™"�@¨©ÇÄËœH¢åÝÄòS?/…@6œð¦ªŒ½K¡ûº—.f×ùMlâûJ}ñg±Õû«@Ùg­4$)9ÔÌR-‹´©Æ`ôêå?‘“å<{ßz9n5ì�’«”‰¹?Ç^ï¸ö¸°·^ ãüð“ (›øg­ìª÷¶ùn'®8`%‘æÁF9ttë.øÆ¼îÃ\ઢHV€ 4mÓXt§°Œ¥¥VÍ®ë´éÑÊ Ó Æ<ú”¸kÙw~šm-ôD \�>9xZzgzcΧ‚h’ªLZwf`ûZ!¼e7ô Õ\ðö¬b/‚n_õ•†¦¨o›Î(í}ÊΖըðSñS»l3âÈ«Âöôv {1¶s9¯jn(ŒÔ7YÍ ð¸~]ãú1§y {³»ÓS.¿ðý§ñå3ZZÈeÊø(Ýj?5xæqhOyåܦ1—V@˜§Œvá�’š£…ŸŸÏ‹×mô£c’‘ù™íöNŠÅÏöMNoqò&”îKe µÚöÉÈE¹"§3ÿ¡‘Ï£d€ý'ûEfK1dKXôÓ/IyJÙ‚+“lÌ}ÃÛßl›«¢Ò¶"Œ”w_ÞZdP¥Z¨$0Lhꃻ‹B9÷ÉǘeItl…’GÅèsü• ‚äª7è|”מ2YÍ ¨í$ס!¨šmú TùÇ&{hÇ9÷Tð\ø6H汣ϻ2 –}Ô~fÈ”ðÉP·^@¬D‡míé}÷¯ó­Ï´¬‘©^­ÚŽ ffþ$žû„¯‘®»Àóï±1n?”j í_h†V©‡íÆ«A_@~’\[ç®5��1ÜN!‹²Õ[dsNØ1ÂsAûcÉXÊq|hÂ2ú¨Ú†òÎÙòª˜>`¥”|Þ’·ug*~ŸÄ}ccõH\b?ß `ÖD´ÄT½®ÖüÒ['¶´^}�Ö¦#¦ 8óøíÉ‚$Ö,mU—eAIßð·‰ -—_`õ?NÌ­]_O³¬³\1lX.’ ñ“Þ¼æ ‰,íGQB¤ýízùõ“?ß9f‘ôÁÚyçVc·ý&ÔÀQpÂò®V‹£fvÎþ3kMê�rô…)ÚO+nˆ±;“G›óte>GP¾¯4¥Ï+zŶµÂ‘Ä}ú1uW3G´:h°~dŸÞíȵzg+ê0ç„_]åR…xŠ[„yþÔoÚf{ê¶ŸI©]Ê^¦#«>xA ÛDŒlÇämðq¤Ê7eg#»VY5+Íç+ÿýÕI{óyñÄ×8Fž|ìµ> |¥Fx\ÒJ8ö ‡¾ÐvdHgƒžˆ<_zŠbƒR�\é¯Yš5t·Fw+RÜNìÑ>yú’FÌô>Óp[«yéñ�ÐÓ®Ñÿ ¬�Ԧܪ¼Ô� |7 F®Åa’tN:]yS]aDÅî-B‰Ló_¿¹ƒe P=Ôù¸µM �<tµ·ù5ôÜmÎÞÚÜïšöøœé�Ö²òŽý—K²ÂÊ–h»ïÏÆÎ¯Èãzþ™"'δ~v­Üg_Ÿ!²ÆÂ|º³ÛK½ÚïfçøNö.‘² ѵÙF¿ÈU5´_x˜ˆÙ‰ù žEž„ý½‹½¿Q¯¥ÆË ñ�¢ z4à½MOÿ¹xKï}w7LÆƾÉd,žÙLÁžr¥E?ûŒÂ-lÖŸ” àý›Ð‘>#LÄ¥¡¯aE}Ìç õ´�JzÒ,¨eé|xoi3`e½Vàý·“—…Až�tm @ý\Je³höl½yÇžšV˜¹ÍŽÇ¬XWŒ7.`všèÿ ìE¥:!†A_qï¥3»kà]?úÃçt4¾¥r?/ÿ™¨Â\üÔêD6 äôœH�|`ò8]£4~Œ¿ãànà Šô²‚™=Nî ’Vz}'“yíž�$–ôQ å>cžU:ÚZãˆÙ ´&d—ŸT„âšñÔ÷"Œþ’ìj`תnõHÿ’ñ‰‡ Xj àS’:½}I*›À€pÞ[«ü„Ìógð|Ó™74Zêç$!ú£Í¤îÎR]ˆ³â™-²ôÿŒq qο†ÌA|DÊ>×ðz–�_|mݤpkþBAV¹yýä0Ø ?b Å!óýé�*½Ö1´Ð^—*<÷ê‚ñq^ҙͽˆß% /îh>Ïð(Ьî¶~C‘¿ö”]J‚ÖdÜí%��üÓïí,ðƒ’&qÝ«%\y� ¿H³íÜÀ@Ài¡ �€¾Ì*«µ™õÃWƒ~½�6øvoÿ‰Tq_»YÝ­ðg~Qÿ¨Ù(à?+âPVö=ƒ0LæöTôJ•üò{™ìð²~"IF™ûsL¸±$Ð}ä²eØ�”ø…¡âj‘©¸p×djéu<&J ðJÚ%üšÍ_¬3Uí‡Í«ž ïŒ*òÐÌÑpÏÎ硯ðúÀ4„«éì€Ô;¥sD>Ÿý °eøÒñsˆß{Ï[Kù"ÝÙE�ZVïŒGw,:½ùU%Y-BÂ¥ ü‹ok.:©€î¥nYóWíV÷˜\MgŽyº £êé' låÇ~Ê»+ºóŠ­ùp[™Ïoš™HEeö¦^Tp©6ºü‡}þÖäoø^ïOToü„é†Tס�Ìÿ&Ör‚lãKt‹J§P‰d< Ú‘ðÈCL…#ßKyô£¯ö,µ\t¨ÅØ|ÑŤ Ö>õjÕv™ÚÀ¢ªZÓ1s¿¬âU«|™‡_³y¦ñé½i&E«¼24swœ”#ˆ¼ÂÉü©T’Yu*‚y¿fZÄpÿеyÅ9z¢ åw$èþôº%ÆìK‹ kà¶Â+i˜¬;Q¸0w�èGCpýæ?V³Õ‡N0p8ê,‚çÜjcŒ cPG„‘÷âÏhå—7«Q•øÞ»‘V2¬šISE*óˆ° îÃl^½-�S½wŸíd_𭹋�zŸÂ‹bâ‘HÐ]ùŒ—®aâ‹ã-/F˜ Ä2&«ÒT¾š2qφÓsŽÎ°5¼ùÊﲓþÁ4óɱ0Ie’7+]±ÆëdÑ÷:Ï®X7ï�~2mO:]µ˜ùaŠ«±á5„ñ²N&g<€^n äʼXvJÄ켚o¦BÕÁ6a£ ´>åBëµëiü TÂÉS#Õ¹i~¦Ïð§)~½hÚ'«Q¬� –#h(j&óR�]9ù;ÝLü&žZ;Ðן $ ²‰ÿÓÿ''¸“e�h}yQ×ÚçC,®Uì„)œj¼ ?=(+“‡²ÜáÛ�<°õÇŽÙf½)„c‡k ÷›!¦í=ÆÏúRR®þg§æ�¢ë5ßœ>bd$c’&Ï„ðzÚYÇBbÊ)¦¦&á�}*ÿ‡)þ;¯âý{“þ`Ë&=a´Á;!нbýñ9UFúÙ�Z«"Î-��Œ1ŠŽQm!òL5êŽËßÁhmÎ˱µM ߦâ––\ByŠ0l*; ërŠKœMã´ñ(ÊUX(Wµ#Pü= TlËÊ¿ÓZû*&ˆ7øµÒ”"j%b‰ùÝbEÿFd™EB<¡¦f±ÈHx~n‘=ÿj/Ò1:‹g"Rsëüu¡Ê#òä�Ñf|üU#ªõµ½DÖmB—¸ü¡.ºÝiŒôC‡Ü�V�Ï\’˜ äoüÔ²MZ$`±+ ŸÏMíK³NôôþüÇäêjàÂs2Rn™ÌäåeÄ¢®­-´Íî*°S¸S*¦žÕ䟜cd ïp?}ÍèÙ¶Ë1C-`^ ‹NÏþ%sö Ýk²� !uHúDß�oôáæóòŽ F$bÙ-Ž„':šÚôA_”H¬…r÷WÌR«¯ƒnnˆ¤1't20­t}³ƒ�{ ‡8Û6-ºÀ—âïdAb%½j C:B¦L‘¢:êÀ¹‡Ø›Hµ\×4䉗W÷Ü8¯¬©¨·d6»~x0üƒù ù툸v2f-@FóeÚ¾\•ì-Þš{7á�6ü•y,±&˔饚õðŠ@:Ü6-É¿ ä/‡³ *b'pܲ6K^eêcÝBl±Š'z¿|Æ1†¾µÜÜÆ„§ùtt{^Úçzöcùáš`¿´Õ xj’˜aCž¡Vz¬VÊÕ%'¾ÑÅ$»×ÉoÍLþ�plõêÔ¸E½S¯ÂC÷ˆa˜üKvˆHðRHQJ–ÀsUº/ÏÅÍ#Sy¬ÏóÔÕ[AJ{�;´WbëKk#çÜ%òÓ¨ìsɘÝáªC!ö4N…“×±vf®u�æ ¸?1 #²ãŠÇ¥þ¹„‰b—yѱ –(å8¼ §±uü{l*–È¥‹Š¯Ú'(ž<Å8 ‹ßáÄ‘WD¢qêéD,‹Ê-ЇþájSV•°qŒî�¦–Ý~;ˆk´êZŸj+å7µC[Pëçò¡›À"ç’f}Ë8òb¿;|�Õ 'éd®ž nÕÆWõPèË|#Övù”I�uîÕÉ)ô^1�ôÚ$ìS¿ ÚÒoø.|áZè.¥”Ú§E3€ËÅIGuKzú¤ºù÷‰6�í³Š\;SÁÏívÏ<�œk2ÕäD”Ã<ïªxqD<ò?¨¼3%kúþv5�ƒ¼ˆª¦DþŠ=›/YÕj ¾Þ´Áý̲QPá·%.8Ò"éùvw.[9^†n‡Þ½5úl¤%i…£Á)¿F¼ß�o¸aLv ÷„?™væ íeI/:'hHö,ÑmèR õ0rÉ?¦ÊÁ ¬3Û»§]$Õ½Ãܾ�Xй¨ú üˆ Hp`Ƀ§¡Üƒ.Âç~­`µE¥À¯¯�*}¾œM(¨l€f“_bb_Hg)½GmI$9bùzµwôù´C³€mˆÕ¯ ›øŽÂ ;ºTH�sðÇç¡ÇȦŸÃš/ªxãÉ-¹¸>1×…£cêdé\âÜøÓ‘ÇÓ™!§ò`P\ a·t�d¢ ÞNj$|}ߥ=•Yƒ®û #Êúi—°i8Í{‹ô†Ý¯´ü¤ÿ„¸�sóÏ=ZMUHÚÔ€Ñèuf˜aÕ,?ùÆiÂÅeÏ¿¸ñë�à ¢Q¯õÂ[¼h:ëTA<ËÝÞ´ùÍÉgb{ЃT•³5Ô£øãÝs¨ÆðÝs«wÖäBI Ý [¢zÑè¨OÊM`.” »�÷ª ÓÕã^¯¾zJ‚U_sà×]üÉà³ì‚{W„úz¬ƒ¹�êìâ›b�#Åe4€bR~+äu ðïÕš¦7óÉ ˆB1 ¸¥YITP �ª4û+££)Ât';îw`É2‘6øu輡lPD«±èÑl>b0‰ž©4»ˆ×UûÃS)c9 `:I¿F%ÞpšíW´KÔ!5Êcº âU²�1·:Q¡sÕY7¢8 >ÝVþ¼®È8~Ü-ácõJ¼^ þx}Ô#a8}�#b‚™°mSC'W¤õ-<ò†c•u\ð¯Ê†VËa^àßSÚ’‚>7´�5[3�(ê—®Qú±m„8{ÌG@Ö¤�«5,½�µNý‹` �ç0´@Fo¼?Wˆ¶qÙfh™ÿLw|}ܸ>ýÂæi‡›^U…èã%-s(XˆÔ±tlÊS�äP€rßE/ßøfzEÀ§Ý0j#2d-Û%C´Ã#[�·"^,½±,Qåá”xl.sZÑ‚´þ ¾ÊÞŸ*IÍbdÚÔU†užüæfE3§5Éàç*ÂÁw·ùë~ß¹õðDô³v‘ÃÓ'ÿBK¸Ú%%ÙŒ7scrapy-scrapy-5569de3/tests/sample_data/compressed/html-gzip-deflate.bin000066400000000000000000000174671525051012400264610ustar00rootroot00000000000000xœ5Uu\“ÿ÷U�f„ 1iP@ ‰Ñ)0Jº‡tw éîî!Ý%!ƒ�ÒHHHǾãó{ýþ}Îsî¹qî}ÓFa?1yÃöá zWŽAÕw·Ðãv"ÃaçÉI×)rc @B¢ cŠ®bÍ2ƒÄ[6dÁ¢�w€_;Ö–)£XŒ— Í÷m6§ºh†Kû”×”�Øí ¸ßÝﯣ€Ë‹cΤ;rkpðaq?åe‰4oòv híYv0:2<ää¼Z€¬D!þ>Lí¬\˜x]ýº9‰dæÌ¥}؉Òp¸O}@n^ÞC�[Ûƒö;;HØÝÅݾ©G‹w“ªG{ÏŠÁšõÑñ•Xœöˆ\ëPs|öy° à@°p ~¸;êßø¾&ªè1Ù2r…¼ýÚ£¿B]³VÕݹLkh´\[/q ¤%@.!Èó›ûËßwbwçHD?Ïñꚟ÷bí�Õšïí‰ìxÍÈè÷ñíBÖì*­Ã † 4z¸ÙÞwLËÝtì2ºsuéþôi›võpÍÿäþ±MûÖþø v|UËy¹plßÓ:Bëçtµqú÷ˆY † „ÝA¡ÖÞ~V+ÀWO|‚–Z`' ¹dw{Åc:\Œ: k¯¬œ%<,¢ýµ¹ÖØÓZu{A›ã�ëÖt¤*~÷ËC¹|áð¤ì÷žnîV€¿o}ÑȰj«žEÑ™e“"rï6NÕè¸�!¯´Ì+Õ�w±xóa½Ð�¤}$*T9»[cÅaqO2;7{µ¨é4[5èæô#dùÅu¦ôhÐmâ«MÖÈÁýÍuϦ-Ÿ ˜¸Z]š(’ˆœ„MÌ š›ÝWˆÄ–©–'¾+Í4d0Ѩ�xÏrÝKUd/¿—äH6ÑP…ñ²Ò‡óµ¬6BdC”»]Hô‹î‹ÃWÿì‡[’”öx4Ó�Ç–©IóQ€ÂõAÏœj•µØ·LŸ/7²ÊGÆãÚ߬fDbCd±)™Aسsv‹6°\Ò°·VÆ] )±@Êõ|£ ÿáùˆaVèMÓÏH®÷…™†j]^(…ÅÊ0º7í{ez…Έ‹R¾·­*Hº¶{ˆ!ñŠzw¹ö5£��Izâ¹jI¼âG©ͼTOxºº”Õxÿg>¥Ú�¡'I«UëŘtëÓT›)s¯¢•»äeTÖ³¶Î’ôŸ�¬ÊüÉÌ€XØËAÒS¿Ѥ+S’çˆÊj!>P½D æç¤¹éŽkºÒ˜†k{ë9{ëÒž}á ⺺Dà@TóÈ”Ríj1fIMt DmÚ¸¨×D•¹wåh;ÛU)©)À̱ƒ¯hòðáªÅuØÞîYÂ$C÷®äØutöÁm»“�Ö°àÅ¡ó"ºÃt‹¯ˆ+D[#gî•cÔóý»¢‘«Ñxf¬kÆ Ë ˆ€4‰År¡!‹KÒÜ«oÅ¡å—6õPÖ$§  k 0Ä¢õͤ’ˆ¡Z`#zq·p—h|UBñi™RTóÂÕ!1-2bŠû–ÅĨÕþt„îgÜLO8ä"‹CGŸ,e@(æì‰Ž ^j”*š{õ ΖX=iÊ#” ì|¼2!}:Ú€Èwïo¬8"͘)W«äf³³-‚ [‘(gtÃ[{Hk˹5¶MT!qøËŠ.óÐ`Ky@ ±ƒa×PJËh -ëÉ G¿Þ!féÁÜ¢¦¯’ÚRmµ~"Ovh”<äÑ›€‰Sʰ!ó-Ï É·&Tè $`ȇD 45žÆœ#f¥ ¹pfV`&Å€1»x¢•ªé׊1ëî,Û‡O‚ t1^ƒ0`R”=>ú +d*è›q¶…aר€tÒ{ˆâ¥ ååçR Ò[áÌlo«ž`蓪¾@T”°ä`Ö„ @¢Žœ™¡©“Jw›µ–¼‚�ð4}¤uχ¯²3rX´¤Ì;ãD%@ŽUÅD^­fYF1Ÿ ÖM“ ªÌ&ùb¬€$Œƒ¡‹€ÒH†·I@ I”[`$õ½ŠGBÓgæ\Þ¤À•a {¶±Ê&ElÅްÄfñ¡ÕË(Àcû7^AGË“.Ò¨ü/¥ð›Ö�†Ê v¦âaZ�¼ºW\)¸KÁl->6O™L�µ@}©ûã�2`d´_JìÎ…³`VV®\!CÛÚ(ôN%±M-‡™”×U©F±,˜ŸŸÈú=ø©†õ¿r+k=&t»”WŸj²|‹Ÿ‘#ÔÂÁÊ+ÒZˆSâp̾ǯâ0=|NÉ÷ β2Õ@<[«N²K”èÆ!€úm›�TõoÎï%0l™ä+2ù—�›æM]&Bž’™U‡“—n\­lë'?âÐ'~‚eGg¥Ó§º®ãvZĘkBi¤,o�v1dŠB˜–µˆuL¾"z¶Ì>‚ '(Yì¬~Ь5 þ~1AûàéD «Ž;)La¯Ø3W$_D0=@A-f§’“(ƒ5vµé«Öb´-“)cßïÔª–3Kj·É£3O&ø†œ9ßgšaÉ÷Ѧš ,Lb/ÍwøŽ|šUŸ cà•UéBîà™"�@¨©ÇÄËœH¢åÝÄòS?/…@6œð¦ªŒ½K¡ûº—.f×ùMlâûJ}ñg±Õû«@Ùg­4$)9ÔÌR-‹´©Æ`ôêå?‘“å<{ßz9n5ì�’«”‰¹?Ç^ï¸ö¸°·^ ãüð“ (›øg­ìª÷¶ùn'®8`%‘æÁF9ttë.øÆ¼îÃ\ઢHV€ 4mÓXt§°Œ¥¥VÍ®ë´éÑÊ Ó Æ<ú”¸kÙw~šm-ôD \�>9xZzgzcΧ‚h’ªLZwf`ûZ!¼e7ô Õ\ðö¬b/‚n_õ•†¦¨o›Î(í}ÊΖըðSñS»l3âÈ«Âöôv {1¶s9¯jn(ŒÔ7YÍ ð¸~]ãú1§y {³»ÓS.¿ðý§ñå3ZZÈeÊø(Ýj?5xæqhOyåܦ1—V@˜§Œvá�’š£…ŸŸÏ‹×mô£c’‘ù™íöNŠÅÏöMNoqò&”îKe µÚöÉÈE¹"§3ÿ¡‘Ï£d€ý'ûEfK1dKXôÓ/IyJÙ‚+“lÌ}ÃÛßl›«¢Ò¶"Œ”w_ÞZdP¥Z¨$0Lhꃻ‹B9÷ÉǘeItl…’GÅèsü• ‚äª7è|”מ2YÍ ¨í$ס!¨šmú TùÇ&{hÇ9÷Tð\ø6H汣ϻ2 –}Ô~fÈ”ðÉP·^@¬D‡míé}÷¯ó­Ï´¬‘©^­ÚŽ ffþ$žû„¯‘®»Àóï±1n?”j í_h†V©‡íÆ«A_@~’\[ç®5��1ÜN!‹²Õ[dsNØ1ÂsAûcÉXÊq|hÂ2ú¨Ú†òÎÙòª˜>`¥”|Þ’·ug*~ŸÄ}ccõH\b?ß `ÖD´ÄT½®ÖüÒ['¶´^}�Ö¦#¦ 8óøíÉ‚$Ö,mU—eAIßð·‰ -—_`õ?NÌ­]_O³¬³\1lX.’ ñ“Þ¼æ ‰,íGQB¤ýízùõ“?ß9f‘ôÁÚyçVc·ý&ÔÀQpÂò®V‹£fvÎþ3kMê�rô…)ÚO+nˆ±;“G›óte>GP¾¯4¥Ï+zŶµÂ‘Ä}ú1uW3G´:h°~dŸÞíȵzg+ê0ç„_]åR…xŠ[„yþÔoÚf{ê¶ŸI©]Ê^¦#«>xA ÛDŒlÇämðq¤Ê7eg#»VY5+Íç+ÿýÕI{óyñÄ×8Fž|ìµ> |¥Fx\ÒJ8ö ‡¾ÐvdHgƒžˆ<_zŠbƒR�\é¯Yš5t·Fw+RÜNìÑ>yú’FÌô>Óp[«yéñ�ÐÓ®Ñÿ ¬�Ԧܪ¼Ô� |7 F®Åa’tN:]yS]aDÅî-B‰Ló_¿¹ƒe P=Ôù¸µM �<tµ·ù5ôÜmÎÞÚÜïšöøœé�Ö²òŽý—K²ÂÊ–h»ïÏÆÎ¯Èãzþ™"'δ~v­Üg_Ÿ!²ÆÂ|º³ÛK½ÚïfçøNö.‘² ѵÙF¿ÈU5´_x˜ˆÙ‰ù žEž„ý½‹½¿Q¯¥ÆË ñ�¢ z4à½MOÿ¹xKï}w7LÆƾÉd,žÙLÁžr¥E?ûŒÂ-lÖŸ” àý›Ð‘>#LÄ¥¡¯aE}Ìç õ´�JzÒ,¨eé|xoi3`e½Vàý·“—…Až�tm @ý\Je³höl½yÇžšV˜¹ÍŽÇ¬XWŒ7.`všèÿ ìE¥:!†A_qï¥3»kà]?úÃçt4¾¥r?/ÿ™¨Â\üÔêD6 äôœH�|`ò8]£4~Œ¿ãànà Šô²‚™=Nî ’Vz}'“yíž�$–ôQ å>cžU:ÚZãˆÙ ´&d—ŸT„âšñÔ÷"Œþ’ìj`תnõHÿ’ñ‰‡ Xj àS’:½}I*›À€pÞ[«ü„Ìógð|Ó™74Zêç$!ú£Í¤îÎR]ˆ³â™-²ôÿŒq qο†ÌA|DÊ>×ðz–�_|mݤpkþBAV¹yýä0Ø ?b Å!óýé�*½Ö1´Ð^—*<÷ê‚ñq^ҙͽˆß% /îh>Ïð(Ьî¶~C‘¿ö”]J‚ÖdÜí%��üÓïí,ðƒ’&qÝ«%\y� ¿H³íÜÀ@Ài¡ �€¾Ì*«µ™õÃWƒ~½�6øvoÿ‰Tq_»YÝ­ðg~Qÿ¨Ù(à?+âPVö=ƒ0LæöTôJ•üò{™ìð²~"IF™ûsL¸±$Ð}ä²eØ�”ø…¡âj‘©¸p×djéu<&J ðJÚ%üšÍ_¬3Uí‡Í«ž ïŒ*òÐÌÑpÏÎ硯ðúÀ4„«éì€Ô;¥sD>Ÿý °eøÒñsˆß{Ï[Kù"ÝÙE�ZVïŒGw,:½ùU%Y-BÂ¥ ü‹ok.:©€î¥nYóWíV÷˜\MgŽyº £êé' låÇ~Ê»+ºóŠ­ùp[™Ïoš™HEeö¦^Tp©6ºü‡}þÖäoø^ïOToü„é†Tס�Ìÿ&Ör‚lãKt‹J§P‰d< Ú‘ðÈCL…#ßKyô£¯ö,µ\t¨ÅØ|ÑŤ Ö>õjÕv™ÚÀ¢ªZÓ1s¿¬âU«|™‡_³y¦ñé½i&E«¼24swœ”#ˆ¼ÂÉü©T’Yu*‚y¿fZÄpÿеyÅ9z¢ åw$èþôº%ÆìK‹ kà¶Â+i˜¬;Q¸0w�èGCpýæ?V³Õ‡N0p8ê,‚çÜjcŒ cPG„‘÷âÏhå—7«Q•øÞ»‘V2¬šISE*óˆ° îÃl^½-�S½wŸíd_𭹋�zŸÂ‹bâ‘HÐ]ùŒ—®aâ‹ã-/F˜ Ä2&«ÒT¾š2qφÓsŽÎ°5¼ùÊﲓþÁ4óɱ0Ie’7+]±ÆëdÑ÷:Ï®X7ï�~2mO:]µ˜ùaŠ«±á5„ñ²N&g<€^n äʼXvJÄ켚o¦BÕÁ6a£ ´>åBëµëiü TÂÉS#Õ¹i~¦Ïð§)~½hÚ'«Q¬� –#h(j&óR�]9ù;ÝLü&žZ;Ðן $ ²‰ÿÓÿ''¸“e�h}yQ×ÚçC,®Uì„)œj¼ ?=(+“‡²ÜáÛ�<°õÇŽÙf½)„c‡k ÷›!¦í=ÆÏúRR®þg§æ�¢ë5ßœ>bd$c’&Ï„ðzÚYÇBbÊ)¦¦&á�}*ÿ‡)þ;¯âý{“þ`Ë&=a´Á;!нbýñ9UFúÙ�Z«"Î-��Œ1ŠŽQm!òL5êŽËßÁhmÎ˱µM ߦâ––\ByŠ0l*; ërŠKœMã´ñ(ÊUX(Wµ#Pü= TlËÊ¿ÓZû*&ˆ7øµÒ”"j%b‰ùÝbEÿFd™EB<¡¦f±ÈHx~n‘=ÿj/Ò1:‹g"Rsëüu¡Ê#òä�Ñf|üU#ªõµ½DÖmB—¸ü¡.ºÝiŒôC‡Ü�V�Ï\’˜ äoüÔ²MZ$`±+ ŸÏMíK³NôôþüÇäêjàÂs2Rn™ÌäåeÄ¢®­-´Íî*°S¸S*¦žÕ䟜cd ïp?}ÍèÙ¶Ë1C-`^ ‹NÏþ%sö Ýk²� !uHúDß�oôáæóòŽ F$bÙ-Ž„':šÚôA_”H¬…r÷WÌR«¯ƒnnˆ¤1't20­t}³ƒ�{ ‡8Û6-ºÀ—âïdAb%½j C:B¦L‘¢:êÀ¹‡Ø›Hµ\×4䉗W÷Ü8¯¬©¨·d6»~x0üƒù ù툸v2f-@FóeÚ¾\•ì-Þš{7á�6ü•y,±&˔饚õðŠ@:Ü6-É¿ ä/‡³ *b'pܲ6K^eêcÝBl±Š'z¿|Æ1†¾µÜÜÆ„§ùtt{^Úçzöcùáš`¿´Õ xj’˜aCž¡Vz¬VÊÕ%'¾ÑÅ$»×ÉoÍLþ�plõêÔ¸E½S¯ÂC÷ˆa˜üKvˆHðRHQJ–ÀsUº/ÏÅÍ#Sy¬ÏóÔÕ[AJ{�;´WbëKk#çÜ%òÓ¨ìsɘÝáªC!ö4N…“×±vf®u�æ ¸?1 #²ãŠÇ¥þ¹„‰b—yѱ –(å8¼ §±uü{l*–È¥‹Š¯Ú'(ž<Å8 ‹ßáÄ‘WD¢qêéD,‹Ê-ЇþájSV•°qŒî�¦–Ý~;ˆk´êZŸj+å7µC[Pëçò¡›À"ç’f}Ë8òb¿;|�Õ 'éd®ž nÕÆWõPèË|#Övù”I�uîÕÉ)ô^1�ôÚ$ìS¿ ÚÒoø.|áZè.¥”Ú§E3€ËÅIGuKzú¤ºù÷‰6�í³Š\;SÁÏívÏ<�œk2ÕäD”Ã<ïªxqD<ò?¨¼3%kúþv5�ƒ¼ˆª¦DþŠ=›/YÕj ¾Þ´Áý̲QPá·%.8Ò"éùvw.[9^†n‡Þ½5úl¤%i…£Á)¿F¼ß�o¸aLv ÷„?™væ íeI/:'hHö,ÑmèR õ0rÉ?¦ÊÁ ¬3Û»§]$Õ½Ãܾ�Xй¨ú üˆ Hp`Ƀ§¡Üƒ.Âç~­`µE¥À¯¯�*}¾œM(¨l€f“_bb_Hg)½GmI$9bùzµwôù´C³€mˆÕ¯ ›øŽÂ ;ºTH�sðÇç¡ÇȦŸÃš/ªxãÉ-¹¸>1×…£cêdé\âÜøÓ‘ÇÓ™!§ò`P\ a·t�d¢ ÞNj$|}ߥ=•Yƒ®û #Êúi—°i8Í{‹ô†Ý¯´ü¤ÿ„¸�sóÏ=ZMUHÚÔ€Ñèuf˜aÕ,?ùÆiÂÅeÏ¿¸ñë�à ¢Q¯õÂ[¼h:ëTA<ËÝÞ´ùÍÉgb{ЃT•³5Ô£øãÝs¨ÆðÝs«wÖäBI Ý [¢zÑè¨OÊM`.” »�÷ª ÓÕã^¯¾zJ‚U_sà×]üÉà³ì‚{W„úz¬ƒ¹�êìâ›b�#Åe4€bR~+äu ðïÕš¦7óÉ ˆB1 ¸¥YITP �ª4û+££)Ât';îw`É2‘6øu輡lPD«±èÑl>b0‰ž©4»ˆ×UûÃS)c9 `:I¿F%ÞpšíW´KÔ!5Êcº âU²�1·:Q¡sÕY7¢8 >ÝVþ¼®È8~Ü-ácõJ¼^ þx}Ô#a8}�#b‚™°mSC'W¤õ-<ò†c•u\ð¯Ê†VËa^àßSÚ’‚>7´�5[3�(ê—®Qú±m„8{ÌG@Ö¤�«5,½�µNý‹` �ç0´@Fo¼?Wˆ¶qÙfh™ÿLw|}ܸ>ýÂæi‡›^U…èã%-s(XˆÔ±tlÊS�äP€rßE/ßøfzEÀ§Ý0j#2d-Û%C´Ã#[�·"^,½±,Qåá”xl.sZÑ‚´þ ¾ÊÞŸ*IÍbdÚÔU†užüæfE3§5Éàç*ÂÁw·ùë~ß¹õðDô³v‘ÃÓ'ÿBK¸Úscrapy-scrapy-5569de3/tests/sample_data/compressed/html-gzip.bin000066400000000000000000000175451525051012400250540ustar00rootroot00000000000000‹a-*@½�]“Çu†ïù+^Èr•BÎtÏ`7‘’8%•] K®Ü$A.*û¡Ú]J1}ºäb%>�yFÇå*q±³ƒ™sºÏyÏ{>úÅ?}ýçyù_ùfõï/¿ûvûäÅÅýÕeþÏ~÷fûdµzq¸¿Üo_î^í//÷׫ëÝë‹Õì^ÿÏêëÝõay÷âùñŠ'/žÿæÉ‹W7oþ–ÿûãöÅþjûäåÅánõææõû«ýõýêbw·zµOwº¿Ý]ß]îï÷oV?î/V¯o®ïóûë×7o×ïVÿýîÃáÇß=KwηÉ÷ûú°¿[ݮ߬¾¼Ü^_ܯ®Ò¥oûÛûÕûôùþöçýá>ýôûÕÃ3ïÞß}zÚ¯Ægß|úñw«ïß__çïú×›Û«÷—»g«Õ_ÿùåêp—žãp½_ýts½:\\åÞíß~ñþýÕå.nï÷«ï÷¯/ö×ïn¿¸É}q÷áòðîû¯V«7‡ýjw=þçj÷¿‡«Ýå~õŸ»÷ûÛ»û›·owïß^ï.®ò%ïö—û‹ô¾é»ÒצW[åköŸž{u·¿ÿp|œ$¦Õ_÷÷÷ÿ³»úñmzó»û÷éϯWÇçyµ¿]ý”þöõÅaÿfŸúëtÁîúCºàp½úaû*I,ýÁW«Ÿ¿Ø_`ŸD•ŸðÃ(­‡¯üá&ßåòxÕ»ôë÷«—·»C–P’zÒÎÝáþøËôïüÛÇ_{�ñ6že6>ö»Ã«ñÕÒÝ«ôjI_ß<ÿvõQŒ·«ovw{þíMÿ—/÷W?Þœ~sÔî~TéeÕêÛã÷¾»ÿýW«ïNW~·»ÝÝ_Ü\µzyúðezû‹›Ë´Nv×»ýíÍ«¤ƒ×?ç—üj•ž÷võè=“ªo?‰òö�äz÷èÛÆ· Õê»Ãõûür“é§›Ûô,:}ퟒˆnÚ]®¾üô¯Ëû�â—DúšK¢ÿ�{û°:®ay¤û~ºï÷û÷i—Ò“|ù×,ìÛônI¡w—'¡<7È“÷»WI‘¯nnÓU|Z?Í7æ‹ûÛmú÷6«&íÚ‹ñ‡ºªªÓí£âãß´¿¸¬úõ�~ uµîO?ýâwM¨×íñ§çéYžä~|²£ÉÈO¸Ze£ófµKûéú�O_ï³$Ÿ¦§y–o4Ú¤ãÿà¢vºÉ‹†Mœ¾SÝoÚé[ÅjÓÔÓ÷ÚTqÓ¬ÅuqØ„éû…tѦîÅuͰi›Éëþùø¿ãu5sV­QDŠ%E'EÔÓêJŠF­Iý¦š^$aS¯Í;$Eté–K+¢V;B,”aSO«4)"ˆ5·Þ´jG„M%¾s[£þ¤ˆÞì꤈f¦¿w¶"ÌŽ¨£PD5Eˆ[%Eq¯MU+åobÜ´)IŠ� k‚ùÞ™ŠjG'EÓÔmÚi«ž¡VzU™ý•7•øÖ´F6íô÷&E$Ó9-µÙŠ0;¢š^P½y‰¤ˆfÚª‡!-ºé§Zo¢rÕõ¦5.¸Nÿ7û!¹þjqÝ~˜¾¨Wø%99a˜’ÛO¥�KRC¥TŸ–xÒ˜QCgÌÃl5L¯áƓެà¤!àÐã•Ô ŒM½IFNà›äó“ZÅÉÞFìš™jhÄnHj˜–pÂDª•”Ô§¯j{ƒZ’Tl“Ôлø!¦�³ønhÔnh¦¿·7[:á±±’Ä‹¶ÉÏL¯Þ¤¥®Œ€5jPÐ`¦Zµšé÷pkÓ½ªBTI �mÒÝT4]5 P�»kúéf«Aí†é÷H+XXêV¹èμh«‚ü¤†Ö.E˜VƒÉE«Ð·UDwPÅ*µÁ6¹ºZí†AE4²bL Wsü 6jP»AdèMâ²V¥V¹ñcúE²Q®7…[ʇDUúŸÔÐÿ–‰€‡è}Z &U½Šª“p庵rÑѤiF5 —kSnU¾!æ¬ß'dcÔ`vƒðq­ª¯Ví!�^༄JsnH‘ŽcKŠJ–ªDn¦ Ô?­“‹V|\«R�µ1$9{,8¥ ]tëJ¢tùm4OÇd©ö &ÍnUõxY )U†ël”»Õàú‚¤¯iƒTÃiaN«Áä¢U}T«êjãÈ] JœÍ¯Tíç±+H©¡–jˆ‘”Qƒ#m… Ψ�Ij4ESËÞÆ1¬¹`^U÷UÒ7„‡Å4©†Ö䢭n©•éZ›ˆ¼©M•ÇØG­U¬UwL–¤TŸÔeÔ°Ün/Q«=³6a{S)+«Aâ¹9KâƒyºÜ÷ø�,�VƒÉEK5˜XµVÊR^�ÂScµJåžl™, 9×ôà-�Û �¸næµAú™Š“ÔŠSJj ë˜,÷‹#P¶QtkrÑV °šò{E²E¹z+g3#ªJ •Y‰„jXn7(ÀjzÖŠJ^K£T¹ ¡—¾AõhÄq²‡ ßZ“‹–j0°ÐeÂTR&¦•‹®™dn»Qu¸< /£†Åvƒ±P’8ÿ Fm Fr�2I�Sj�Ã�cr¨µJ0™h«áÞ*Cg%ˆÕf:({Z™¡ *üÌ©!3˜<´U‚ð •¢ÂUëSNÑšKí—±IQy�¦V½Jcß�ŽŸMZ)¡QÛ¾RëM ÉÊÃCDVv0 ×c§¨T‚©F»†´929h«åŒH“”�½ªPÜ8  ’)Ç‚Shü¨ghZ &m• \®›|¤ÕPˆVMÝ»DUì•� cÇ�Þ &ÿl• |‚›ÆgbSÕߪî´Q 982ÞjŸ`²ÏR &[劵Ô4¦¨h„V�ÎuÕ”�¨HxL€%,¶ÌËVj]º¹”ª»®U›F%¸¡‰nB`¦¿ƒö &ól• ÿJŽB9ˆ¤‘Ïx„RÎ+A¥Y�Õj"åiÄ–QÂr;Ak&I©š6Ü`µÖ%bsÍ„c­†”Ûùtgk²ÎR "’\«œŒš_•¤!¤–tà ¼e�dTƒCfúm�K97f¯Õà5£"*šÁõqƒŽÓ[•Gh ꤺÅòÍ�)XZ+«J颿Ԫ�I£ÔŒÄ>mN�’FËíƒiÁ­U%k§F§+ê¼U­Hc“ºiŸËd”‚šÖÁb™f5Ri­*V:nD5í2Ð)[$Ä g¦×žÅîK37†bwÍ�ŠÜè’¨ò}yJ€* ˆµñ™éö9æn±scø µ*?êLÁ�óŽ­êÞG¸•�'C·Ú,–`V­ÜìŠÎ¨3é@xÑ6¸´f­N+”Äu!GÔ�区ŲËjäþZÕóªQ’QyÛy±Ô²i´VÅ_j~€C‰­²ß£TKº«M �{¦u°XfYÕ¸hTu­Çh(ž¶vóos)µÒÁ`ŠÇÙèQÛ¢ÅË*Z«â°Î”œÄhòZ­jÄuàöÁàÎý‰Ñw)t æ•E^v-'ß KV‚U«™'~zz 1ŸÑÁrûÀà"àµ*F Æ;&¨FY×ì;V$©úŠXÏØ f•…™Y«hT5…æ•æê)`ë…;³ÝÇÓ¼?X0©,Ê2Uw|­Z£®“:¤\é¼Za<ÿDÇÉ æ”)0(Må]¢Õ’³ŠrN�òÝIêÀ«|¾“-=íL)«ãÄÌ¡xªí*Ÿ;%Æ’¬eŒV¹†ª<`ר¢GUÝÓ:X0£,Üí  0T³OfÏD‹§êju z ƒ:a`<£± ånÁ„²(MTþ 5V&º¹0ƒk ©Ö®§?(.|<£Õû`Á|²H~ ­«ìLtCad~,éÀÌWÌ:P1Z®%Öû`Á|²�ƒ*?lUœ¬æv¹Ö¾q0�ªTëá¨ƒÊÆý‚ùd±@Ô€ÞZÕ£G7¦—ã`TEýX fŽOœ±¼i¿X>9š‡Sî\’èfÁôòœh97¨8"ŒûÅò¦ýbùd5NÕµCª“àr$*°©:ämÔ�wT×zx¬+£ƒåö�Á¦†•QåbÑM�é\œœGS©}PÉ}Pù8¹_,Ÿ¬xw½:y%º0rÞTåJɲµgÖ73t°Ü>0ØÔTú«�èæ¿¨I×ÇÙ`öÛà¢j=#—¹X>Y ówçx(ûT¦¸QG(�:Pù6¹>ÆÜ¨õÃbùd5uºW ZT§¼¨=ÕÈ^¨<‘SÙ"wVßxjŒÅ¦Ã‚ùdA<º.à¨ø•}|Ô'<¥ůÖr¸`uêµ�ÖÁ‚ùd‘ÜèU·pTœ�êhTĨ×Ï\ÝuÕù>ýaÁ|²3½š¢Õ;ªb"Ÿ£ *äÝ,“1òR–èT�=­�ŲÉê"uît­æè•ƒoÔq ­¬s¬Õ £ôÙEÃb¹d«uêšW¦*VÜ!­ë«ãÕÉå�Ÿá5,–I¶pGÓŠ½Þ)TªdÖªüðX—-lÚñ|`�IË#[ ˆÝ®:ÌBg:Žó "¢kÓÕ�g ˜*ƒñ<Û5,–E–0˜#(¶´SYäÊè©•uñn\x|‰ÑÀÿï0hPyZ5É"ß"ü€¯ŠWäªöSB†Å2ÈV‚¯ &Br“òÝÉ-­­‰_+z<û—~àùý«›7ÿ±{u¹Ï:Éÿ]½º¹}³¿ýãÓúiþèb¿{óX]«Û›Ÿï~Ü¥›‡§Û¾þóËô÷�¿übûÍóoWÙ½Þþ›ïøóÕë›Ë‡{¾œº¦yºýÓÔ5ñéöûG׌¯œ_âOSW#¡wa>m>~8óŠñ‡ðø‡Žþöñ�ï3|ºøAg£>^|RÝim#J9Öú|En;*µ‡7еà寂åÏ?Å 7ëÓ®ö¹=KPÎÝá3›’¥�¬É³ÿ[<ÿ{‹;|‹3 —�FW�¼ÅYiÉÂQàç‚��Ü( \`ß–�Fû î‹]¿SÒÀµüszC`éÇv^”Èùš%¤Ñ<ã•ÏpVD=l{ÊÒeiàÚ¦7] üÍXDó’Ê"®)iàÚ 5†xk‹“¶8UfríþfiÄghß¡zð¼4($OÒ ût¸EÙÛ–*e¶x¸ùÒÏÐJÑúœ�® h©Ì¶€×¼a[ðÆ€À—�FÍ>"©óÒÀ‡cý³4àîì5qîÌäNþÍҨ؊’N'¤kƒqÎ/xÍ–Šÿ&wòß! ´R�÷>/ á 8«£Z›‚×ijí'wòoý´]“ÝÀ|Δ4pm’¥Áž'ÓLîäß,�áh+IÎKƒ c3²ÀÕ_ðèS¯Oíä¿Cð5>ï„4pmP$Vò„!ZêBi€ˆ4úg ‘šØÖóÒ bÑ-UQô„[ʶvrC½Õó¤ÑvÊ\Ke›[ìË,û ô°„Ôj|o<§l®4x§Ìõ°¸ãu¶…ªãøí‚]Éäó¤Ñ|ÊÙ}‰ÒÀµÁ¸Ë†Às¬ v­¡Ì̼–Pü,i ¿�Ç%NIƒ×ʈx¼† ÁVÞ˜+ –ýLôÕ 5nÑ·Èöpl„}Û+�3.fI£YÓÚX¯)z™�® Úq-â¬9ˆŠ­<„0Wð kd=ÎJyQf®ðPúYˆŠ­<ÎÞ˜'�×FOx}B¸64è=æ *¶ò úª™Ò µÑÍ–ò¢-²=8Û¯Œ¨Øn äÎçÒ…4z\-}:! ^ø†´'æ *¶òÍß��k�¥Y£¦ÏJy’3ÍE¯ÉˆŠíJî|öXIî°n(¯0! \Äã½&JŽVWòy¸SÈsÏá¬ßXcvö¬4PªŸ½&#*x—5Z;c=‹LÒ€÷^cvvB¼6pE¼&#*Ò~N”)X©‚4°še�1ØYi /ÊñYƒ6p¾\#·�c…·8)§, ú¶j6úB^”YÍ¢×Ôø’}ø-X©‚4°¶g@¼~VÈ‹2«‰3ôgáË$ X]±Ÿçj~% ¸6Ic®OA^”YÍ¢×Ôø2ù<¸®ÎbKd‰‡�¾íœ4ZäEq²zÁkÎÁ—kdsqQÁJ¤ÉŠXE:! ^l ˜Sa| zI>ž`¬TQ ÏavœÒ"/ÊÑ*³2E|Iè+ìc{�ea–&Ic¦Ýh ¼([yôšÈ©àdÀŒÐnЬ怚@Ï0´³íò¢Ìñ¼&r Zž„Ðn0šõÜq’¼÷Ð̶È‹2*ƉiYh7Èò0ÀiVY}¡N‡8op–ŠQ1Ù˜Ñ6Ǥ-ŒÚbÁGS¤S�VH³£¶yQŽÝñðÄŒ¶-�N€w ~E:Ei�ìÃì�‚¼(×àÜÚYh{@Ž gŽÍbÒ̧ ˜�›�3¨oÂëI¸ú m'iൄ,æ0é Zç$�¹;Ù²¦{0Ãòd´=à®ÂqR›]”í”Ñ„4xmhŽj�¨ÏÏÎÒ€7ÄÁN›]”íKüô¬4ðù˜ÿgŽ*Iƒñ%i ñŽ÷)Øl–FDدg£/äE9F`Žj�ˆ§Œe<O�ƒv 6»( ¸¶Ÿ�kk‘å�1DBÛ¸SÈ+ ÈbGÄYsò ™Œqá„4xm0¢¢H¬æ7¤Ø‡E¤k)6港 ��4‚#LÏKyQŽp6uŽ=ð ÉG'<„xƒbcŽûŠÒ ž£n6ÞÀûpŒ€3ÍŠ=äô#¢NŽûŠÒí%iÌÌ ´È‹2¯SÀh¹9ö 6†sY¾—%"Æí1¯0! ŽVÑöc7 åÆ¡ÙY(9Îe‘+JôÔÏæ7:D(8Í¿ÀØ h¹9ö0Ã�³0 q_QäaÑŸŸ™Ž’¥Ákƒ¹mÂh¹qˆq–ZgÎì�ïJû•4ÈÃ"“Êóåsï$¯ Ö7½!Zn'œÑ!Æ)”{aV�òÁ=2ôáÁ  44ËÅŒ]’£mÚɈ’pbiÁƒ¥A߆1éÓ¤QàEùùC´´ÍX™×gY|œ‚é‘í åi·Y?2GE>¥ÀoP$ÆX9b&Š=XQÔÿŽùŠPžt—ûñym0¾ÔÌ4Gb=zBœ+[ˆ‚ Ò@dÑúÉ?y”¯ FT—êH¬GÛ ßv¾ëWÒ dŽT(Ïb-L®àˆ©€! ¹UB³}�ûâoóõqV�ÜW(Ÿª–¥Ákƒ9*ŽKñZâÿ{ÌÆÂ·ùú�ˆ•}¡»¾xönžxÃÈ} akZˆÄVƵAÑ*ûó‚4°Ë¯Ctsš �Òàˆ‰½&Gé¼6h'üËÞ÷þE´äæ²N3!@^”âGÆ8ö9KCGxÞDÁŸsuw’MËÁxòtrJCWé1†ÀAä…¸´/Ôöû\ÊGB�è×:¬í9+�/Jñ#ŽìæÑøYd× Ù^‰œ�¤ÌDÄüs‡=§y!( ^CpÖ‚ãÒkpðô‚b>’0Y’MOÁ8?”Ï%Ê«‰9?æ/ C̈Kû/ZX‰Ü=H×"†ì0º>MOAipŒÀŒGé:.Å«·8g½àϹ¦6bmÛÄP>Ã0{!Í0D).%É꾈×)eg “ñÔ²±Þiz Jƒ×[FÂÈWs\š"ôy¼/9;K˜,b­a‡\ÃiÛçÒè 6�¢é†èCP\Úc A(ìKÎpQf""“Ñ!Ö;Í’AiðÚ`n›£t¼–âÒômèóx_r®šPHÄéƒë–NóÉP¼6Ø2ƒSÈýgÑav)”ü9w“‘¾×ä=ZŒNóÉ@^1bˆ9œE‡ùÒÀQ:"_®: øÞ-ÆÒàµÁ¶€°è ΂󜡄ntÕQàŽod9OÓÚ@^”k‰ C0_ÄœE‡¢4t~ÀUÐ"c{š‹Òൡ1D)NœE‡(OD+ _îϘqm1×vN¥ÜWV#†@ ÆœE‰)`=îÓ ÕÅ“"&¦œ£4tW@C`oHA˜ÃSª È—k°ö ´È_Ÿ&ù�4J9eF_ÌgñN!tXà_ëòå¬€Ùø©™ï( ^:ïQª ÀS6�[*Iƒg€Ðþ jû$}t<,5ýãâþêrûäÿ=lW£U$scrapy-scrapy-5569de3/tests/sample_data/compressed/html-rawdeflate.bin000066400000000000000000000175251525051012400262170ustar00rootroot00000000000000½�]“Çu†ïù+^Èr•BÎtÏ`7‘â(%•] K®Ü$A.Êû¡Ú]J±~}ºäb%>�yFÇå*q±³ƒ™sºÏyÏ{>úÅ¿|ùç{ùßùjõ/¿ýfûäÅÅýÕeþÏ~÷fûdµzq¸¿Üo_î^í//÷׫ëÝë‹Õî^ÿmõåîú°¿¼{ñüxÅ“Ï�óäÅ«›7Ïÿýaûbµ}òòâp·zsóúýÕþú~u±»[½Ú§;Ýßî®ï®÷÷û7«Ÿ÷«×7×÷ùŠýõë›7‡ëw«ÿy³{¹»ßÿîYºy¾S¾å—‡ýÝêîpýfõùåþðúâ~u•®~{øù°¿½_½OŸïoÚîÓO¿_=<öîýÝÇþb|üÍÇ·úîýõuþº¿¹½z¹{¶Z}ÿåŸ_®wéQ×ûÕ�7׫ÃÅUþáÝþígïß_]þáâö~¿únÿúbýîö³›üÑgw?_ÞýaÿÅjõæ°_í®Çÿ\íþ÷pµ»Ü¯þk÷~{wóöíîýÛëÝÅU¾äÝþr‘^9}WúÚôj«|Íþãs¯îö÷?ß'Ijõ×ýýýßvW?¼Mo~wÿ>ýùõêø<¯ö·«Óß¾¾8ìßìÓC™.Ø]ÿœ.8\¯¾ßß¾JKðÅê§ÏvÇØ'Qå'üy”ÖÃW~“ïry¼ê]úõûÕËÛÝ!K(I=)èîpüeúwþí㯽N�x›?Ï2ûÝáÕøjéî‡Uzµ¤¯¯ž³ú ÆÛÕW»»¿?ÿæ&‰ÿó—û«nN¿9jw?ªô2‹jõÍñ{ß¿Ýÿþ‹Õ·§+¿ÝÝîî/n®¿X½<}ø2½áÝÅÍeZ'»ëÝþöæUÒÁ닟òK~±JÏ{»zôžIÕ·Ey{Hr½{ômãÛ„jõíáú}~¹¿É�ôãÍmz–¯O_ûuÑí�»ËÕçÿuyÿA|ã’H_ó§$úŸ³`oVÇ5,�tßïN÷ýnÿÃ>m´Cz’Ïÿš…}›Þ-)ôîò$”gãyòâ~÷*)òÕÍmºê�Oë§ù£ãÞ|q»MÿÞfÕ¤�{1þPWUuú¡}ôC|ü›ö—U¿þ£ÓO¡®Öýé§_ü® õº=þô<=Ë“üÏOv´ù W«lwÞ¬vi?]ÿñéë}–äÓô4Ïò�F³tü\ÔnB7yѰ‰ÓwªûM;}«Xmšzú^›*nšµ¸.›0}¿�.ÚÔ½¸®6m3yÝ¿ÿw¼îƒfÎ*¢5ŠB⢤!⤈zZ]IÁ¨5)¢ßTÓ‹$lêµy‡¤ˆ.ÝriEÔjGˆ…2lêi•&E±æÖ›V툰©ÄwnbkÔŸÑ›]�ÑlÂô÷ÎV„ÙuЍ£q«¤ˆ j¥üML‚›6%IRaM0ß;SAí!ã¤ašºM;mÕ“"ÔJ¯*³¿’"â¦ßšÖȦ�þÞ¤ˆd:§¥6[fGTÓ ª7/‘ÑL[õ0¤E7ýTëMT®ºÞ´Æ×éÿf?$×_-î!¢ÛÓõ ¿$'' Srûâ©rIj¨”êÓO3jèŒy˜­†é5Ü`Ò›œÔ zc¼’„±©7ÉÈ |“|~R«X"ÙÛˆ]3S �Ø I ÓNXC€Hµ’’âôUmoPKRƒŠm’z?Ä´qß �Ú Íô÷öfK'<"6VRƒxÑ6ù™éÕ›Ô Ô•p£F ÌTC«vC3ýnmºWUˆ*©Á ³Mº›Š¦«FêqwM?Ýl5¨Ý0ýi KÝ*Ý™mU�ŸÔК' 9$T»!/’ÅI�Níaª“nQ¢së·m(HÑ›1qa ÍuY­‹»èNí†hâƒ[Ô+en’D8“ÔÕ>MvÐ!¥t?ágª¡w»Á¥J8L‚¤õ&œQ]‚Ù[I ƒ‹BmÔ5[ f7š'YLÁ%ì«Ô Üs¯:‹MÛ€I‘ÔP`;S ƒÚ â=”ãJjh”„@ZE²ÕYl&'²I._°»9h0@y¶Ôn˜6Õ�"3J5F¥mmTZçl‚ ,ÓW8’âõ´8fªa­vƒðq��{I †ÌPÐ,©¡2âͫ׸òV¹ò#%¾ønX«Ý ’b�Z›Š� jûµ•‰Ar†Óp1I Á¨5©¡Y>ãИ¬´ /SÈ%,uc hRƒHRCePRƒŠ¶Óý„) q f«Aí†éïí§6�ÆY6ëM0ñ@Ý™U”ÍÚEÑumj%fªÁd¤C‹væá’Ì«FãTs¥„I—æÈÝ ªt?±ŸÇ¼„g«Áì‘•ìÃÕ«Fs¯$6•µ®•ÃOjèÍýƼ„XJ3Õ`²ÑÊ w†‘©Uä›Ô ¶_›Û ѹèF-¤1/±xuFcrÑ�a :Eí9PŒ³Ì«Wù†hrÇI ª(Ã)ÞÍTƒÉEG“SïŒÏj€5ð›Ôà�R0%2u®A iÌK,Î)5&­*G:µæ¢!37}¯F•ÔÙ¨h»‰&:cñæâ€Õä¢ÊìÃT-P0ë2©ÁEÑ•ó!¹L©A-’ÙjP»ÁTшÄeRC#ìo0æ&ÙrÇ))F4©¡–jP¥Ç3Õ`rÑÑ”¨d­¬~¨ MبºÎ:ùSS S�ÕxÊ7TÆüÆœ³þÈ5˜Ý ê­Z s}‰Qƒ ßTÁH=Ö©"˵Û5¹Lmz‘ÄÇ¥Ój0¹hú¶ŠèªX¥6Ø&WW«Ý0¨ˆFöCŒé!á*cÎ�ÔF j7ˆ ½I\ÖªÔ*7~L¿hB6Êõ¦pKù�¨Jÿ“:ãß2ð½O«Á䢣ªWQu®\·V.:š4ͨƒármªÀ­Ê7ÄœõûˆlŒÌn>®UõÕª=äÑ œWƒPiÎ )ÒqlIQÉR•ÈÍtÁƒú§Õ`rÑŠ�kUª±6†$g�§¤‹n]é@”.¿�æéâ˜,Õ¾Á䢣٭ª/«A ¥Êp��r÷£\_�ô5m�j8-Ìi5˜\´ª�jUýCm¹+Ai‚³ù•ªýÁdž­Dà_ÉQˆ"‘” ò�PÊy%¨4ë±ZCM¤<�Ø2JXn'¨`Í$)UÓ†¬ÖºDl®™0Àb¬ÕPƒr;ŸîlMÖY*AD’k•“Qó«’4„Ô’\�·¬‘ŒjÐcÈL_£­Ñb)çÆÀìµÜ fTDE3Ø¡>nÐÂqz«ò̓A�ÔA·X¾¹1Kk…bU)]Tã—Z50iÔ�š‘£Ã§Í©SÒè`¹}0-¸µªdíÔètE�·ªilR7íóc™ŒÒAx@PÓ:X,Ó¬F*­UÅJgÂ�¨¦]æ:e‹ä €äÌôÚ³ØÝbiæÆPì®™£S‚]U¾/O P%±6~#3Ý>ÇÜ-–cn ´VåG�)rÞ±UÝ[ãˆ×c¢²³ãdèVûƒÅÌj •›]Ñu&/Ú—Ö¬ÕI c…’¸.䈺±œQ·XvY�Ü_«z^5J2*o›çí˜Ì}­fc�u1‚ÎGs7Ú'/–ZV#�ÖªøKÍp(±Uö{Ô�jIwµIáqïôË,«Ú�ª®õ ÅÓÖnþm.¥V:Lqà8=j[´XbYCkUÖ™’“M^«U�X£Ü>ܹ?1ú.…nÁ¼²ÈË®åä[aé]´ÔÊC°j5óÄOO�á!æ3:Xn\d´VÅhÁxǤÕ(ëš}ÇŠ$U_ëû`Á¬²03k�ª¦Ð¼ÒÜ@=ål½pg¶ûx:€÷ &•EY¦êޝU+bTÃurC‡Ô�+�Wë#ŒçŸè8yÁœ² Å ©¼KT£ZrVQÎéQ¾;é@x•Ïw²¥§Ý‚)euœ˜9Oµ]ås§ÄX’µŒÑ*×P•ì[ô¨ª{Z f”…»T†jöÉì™hñT]M£ToaP' Œçc46¡Ü-˜P¥ ƒÊ´ÆÊD7fpM!ÕÚõôÅ…�‡c´z,˜OɯA¡u•�‰n(ŒÌ�%˜ùŠY*F˵Äz,˜O pP凭ГÕÜ.×Ú7æQC‚j=uPÙø _0Ÿ,ˆÐ[«zôèÆÁôrŒª¨«ÁÌñ£ã‰3–7íË'GópªÃÀ�KÝ,˜^ž-àG„q¿XÞ´_,Ÿ¬Æ©ºvHu\ŽD6U‡¼�:P㎂êZ�uet°Ü>0ØÔ°2ª\,º)0�‹“óh*µ*¹*'÷‹å“ïN£W'¯D7FΛªÜA)Y¶öÌúf†–Û›šJU¢Ýü5éú8ÌžàcâƒG;Ó:X,Ÿ¬¦µÊCºÓàÝðU•z ¦ê/Öjæôãº=£ƒåö�Á¦&�¦NY nò‹l~­Ü¼²,[wüEïç¢ö‹å“£I© ªâÊ�r_i4nÞÏ8™MÙ¢A@½ytl»ÑÁbû@šƒbÆTåPPu2�¬}¯äðA×%t<"LÇÉ‹å“Õ,çA1ÉŽ›\›äiãúÇÁ+²6X�Ô®gðEýbùäh«AeJTÈ�t ˆÚF•äçâ) &ÏhÓ¶h±|²¦Ý«¨U…aP¸H•=�ÓØ\ÎS‡^G?Ш_,Ÿ¬^¢WÓ!”{*;Ó¨´Æ¨¨º›ñ�<]ëØ/–OVÓÌÕ¨õZ™µ\ñ&âd™ëÇ ¦t 3ëz†?X,Ÿ¬›é•DÔ­‚Ê�5ê½Qj°B-{Íó›ÖÁbùd5N^Mû¨•Ü‚:¡Q'è�:P}™ò é±F@Çh‹å“Õø“^å“Õè ²”�Üᄪú¶‘u¤•ª8Ög\T­gä2Ë'«aþîe?‚Ê7ê¥Q*ß&×ǘµþ`X,Ÿ¬¦N÷ŠA‹ê”µ§Ù •'r*[äÎêO�±ØtX0Ÿ,ˆG× ²��ú„§t øÕZ¬N½¶Ó:X0Ÿ,’½êŽŠ³S��ªƒuàúùƒ«»®:ß§?,˜O`¦WST¢:cGULäsD…¼›e2F^Ê�ª³§5°X6Y]¤Î�®Õý rð�:Ž¡•u޵:@aÔ€>»hX,—l5 N]3ãÊTÅŠ;Ä¢u½¢cu¼:¹¼ñ3¼†Å2ÉVîhZ±×;…J•ÌZ•ë²…M;ž¬1ébyd«±ÛU‡YèLÇq>ADtmº:ð¬Se0žGb; †Å²ÈRsÅ–v*‹\=µ².ÞM€ ��"1øÿÝ& *O«&Yäã[„ðUñ*ƒ\Õ~JȰXÙj@ð5ÁDHnR¾;¹¥µ5ñkcE�'cÿÒ<¿uóæïã?v¯.÷Y'ù¿«W7·oö·|Z?Í]ìwo«ëbu{óÓÝ»tóðtûý—~™þþâñ—_l¿zþÍê/»×ûOó-¾z}sùpÏ—S×4O·_O]Ÿn¿{tÍøÊù%~ñ4u5zæÓæÃ‡3¯�èèo_ñø>ÃÇ‹t6êãÅGÕ�vÑ6¡”c­OWä¶£R»qx] ^n,XþôSÜp³>íúgŸÚñåÜ>±)YðÈšl1û¿Å󿷸÷8z itiÀ[œ•‘,nq.ØØÈ�ÒÀµöm i´Ïà¾Øõ;% \À?§7–~lçEi€Œ�¯YBÍ3^‰ð g¥AÔö§,]–® `zÓµÀߌE4ÿ$i ì!âš’® Zcˆ·¶8 `‹Se&×îo–F|†öªÏKƒBò$ ºO‡û'P”½m©Rf‹‡›/!�ð ­­Ï iàÚ€–Êl xmÀ¶o | iÔìS ’:/ Üq8Ö?KîÎ^çÎLîäß,�Š­(étB°6gá¬ñ‚×l©øor'ÿÒ@+yïóÒ ®€³:ªµ)xM<Û~r'ÿÖOÛ5Ù ÌçLI×0!YŒ!àIp2ÍäNþÍÒž�ö°’ä¼4¨06# \ý?�>…ðúÔNþ¤oQãóNH×Eb%?A¢¥.´‘ø§H£©‰m=/ *ÝâQE?Aø·¥lKa'7Ô[=O]a§Ìõ°T¶¹Å¾Ì²Ÿ@KH­Æ÷ÆsÊæJƒwÊ\‹;ŽQg[ˆa¡:ŽÐ.Ø5œH>OmÁ§œÝ—( \Œ!€»,`<Ǻ`×ÊÌÌ“FC=žžx^TTX`'°G°€!pÖO~>¸/žÞ4Wømdç&¤Ák-#ÙVÆ8é¬`×D7³¤)NÁÙVg¥Ñ /Š“v³eä�‚6†öÛ5¬ež+ ´¢ç×J×ë›bwÆ-Þ�íj3OÈÒÔ”§ÌÞ)È‹r έ�…¶ä¨pæØ,&½Á|Ê€¸ i0“�ú&¼ž¤�«ŸÐv’^KÈb“Þ uNÒ˜»S�-kJ±3L OFÛî*'U°ÙEiÐNAMHƒ×†æ¨ÖˆŠñüì, xCìT°ÙEiоÄOÏJŸ�ù樒4_’¶áxŸ‚ÍfiDD€ýz6úB^”cæ¨ÖˆXpÊXÆCðÔ8h§`³‹Ò€kûÙ¹¶yQŽC$´�;…¼Ò€,vDœ5'¯‘ÉèNHƒ×#*ŠÄj~CŠ=pXDº–bcŽû ÒH#8Âô¼4�ågSçØß�|tÂCˆ7(6港( ê9êfã ¼Çx0Ó¬Øc@N?"ê丯( Ð^’ÆÌ B‹¼(ó: �–›c�¡`c8—å{Y"bÜó Òàhm?1vZnš�¥�’ã\y°¢4@Oýl~£C„‚Óü ŒÝ€–›c�38 ³÷¥Aýù™é(Y¼6˜Û& �–‡gi uæÌÞù®´_Iƒ<,2é¡<_>÷NòÚ`}Ó¢åÆqÂbœB¹ö`i Ü#C¬JC³\ÌØ%i0Ú¦�Œ( '–�÷%çª …DÌÓtˆ×ÏJ£€�(š.`ä$#fQ{ìâ Èr1ºáZŠ$ ²¢ˆPCùtö, ^C`F‹£tÞ—¡`¥8WM(„ç‚NÍÙib.bÇ ¢P¡BQz)ª,X)®ü§'Ã^WàÌ‹óþ‹-7cŒ‚9Jï0K‰gZžÕ‘óKÒày!ei¸Ц™Ûî ='¥wXw_2÷äw#Nœèù†ò)}Y¼6ðSÄ3¢tF¾¡d³ueIDÏÍù€ÓÔ%�Zæ bF”Þa!”l6â ¬,A>¸+dªbœÒêÑ�1†Àhš£ôRŒÍ6›3÷äw#VÏvh[ÏJyQæ ¢°×ˆ/*ÅØ{2÷Xgƒ¹ÿ­I(ŸdUôèÕC*$‰³`n©( ®>§ky2"ËP>_°ˆЫ#†è±S†9‹ûaCÁŸsÖÙ`-q‡+æ4‘ ¥Á8�;fpØŠ’]Ãìq(ùsD_Xgƒu!Êè¬4pí2·À¢„f‰³`†OS*`=îLà“>¸né4Ÿ ¥Ákƒ-#18…Ü/qf—BÉŸs7é{MÞ£Å8à4Ÿ ¤QàEã †˜ÃYt˜/ ¥#ò媣€ïÝb0! ^l ‹Îà,8ÏJèFWîøF–ó4­ ¤QàE¹–˜0óEÌYt˜!,JC÷ç\-2¶§ù·( ^C”2áÄYtˆâñD´òåþü€×smç¤QÊ qe5b´`ÌY”ø—Öã> Z]<)bbÊ9JCw0ö†¤�91<¥º€|¹+`B‹üõi’H£”SfôÅ|ïB‡þ…±!_®Á ˜�ŸšùŽÒൡ󥺜jûâã¡¶OÒGÇÃRÓ?.î¯.·Oþscrapy-scrapy-5569de3/tests/sample_data/compressed/html-zlibdeflate.bin000066400000000000000000000175331525051012400263650ustar00rootroot00000000000000xœ½�]“Çu†ïù+^Èr•BÎtÏ`7‘â(%•] K®Ü$A.Êû¡Ú]J±~}ºäb%>�yFÇå*q±³ƒ™sºÏyÏ{>úÅ¿|ùç{ùßùjõ/¿ýfûäÅÅýÕeþÏ~÷fûdµzq¸¿Üo_î^í//÷׫ëÝë‹Õî^ÿmõåîú°¿¼{ñüxÅ“Ï�óäÅ«›7Ïÿýaûbµ}òòâp·zsóúýÕþú~u±»[½Ú§;Ýßî®ï®÷÷û7«Ÿ÷«×7×÷ùŠýõë›7‡ëw«ÿy³{¹»ßÿîYºy¾S¾å—‡ýÝêîpýfõùåþðúâ~u•®~{øù°¿½_½OŸïoÚîÓO¿_=<öîýÝÇþb|üÍÇ·úîýõuþº¿¹½z¹{¶Z}ÿåŸ_®wéQ×ûÕ�7׫ÃÅUþáÝþígïß_]þáâö~¿únÿúbýîö³›üÑgw?_ÞýaÿÅjõæ°_í®Çÿ\íþ÷pµ»Ü¯þk÷~{wóöíîýÛëÝÅU¾äÝþr‘^9}WúÚôj«|Íþãs¯îö÷?ß'Ijõ×ýýýßvW?¼Mo~wÿ>ýùõêø<¯ö·«Óß¾¾8ìßìÓC™.Ø]ÿœ.8\¯¾ßß¾JKðÅê§ÏvÇØ'Qå'üy”ÖÃW~“ïry¼ê]úõûÕËÛÝ!K(I=)èîpüeúwþí㯽N�x›?Ï2ûÝáÕøjéî‡Uzµ¤¯¯ž³ú ÆÛÕW»»¿?ÿæ&‰ÿó—û«nN¿9jw?ªô2‹jõÍñ{ß¿Ýÿþ‹Õ·§+¿ÝÝîî/n®¿X½<}ø2½áÝÅÍeZ'»ëÝþöæUÒÁ닟òK~±JÏ{»zôžIÕ·Ey{Hr½{ômãÛ„jõíáú}~¹¿É�ôãÍmz–¯O_ûuÑí�»ËÕçÿuyÿA|ã’H_ó§$úŸ³`oVÇ5,�tßïN÷ýnÿÃ>m´Cz’Ïÿš…}›Þ-)ôîò$”gãyòâ~÷*)òÕÍmºê�Oë§ù£ãÞ|q»MÿÞfÕ¤�{1þPWUuú¡}ôC|ü›ö—U¿þ£ÓO¡®Öýé§_ü® õº=þô<=Ë“üÏOv´ù W«lwÞ¬vi?]ÿñéë}–äÓô4Ïò�F³tü\ÔnB7yѰ‰ÓwªûM;}«Xmšzú^›*nšµ¸.›0}¿�.ÚÔ½¸®6m3yÝ¿ÿw¼îƒfÎ*¢5ŠB⢤!⤈zZ]IÁ¨5)¢ßTÓ‹$lêµy‡¤ˆ.ÝriEÔjGˆ…2lêi•&E±æÖ›V툰©ÄwnbkÔŸÑ›]�ÑlÂô÷ÎV„ÙuЍ£q«¤ˆ j¥üML‚›6%IRaM0ß;SAí!ã¤ašºM;mÕ“"ÔJ¯*³¿’"â¦ßšÖȦ�þÞ¤ˆd:§¥6[fGTÓ ª7/‘ÑL[õ0¤E7ýTëMT®ºÞ´Æ×éÿf?$×_-î!¢ÛÓõ ¿$'' Srûâ©rIj¨”êÓO3jèŒy˜­†é5Ü`Ò›œÔ zc¼’„±©7ÉÈ |“|~R«X"ÙÛˆ]3S �Ø I ÓNXC€Hµ’’âôUmoPKRƒŠm’z?Ä´qß �Ú Íô÷öfK'<"6VRƒxÑ6ù™éÕ›Ô Ô•p£F ÌTC«vC3ýnmºWUˆ*©Á ³Mº›Š¦«FêqwM?Ýl5¨Ý0ýi KÝ*Ý™mU�ŸÔК' 9$T»!/’ÅI�Níaª“nQ¢së·m(HÑ›1qa ÍuY­‹»èNí†hâƒ[Ô+en’D8“ÔÕ>MvÐ!¥t?ágª¡w»Á¥J8L‚¤õ&œQ]‚Ù[I ƒ‹BmÔ5[ f7š'YLÁ%ì«Ô Üs¯:‹MÛ€I‘ÔP`;S ƒÚ â=”ãJjh”„@ZE²ÕYl&'²I._°»9h0@y¶Ôn˜6Õ�"3J5F¥mmTZçl‚ ,ÓW8’âõ´8fªa­vƒðq��{I †ÌPÐ,©¡2âͫ׸òV¹ò#%¾ønX«Ý ’b�Z›Š� jûµ•‰Ar†Óp1I Á¨5©¡Y>ãИ¬´ /SÈ%,uc hRƒHRCePRƒŠ¶Óý„) q f«Aí†éïí§6�ÆY6ëM0ñ@Ý™U”ÍÚEÑumj%fªÁd¤C‹væá’Ì«FãTs¥„I—æÈÝ ªt?±ŸÇ¼„g«Áì‘•ìÃÕ«Fs¯$6•µ®•ÃOjèÍýƼ„XJ3Õ`²ÑÊ w†‘©Uä›Ô ¶_›Û ѹèF-¤1/±xuFcrÑ�a :Eí9PŒ³Ì«Wù†hrÇI ª(Ã)ÞÍTƒÉEG“SïŒÏj€5ð›Ôà�R0%2u®A iÌK,Î)5&­*G:µæ¢!37}¯F•ÔÙ¨h»‰&:cñæâ€Õä¢ÊìÃT-P0ë2©ÁEÑ•ó!¹L©A-’ÙjP»ÁTшÄeRC#ìo0æ&ÙrÇ))F4©¡–jP¥Ç3Õ`rÑÑ”¨d­¬~¨ MبºÎ:ùSS S�ÕxÊ7TÆüÆœ³þÈ5˜Ý ê­Z s}‰Qƒ ßTÁH=Ö©"˵Û5¹Lmz‘ÄÇ¥Ój0¹hú¶ŠèªX¥6Ø&WW«Ý0¨ˆFöCŒé!á*cÎ�ÔF j7ˆ ½I\ÖªÔ*7~L¿hB6Êõ¦pKù�¨Jÿ“:ãß2ð½O«Á䢣ªWQu®\·V.:š4ͨƒármªÀ­Ê7ÄœõûˆlŒÌn>®UõÕª=äÑ œWƒPiÎ )ÒqlIQÉR•ÈÍtÁƒú§Õ`rÑŠ�kUª±6†$g�§¤‹n]é@”.¿�æéâ˜,Õ¾Á䢣٭ª/«A ¥Êp��r÷£\_�ô5m�j8-Ìi5˜\´ª�jUýCm¹+Ai‚³ù•ªýÁdž­Dà_ÉQˆ"‘” ò�PÊy%¨4ë±ZCM¤<�Ø2JXn'¨`Í$)UÓ†¬ÖºDl®™0Àb¬ÕPƒr;ŸîlMÖY*AD’k•“Qó«’4„Ô’\�·¬‘ŒjÐcÈL_£­Ñb)çÆÀìµÜ fTDE3Ø¡>nÐÂqz«ò̓A�ÔA·X¾¹1Kk…bU)]Tã—Z50iÔ�š‘£Ã§Í©SÒè`¹}0-¸µªdíÔètE�·ªilR7íóc™ŒÒAx@PÓ:X,Ó¬F*­UÅJgÂ�¨¦]æ:e‹ä €äÌôÚ³ØÝbiæÆPì®™£S‚]U¾/O P%±6~#3Ý>ÇÜ-–cn ´VåG�)rÞ±UÝ[ãˆ×c¢²³ãdèVûƒÅÌj •›]Ñu&/Ú—Ö¬ÕI c…’¸.䈺±œQ·XvY�Ü_«z^5J2*o›çí˜Ì}­fc�u1‚ÎGs7Ú'/–ZV#�ÖªøKÍp(±Uö{Ô�jIwµIáqïôË,«Ú�ª®õ ÅÓÖnþm.¥V:Lqà8=j[´XbYCkUÖ™’“M^«U�X£Ü>ܹ?1ú.…nÁ¼²ÈË®åä[aé]´ÔÊC°j5óÄOO�á!æ3:Xn\d´VÅhÁxǤÕ(ëš}ÇŠ$U_ëû`Á¬²03k�ª¦Ð¼ÒÜ@=ål½pg¶ûx:€÷ &•EY¦êޝU+bTÃurC‡Ô�+�Wë#ŒçŸè8yÁœ² Å ©¼KT£ZrVQÎéQ¾;é@x•Ïw²¥§Ý‚)euœ˜9Oµ]ås§ÄX’µŒÑ*×P•ì[ô¨ª{Z f”…»T†jöÉì™hñT]M£ToaP' Œçc46¡Ü-˜P¥ ƒÊ´ÆÊD7fpM!ÕÚõôÅ…�‡c´z,˜OɯA¡u•�‰n(ŒÌ�%˜ùŠY*F˵Äz,˜O pP凭ГÕÜ.×Ú7æQC‚j=uPÙø _0Ÿ,ˆÐ[«zôèÆÁôrŒª¨«ÁÌñ£ã‰3–7íË'GópªÃÀ�KÝ,˜^ž-àG„q¿XÞ´_,Ÿ¬Æ©ºvHu\ŽD6U‡¼�:P㎂êZ�uet°Ü>0ØÔ°2ª\,º)0�‹“óh*µ*¹*'÷‹å“ïN£W'¯D7FΛªÜA)Y¶öÌúf†–Û›šJU¢Ýü5éú8ÌžàcâƒG;Ó:X,Ÿ¬¦µÊCºÓàÝðU•z ¦ê/Öjæôãº=£ƒåö�Á¦&�¦NY nò‹l~­Ü¼²,[wüEïç¢ö‹å“£I© ªâÊ�r_i4nÞÏ8™MÙ¢A@½ytl»ÑÁbû@šƒbÆTåPPu2�¬}¯äðA×%t<"LÇÉ‹å“Õ,çA1ÉŽ›\›äiãúÇÁ+²6X�Ô®gðEýbùäh«AeJTÈ�t ˆÚF•äçâ) &ÏhÓ¶h±|²¦Ý«¨U…aP¸H•=�ÓØ\ÎS‡^G?Ш_,Ÿ¬^¢WÓ!”{*;Ó¨´Æ¨¨º›ñ�<]ëØ/–OVÓÌÕ¨õZ™µ\ñ&âd™ëÇ ¦t 3ëz†?X,Ÿ¬›é•DÔ­‚Ê�5ê½Qj°B-{Íó›ÖÁbùd5N^Mû¨•Ü‚:¡Q'è�:P}™ò é±F@Çh‹å“Õø“^å“Õè ²”�Üᄪú¶‘u¤•ª8Ög\T­gä2Ë'«aþîe?‚Ê7ê¥Q*ß&×ǘµþ`X,Ÿ¬¦N÷ŠA‹ê”µ§Ù •'r*[äÎêO�±ØtX0Ÿ,ˆG× ²��ú„§t øÕZ¬N½¶Ó:X0Ÿ,’½êŽŠ³S��ªƒuàúùƒ«»®:ß§?,˜O`¦WST¢:cGULäsD…¼›e2F^Ê�ª³§5°X6Y]¤Î�®Õý rð�:Ž¡•u޵:@aÔ€>»hX,—l5 N]3ãÊTÅŠ;Ä¢u½¢cu¼:¹¼ñ3¼†Å2ÉVîhZ±×;…J•ÌZ•ë²…M;ž¬1ébyd«±ÛU‡YèLÇq>ADtmº:ð¬Se0žGb; †Å²ÈRsÅ–v*‹\=µ².ÞM€ ��"1øÿÝ& *O«&Yäã[„ðUñ*ƒ\Õ~JȰXÙj@ð5ÁDHnR¾;¹¥µ5ñkcE�'cÿÒ<¿uóæïã?v¯.÷Y'ù¿«W7·oö·|Z?Í]ìwo«ëbu{óÓÝ»tóðtûý—~™þþâñ—_l¿zþÍê/»×ûOó-¾z}sùpÏ—S×4O·_O]Ÿn¿{tÍøÊù%~ñ4u5zæÓæÃ‡3¯�èèo_ñø>ÃÇ‹t6êãÅGÕ�vÑ6¡”c­OWä¶£R»qx] ^n,XþôSÜp³>íúgŸÚñåÜ>±)YðÈšl1û¿Å󿷸÷8z itiÀ[œ•‘,nq.ØØÈ�ÒÀµöm i´Ïà¾Øõ;% \À?§7–~lçEi€Œ�¯YBÍ3^‰ð g¥AÔö§,]–® `zÓµÀߌE4ÿ$i ì!âš’® Zcˆ·¶8 `‹Se&×îo–F|†öªÏKƒBò$ ºO‡û'P”½m©Rf‹‡›/!�ð ­­Ï iàÚ€–Êl xmÀ¶o | iÔìS ’:/ Üq8Ö?KîÎ^çÎLîäß,�Š­(étB°6gá¬ñ‚×l©øor'ÿÒ@+yïóÒ ®€³:ªµ)xM<Û~r'ÿÖOÛ5Ù ÌçLI×0!YŒ!àIp2ÍäNþÍÒž�ö°’ä¼4¨06# \ý?�>…ðúÔNþ¤oQãóNH×Eb%?A¢¥.´‘ø§H£©‰m=/ *ÝâQE?Aø·¥lKa'7Ô[=O]a§Ìõ°T¶¹Å¾Ì²Ÿ@KH­Æ÷ÆsÊæJƒwÊ\‹;ŽQg[ˆa¡:ŽÐ.Ø5œH>OmÁ§œÝ—( \Œ!€»,`<Ǻ`×ÊÌÌ“FC=žžx^TTX`'°G°€!pÖO~>¸/žÞ4Wømdç&¤Ák-#ÙVÆ8é¬`×D7³¤)NÁÙVg¥Ñ /Š“v³eä�‚6†öÛ5¬ež+ ´¢ç×J×ë›bwÆ-Þ�íj3OÈÒÔ”§ÌÞ)È‹r έ�…¶ä¨pæØ,&½Á|Ê€¸ i0“�ú&¼ž¤�«ŸÐv’^KÈb“Þ uNÒ˜»S�-kJ±3L OFÛî*'U°ÙEiÐNAMHƒ×†æ¨ÖˆŠñüì, xCìT°ÙEiоÄOÏJŸ�ù樒4_’¶áxŸ‚ÍfiDD€ýz6úB^”cæ¨ÖˆXpÊXÆCðÔ8h§`³‹Ò€kûÙ¹¶yQŽC$´�;…¼Ò€,vDœ5'¯‘ÉèNHƒ×#*ŠÄj~CŠ=pXDº–bcŽû ÒH#8Âô¼4�ågSçØß�|tÂCˆ7(6港( ê9êfã ¼Çx0Ó¬Øc@N?"ê丯( Ð^’ÆÌ B‹¼(ó: �–›c�¡`c8—å{Y"bÜó Òàhm?1vZnš�¥�’ã\y°¢4@Oýl~£C„‚Óü ŒÝ€–›c�38 ³÷¥Aýù™é(Y¼6˜Û& �–‡gi uæÌÞù®´_Iƒ<,2é¡<_>÷NòÚ`}Ó¢åÆqÂbœB¹ö`i Ü#C¬JC³\ÌØ%i0Ú¦�Œ( '–�÷%çª …DÌÓtˆ×ÏJ£€�(š.`ä$#fQ{ìâ Èr1ºáZŠ$ ²¢ˆPCùtö, ^C`F‹£tÞ—¡`¥8WM(„ç‚NÍÙib.bÇ ¢P¡BQz)ª,X)®ü§'Ã^WàÌ‹óþ‹-7cŒ‚9Jï0K‰gZžÕ‘óKÒày!ei¸Ц™Ûî ='¥wXw_2÷äw#Nœèù†ò)}Y¼6ðSÄ3¢tF¾¡d³ueIDÏÍù€ÓÔ%�Zæ bF”Þa!”l6â ¬,A>¸+dªbœÒêÑ�1†Àhš£ôRŒÍ6›3÷äw#VÏvh[ÏJyQæ ¢°×ˆ/*ÅØ{2÷Xgƒ¹ÿ­I(ŸdUôèÕC*$‰³`n©( ®>§ky2"ËP>_°ˆЫ#†è±S†9‹ûaCÁŸsÖÙ`-q‡+æ4‘ ¥Á8�;fpØŠ’]Ãìq(ùsD_Xgƒu!Êè¬4pí2·À¢„f‰³`†OS*`=îLà“>¸né4Ÿ ¥Ákƒ-#18…Ü/qf—BÉŸs7é{MÞ£Å8à4Ÿ ¤QàEã †˜ÃYt˜/ ¥#ò媣€ïÝb0! ^l ‹Îà,8ÏJèFWîøF–ó4­ ¤QàE¹–˜0óEÌYt˜!,JC÷ç\-2¶§ù·( ^C”2áÄYtˆâñD´òåþü€×smç¤QÊ qe5b´`ÌY”ø—Öã> Z]<)bbÊ9JCw0ö†¤�91<¥º€|¹+`B‹üõi’H£”SfôÅ|ïB‡þ…±!_®Á ˜�ŸšùŽÒൡ󥺜jûâã¡¶OÒGÇÃRÓ?.î¯.·Oþ‘¦bÜscrapy-scrapy-5569de3/tests/sample_data/compressed/html-zstd-static-content-size.bin000066400000000000000000000176021525051012400307660ustar00rootroot00000000000000(µ/ý¤U$•ûš¬¤,àŽFM’Ë]Ñ%¨¡jwÔ|ò;€Œ2Wöþ)¥OEŸR€a!Æ#&B^\̓b0G£`L£h ŒÁ 0�ÁD"N{D,Å‚Cƒ‡¤±LÓMy€ÁY"J£4�B“ á44X˜ÉÄiKc8Lk Îc1Óš%�NC¦±,­™,J„s(YZCa˜všÃ4ÅqÚãÑ*8$8-"´Ų`p�ˆCÒÄiX"ŽfÁ,"D8 Ú„ „ÓËâÑ" ‚Ái,Hs <<8�£5’£´ˆE„ÒXZôÎ2™8KkE²´fQ$�EY”q&§5 q`‚±´Å‚ §¡±L‹Á4΢œ¥M,‹F¢88miŒ¦8$$L›à0-â,­±L‰Ò(@4M{à4 ¤™hšä´F´Óç84å4‹£=ZcYœq,– “Å„ ‘F´ɢ�Ó ˆBÁ²,NSç4´ÊcB²`€dYÀ4–æ�hNkÇÈd²`Bk,J;‡r–ȧ-J;�D9 8pà‡Åca"œÆ¢,ËD `$“ÅiÐ0­QZ¦Æ29 Ó” e‚ˆ@Nã�PÚƒÁa,ŠäHšâÒ PP&œÆ¢0"K;ÍÒš%"q�Rá0ZÓ(ɲ`š³´q$ Çi–™HDG¢8 ¤)Å«€‰P,<(’GS–…ƒ#�ÄB�ã�X,gi�G‹X@°öh”FqÎc,šC±À�Æ¡X`"@ p0D 8@Hq3}­pU�èçÊu²1ŸW…›w‹ê»�Öõ1é¢G=¯‘"¢Ñaª×Š˜þõUט1�Ý¢®3)êi&M4Jº¶ÒDüOé½J3�Ô^\ºFÆ]šé;½»½+ªsÑ©z¿N^ßý«|ë«ÑB—×¾¢â•ÿõáú×U«ÃÅtãÿUÑÕ׈ù£oSJ\_'®õ§‰Œ WooBñ�ë-û õ.º¯OÕý-âZ=á�”®ªòb¦[ÄuznB”øUÕ\̯ºÅ…kõ„ÈI�rÏ{¹þáª\”.Ó­J{Ñ‹_ç:w]W¡.rÒŸ÷tñ›ÞÛÌõÝÂ/ò­BT/âj®oqªËãª.&/þ÷]ˆ›ìUÎEº¸¼x›r�·ö^&ãЯÒä5.WåÒÍ[[÷ò›|¹ á¾yPœÃ%c`Z³4 –†òXF£8çÆ4ŠÅcš‡/£bZgôù—¿;; 0…xD$N„&±`à@xxÀ È@À²  ©aŽãáñxjtü)¢úÝgª³×­ˆW?ߊƼõç£o­ÉI]êóy§˜NJÝ"ÅûOWZ¨~ú¡ñînUè«"£ÂTÿK*tºÐê^Tw]ʘw4._æ÷4Q1Ò<:ê¥"žó‰IÓi­×庵Rü�‹Î[¡¢:oõ£—¦Rˆ»4ý\ôÞ1ïe„pï˜èÇô½rýD^Wû™ˆ7>½øôJ¹h¼&B¤òÈK‘×ùD·bæy)ÞŸyD¯ŸKÑiZ×Åÿ¹’÷™x]<”ɺtшŽÏ/>Þ�¹ˆëg^=%þG««÷ÖéDÜD_gºT£ ìiŠ……'T¤Ž(­GôtuT¼„ôœþ#B¥ÿ¸ŽùtÞé)®ã=D_ˆÒ;D˜ÔQ¢„¬.í;J”‰G(SñO”ˆ’C¢D¤ÒQ²”wF˜ ¡¼LÌ£tJª(�Ô¿PªTNêˆ2­…2SB(/¥b(óÒ©iªSˆ’úBzOg'¦(úÉ™)“ú’Ê”ô)Rç´S…>Å�*ÅR*¦é)J*Q¥;J¨ØbIYÙz;ózZ™ªØúJ™™ë�bëwjÝ­2©/½R…©ÔbW©XZ,»ïW…š·ØºBUæÅô~¸|•¸RåbJ©.)ö¥êê”-]ÊÊûBº–®Å�ƒ^êu1e—þcGK©jR×¥ì¹ÖRÆÒ—Œ|ˆ§gê+KVûw:ó蘦2f½2_õÊ¿º2¶X"ý=’õ螈%uľŽÐ‡Žé1u@†‰!tH´â�!ÆØ-exTg,bŒ1– 9$ıÄcÑ ¡™£Q(Ó($0† ŠÖ¨DдÅàÌ @Ó ˆŒC*i×€, @pàð¹(Ba0(T4Nhõ#ĶE!Ûö«õ/šX–/­(±;³<ºp‹CýŠ9{íI·Ôö`&qÅLªmo£Çâ‰x\:­òÒÇÝÇ ƒ$xu/á4K7y¨_îç'‚h‘o3ŸBÅZ™s§9`£UøØI‡£ó‹{Äó”€u-‚Ù¿ÆÔPéQ³Ø•�•¦Þ¯ç�6)Å|%À¤÷$ók8§ŒepL¡"Âá6f|»JÝ/ùl˜Mvqø®&Aû83³ü>¨mCù�”«ãÇs]¿XÁ~IWq€4a ½Œ0†­‰ü!À=®Hš^)áÁ+Ž-LË!ù£Ð‚Å;‚ŸæÇ®C(X®‚Æ1Uï‹cDù!Ka kv¸w$�ÀÄ.BS®õ°Œ¾±8¨3ø·pVŒþ¡ñCQQx[S]w•iVš˜€Ò"<\Hm™J°ñ«FKŠp”pàÆyúѰßôØ&/�¬âyY�& 4 ¦g‚[�ô±ì#ây�ÝûÕGPX_¥SÂîWûæ‘3ËêcÔîêe¼�hëCÖ(Ô.™œC§óCÎ2 �JyZ�œnÛÑ";ÂÆ¿>‘râë\ûµá~°1‚ú4w„S�á°Ê¯o�ÿ‰uªjfþ˜Â1±ãnán™ñ'FÏž] ª‹èÈž°ºµö£|| ÿ´ár%wñ.W.°(úÿ¿áñ©ßCþqÚgW oóÞ¾�á �ˆÊWéAË` …¸¨ý2ÜVÒ³WI=uïs�è©WTï×2ai»ÄQlÝZ]�n¡K½Šå[û€6‚D�”—t'xïóÞT%{¼tý™ÔOòøøÃ{^‚ëh�Àeo¡÷¾Åñ¾{,=õê¶@ø¶Ÿ¨ïµ>ÛJÏdÞý {pOC*«TþJ½›pãôŒèm 1ûêñª,ÖÃ!x?ÆÌr~êãÚ‰ÿmÝ�»°˜Šðú ÄBå¥@C^ᘰº:Ùü›ú¥Z‰¹ký@x裖Jµï´{ +ÃAôó$ï ¹Íåt¸Q·þÂDg·ZMÔ2ö§$ü!`ÚÅÂõAb³‰°ódÀïAm†eŒ3$é]�ô”mz-åF=¿ ñ‹pZØÀßÀþ†V]ºÐVÅ."�/ÈÐ&Ó¢U!ÕŇ=nïQâ ãj‘9ýn¨�µTœ�xe„ì#tDÝ/|!žýxv\ô~‚W+Œéóµ®Í°Ùé¡�ðx¿Xž)ãî¯Ñõ�ÂLÞéF‡œm¬=fžñú—”™>}2L:¼›fÈÅetIÔ³W”¦-³CxôªN<Ý$W‘‡év²ÛïÞBßñ�©oÔ;#˜ïÑ}[Ù‡Mêü�„˜¾}(¯‡,O™è¿Wo6§¿œÛòÎ#?ÔL¼î!ó:VÀšyÄÒéMÿ:(ucI§“=å�]‹ GëÌú ÓwàŸuc´©ž#�ÞÏl£d Ëá°pÑ¢%ëcd¾cŽ ¦$-“÷¦‘ǦUBÀù—ë�ŽXP¸í‡¼’x+ëMöG�LÏáFÁS¤nѤX¶8òl3ÿ-JcÝû4ÿÿð¾ÿ{1Ew–µT$cž²UÃÒ�všw„íF e»Ðl¦ˆã�ç7ÿëÂÜÜgã>cT5%t_\Y󵯹÷!h±Z•Ü >,ʈ[¿#hÕ4;lZµû�²ÞfV ¦µÜ×R") jìY3«çt~�6p-=ì]!¡P±ÅÅ?»ú¨N¥·ÐGÐ>ž�Úß ±YÄÉ~ëÁ\Æ;^‚Œ%{ûõ0*=RGKW1±Íô×€ŠpØï–CvÖØ”#ßÃzû¨oôg–Ç!_ÇÁ¾Ÿiú W¬¯g¥6ÅŒþ­Û›5JظˆåRÏ•E–¶g�þ ê?–z+y„®íÈ7Š·TéQL° x†ø™Ý¯iž ›<ÆöGýzôˆè¹ÂÄù4�,’'Üý· x™¼&Çè¥j<'¤6ÊÌ–g]˜û6¯Ýy çjX¼Œa²GNnË:®çÉæ9­êþ(ñvO¨»Q±õm¹ÊxÝ6œ~HßÈ2�ÔBûTî5§qE|¦H•cbÖBF´ß†{T0y ³1í;,™ƒ>ps*¥�¤{�uF»Ý¹-#55e�PC»6cÒ§r·4Èô]ùFI!¿;)EyÖḦ¢8ðmR¹^™¥˜ÆÃÐòÈ3¾�ÜŽ'7ØFîTZ~ŸëäÂ5Måï6]Ú—Ùt*xãF(©]‹åÜÕ@ôÅô.±ÝNoœf«¿âæã�}@ñÜ︹]–$ý¤èéigIý†s+Y~ìÝbXlãAÆ”ëöID’Äy×A,›R P7Z¡+ÀdV!Œ"âÓã¿ÿS?FÔi‚v?æ4¾Àsfa1Î�ÂeKŒ@0‡Ðú´ëqé1Õ‰Þè€,®b÷•Qt1ªT¾SIo¹ÒÜX˜Œ©83þb-Ø(¡Å½Ê¸sô¬•Ê’` �Ü’6¦vfHÆÚúÆE�À i1%©ú‡³KŸî¦2þ±áÑE[c "çOWg¢¼`Y0Šƒcw1µJ^L±/öaL¶Ë6ü(›¿ÁˆO‹“G2¶µUØÂ ©ÓÍ1Å}„l‚oÔNg×¾G-DºÓI¥H~)½WgLbÀ3ç ÷ªá£àå­ˆô–cпmVÇtB¶²6ZýFÉ=,j´u(”ÅÉWeˆŠaÛžbˆshë„´–!@Y“�Ç:ëäQ¨ìgxG 1ë¤çÐF†Ã[!h{©}ÿCv÷$0ç„åÉêa¾›¹—@4l·„‰¢:^Á«6—®D ãpu…I»û?‹Ë%ú3s²•÷õéÁ^o²M&Ÿ 58Dü¡Æ5Vã¾IM9Çmúäôý¬ñaÛ(ko_¯D³d9eNðx}ÒKiU©ŽYîë `g ÿ³ëúðÊ«¿ajzAWÁ.ä÷ v£ÞåCCÍ–¸ß©+u"UåÆZÔ�Z©…`ÊÆ'ñºˆ¼«8#ánw^§XRÕ?uWêj€/t°Ð®€üÈ:!ƒšÏþ yÒ›û¬UQªM¨€Þ(ŠÊƵ¨ÊV…Mn7yøÄ”‚•Ū{Ý”½†²$Ú* çØþ‡õ³Ýj� ëÕGÉÆúÉáôF‰”pÀ¬•:Iä²4޶¼¸w—|f’¶Y<ÞÀT B5m×ßF!ˆùVVW9+Bî½@®:ë™E„¿v¦îô8 5e W…ïhÁÍ­z”ë#A°Ë ü ˜C¾ {å› …v,4ºSP¼^}8âº3Ñ6@ Dƒúszø Kï3qËye®HêŸkB%Wi›óƒ ŸO»âJQzÍQÏ�Ó®üÕ4üd™ÏúI°ñ˜$Ô ó.ÔîWd»™ÇuÓqbÒRòèà_a¹iî—‘‡3´b‡ŸeÀ(¬XP$}4H´7~ïÔ¹±vͺ7n`aÅñ|çU9Ö‹€b�ú1‚yÁ½áÔ|Ýe�¼mÎãߨZY™k:½±Ã$µû?ž»ùúÎÆ9+-^ׂaÜç�ñ|ãÝÞÙ-«yΰnNüüʲ{+Oƒ"gÛ×l-\:Vâ:ï¤Ê0G¯ùÉžQžóâI\û{öžeWâÜÍÛF±Q¬õnM šæì ˆî‚ŽÜßM ‘c0n,‡„�5©ËÛX»œ�a`.íÌ©á1G–µ8Z¾A¢>YNƒo8š �Þêêv¢‰ÈG„?Eíå>è(ÿ¯Ê;ÅN?€­¤E„6þú.šä�Þ(RqV`¬Šˆ÷‹qc kC2s—Þ`T ÖB ‡ ¯ˆ(vRbÚ½4ò‡ñ>Às€oN´ãøúoÏ"ù壑yÝ€¤”ƒ(¥®6Ùp°/1´ß2ß³û Uô¯Tµ¹U9Y&Ø:U(ôÝß)¦t=I…&y•>95L…í©÷Ù�ì­vvÿô»½R}¯ú’DåÔòØGu%›r•lÔQASˆÇLé…Ú()„¯¦‘ëir›v@ï-u[>ÞC¢vÚŠÜN!‘À‡ÔºLæ¾Ýµqªñ�“œ¨Å‹rašÉ /7ÚåOÇš$t%˜OÔï0 4–ä^±eþã¨ÿFD!@—s!šeÁw%Ôy³ùŒx¥–ó T/4Óš´¤Æ.98—œžÃÏN¯Z±ÿã\®^x­=y·€/Ah^Tù"€uôì$aþ¹¥Ï =�Æ'¢-Ê+ôèš¡,e%�·Ï„tB&º ū۵öÃ×$SjëÞ2•˜ÎoIÊêP†‡ñ_™b-H€r$N9:±o»6` ™Z7k a�1Ö$ñ4#y&`Ž õõIª‡êÚU±Ž)i‰ÊÓ"µ)·ÿðÉžüZËM “�b”ºZéhÅV-âÃŇ$ýûÁ¼”�YR——sPXËK…'ÀN>vŽDIÒF�ãK[üÒµµ|Iô$ÏÃ÷I`øôQl[ln¬•)ʆÝ%jµs?L�ò,Ù”£Û&AH{‚~<$q»v×oô‡9ªÿ­äż{—‡@û“Ä^ `°Y„V Ú,¸uyDslmʱ6güt@y­Pïƒu˜9‡ÍÆ$Ë�£$¾¯¦µ¢ÈŒy ‘M’à�wÊ."ÃvS1¬ø>\˜ ìWehØ”ƒòç݉K~R�ME±#\‡5,_õ% lvWg,4–‡§Ì’ë§lª•ê^�½EYv æP?¾=gí6å?ì¸Ö„=|°A9!‚ü»Kž¤JØZÆ~hDZ[ÝTAüÛ+�“�*I¡U:w‚êÊ5‘R6å�Êi•ª¢ÂÝåGQ2UÌþâPLvNë<„·Ý#ÆÁ1ÏTƒ#í •JQ¦ºÀà·Å@“× •[lGÞ¦ÚaÁ�™H’Zÿeääø` ´kóŒ¦!¹ý=˜°®‡�<øÖÅÿÊ7K)jsŠ)åv0Ó*¼ä#ïí·_x�Ȭ7÷\åDÑžŒ‘Êê²î!ô¾6äúàÊúQ¬>ì€ÞzÝc~î=\"Û%Uqµ)_ \¤1þÐYq³bF3l˜tzH¯W$ò‚Sþë�€pÔ:,¿<¸KJ1’xß‹—ßÏåðñ1è�©bœò—ÏÓÒÍiÿîHÛû#ZY–1ŒXhâ mÏS:6"œòé¿}WI�1èá&ëòYOvqûO”©ÔbÞ7ïá z\S¸Õ. Ûá6E�ˆ§D'ï£dE¡m_±Ê¹j^vûG“æÑDU=† OÝ2!N(iUƒt­2¬…˜Ñ¢´‹†ò¯‹~�XÀ˜kA±—Ni«©7ͯ|®Ãí›ʱÁßK�‡æNOv4t¢ÓõµDÈ/aÝœÇZ&¡'ÿÖaÖÙý¢£Aþ;5üêÈ´WãÞýëí˜å®ª6Èö®¡6„•ä+ü–[…ŽFÞ)*œÊfÝCŒ¥ùÍ NÍ=qƒVö˜�fÙÖ.-°ÙqËO’ªXHiÕ�4àÐóWð¨¸$kƒ”2¦!EÎF•½Ð“ D¡�N Ñá$µÔ2†�ÒCî"¦\¿iŠ?„uy‹CÙø‡J�EFÀ¡=Ã%§(/£¸jY­{^JÒR´1Ìâc!( ¼!n ¤ªÂó‘Ý×ÑÂÝÃ’v‰ÔZÕK ¤QmÆÿ;®/­úWØÂÞ¡š{(ª�=,18“ `Dp�.äàK¬ÈdæK—´`Kàmí̵;Äf€}Ð,¥ ÆØ²ÁýåœfÙ::ª\=8¥?tŸ–2EW�`Út˜â%��sO)sÁ,ks¿ô\[)"‡KîÍ2ª†lµ½*|–ý)Ôf%PÀÂ|N£Hš=¥9⪠ID&'œN"ÉZ¿d¿Ùœ×}x`3ƒ r½¤ÞÌ�g þû�Øž EHÅÃ$�ó޹ýŠ2¬#‡ŒBqˆ�4¬¼lMÉnËŸTd?ÏçC¼j�ÝG«žç}  D26'‰¥Wå8WØ­Ñv3[Ø7Bªõ•)¥"ò¤¸Zì!6 JzOFû\Äb{T«³Üe�PÀÔ¸y,V±Àöˆ#¸ä¾<ÑÊ[´Y÷:ŸE%h‰LLÖàXÝ,èé öÊìgJ .$IÁ µaþ©7‹zX6zDûnjè«;z“AÞÐC“Ü;i„ÈB×±d74ÙЀu+ú~Y÷ ÏÚ%·=ìíj‡ËÞC}Ì¡<ÀÈ’×Ó5Yááe1ÑìôK®�0¢- vhÊG¡#qŸAÅ ÍdØ'1Âí\T¤U":%ÍKU‚Î:˜�ÐÒh9¦b¾qà�R/�>Äy€(zqEµïÙZ®¡=]­Œ,0%‹¸îm¿ †Tq¼�Ô]VÚ˜¾~îI©Šœ¥¾wA-¬ë–”é�’ùÑ»«ÈH‚(^ûbä4O¨\iWŒ¨*Ø]¤| jâ/ÓaŠcÓi¨‚ˆ€ù0ƒÒȺ¥„V °¡¸��ÂÿP#…t”+öUœ2²é>Û{€¿˜Í*r2X¯èðD§‡ÞÙÏ¥7vIâ`¤"f–þÏLdj=nrîG—Wñy3^-æöu}YþñÈ„ƒ'¥|ƒˆ„ˆ}mFPõa#àºQcå†`†|ÊU“ ¦ƒÊ®M+A·Yñ>º+EÅ&|³=ÇÕó�{$ûC9D¹/^v€g~R£:ªõsK™ˆyTS Dlè7ŒWÛ«Åâü=üè¾¹q•hA²ˆÞüLz�ÐF„kÔ¤£!œçІXÝ >�j¿D°‘§6’ABÍ2zOÉ þñ[€÷£Ë&�«R<¹o‹"ó™ÈPe2ߨ@É�¹ øzŠéðÈN†ÑË›Â_ÍI%Mü£ Å3?§çCŽ3÷#b@À¸CK@CfAŸÝBZ ¾?“°ÉqZ"1À %çË¿ú죃ˆbåçï?‰ †?î/ú0fm 9™­-Pñ¢Ô¦MX(š>TD¼¯à^ýöYÛ¨Ã�ôßQTˆÒ’Mt_'ôo=דì3çK+¶ÝvÏüØp㞎²Ò*$“EŽõr¤$;ÑJ×а| 1x¢*'8f©}N [)ô½pÄ-54S\¶)\)þtÏ ™‚Þ~/øƒRÄ,ÌñRÐÚû| ù*¤<ÁÑ–\‚ʵªΙŭÏyg§ó‰b_<Ó:h°”Ϫ>b‰È;"Á¸Ä2¸Y¸\oëÞ>‚ÚB2Ÿ[¶¨xê,kexM>¨”¡Í`ŠÏâ.‚× BVòá&(Òš[ØD»äV€~èADŠ‚BÖÃÔ㡊ÜZ­,lSÿ¯RNºÇ�R`H¦&²„jeepfŠ·%’3"yi%‡õsÖÄŸˆg?iŸT™ÂÓa òaP|h>A\«ò¡`)q»ì˜üßųwMÇ`$ªŠMb öÏ%}Ô“šU�Üý&¸°E3%¢�І›N6W öÖlÑr•´u:@uvQ€úf𹀕ÏQÔäþ EE$3ŠÐŸóô•˺‡Âá70‡$‹Y <'Ôæ©½øE½ÈßC/JL¦à$Ó0{bKPŒ"7Œzeý÷ÖbiÙu"Òïz{W KÖ»3s“›?ö·«É²ƒÖ´ï8³¬¢…0ƃû4‡6;Å€àÄYÒ.$¢ŒIí tvíìžb8e�ÍÆÈ:*xTôÆP,´ 7[ª Ñ­Ýoe—m–(”2< Æ)ÛÉÄˈ¨W(Öê‘^¦Ê耡I‡Í"½7ßtI‡l(ØîŽŸÞ—¾º�_O»ÀA<‡U%¨~¥ úqv†t ˜M•Ÿ÷–púŠ ñÏBIŽ‚\Zýð×/ û0«éÝòØdêä‚•[Kˆ}¼Þþ¦k½ÝZkŸÂWJŸQ~%+wï~R0µ›&ù_7PzAgÄO�bç“ä(Uä}‰;Õgh÷ÏÒ«]ͦ¯Éÿ,×õó «AÚÞvUßš ã|;Ô¸¡tì˜ø¨"scrapy-scrapy-5569de3/tests/sample_data/compressed/html-zstd-static-no-content-size.bin000066400000000000000000000175771525051012400314130ustar00rootroot00000000000000(µ/ý8•ûš¬¤,àŽFM’Ë]Ñ%¨¡jwÔ|ò;€Œ2Wöþ)¥OEŸR€a!Æ#&B^\̓b0G£`L£h ŒÁ 0�ÁD"N{D,Å‚Cƒ‡¤±LÓMy€ÁY"J£4�B“ á44X˜ÉÄiKc8Lk Îc1Óš%�NC¦±,­™,J„s(YZCa˜všÃ4ÅqÚãÑ*8$8-"´Ų`p�ˆCÒÄiX"ŽfÁ,"D8 Ú„ „ÓËâÑ" ‚Ái,Hs <<8�£5’£´ˆE„ÒXZôÎ2™8KkE²´fQ$�EY”q&§5 q`‚±´Å‚ §¡±L‹Á4΢œ¥M,‹F¢88miŒ¦8$$L›à0-â,­±L‰Ò(@4M{à4 ¤™hšä´F´Óç84å4‹£=ZcYœq,– “Å„ ‘F´ɢ�Ó ˆBÁ²,NSç4´ÊcB²`€dYÀ4–æ�hNkÇÈd²`Bk,J;‡r–ȧ-J;�D9 8pà‡Åca"œÆ¢,ËD `$“ÅiÐ0­QZ¦Æ29 Ó” e‚ˆ@Nã�PÚƒÁa,ŠäHšâÒ PP&œÆ¢0"K;ÍÒš%"q�Rá0ZÓ(ɲ`š³´q$ Çi–™HDG¢8 ¤)Å«€‰P,<(’GS–…ƒ#�ÄB�ã�X,gi�G‹X@°öh”FqÎc,šC±À�Æ¡X`"@ p0D 8@Hq3}­pU�èçÊu²1ŸW…›w‹ê»�Öõ1é¢G=¯‘"¢Ñaª×Š˜þõUט1�Ý¢®3)êi&M4Jº¶ÒDüOé½J3�Ô^\ºFÆ]šé;½»½+ªsÑ©z¿N^ßý«|ë«ÑB—×¾¢â•ÿõáú×U«ÃÅtãÿUÑÕ׈ù£oSJ\_'®õ§‰Œ WooBñ�ë-û õ.º¯OÕý-âZ=á�”®ªòb¦[ÄuznB”øUÕ\̯ºÅ…kõ„ÈI�rÏ{¹þáª\”.Ó­J{Ñ‹_ç:w]W¡.rÒŸ÷tñ›ÞÛÌõÝÂ/ò­BT/âj®oqªËãª.&/þ÷]ˆ›ìUÎEº¸¼x›r�·ö^&ãЯÒä5.WåÒÍ[[÷ò›|¹ á¾yPœÃ%c`Z³4 –†òXF£8çÆ4ŠÅcš‡/£bZgôù—¿;; 0…xD$N„&±`à@xxÀ È@À²  ©aŽãáñxjtü)¢úÝgª³×­ˆW?ߊƼõç£o­ÉI]êóy§˜NJÝ"ÅûOWZ¨~ú¡ñînUè«"£ÂTÿK*tºÐê^Tw]ʘw4._æ÷4Q1Ò<:ê¥"žó‰IÓi­×庵Rü�‹Î[¡¢:oõ£—¦Rˆ»4ý\ôÞ1ïe„pï˜èÇô½rýD^Wû™ˆ7>½øôJ¹h¼&B¤òÈK‘×ùD·bæy)ÞŸyD¯ŸKÑiZ×Åÿ¹’÷™x]<”ɺtшŽÏ/>Þ�¹ˆëg^=%þG««÷ÖéDÜD_gºT£ ìiŠ……'T¤Ž(­GôtuT¼„ôœþ#B¥ÿ¸ŽùtÞé)®ã=D_ˆÒ;D˜ÔQ¢„¬.í;J”‰G(SñO”ˆ’C¢D¤ÒQ²”wF˜ ¡¼LÌ£tJª(�Ô¿PªTNêˆ2­…2SB(/¥b(óÒ©iªSˆ’úBzOg'¦(úÉ™)“ú’Ê”ô)Rç´S…>Å�*ÅR*¦é)J*Q¥;J¨ØbIYÙz;ózZ™ªØúJ™™ë�bëwjÝ­2©/½R…©ÔbW©XZ,»ïW…š·ØºBUæÅô~¸|•¸RåbJ©.)ö¥êê”-]ÊÊûBº–®Å�ƒ^êu1e—þcGK©jR×¥ì¹ÖRÆÒ—Œ|ˆ§gê+KVûw:ó蘦2f½2_õÊ¿º2¶X"ý=’õ螈%uľŽÐ‡Žé1u@†‰!tH´â�!ÆØ-exTg,bŒ1– 9$ıÄcÑ ¡™£Q(Ó($0† ŠÖ¨DдÅàÌ @Ó ˆŒC*i×€, @pàð¹(Ba0(T4Nhõ#ĶE!Ûö«õ/šX–/­(±;³<ºp‹CýŠ9{íI·Ôö`&qÅLªmo£Çâ‰x\:­òÒÇÝÇ ƒ$xu/á4K7y¨_îç'‚h‘o3ŸBÅZ™s§9`£UøØI‡£ó‹{Äó”€u-‚Ù¿ÆÔPéQ³Ø•�•¦Þ¯ç�6)Å|%À¤÷$ók8§ŒepL¡"Âá6f|»JÝ/ùl˜Mvqø®&Aû83³ü>¨mCù�”«ãÇs]¿XÁ~IWq€4a ½Œ0†­‰ü!À=®Hš^)áÁ+Ž-LË!ù£Ð‚Å;‚ŸæÇ®C(X®‚Æ1Uï‹cDù!Ka kv¸w$�ÀÄ.BS®õ°Œ¾±8¨3ø·pVŒþ¡ñCQQx[S]w•iVš˜€Ò"<\Hm™J°ñ«FKŠp”pàÆyúѰßôØ&/�¬âyY�& 4 ¦g‚[�ô±ì#ây�ÝûÕGPX_¥SÂîWûæ‘3ËêcÔîêe¼�hëCÖ(Ô.™œC§óCÎ2 �JyZ�œnÛÑ";ÂÆ¿>‘râë\ûµá~°1‚ú4w„S�á°Ê¯o�ÿ‰uªjfþ˜Â1±ãnán™ñ'FÏž] ª‹èÈž°ºµö£|| ÿ´ár%wñ.W.°(úÿ¿áñ©ßCþqÚgW oóÞ¾�á �ˆÊWéAË` …¸¨ý2ÜVÒ³WI=uïs�è©WTï×2ai»ÄQlÝZ]�n¡K½Šå[û€6‚D�”—t'xïóÞT%{¼tý™ÔOòøøÃ{^‚ëh�Àeo¡÷¾Åñ¾{,=õê¶@ø¶Ÿ¨ïµ>ÛJÏdÞý {pOC*«TþJ½›pãôŒèm 1ûêñª,ÖÃ!x?ÆÌr~êãÚ‰ÿmÝ�»°˜Šðú ÄBå¥@C^ᘰº:Ùü›ú¥Z‰¹ký@x裖Jµï´{ +ÃAôó$ï ¹Íåt¸Q·þÂDg·ZMÔ2ö§$ü!`ÚÅÂõAb³‰°ódÀïAm†eŒ3$é]�ô”mz-åF=¿ ñ‹pZØÀßÀþ†V]ºÐVÅ."�/ÈÐ&Ó¢U!ÕŇ=nïQâ ãj‘9ýn¨�µTœ�xe„ì#tDÝ/|!žýxv\ô~‚W+Œéóµ®Í°Ùé¡�ðx¿Xž)ãî¯Ñõ�ÂLÞéF‡œm¬=fžñú—”™>}2L:¼›fÈÅetIÔ³W”¦-³CxôªN<Ý$W‘‡év²ÛïÞBßñ�©oÔ;#˜ïÑ}[Ù‡Mêü�„˜¾}(¯‡,O™è¿Wo6§¿œÛòÎ#?ÔL¼î!ó:VÀšyÄÒéMÿ:(ucI§“=å�]‹ GëÌú ÓwàŸuc´©ž#�ÞÏl£d Ëá°pÑ¢%ëcd¾cŽ ¦$-“÷¦‘ǦUBÀù—ë�ŽXP¸í‡¼’x+ëMöG�LÏáFÁS¤nѤX¶8òl3ÿ-JcÝû4ÿÿð¾ÿ{1Ew–µT$cž²UÃÒ�všw„íF e»Ðl¦ˆã�ç7ÿëÂÜÜgã>cT5%t_\Y󵯹÷!h±Z•Ü >,ʈ[¿#hÕ4;lZµû�²ÞfV ¦µÜ×R") jìY3«çt~�6p-=ì]!¡P±ÅÅ?»ú¨N¥·ÐGÐ>ž�Úß ±YÄÉ~ëÁ\Æ;^‚Œ%{ûõ0*=RGKW1±Íô×€ŠpØï–CvÖØ”#ßÃzû¨oôg–Ç!_ÇÁ¾Ÿiú W¬¯g¥6ÅŒþ­Û›5JظˆåRÏ•E–¶g�þ ê?–z+y„®íÈ7Š·TéQL° x†ø™Ý¯iž ›<ÆöGýzôˆè¹ÂÄù4�,’'Üý· x™¼&Çè¥j<'¤6ÊÌ–g]˜û6¯Ýy çjX¼Œa²GNnË:®çÉæ9­êþ(ñvO¨»Q±õm¹ÊxÝ6œ~HßÈ2�ÔBûTî5§qE|¦H•cbÖBF´ß†{T0y ³1í;,™ƒ>ps*¥�¤{�uF»Ý¹-#55e�PC»6cÒ§r·4Èô]ùFI!¿;)EyÖḦ¢8ðmR¹^™¥˜ÆÃÐòÈ3¾�ÜŽ'7ØFîTZ~ŸëäÂ5Måï6]Ú—Ùt*xãF(©]‹åÜÕ@ôÅô.±ÝNoœf«¿âæã�}@ñÜ︹]–$ý¤èéigIý†s+Y~ìÝbXlãAÆ”ëöID’Äy×A,›R P7Z¡+ÀdV!Œ"âÓã¿ÿS?FÔi‚v?æ4¾Àsfa1Î�ÂeKŒ@0‡Ðú´ëqé1Õ‰Þè€,®b÷•Qt1ªT¾SIo¹ÒÜX˜Œ©83þb-Ø(¡Å½Ê¸sô¬•Ê’` �Ü’6¦vfHÆÚúÆE�À i1%©ú‡³KŸî¦2þ±áÑE[c "çOWg¢¼`Y0Šƒcw1µJ^L±/öaL¶Ë6ü(›¿ÁˆO‹“G2¶µUØÂ ©ÓÍ1Å}„l‚oÔNg×¾G-DºÓI¥H~)½WgLbÀ3ç ÷ªá£àå­ˆô–cпmVÇtB¶²6ZýFÉ=,j´u(”ÅÉWeˆŠaÛžbˆshë„´–!@Y“�Ç:ëäQ¨ìgxG 1ë¤çÐF†Ã[!h{©}ÿCv÷$0ç„åÉêa¾›¹—@4l·„‰¢:^Á«6—®D ãpu…I»û?‹Ë%ú3s²•÷õéÁ^o²M&Ÿ 58Dü¡Æ5Vã¾IM9Çmúäôý¬ñaÛ(ko_¯D³d9eNðx}ÒKiU©ŽYîë `g ÿ³ëúðÊ«¿ajzAWÁ.ä÷ v£ÞåCCÍ–¸ß©+u"UåÆZÔ�Z©…`ÊÆ'ñºˆ¼«8#ánw^§XRÕ?uWêj€/t°Ð®€üÈ:!ƒšÏþ yÒ›û¬UQªM¨€Þ(ŠÊƵ¨ÊV…Mn7yøÄ”‚•Ū{Ý”½†²$Ú* çØþ‡õ³Ýj� ëÕGÉÆúÉáôF‰”pÀ¬•:Iä²4޶¼¸w—|f’¶Y<ÞÀT B5m×ßF!ˆùVVW9+Bî½@®:ë™E„¿v¦îô8 5e W…ïhÁÍ­z”ë#A°Ë ü ˜C¾ {å› …v,4ºSP¼^}8âº3Ñ6@ Dƒúszø Kï3qËye®HêŸkB%Wi›óƒ ŸO»âJQzÍQÏ�Ó®üÕ4üd™ÏúI°ñ˜$Ô ó.ÔîWd»™ÇuÓqbÒRòèà_a¹iî—‘‡3´b‡ŸeÀ(¬XP$}4H´7~ïÔ¹±vͺ7n`aÅñ|çU9Ö‹€b�ú1‚yÁ½áÔ|Ýe�¼mÎãߨZY™k:½±Ã$µû?ž»ùúÎÆ9+-^ׂaÜç�ñ|ãÝÞÙ-«yΰnNüüʲ{+Oƒ"gÛ×l-\:Vâ:ï¤Ê0G¯ùÉžQžóâI\û{öžeWâÜÍÛF±Q¬õnM šæì ˆî‚ŽÜßM ‘c0n,‡„�5©ËÛX»œ�a`.íÌ©á1G–µ8Z¾A¢>YNƒo8š �Þêêv¢‰ÈG„?Eíå>è(ÿ¯Ê;ÅN?€­¤E„6þú.šä�Þ(RqV`¬Šˆ÷‹qc kC2s—Þ`T ÖB ‡ ¯ˆ(vRbÚ½4ò‡ñ>Às€oN´ãøúoÏ"ù壑yÝ€¤”ƒ(¥®6Ùp°/1´ß2ß³û Uô¯Tµ¹U9Y&Ø:U(ôÝß)¦t=I…&y•>95L…í©÷Ù�ì­vvÿô»½R}¯ú’DåÔòØGu%›r•lÔQASˆÇLé…Ú()„¯¦‘ëir›v@ï-u[>ÞC¢vÚŠÜN!‘À‡ÔºLæ¾Ýµqªñ�“œ¨Å‹rašÉ /7ÚåOÇš$t%˜OÔï0 4–ä^±eþã¨ÿFD!@—s!šeÁw%Ôy³ùŒx¥–ó T/4Óš´¤Æ.98—œžÃÏN¯Z±ÿã\®^x­=y·€/Ah^Tù"€uôì$aþ¹¥Ï =�Æ'¢-Ê+ôèš¡,e%�·Ï„tB&º ū۵öÃ×$SjëÞ2•˜ÎoIÊêP†‡ñ_™b-H€r$N9:±o»6` ™Z7k a�1Ö$ñ4#y&`Ž õõIª‡êÚU±Ž)i‰ÊÓ"µ)·ÿðÉžüZËM “�b”ºZéhÅV-âÃŇ$ýûÁ¼”�YR——sPXËK…'ÀN>vŽDIÒF�ãK[üÒµµ|Iô$ÏÃ÷I`øôQl[ln¬•)ʆÝ%jµs?L�ò,Ù”£Û&AH{‚~<$q»v×oô‡9ªÿ­äż{—‡@û“Ä^ `°Y„V Ú,¸uyDslmʱ6güt@y­Pïƒu˜9‡ÍÆ$Ë�£$¾¯¦µ¢ÈŒy ‘M’à�wÊ."ÃvS1¬ø>\˜ ìWehØ”ƒòç݉K~R�ME±#\‡5,_õ% lvWg,4–‡§Ì’ë§lª•ê^�½EYv æP?¾=gí6å?ì¸Ö„=|°A9!‚ü»Kž¤JØZÆ~hDZ[ÝTAüÛ+�“�*I¡U:w‚êÊ5‘R6å�Êi•ª¢ÂÝåGQ2UÌþâPLvNë<„·Ý#ÆÁ1ÏTƒ#í •JQ¦ºÀà·Å@“× •[lGÞ¦ÚaÁ�™H’Zÿeääø` ´kóŒ¦!¹ý=˜°®‡�<øÖÅÿÊ7K)jsŠ)åv0Ó*¼ä#ïí·_x�Ȭ7÷\åDÑžŒ‘Êê²î!ô¾6äúàÊúQ¬>ì€ÞzÝc~î=\"Û%Uqµ)_ \¤1þÐYq³bF3l˜tzH¯W$ò‚Sþë�€pÔ:,¿<¸KJ1’xß‹—ßÏåðñ1è�©bœò—ÏÓÒÍiÿîHÛû#ZY–1ŒXhâ mÏS:6"œòé¿}WI�1èá&ëòYOvqûO”©ÔbÞ7ïá z\S¸Õ. Ûá6E�ˆ§D'ï£dE¡m_±Ê¹j^vûG“æÑDU=† OÝ2!N(iUƒt­2¬…˜Ñ¢´‹†ò¯‹~�XÀ˜kA±—Ni«©7ͯ|®Ãí›ʱÁßK�‡æNOv4t¢ÓõµDÈ/aÝœÇZ&¡'ÿÖaÖÙý¢£Aþ;5üêÈ´WãÞýëí˜å®ª6Èö®¡6„•ä+ü–[…ŽFÞ)*œÊfÝCŒ¥ùÍ NÍ=qƒVö˜�fÙÖ.-°ÙqËO’ªXHiÕ�4àÐóWð¨¸$kƒ”2¦!EÎF•½Ð“ D¡�N Ñá$µÔ2†�ÒCî"¦\¿iŠ?„uy‹CÙø‡J�EFÀ¡=Ã%§(/£¸jY­{^JÒR´1Ìâc!( ¼!n ¤ªÂó‘Ý×ÑÂÝÃ’v‰ÔZÕK ¤QmÆÿ;®/­úWØÂÞ¡š{(ª�=,18“ `Dp�.äàK¬ÈdæK—´`Kàmí̵;Äf€}Ð,¥ ÆØ²ÁýåœfÙ::ª\=8¥?tŸ–2EW�`Út˜â%��sO)sÁ,ks¿ô\[)"‡KîÍ2ª†lµ½*|–ý)Ôf%PÀÂ|N£Hš=¥9⪠ID&'œN"ÉZ¿d¿Ùœ×}x`3ƒ r½¤ÞÌ�g þû�Øž EHÅÃ$�ó޹ýŠ2¬#‡ŒBqˆ�4¬¼lMÉnËŸTd?ÏçC¼j�ÝG«žç}  D26'‰¥Wå8WØ­Ñv3[Ø7Bªõ•)¥"ò¤¸Zì!6 JzOFû\Äb{T«³Üe�PÀÔ¸y,V±Àöˆ#¸ä¾<ÑÊ[´Y÷:ŸE%h‰LLÖàXÝ,èé öÊìgJ .$IÁ µaþ©7‹zX6zDûnjè«;z“AÞÐC“Ü;i„ÈB×±d74ÙЀu+ú~Y÷ ÏÚ%·=ìíj‡ËÞC}Ì¡<ÀÈ’×Ó5Yááe1ÑìôK®�0¢- vhÊG¡#qŸAÅ ÍdØ'1Âí\T¤U":%ÍKU‚Î:˜�ÐÒh9¦b¾qà�R/�>Äy€(zqEµïÙZ®¡=]­Œ,0%‹¸îm¿ †Tq¼�Ô]VÚ˜¾~îI©Šœ¥¾wA-¬ë–”é�’ùÑ»«ÈH‚(^ûbä4O¨\iWŒ¨*Ø]¤| jâ/ÓaŠcÓi¨‚ˆ€ù0ƒÒȺ¥„V °¡¸��ÂÿP#…t”+öUœ2²é>Û{€¿˜Í*r2X¯èðD§‡ÞÙÏ¥7vIâ`¤"f–þÏLdj=nrîG—Wñy3^-æöu}YþñÈ„ƒ'¥|ƒˆ„ˆ}mFPõa#àºQcå†`†|ÊU“ ¦ƒÊ®M+A·Yñ>º+EÅ&|³=ÇÕó�{$ûC9D¹/^v€g~R£:ªõsK™ˆyTS Dlè7ŒWÛ«Åâü=üè¾¹q•hA²ˆÞüLz�ÐF„kÔ¤£!œçІXÝ >�j¿D°‘§6’ABÍ2zOÉ þñ[€÷£Ë&�«R<¹o‹"ó™ÈPe2ߨ@É�¹ øzŠéðÈN†ÑË›Â_ÍI%Mü£ Å3?§çCŽ3÷#b@À¸CK@CfAŸÝBZ ¾?“°ÉqZ"1À %çË¿ú죃ˆbåçï?‰ †?î/ú0fm 9™­-Pñ¢Ô¦MX(š>TD¼¯à^ýöYÛ¨Ã�ôßQTˆÒ’Mt_'ôo=דì3çK+¶ÝvÏüØp㞎²Ò*$“EŽõr¤$;ÑJ×а| 1x¢*'8f©}N [)ô½pÄ-54S\¶)\)þtÏ ™‚Þ~/øƒRÄ,ÌñRÐÚû| ù*¤<ÁÑ–\‚ʵªΙŭÏyg§ó‰b_<Ó:h°”Ϫ>b‰È;"Á¸Ä2¸Y¸\oëÞ>‚ÚB2Ÿ[¶¨xê,kexM>¨”¡Í`ŠÏâ.‚× BVòá&(Òš[ØD»äV€~èADŠ‚BÖÃÔ㡊ÜZ­,lSÿ¯RNºÇ�R`H¦&²„jeepfŠ·%’3"yi%‡õsÖÄŸˆg?iŸT™ÂÓa òaP|h>A\«ò¡`)q»ì˜üßųwMÇ`$ªŠMb öÏ%}Ô“šU�Üý&¸°E3%¢�І›N6W öÖlÑr•´u:@uvQ€úf𹀕ÏQÔäþ EE$3ŠÐŸóô•˺‡Âá70‡$‹Y <'Ôæ©½øE½ÈßC/JL¦à$Ó0{bKPŒ"7Œzeý÷ÖbiÙu"Òïz{W KÖ»3s“›?ö·«É²ƒÖ´ï8³¬¢…0ƃû4‡6;Å€àÄYÒ.$¢ŒIí tvíìžb8e�ÍÆÈ:*xTôÆP,´ 7[ª Ñ­Ýoe—m–(”2< Æ)ÛÉÄˈ¨W(Öê‘^¦Ê耡I‡Í"½7ßtI‡l(ØîŽŸÞ—¾º�_O»ÀA<‡U%¨~¥ úqv†t ˜M•Ÿ÷–púŠ ñÏBIŽ‚\Zýð×/ û0«éÝòØdêä‚•[Kˆ}¼Þþ¦k½ÝZkŸÂWJŸQ~%+wï~R0µ›&ù_7PzAgÄO�bç“ä(Uä}‰;Õgh÷ÏÒ«]ͦ¯Éÿ,×õó «AÚÞvUßš ã|;Ô¸¡tì˜ø¨"scrapy-scrapy-5569de3/tests/sample_data/compressed/html-zstd-streaming-no-content-size.bin000066400000000000000000000175571525051012400321130ustar00rootroot00000000000000(µ/ýXûú«ˆ,àŽFM’Ë]Ñ%¨¡jwÔ|ò;€Œ2Wöþ)¥OEŸR€a!Æ#&?]Y 8p€9cE£X` f €i &qÚ#bÑ(<$�eòˆ8˜ÆhÊ ÎQ¥iš §¡ÁÂL&N›XsÀaZó`p‹ ˜Ö, $p0�eiÍdQ"œCÉÒ ôÓ¦)ŽÓÐ�VÁ!ÑÀi¦-–ƒÃxD’Æ8 NÃq4 f!ÂihÐ&L œÖX�¡i Nc‰@x¨@²xp˜ááÁi­‘¥E,"”ÆÒ¦=p–ÉÄYZÓ(’¥5‹"i,Ê¢,ˆ3™8­ñpHH˜ˆŒ¥-T8 �eÂX”¦9på,mbY4ÅÁiKc4Å!!aÚ‡igi�eÒH”ö˜@²� iÚ§Q ÍDÓ$§5¦�¦88Ç¡‘(§YíÑËâ4ˆc±L˜,&Lˆ4¦M œöX@ �eqšÂ8§¡ ÐP’< “Ȧ±4‡D“pZÃ8x@&“ZcQÚ�8”³D&8mQÚi$Ê�XÀ�ç8,1à4eY&NÛ ™,Nƒ†i�Ò*0í0–Éi˜¦L(Fr‡„Ò cQ$GÒ�ö Xp€‚2шà4…‘XÚi–Ö,‰ãh” ‡ÑšF�H–Óœ¥µˆ#Y8N³4ÈD"Â8űÀ MÁ((XŒH„báA±ð �<š², $ ‡Äb9K{ŸwŠé¤Ô-R¼ÿt¥…ê§ïîV…¾*"<*TÿK*tºÐê^Tw]ʘw4._æ÷4Q1Ò<:ê%â9Ÿ˜4�ÑJq]®[+Åÿ¸è¼*ªóV?zi*…¸KÓÏEïó^F÷މ~Lß+ÑOäuµŸ‰xãsÑ‹O¯”‹Æk"D*�¼ty�Ot+f>‘—âý™Gôú¹�¦u]üŸ+yŸ‰×ÅC™¬K�èøüâ3áý˜‹¸~æÕSâ´ºÊÓ„Ö=ÞÕu}<|¦®×Wæ›w·2}yz¿MŽL•H×wºþÛ\˜^·©t­y{º|÷¦s9õy_/ëY½Ò_«[£Ú÷æ1óÞÌÿÖxhyó¹·V?“n>÷ÖéDÜD_gºT£‘âf ìiŠ……#RGE”Ö#zº:*^BzNÿ!„ˆÒ\Ç|:ïô�Š×ñˆž?¢/Dé‰!D “:J”�Õ¥}G‰2ñ%þ‰QrH”ˆT:J–òÎ3!”—‰y”)©¢ôñ?J•ÉWNê(ÓZ(3%„òR*†2/�Š�¦b QR_HïéìÄeB?93eR_R,%}Ê„Ô9­ÄT¡O1¤J±”ŠizJ…’J•î(¡b‹%eeëíTÌëieªbë+ef®CŠ­ß©S«LêK¯Ta*µØU*–Vf»ïW…š·ØºBæÅô~¸|•¸RåbJ©.)ö¥êê”-]ÊÊûBº–®Å�ƒ^êu1e—þcGK©jR×¥ì¹ÖRÆÒ—Œ|ˆ§gê+KVûw:óèKeÌze¾ê•uel±Dú{:$#êÑ=Kêˆ}¡Ó3bê€ Cè�hÅCŒ±[Êð¨ÎX"Äc,rHˆ1b‰1Æ¢9B3G£P¦QH` ™cЊԨTð´µàÌ @Ó ˆLC*mØ€, @pà±(Ba0(T4Nhõ#`ÛS�mÃ+~Mf6Ç+ù�2»3ã¢k¶8¯€—3Úž—:Ieº½¶‘=WkFYßæY§�%å(×ôáá½d5Ë´ä1x¹COdÚ" Ýæ?-UÅâ"gŸ5Rq£ÆK²b§�&Õ!Rš©Së«¶¿ÆfaéQÜbS+õU±_Ëwr@¤,‹Cú åª÷$åsM,"dNÚòu„�m(ŸÑÃŽJs³_{6|rM‡âÈYMH÷qT c?t° U4ÒxÇ Ç“¸_Žaü%…â×0q¨‰ëFè ùó t\A4E)£ŸƒÑ_o˜æaïQàƒ•GÔæG7líCXú°ìƒfžª‹/Ö!‚÷!7�Ÿºw88�ÀÒ/ºVNÿ°€ß¢80·-FòÒòþ¡ÀQ~[g]yÖ›�ÁH#ì®ôÚK ó±òˆ0Jú¸™‡ªáÃáØÆ‘Nëâ)X�\Åøp´ÓsÎ-„t(0´À¹{¥<²«_ óØ ìg€nA÷31Î!p£RDô¨¯šSÂÞW[æ�3™ÕŸÂçÄÉ3*âÃÛ(cï.YœCóócÀ20ŠJ[�znÁtÑj9BÉØ¿ê'BØgÖÆì{VÆ8`�·>ܵŽðìc"]ùíÖ ÷Äø‡jæŒøxL9'vî-´-³ö‰Í³ŸÏ ²'k—¬Ö~ƒ�O\EdSd)þÃåÊÅíÿ%>¾Ù{†?vôìá"òÄ[ŒGh}�k£ÞAû*(h•©¡K\´_ÞÛsö©g¢®¤¥ÞýõŠhýŠ'ìj—hV…èe�Ô¯aö ÀÛPŠ  /¡œ@˜ÞGPIŽ×¡_-!õÕåFÚËK ý©ìmñÞ¯Žo�Ù¦þj îo»�:]ë°�°ÜÌŹ ¯ÃF%NE�Ê©ož´·Nû¬!u_ ïÐez8.ï'’Y6J}};¹ß¦ró~ ‡>M±}éj¨ ;™V×µ6ë¥^¨V‰îRÙhùŸËp ÿüM’wp6-yÞ¨×ÿ¤©Ô³“­æ[kTÕÐr±ˆ_@´´# žŒø÷<›a§Œq½ë|B7� åÊŒ”Ã.‹è(lÜo0ò†Ö/³›Ðþ�ÿÜ…ã?€�r"®~Ã0Þ3ׂ«…búC𥶅ù+ È#èDý¶Q„G\½y_üqÂ*AŠR©þ‚Š<ì·#a¸û–42h£\–2;BÐ�qe4B¥™§[¿¼BÈ”¤ ˜þ ’GΦùx]áE_ËœiKêV}½{;É¿„Æáq;»÷Û¯Ðg1äñ�òÕ÷poë6óDE¾‡ [âûžÙ?4é!ÛSÞÅï»ÂæéßçÄš{dØgjâÉþée<‚Ú#ŒNõGÁAÁ‹<þÈC>ñb]8x‹Ó™~U÷4ÖÔ¦ÔŽ�p?G7ªE_½öˆÑÂ%-Y˜ÚyE䘵’„2L†›† Éú¿(‰lĶÂ%9ämâùj¬xoÇ>‚¨2x“6ŠAMÀ!ÅR¯‘g³ùËÖ·ÿµý�Sn·%SrQï)5<ÑX¯Óô#Øg$ºQâ9PŠ0½Sèü±®Ùæ†Ì8LÈÅÕ5•u_T«Ék�Ž�¬8ü°·Ø–•DÀ`ˆ[Ä#¸×8æ#µ{Ô S^@ÖÍÓXÍ ^ÕRØ”Ü6¶ã¯9²}8DÈVzÝ®¨#âi±ëŠ‹�ììcQçÞ�Pô�Bh©Ó[Döý:ÁœÔ9^[ö†ò#PW"úkKJcbòôZq±³ßX.¿ï¬�”#Ýí÷æ[;ÑŸ-�ã+Ǽ—§ä鮨¯g�9E£þmÙ^Zj>ßÏe|}–iÏz¼3¤ÿXéMÓ*¶ãl”P—(C˜]�Ë�ŸÞû%óT¾¾§ôGk ýŽè�ˆã|š`Éî~ [ÐÖ~YN“»öR¦Ÿà ±�*eKu ¿¯VÞEœ–i>9?^R‹Ödìr„ßÛÔ;.J²qO«?Ôø¨O¨7Jª¾íó™ÛjOjÝè—ÆTk±w*6šXD$T¤¦˜>k�#ZßÔDÄ‚Á1Iž I»ëèm ÔGô“¶¸º=ݦ-5å°G�¡}˜±ÐS9Ž&M^‰6I¥€ßO)…œµ;nQS·ðÇaÌ8 LQ `)†–P@žqíKû>ù¸É<êp§búýt©v.a*A`Ó¿Ìœ§·ñeríZÂ#ÐÞ(ÚÑ"f÷÷ˆíÄá4äþÕ·ô†lй~¯Û¨\™Û­$Q3®çs;Oõc-·öò#Ïb€¾:¦œµˆ½+"?+Ò£•ÎìJq¿"à‹x”î3èÇÆûœ ÃÇœég0,F³QÖŽ äbv¤4)èùËnLu‡oîé€âWà†¶£[Ó¢RѶánÞs°Œ‰$3¬b]ݨÕfX%Ÿ"y®› H0�‹œÏÏ2¦Ñ̰Žu±�‹ï�Ùº€MRl·Hžë¦ü±ßèÚ«1‘³WW×@y‘ØäÄP)6h1%!K^ ™_lÛ˜@“mxL6¡©‡;�Ô(žµ•yaOÓcÅ1Õ�¾…l2Ù(r³ã¿d†tÇM…H~í¾oLüÂ3ç ÷¢â£äâ­ˆ°–c×°¾­Çô.[Qíy£~ü9,L´r¨¿Å‰ºÊ¨Œ#diæüChc'­GÈTÖÅãnëÂQØí|o|GÛŸ€3´™‡£Žhc$û¾ »òë›óÏå©EêJÍš¿O¶[¶D‘ØÓ¨Ê_¨6w¬Ä»q�b¤=óŸ% r;sö–—ðéaR•q\DL‹,5BPþ�ÝKãRSœ³þäÑ}?èñÑl”¯¡|Wcne‘œöxZé ªÔWQî… `Œ•åÙ…û ÛÜß`1=rU•ê. {E·Q£>äƒ9>ŽÐ–Dß©XuFðªÊ{-UÖÝÞS„ÁŒâç'Q»xbW¹#Önª_籬kªW+G€O¨6“Vh5 gš2C¶þþp? ÖÓ#Ü U¹¼ÍvÐ٨DžŕEe¢ '2yšˆ)çiU ½nt_ü$–@æØÆððÙ"´Í· ‹ìG#býùp¼� Â9âšD ‚ v›œmK„ûÊ Ïl�mº�Wñv‚PÎmBHþ‘o¥ú*E#{ïs…ZÏàˆ´3�ðŒsÚC,c¼�¾£-Kn­±î 6»œ!gà ¤ÈþÊô"'x:¦a¡éHAqøêÚûÎÔIÛÈ@ ªêOë‚®BϹz�É•–^èéhñL•œÐÛ€0¾bœà1½F×3¬ WéÃ’‘ 2ž¯ú„¡wœ?Ô 8æÅÛîV”ÛÌ8º©`Þò¨ð‡å¿ÂªmÞ—”‡C¼"îjÀ(­ˆ DËú6H´÷(‡h”Èrˆð}óÊÝ‹Û`qExÜyªkÃ#Z1HlÝÀ¤x?ÐåÒ\òG>7ç×oi­”‹Á<�ËÚÁ…ò2{�Pót–�7=sV\¼ð­%øÈóúƼ½óD±|f�œèë•ÅúVžEζ7ŽýZ6tœxöõâI™ÕŽ@~óëža9g!’°ö×40O¯„�Bm3£äÖ»9)Øžóñ_t—VÕùnA"?â±\--Ǥ®ýFÁØ58ß(ÃÀoÚ÷[ƒÿi¡·üBD$É1ÈÁ\D½®U©&Âä/ÃÑm¯$ƒÿüª©S¤óÜ�hD8gãß¿à$;éý3õŽC¦L�¸‘¿èò£ž5ʘûùã�à™h±—\F8¢“ôÓlÑhß�V?ŒS|5Äs¼ÞaÖ]!þ7p,’4±›wÑÅ!¥XˆR‘&ù�}§ýö ížÙo¨ÒFi¥J‰·_�Mq-Ó…z—ø�bJ¯%©.$iµÓ¦„©¨õ@¿½èÔº°urt}_°4‰¦—°rÊaöQ®…‡M9@6Ê]@SÎÂL¹jm”kvyÓ[áÙ§&{Ë•-øÐOº“±"'TáàãaÝ qóGmvmnªqÈ©>’E¹0Mޤ¤—’ ëò‡c5:®L æ�wÐ#Kò¾x3ï±P÷Md!‰x ¥Ææ8¡­,°¿gj¾—c!šeÁy%äy³á�8§–ãJmT-4Ûš´äÆ.18—œžÎN_­ ÿc®^x�=y·/AjAùj¼u^’°>Œ¥Éž>À'r-ÊC¦zݡǦÓKžøgâ&N&Ö„¢Û|3ZÃÃÿ’Ö”º·+™Îˆc©vutÉá†øRE¦€ ’@ÙˆS¾65¡{¨á‡¸£í9µ§ëËoGd…µS6RI•‘Zwšc"|}G�¨ÛCE檖Ç:-`yÚímʹwƒO¾À²ˆ!Ã$û¥-w‹0Á’Å~¸î�â£>T% –ÔLàŠ ×ÿRq ˜ÇÇöHÀŒä£öÒÖç…ZÚÚ®$ŠØÄz�ö0�ñI>}ÞfD”7V¥_ZÚ°¢H„£v Ä«¤˜líšýcÃ4tlŒ'Æ~hÈóƒný§ó¿òg)GeN)¡Ü‚fZ…ú‡|ì¼ýô ½™5çž®Œ×¢}EÚ­Vo~O) Áµ¨³—pÚ:¡7U„Wîå�<£ʆQÝKE‡§¹;l=qˆúúòYF¨‡®2“ø@¾à!íìz Å /Qõ9¶Y:”q;û«0È1ËûM�Îö¨ÍY•d~ÇF¡ø"o’…Ââ·íC¿­Q`P7Ý=Ñ!ëjL‹ƒiÙm—Îlvj¸å(» ,Ķê3½|xü±>h.¹ö ÅiÔž³"¥PùC÷!ò£;A8IˆüC{ðmÑ’ëãF:ì0k]Îÿ!¼þ!î±äˆ>ÜØp¦)RÈ(qRK£=L§�—JZÊoŒºúX–ÉþŠƒtUx†±û@Y˜Ûrø\,±áVu)yÑ@þZ›ÇWKÿ >ì9Þã0J`#‡"ƒ£m0 ®¾B†û•ñ›|IMK“X �9b‡=Ø/Í’ü`¬ËK®¿œhæ˜['CGìê!Rë‡Îh 8½ql1 -'�)^ðú÷Ä6ÔbmÞïÚš6rhrožQ=3dk•UÁ31O•mVÜ5Æz(âcúôŒÂÂ4„xWˆ&!J0Áât6%kÿ%dsÞØ¡ Ì ‘k”úˆ0ƒ0[ Lìd{¯½LÖÊy©›(涅°†* E@‡a…³5õ»--nÐ�È<ß"N{,­džÊk.d˜Êص$–C•3óå7rDׯl `{±á¬ÖÓa-©3Dêz1”,RLÚç³"ÞÃØCRgõ Y€pwñ˜œ& ¡Ñ#z”à"ÊóòDmoÙfÝè|J€%2™„-†±šI:ÐÛEÔ!³/St!1E$j�Aø£Þ,ÎǾˆõܬ¡¥w&õ¦²¼a†ŒÇ±wÀˆ­ Ý"Énèd²£êV´ÿg´ŸÑKniØõŽ»Ý«;•C €Ñv4'L×=ÃÊ‚·9ñé¯x'ú„ˆ{6íP™�Bwâ>‹Š1›ÉeŸ¹5ÓÿñAåÐÖR�´°ÝëJÛÊ pÊ�ã m΂�.¬óÑÛUôPËþy*sØrikÐãFd1ÎE$`ó¨x›ßºî·Û†iç“3æØ>êt.1‘ýÅÙ�Ð�ŽŽbÏ9[ÎÊ�.ïƒÖ ,ž’åª{ª7á&¯¨Àò=“ýi¦Mž{hªbùRO]PY¢¸Kú¢¾ùÑ�+üríé¾8*)Í/WÙ« { _š‡–i!ÚtÚ«+â =Ì ³÷Ÿb)§Áÿl(ž,$´ð¯ÔH!kÅÆ§°ÜÞw®ÞÄò´!³Yn×¶­V¢óá! öW ”Ü�ÝŸ8’TÔ™¥ã!ÙZ‡›ç£ÿQd.�'ƒ¹q•¾([< ãà‰D)d%b#"E_[#(q’’ŒnäÌ¡®#˜Ó›òáªÉ�éಋÄJXfV„½·¢„ß�Éq§ù?L ýVÜJ/7Á3bLÜÕÊß³MD$ÕLZ'ù�‚«Í~±'K?: Emu-ˆ ¢&¹„ 3’g4‚áÚœÐ�p®° ðî&#ÈÙÚ¯jä9‹d�©YÓß“å'ÿŽüèÉHæÇ®0ßîÛ¢HÜLk–#ÅnT°Ìvî(z-Š1y$­ÃÈÚM§Vç%s•&‰"™À©�!·“‹;91hÜqL@&³€‹&�ŠÉ®/”ÉÙdœ-±`(†óå�^ýÿÑC@±óןD�â¯W}øfL ýÌVªSªÓ& <€š®”"²kÁ9ýîY/ÔñFzÞQD‰ÒÉ&Š»ÓDôg=×Säç‹S“d»Šw~\¸‰vŸÎri “yƺqûT'»x¥Ï Å[¾„› ÌQÁÑ$fè>G�-DªV<¢,5�)NJ‡P®ošçLAË~kXA(b.ž±|nZm˜|Œ�*$<Á×–\áêmQqÞÜâÀÈyΤãÊb^žQ4@•�¢>dE$ŠF‚q�0xù¸ìGkž!m! ™®+YU4G–±0¼LhP�ÐF`ŠËã^ŠW BVø0S%“[Ø£­rëÅ~h1 Š‚6ÖAÔÊPÅ%n©V–wŠéë¯'½!M2dWrŒj&d(fÂNÞ1¢ÈÒ4%éçh�ý�ˆemÐ. … ¥9²Â@Ña%ù ¬‚*¸–íÃ<ÀS2b½ì€üßµgûš¾'€xªŠM½VìŸsú¨;(4®ªâXwMðEdBÄ8¤6™Nv.L�³ËæÊhëu”2jï5âõM¶sAµÍ!  ]Œœ‰Hf¡3giNͼÇ�Ãán` I†´ØN('©GÞ Šq‘‹^¬PÁIà ðÄ%¡¨E÷nVzeÏo‹ý¶e·HdøðÀ2Î ö½wgè&7~ìn·œeW|£åŒ®Š¢ÂHÖw§ ­'\'b؇‡aŽÆ]~12:�t7ÞÙ/,Ý�ñ“ÉN˜ñOÖf,ØT1di9iãÒl_+(,­Ý’íÒl5�ãNõŒYßÉë”§Žá°·�›Õ£ÞL•Î+IœÍ"½7ßïtI‡l(X펟ޗ¾º�_O»ÀA<‡U%¨~¥ úqf é˜MÕ>ïÍp÷Š ±ŸH‚¥\Zýð×/ û0«éÝŸðØdêä‚•[Kˆ}¼Þþ¦k½ÝZkŸÂW JŸQ~%+wï~Rpº›&ù_7PzAgÄO�bç“äXªˆ‰wªÏ$Ðî%ž¥W»šM_“ÿY®ëç>VÜ½íª¾5Æùv¨}CéØ1?ø¨"scrapy-scrapy-5569de3/tests/sample_data/compressed/truncated-crc-error-short.gz000066400000000000000000000036121525051012400300210ustar00rootroot00000000000000‹O©uPtext.htmluWˮܶÜç+è•73cd}|áÄ Ž$YdII­=©ð1²vùˆû…ù’T5¥97ÉÁF’ýªªn=¿zÿéÛ_~ûé;3•Ù¿ýê¹ýó<‰ôâÍãª‹ÃÆ þ=O_¿ý i¶Á|wÞ‹9›�±ÛÎï]î¯ß~u¬Üý؇»ååÚ˜wwë¼ W3¹9‹MM™ÄÌÎ'“ë†Þס%%ó¶$×ÕÓfÖ˜nÉ»Év¯³ñr…µ˜\P˜+8ˆ9)!ÒßNf´¹ 7yÂsM8¸‹áv›z€ãdÆš'DF£Ì­õsÄ.zɉ@R\¸ÇŽØwy–Ðr=Ù´ÄDÒžü.®|KƒC49Î(�+ÑŽÝQ‹ù †2¬ž ±ZîqdEEìBŸ°Ø%s·ÉÅš …%†¼ÆšJŸäb>!‡Þ!U•E€(¥XÃÐ|`Å^™f—z/ðy[­+jF Ÿ%¡fOZ>¥‰s^¬"kq79kð§#ê=do™­výY¬‡§HÛjK?µ”» ˆ9Ʋ*Ø�éo‰ÎËp1?râõÈ<‡m„˜†z¯Xk 0é°öI³:ñGt�w6Í8+š¹&üŸPUãæö²½]¤TõÙ nÌP¢1Å™L¼˜Ÿá }Ë>®-¤½Ìቕe8ð>í¨Cö•™ŠÖ)Åàz$u3]`‚é,QøˆÐØÕn´õÊ™ˆŽQ[j@³·ƒìŽsÀ|59ª‰=ê/6•‹y§>“ HÚåÏ?þû‘ˆžªK¾2ïR€þÖtä%ÛðÂô¨…,¸ëT”h@h«7vY`‡f7« Á2î“Kã?p©`£Ò,yq»Ç 4ò¥wEZ¤}ł쀋Cy8èÔÙÅ„ˆÔð²¬ÀÙ™:4˜ß«djÜQHÑ‹Ñ-ÆJàäÓ^L&¦·³êÐbsnºÚ "« ÜHAÚ gaÔYUhOô ¹é©*#4òb¾ß´’(~.„Ø Åã¤Gv…kÀÀ<»!0¯|«fR„醠¬“² d�g…R˜×�w~¨-Âôã9—º¸�Žâ‹®@R܇:w\‚ï åº@ù�ëÊÚ&ćš ŠªjƒªÐà<ôg`X€q ó «�s¢ªyâ#ß«&_ Äb"ú¨‹po?;º‰KH³×®=XÉ\þžˆO¹ÖÊÄZv�®‰½êaåêíT¢0½>Òe>·6)¦†Þœ–â29–É�p=ÎINÆ£š$ Š2$;Û‹ù Mk Ö)ªƒÜIøb_hèÎîKÙè� Ú,Ì<”†uÓ©è*ÓN”!É+¬é'• (`*,¿ jž§u|@n\¦/#°†ÖKê–«”1â•8X(Y[k9OP¡Ø@Åk]tµi@æ.Ù^_nÈñ`£Ô–0À°á!ð'$óN_W$åÈr¢†ð$ÕaØ v˜R’©¼ù]ÆBò©¿ðú1¡#âø³�ñÆóú©¦óÁá-mv±( þ!d^ã;‡‚q6Ý^j~H.g ú8I›v¿hpùZ!w{“2¤X†Ž%@(QÖ¸®6]]– éš÷ˆS캦N³6I8¡aj›,Ñ)xA°Q¡P’¿“sŸëØc0¯ÅÔ¦ 8>Ô¾4>þ<\A‘�ß^?´ü{q=ú›¨SÄ·1|®)¦Wæ×eW�¸HØÛ€¢ ªd= %Î@�M0Ñïz6¢ îB•§eTèÕ¥å`ë\ufHŒ”Ì &wEBÛFAqv=Ølsü»,½ÖyrQÇÛˆ iÕ8(7¬0¬BÃ΢¶˜>td…–@ò´=Šõ5[ã² 4+?x( Œ ui˜EÎ|™6Å®Ñ •DÐÓÑõkhuFo¹ƒ Róww�‰#¸d18!Ö½õ@ù±ˆ ïhµ,¿iŸ˜øzägèscrapy-scrapy-5569de3/tests/sample_data/compressed/truncated-crc-error.gz000066400000000000000000000132061525051012400266640ustar00rootroot00000000000000‹í½`I–%&/mÊ{JõJ×àt¡€`$Ø�@ìÁˆÍæ’ìiG#)«*�ÊeVe]f@Ìí�¼÷Þ{ï½÷Þ{ï½÷º;�N'÷ßÿ?\fdlöÎJÚÉž!€ªÈ?~|?"’Ç¿ëÓ/OÞü>/OÓy»(Ó—_=y~v’~´}÷îwï�ܽûôÍÓô÷þö›/ž§»ã�ôM�-›¢-ªeVÞ½{úâ£ô£yÛ®ݽ{uu5¾º7®ê‹»o^Ý}X»xYÝn½7dzvöÑQ’<æß-ÊeóYÎîÇåujüxžg³£Çe±|›Îëüü³�îN›æîëöºÌ›1ýúQZçåg5üÁ<ÏÛ�Òöz•öQ›¿kïrƒ»GIJÏãßu{û{Åyzvš>ø¾|„O» ÏN¼/ÜïåËYqþýímý  ò§Ñ:¯¯ÇçÙrz=©ÞmïŽï�wo݉€ã†ÓlY-‹iV~¤à=êÕÙõ¢ZÎ.Ö“Iv=žVãõ[~»-Ú2?ú�“ûn^N«Ež¶UúJÚ¦Ÿsãt;}3ÏÓKÂ1�äM›Ë´Zåu6J'YYæmšQÛi™5 úNkúù'�ï èÇ‹¼ÍÒe¶ ¼gy3­‹æû£tZ-Û|Ù~öQ§»U�7ôy“VÔeúéÎNJ��Wõ‚è“7i–^çY�fÓºjð×U1ËSb£‹<­Îtš×m35ƒe›.²Ÿ®jÉrM€hÏ©Ûj9J[ßW¿7Î&u•ÍÆnúã´©§–žÙOgïÆUuQæÙª >«üÙݲ˜˜é¼»;Þ]ËñO7Áôýtv™ ì�Žß•ß"]ê7C\²Ê¦oo¹ó�éhà]#éï¶5«¦ëMÊ�qMw½u¾^N1‹[wÒ_l›IÓ�2™×Û MÓtNd^­Û�ïŒ'åº~ ßÖðû—YIo}öYúÑG±—7wŽ—?ún>!]“§¯ù»�îiçEƒ�.ˆOÀÖ[�Óø›gE^Î>º3Îf3ý¸˜•¹~ÚöK‚O~ µ¸%¥¸³o†TÝ!=½©<šx”ò ø¾¤º{Ž›âb¹^='m©lýbVevý%‰BÑ^?JwÆ÷GæÃ“ª¬êG?öñ�ïìì|Ì€ýNð3�´#,‚ã±ÂiVWë&/o/rC�„Î4‚÷µA\e—¤ÛjU,.ÒÏÒ{:f|X,§ég;†º7ˆµ›°ÇÕQ±è  Ây!�‰ÒÒÊÓîðñäe“¿ÿÉ'›ß&í5Áx–ù•ôþÑ�ÓÏÁ�hÓ÷úï¶% ïÀßÜú¸ÌÏÛ�GpººSÙiøƒíb9ËßQ[B9ÚHܺ1ΖÅ"ksRé@ƒyúK„6ÀwïTFÿïⵃØ[ÊÑ·¸³O;œ×p±;Ïjòf}#äw%¿¹7 §¦>Ÿk+�On!�.ð¼¯ßÄçQ÷84ÆÛ¾öÞèòó-yùýøøV<|[þ½5ï¾'ßÞ‚gû¯ÜÈ°Ìæöe=þá\þ“Âê½Pý÷¿È~‘œüÌϤßû>ðÁ_ãÕº™o}ïãߟõx:­ÖKù㯎·÷w?ÝÝÝÝÞÿøûwz�)¾Ÿ¾}™]ä—E~¥ ú_dÔ­ñGÆSòGÚü´Ìñ×ÖÇ‚äÇ4Ô‹lŒPÛ�;Cø˜¿Ìškø8i[¯s!%}H¾}´õ1·æÑÇ`~ÛSYM3 3^ÕU[M«2ý=Rmx÷nÓ”§�äo‰£?¾Cø±Û¥-®Ûb*! õôÓÍLJvH�?¢‹¼Õá4O®ßd/H…¸�}oçû‡iCÑ\M ^T³|\,ŠcŸäøæ[k7L¼_rÊÆMî㻜¡é�T³kõ,kÄ�5«eÞ>£>J)ŸW³Ï>ZU RÆs@æïñQZÌ‚–”x<+.ñƒÝå™y1›å¸ äßÿ÷ÿɳÓï¾~süæT�g±¦Fw¯N_>½úêôÅ›/ö¿ø‰‡oŸ]|gñtï'¾{¼ø}^þÔ“ßçäøâ‹Ÿø©Ïâ»OŽÏ¾ÃŸ_œ}ÛûþôøâôåË“æøäâôøé»ßçôÛíw³ßûÅê÷Y¼+_}þlç§~â÷9>~¶zùæî›'OöO¾Ú¡wƒÏ~Ÿ“o_üäNö{¿j~ê»?ù–úÓ~ ¯wøîÕW?õ{ÏwždÍ“/ž¾½x½üÉõO½>¾þâéé»—Ÿçúìäà“—ŸŸ^dßþNùS?½³|~oVý>¿÷«Löö/§÷^]gß}±3ùöÛö§ö~òzº7¿—}wùò§ßý`úù³õÙç/šßç÷~ñƒ—¯gW?õ{gñü÷~µš}þ®üÎî¿™}û;«ßçÞ«&ýäÛôݲoÏV“Ÿ¾"˜O擟Þ?àŸÅ“Ÿž|þìÓì,g‹Ÿ\Ͼû“?Mã¸O￞Ü+ç““'O&Ÿ§œ.ââõçÏšÉÉq{vúîr²xuéÃz¾wú‰ Ñúääêà峋³ócš—/~úøÝß}õö§¾}öÓ/Oâ÷:;xupðìàìí¾ýwO¿½÷‹^"SsœpW8ÿ… Š×üÇ«¼©Öõ4gïf¿Ç쳋ãËåËgW;ÓƒÝçŸÿ¢ê÷¿þ}(¹òòayP~~rýbþÝ·'_¾ýîÛç'ßý½~ÑÉO}q¯.ß¾»wõ“?¹ú½Ÿ¾~öz±{?ÿéŸ~öƒßç§Ö_Í.—³ó/÷‹/Ÿ”oî?1Y}ÙÖ×;g?õ|R_½œ¿®Û×Ï~ŸoÿÄÉ““ß{ÿiý»¿0[¬ÛÏîÏvîߟîÎn}�¼A¦m¹³ó»É¿B‘/H;\äõ®HóûË¿Ýï5Õd8d�ßAÄ»½^&™ä×ä# ‚Ó ©®·”Òzg²}wåË�RÂ<;’ ³ÖÄEÚ-+–yí%x‚’è|´PgœñÒ®éwuá²UysW›�WK²òYÙËô�Ò'u±¼ ÿ¥×Õš“p’Œ+²»Ÿîî?ܽ»È°¦¸º�NmvÕÉÂñ,÷¡×„NµýÓë²ÈÛíj/|wowgwçî+¬…~'¬*5½oçÝÞý� Ç=íñÛ¢æ¢=6m]�©¹Þ&W·™_1jÝ>ÞÛû´?ž{½ñ8òø¶>µ‘8‰¹¸F5›”Ònêe—Þ-ÊeóH]2ñÈž“ñïýúù›oç%¹^Ÿ}´®—›¦±^¶y1ü££Ï1½é—˜Þôsš^Jü�å@š�Žöî§ßY/ótogwwSs^×þèèUuMžàq9¡üAúmò!qw�d­{ÓûìP�äe>©©gö=WÕ-ÂgZ;'ô‹ìøâ-MŸÊÇpœß¿aL{?ÔÉ}ébb}‡%ìs»û€<þ݇<Á·˜Û7Dù/÷ÒcJeï7­xS8㉄WEY¦«:o°Š”¥Í*Ÿ¶Ùt]RâŠRa³µ$­i†»£J³–9`go|›ߤr¾ù ¿÷C�ðתÜÒÏU¹Ýbƺ ÿ!IôÓ¼Îß%³eN>ÞE™-(SCËØ$»Û4‘Ûô;MîŒ3òtªÒ�¹¯sR¤Ëæs¼YÉÐ,wt¾1ÂÞ—¡^PVSÓ7fí~(ïa¾ß&¡¸„Á º�7]2�oÕ´æYdõ…˜³þ+¿ëövŸjm¾XýþˆØGŠK··oêN^ßäÙ¦Wu¶^ðØÌeűUYQB'ÒXg¨óÄŒöàì‘/ØV‹!GJ¿ÞÆ2ËÐ,vÖ»c˜ß¿ØÊ ù7†¤QdJ=ejÚw{}mLp<î’©÷m/(‡Ç£Ž-§^ÐÍ—ËÛ¤^äPÅ‹ŒÊwû I•ÓK؆¡”Š�e\7â`΀Û@¶å6£º)ïá�‘r<‚“ þHº/m@kSÊcè›ÁÏ}ý=,ŠA;A‘ïë²m†¥—ÔŠyQľ̗³¬Þ†f1oõÊ-8ó«,ÚK²*åî-‚¦ï›BÛ6›”ùÆÎ-$X¬²Á¢èòâ³�väïU6›ñß¡°©Æ¬Oªz“Om4þ”O¶¯ŠY;´»zw¨ð×�^TËüpžCm?¢¥ÙßýPÛáWmHË…e¶jòGæ—C&Ä�=në£Çí,¥�¡H>ûè�ë“D/Øââíª~ô†<âFþLo{Ÿ.45B�m(4q;zCî�ìö¨:\]7lÕ: îÿîÀÈX~·Lòè÷ÿýgÕKZQ|BÛúxHÍ|}<úø'÷wï||Çv dxRÒûv¶>¯�焌qg…\4ÊDü }­<§â¥yé ¼”ÚU‚vÆCÍÈ;¢I™bH7}°ƒ�zÞ™¾Ó#�a³þ³J¬‡oM,`¶™Pháê^`"4ºK<Á||—YÐûT¸eî“c–][j’f“I ßnÉ_RÚn%¸tô�™ßÄ›uÞô`¼y?ßÍg˔ヤ7óuýÁ¨<«‹ˆgïâ5Ò= ¯ßȺ?3 ÂͲezú—PŒ†0¦L eHóÙÍ爇† ]|wNKë‡=�ÿuQÒ*¦ '¾Ñaöm&ãà†ùP‡ùpÓÄÜ<¾Ÿ Üo²�»;ÆE þÏ-ò7™Ñ]kF7ÚÑŸän²¨»Æ¢îz&õÿÊóà&ÛºklëîFãúsCø›Ìì®1³»íìÏ ò7YÜ]cqw=“ûÿäo²¹»Ææîn4º?7Èßd~w�ùÝÝh7 ÿ³ˆüM–x×XâÝASüs‡üM1©.ìòž�ý‰¦¼É¾îûº·Ñ¾þ0Q¾Éªî«º÷ÿ–èôáM¶tÏF§ÿo OÞdB÷Œ ÝûK|úð&ùg çÞÿû çÛ çž1œ{ çå›Ìåž1—{ž¹ü‡Þ{x“µÜ3Örïÿ-ÑêÛläž±‘{mäå›,ãž±Œ{CÏ&Ê7&k�AüQ†ùÆÅ Î×–×ùÚŸ œ÷vn•±ÎQ“øsƒó­r¶ÀÙ³‰ÿ/Y:ÙÛ¹UúÈG­ãÏ Î·Jàç¨Qü¹ÁùV™[à "np¾Uî8G­áÏ Î·ÊÞç¨9ü¹Áù&ƒÈ©Xേ?78ß*ËÆÅ³ˆÐz¿qòcô3›”ùQ’<�ïÎŠËø×�é›´˜Q§@ð÷—=4ÿÕ²<…›þ¢j‹ó�ÐÝ� ÷c€±c¿½”W›ÛW”ò-­ˆsDp´áØ‹Š«¾¼ñ5~µYeË�ƒ.'Á 5wŸ’mÚ}<©Ó»vÚ?¾{5ÏÚ¦ZÞmÚº @×ÛyÝ̯ŠåÇGÒ�àøñ*¯Ï·y2?>z­mÓϵíã»h¾@ý¯óiÑ^|ôªºÎÊô¸œäu›~;+Ët;}^-g•},!ˆÉ7Ðà=é;©Ú¶ZÜ~n¾#2G¿•�õ�Þ·¿qyÍ2bY,ß6Û˜Vš]átoœX+ZmOªêm±¼h<á¾Ëß4Y‚”ɦÎúé½éÍËÀ¨|Æ/¦oóv»:?'~ð»�Í´¢+÷Ë´Z¬rb£¢Zn@§ ð½°iæÕÊG‚ÿìJ[oì¡óQ÷O3Ysâ±máµíº¸˜weÙ6\æWM™·¤“©ù»Ø¤fÜnÒ._“ÿŠÐ7c¡?׌o¶EþÈïn¨£ù½£ôõã»ôKœª‚*—¸‚ò'€[ÓÚៅA=£“qôfNIZëlÓYuµL'ùE±l~×ôyÞ4i;§ÙÊR¶ji[¥UJ ‹2íi¢ß5�Çf6E£xJIPãh‘’Äwwîße•Ø�ü[&+f™Áÿ Oº¬®ÏfEë½Ùt]fuJï§«ºš­§àù´:O�饴XBïÝÛ½ûwwö¾&¶})<¯Ê²ºÚ^7y°Yº^�¶ÕZ�~–Ms(„ôœZe9>oi2ò´$¤šíš”F•ÞΡ:ÒWÙ5MÓ,ý|=™d×·ŸŽ�Ý»»»àÎ-¤}ƒ°ìÚTÓ"+Ìi€…¸G”Ñ[5éîÎ�J(ôÑÑ3þ™~ÕƒtE6ø0Y¨‚n39i›ÕDñÏ>úý›¼¼Ññ‡Ô^P/euQYå¶ñm}aƒåë(ÿùÉ Ï'·ï¹r÷×0XÀxºežÕ�HåÏ?bŠ[s°º ƒV&Ú«áEmÜëç&qËÑXÉ:¯ªvØ A‹gÜbû¹Ì„ÑA?>ϳBŸÒEöNLÙ•†{YUe1½îu¹AËÂ"QçÙÛÍ &Úq“*]Õd >:z‰2”ÍÌ÷ønYüla·ª‹Ë dy)¿¤/™L?×h50Hy-¯é—ô‹lõuú±û0Dˆ14W'dŸÄýºˆxßnú2í£Ðg lR­[R|wÙËŸ¶l•Oäw¶;‚âã»C²pL&ûD<:ú¹ºf‡u ´¤pò÷!Õ€õ]ë ©´n?Ã2,ºd[-Ï�, ²�P_ÛFn²�QSq“�¢£7vè‡?üÛZË�–rÃÐû3#1fyvá�yÛ®ݽ{uu5Æ7ôÏ]Ï;˜”Ùòí{”ÍAœ ÷'ŽK,m¯WdÛü]{×åÆ�@º{÷ñïú½“§ÇoŽ¿‡ö¯¯›ññjEŠ6ƒN7yû:¯iJÎf[�½f_dKò¥ëÝ�F©|«©µÎ·wc°ÿ|‰LÙ·‹¦­êë³eðÒÞé¾Q,IÓQð9}÷î÷¿„”ƒ ‡=¯êÅýœT³küœ·‹òscrapy-scrapy-5569de3/tests/sample_data/compressed/unexpected-eof-output.txt000066400000000000000000000406161525051012400274540ustar00rootroot00000000000000document.write('¡¡¡¡¡°ÎÒ½Ã�ºéÃ�¡±¦£¬ÊÇÒ»¸ö·Ç³£¸ß¶Ë·Ç³£Å£±ÆµÄÖ°Òµ¶þÊÀ׿£¬È´ÔÚÒ»¸öÔºڷç¸ßÉìÊÖ²»¼ûÎåÖ¸µÄÒ¹Ã�íÎÒĪÃûÆäÃîµÄ¾Ã�±»ÀÃ�°Ö¸øÒ»½Åõßµ½Ã�ËÕâô¸öµØ·½¡­¡­¡±

    ¡¡¡¡¡°ÔÚ´©Ô½µÄÄÇÒ»¿Ì£¬ÎÒ�ëÆð�ËÔÚѧ�£Ñ§µÄÓïÊý�âÎﻯÉú¡ª¡ªÎÒ¶¼²»»á¡­¡­¡±

    ¡¡¡¡¡­¡­

    ¡¡¡¡ÌìÔ˴󽣬³àÔÆ¹ú»Ê¹¬Ö®Ö�£¬»ÊµÛºéÎÄÇåµÄÊé·¿�þÇå¸óÄÚ¡£

    ¡¡¡¡¡°±��£¬¡±Ò»Ãû»¤ÎÀ´Ò´ÒÀ´±¨£º¡°�â³öÔÆÓÎÊ®¶þÄêµÄ¹úʦ»ØÀ´�Ë£¬�ÖÔÚÕýÔÚ�þÑôµîÄڵȺò±��¡£¡±

    ¡¡¡¡¡°Å¶£¿¹úʦ»ØÀ´�Ë£¿¡±Ìýµ½»¤ÎÀµÄ»°£¬»ÊµÛºéÎÄÇå�ÈÊÇһ㶣¬Ëæºó·Å�ÂÊÖÖ�µÄ×àÕÛ£¬ÆðÉí�¦µÀ£º¡°�¨±¨Ò»Éù£¬ËµëÞ¾�µ½¡£¡±

    ¡¡¡¡ºéÎÄÇ壬ËÄÊ®°ËË꣬³àÔÆ¹úµÚÊ®°ËÈλʵۣ¬ÉúµÄ·½Ãæ´ó¶ú£¬�àòÌÃÌã¬ÓÈÆä°®ÃñÈç×Ó£¬ÉîÊܰÙ�Õ°®´÷£¬ËãµÃÉ�ÊÇÖιúÓ�·½µÄÒ»´úÃ÷¾ý¡£

    ¡¡¡¡¡°ÊÇ¡£¡±»¤ÎÀ½Ó�Õâ¾�×¼±¸À뿪£¬²»¹ý×ßÖ®ºóºöÈ»¶Ù�Ë�£¬ËæºóµÀ£º¡°¶Ô�Ë£¬±��£¬³ý�˹úʦ´óÈËÒÔ¼°ËûµÄÅ®¶ùÔÆ²Êóï�¡½ãÖ®�⣬»¹Ó�ÃûÄêÇáÈË¡£¾�Êô�°µÖ�´ò̽£¬´ËÈ˾�˵ÊÇÀ×Òô¸ó¶þ³¤À�Ò¶·ÉºèÖ®×ÓÒ¶½úÄþ¡£¡±

    ¡¡¡¡¡°À×Òô¸ó¶þ³¤À�Ö®×Ó£¿¡±ºéÎÄÇåÖå�ËÖåü£¬ËæºóÇáÇá»Ó�Ë»ÓÊÖ£º¡°Äã�È�ÂÈ¥°É¡£¡±

    ¡¡¡¡¡°ÊÇ¡£¡±

    ¡¡¡¡»¤ÎÀ�ÂÈ¥ºó£¬ºéÎÄÇå�ë�Ë�룬²»Ñ¡�úÅÛ£¬·´¶øÌØÒâ´©É�Ò»Éí±ã×°£¬Ö®ºóÃþ�ËÃþ�°�É�µÄºú�룬à«à«µÀ£º¡°¹úÊ¦ÔÆÓÎÊ®¶þÄ꣬Èç½ñ»ØÀ´£¬Äª²»ÊÇÒòΪÄǸö»éÔ¼£¿¶÷£¬°´ÕÕÄê¼�À´Ë㣬�ëÀ´Ó¦¸ÃÊÇ�Ë¡£Ö»²»¹ý£¬Õâ¸öÀ×Òô¸ó¶þ³¤À�µÄ¶ù×Ó£¬ÓÖÀ´×öʲô£¿¡±

    ¡¡¡¡¡­¡­

    ¡¡¡¡�þÑôÒéÊ´óµîÖ®ÄÚ¡£

    ¡¡¡¡¡°¹þ¹þ£¬Ê®¶þÄê²»¼û£¬¹úʦ·ç²ÉÓÈʤ�ùÎô£¬�ë±ØÎäѧÓÖÓ�½ø¾³£¬¿É�²¿ÉºØ°¡¡£¡±»ÊµÛºéÎÄÇåÒ»½øÒéÊ´óµî£¬µÚÒ»ÑÛ¿´µ½µîÄÚÒ»ÃûÇàÅÛÄ�×Ó£¬�¢Ê±±ã¼±���½²½£¬�½ÈËË«ÊÖ�àÎÕ£¬¹þ¹þ´ó�¦£¬ºéÎÄÇåµÀ£º¡°ÕâÒ»±ðÊ®¶þÄ꣬¿ÉÕæÊÇ�ëÉ·ÎÒÒ²£¡¡±

    ¡¡¡¡ÄÇÖ�ÄêÄ�×Ó¿´Äê¼�´óÔ¼ÎåÊ®À´Ë꣬ÊÖÖ�Ò»°Ñ¶ìëÓðÉÈ£¬�·É��½³ßÇà½í£¬ò¢�ÂÈý縳¤�룬ÉúµÄ�É·çµÀ¹Ç£¬¹ËÅÎÖ®¼ä¼«Ó�ÆøÊÆ¡£

    ¡¡¡¡Äܽ�ºéÎÄÇåÕâλ»ÊµÛÈç´Ë¶Ô´ý£¬´ËÈËÕýÊdzàÔÆ¹ú»¤¹úÎäÉñ£¬±»×ðΪ¹úʦµÄÆß�ÇÎäÕߣ¬ÔÆÊ¥�ù£¡

    ¡¡¡¡¡°±��¿�Æø¡£¡±ÉíΪ»¤¹úÎäÉñ£¬ÔÆÊ¥�ùÓë»ÊµÛºéÎÄÇåÖ®¼äµÄ¹Ø�µÒ»�ò¼«Îª�¼ºÃ£¬�òÀ´Æ½ÆðÆ½×ø£¬Ò²²»Òâ�⣬΢�¦µÀ£º¡°´Ë·¬ÔÆÄ³ÔÆÓιéÀ´£¬ÊµÊÇΪ�Ë�¡Å®²ÊóïµÄ»éʶøÀ´¡£¡±

    ¡¡¡¡¹ûÈ»£¡

    ¡¡¡¡Ìý�ËÔÆÊ¥�ùµÄ»°£¬ºéÎÄÇåµ±¼´�ò֮ǰһֱվÔÚÔÆÊ¥�ù²»Ô¶´¦µÄÒ»ÃûÉÙÅ®¿´È¥¡£

    ¡¡¡¡ÄÇÉÙÅ®´©×ÅÒ»Éíµ­ÂÌÉ«µÄ³¤È¹£¬´óÔ¼¶þ°ËÄ껪£¬Ò»Ë«ËÆË®µÄíø×Ó£¬�é¹âÉ�¶¯¡£·ôÈçÄýÖ¬£¬Ñ©°×Ö��¸×Å·Ûºì£¬ËÆºõÄÜÅ¡³öË®À´¡£ÓñÊÖʮָ�Ë�Ë£¬ÈáÈôÎ޹ǣ¬ÇáÇáµÄÎÕ×ÅÒ»Ö§�¨Ìå±ÌÂ̵ÄÓñóï¡£Ò»�·¼°ÑüÇàË¿Ëæ·çÎ趯£¬�É×Ó°ãÉñ²Ê�ÑËס£

    ¡¡¡¡¡°ÔƲÊó¼û¹ý±��¡£¡±ÑÛ¼ûºéÎÄÇå�ûÀ´£¬ÉÙÅ®µ±¼´��Àñ¡£

    ¡¡¡¡¾øÃÀµÄÈË£¬¾øºÃµÄ³öÉí¡£

    ¡¡¡¡ÕýÊÇ»¤¹úÎäÉñÔÆÊ¥�ùΨһµÄÕÆÉ�Ã÷Öé£¬ÔÆ²Êó

    ¡¡¡¡¡°°¥Ñ½£¬ÕâÊ®¶þÄêû¼û£¬²Êóï¾ÓÈ»ÒѾ­³öÂäµÄÈç´ËÃÀÀö£¡¡±ºéÎÄÇå֮ǰ¾�×¢Òâµ½�ËÕâ¾øÃÀµÄÉÙÅ®£¬´ËʱÌýËýÈ·È�£¬Ô½·¢�À�²£¬�¦µÀ£º¡°´ó¼Ò¶¼²»ÊÇ�âÈË£¬½�ÎÒÊåÊå°É£¬ÕâÑù�ÔµÃÇ×½ü�©¡£¡±

    ¡¡¡¡ÔƲÊóïÇáÇáµÄ¡°¶÷¡±�ËÒ»Éù£¬ËãÊÇ´ðÓ¦¡£

    ¡¡¡¡ÔÆÊ¥�ù�½È˺®êÑ�ê±�£¬ºéÎÄÇå×îºó¿´�òÄÇÃû»¤ÎÀ¿ÚÖ�µÄÄêÇáÈË£¬Î¢�¦×ÅÎʵÀ£º¡°ÕâλÉÙ�ÀÊÇ¡­¡­¡±

    ¡¡¡¡ÌýµÃºéÎÄÇåÎÊÆð£¬ÄêÇáÈ˸�½ô±§È­��Àñ£º¡°�ù´óÕý�³Ö®Ò»£¬À×Òô¸ó×ù�¶þ³¤À�Ö®×Ó£¬Ò¶½úÄþ¼û¹ý±��¡£¡±ÕâÒ¶½úÄþÉúµÄÒDZíÌÃÌ㬽£Ã¼�ÇÄ¿£¬Ò»Éí°×ÒÂÈçÑ©£¬¶ËµÄÒ»¸±ºÃÂô�à¡£Ö»²»¹ýËûËäÈ»ÊÇÔÚ�òºéÎÄÇå��Àñ£¬¿ÉÊÇÑÛÉñÈ´²»Ê±�µ�µ¿´�òÔÆ²Êó��Ϊ¹îÒì¡£

    ¡¡¡¡¡°¶÷£¬Ó¢�Û³öÉÙÄ꣬²»´í£¬²»´í¡£¡±Ò¶½úÄþµÄÑÛÉñ±»ºéÎÄÇå¿´ÔÚÑÛÀ˲�¢Ö®¼äºéÎÄÇå±ãÃ÷°×¹ýÀ´£¬Ëûü�·Î¢Î¢Öå�ËÖ壬²»¹ýºÜ¿ìÊæÕ¹¿ªÀ´£¬ÇáÇáµã�˵ã�·£¬�¬Ê±�ÄÖ�È´°µµÀ£º¡°²ÊóïÓ×Äê±ãÒòΪÌ츳¼«¼Ñ£¬Ë��ù�ù´óÕý�³Ö®Ò»µÄÀ×Òô¸ó�Þ�¶£¬Èç½ñËý»ØÀ´£¬È´¸úÀ´�ËÕâôһ¸öÈË£¬¿´Ñù×Ó½ñÌìµÄÊÂÇ飬ÅÂÊÇûÄÇô¼òµ¥¡£¡±

    ¡¡¡¡¡°ºÇºÇ£¬±��£¬¡±Èç½ñÈËÒѼû¹ý£¬ÔÆÊ¥�ùÓðÉÈÇáÒ¡£¬�¡ÉùÌá�ѵÀ£º¡°°´ÕÕµ±ÄêµÄ»éÔ¼£¬²»Öª¾Å»Ê×Ó£¬¿É»¹°²ºÃ£¿¡±

    ¡¡¡¡³àÔÆ¹ú£¬Ã¿Èλʵۣ¬Óëµ±´ú»¤¹úÎäÉñ£¬±Ø�ëÒª½á³É»éÔ¼£¬ÓÃÒÔÀ­½üË«·½Ö®¼äµÄÓѺùØ�µ¡£

    ¡¡¡¡Èç¹û»¤¹úÎäÉñÑ¡µÄÊÇÄ�£¬ÄǻʵÛÕâ±ß£¬±ãѡһ¹«Ö÷�¼ޣ»¶øÈôÊÇ»¤¹úÎäÉñÕâ±ßÊÇÅ®£¬ÄǻʵÛÕâ±ß£¬ÔòҪѡһÃû»Ê×ÓÓ­È¢´ËÅ®¹ýÃÅ¡£

    ¡¡¡¡±¾Èλ¤¹úÎäÉñÔÆÊ¥�ù�¥�ÂÖ»Ó�һŮ£¬¹Ê¶ø£¬ºéÎÄÇå±ã�èҪѡ³öÒ»Ãû»Ê×ÓÀ´ÓëÖ®³É»é¡£¶øÕâÃû±»Ñ¡¶¨µÄ»Ê×Ó£¬ÕýÊÇÓëÔÆ²ÊóïÄê�ä�൱£¬×îÊܺéÎÄÇå�²°®�ÄÌ۵ľŻÊ×Ó£¬ºé�¡±¦£¡

    ¡¡¡¡Ö®ËùÒÔ½�ºé�¡±¦£¬ÊÇÒòΪ¾Å»Ê×Ó³öÉúʱÉí×Ó¹Ç�à¶Ôµ¥±¡£¬ÓÖÊÇ×î�¡µÄ±¦±´¸í´ñ£¬ËùÒÔÆðÃû�¡±¦£¬Òâ˼ÊÇ�£�ûËû�ñ´«¼Ò±¦Ò»Ñù¼á�¦¡ª¡ªËµÆðÀ´£¬Õâ¸öÃû×Ö»¹Êǵ±ÄêÔÆÊ¥�ùÇ××ÔÈ¡µÄ¡£

    ¡¡¡¡¡°°¦£¬ÎÒ¼Ò�¡±¦Õ⺢×Ó£¬¡±ÌáÆð×Ô¼ºÕâ¸ö×îÌÛ°®È´Ò²ÊÇ×î½�ÈË�·Ì۵ĵھÅ×Ó£¬ºéÎÄÇåÒ²ÊÇÓ��©ÓôÃÆ£º¡°ËûÒ»Ììµ¹ÊÇ�¦¿ª�ĵ쬾�ÊÇÓ��©²»ÎñÕýÒµ£¬½�ÈË�·ÌÛ¡£¡±

    ¡¡¡¡¡°Å¶£¿²»ÎñÕýÒµ£¿¡±Ìý�ËÕâ»°£¬ÔÆÊ¥�ù¶ÙʱÖåüµÀ£º¡°Äª·Çµ±ÄêÎÒ�ô¸øËûµÄÄDz¿¡¶�÷ÔÆÓ¥×¦¹¦¡·£¬ËûÒ»Ö±¶¼Ã»Ó��Þ�¶£¿¡±

    ¡¡¡¡ºé�¡±¦ÌåÖÊÈõ£¬ÔÆÊ¥�ùÊÇÖªµÀµÄ£¬Ëû³öÓÎÇ°ÌØÒâ�ô¸øºé�¡±¦Ò»²¿¹¦·¨Ãؼ®½�Ëû�Þ�¶£¬ÒÔ±ã½�ËûÇ¿Éí½¡Ì壬¿ÉÊÇÌýºéÎÄÇåµÄ»°£¬Õ⹦·¨£¬Ëûû�·£¿

    ¡¡¡¡¡°°¦£¬Ò»ÑÔÄѾ¡°¡£¬¡±ºéÎÄÇ峤̾¿ÚÆø£¬ÎÞÄεÄÒ¡�ËÒ¡�·£¬µÀ£º¡°¹úʦ»¹ÊÇÓëÎÒÒ»µÀ£¬�ÈÈ¥¿´¿´ÔÙ˵°É¡£¡±

    ¡¡¡¡ÕâÊÇÔõô»ØÊ£¬¿´Ñù×ÓÊÂÇéºÃ�ñ²»´ó¶Ô�·¡£

    ¡¡¡¡ÔÆÊ¥�ùÓëÅ®¶ùÔÆ²Êóï¶ÔÊÓÒ»ÑÛ£¬Ò»Æðµã�·£º¡°ºÃ¡£¡±Õâ¾�³öÃÅ£¬Ç°�ù¾Å»Ê×ÓËùÔÚµÄ�þ�ö¸ó¡£Ò»Â·É�£¬Ò¶½úÄþ½ô¸úÔÆ²ÊóïÉí±ß£¬ºéÎÄÇå¿´ÔÚÑÛÖ�£¬�Ä�ÂÒѾ­�ËÈ»¡£

    ¡¡¡¡½á¹û¸ÕÒ»½ø�þ�ö¸óµÄ´óÃÅ£¬¾�¿´µ½ÎÞÊýµÄÆ�ÈËÄÃ×ÅÌúDzù×Óһ··É±¼£¬Óë´Ë�¬Ê±Ò»Õó¶ñ³ôζ´«À´£¬ºéÎÄÇåµÈËÄÈËŤ�·¿´È¥£¬¾ÓÈ»¿´µ½¼¸ÃûÆ�ÈË�ƹýÀ´Ò»�¾·à³µ£¡

    ¡¡¡¡Ò»�ò�É·çµÀ¹ÇÆÄÓ�µãƮƮºõÓ𻯵Ç�ÉζµÀµÄÔÆÊ¥�ùÖ±½Ó¾�¿´´ô�ËÑÛ£º¡°Ê²Ã´Çé¿ö£¿¡±

    ¡¡¡¡ÕýËùνʳö·´³£±ØÓ�Ñý£¬Ò»½øÃž�¿´µ½Õâô¸öÇé¿ö£¬ËÄÈË·´µ¹²»×ż±�Ë£¬ºéÎÄÇåÖÆÖ¹�ËÓûÒªÇë°²µÄÆ�ÈË£¬×ö�˸öàäÉùµÄÊÖÊÆ£¬Ëæºó¸úÔÚºóÃæ£¬¿´¿´Õâµ½µ×ÊÇÔÚ¸ãʲô�þ»ú¡£

    ¡¡¡¡Ò»Â·Ç°��£¬ºÜ¿ì£¬ËÄÈ˾�Ìýµ½Ò»°ÑÂÔ΢Ó��©ÄêÇáµÄÉùÒô�ìÆð¡ª¡ª

    ¡¡¡¡¡°ÚÀ£¬ÊÖ½ÅÒ»¶¨Òª×��¸µã£¬¶¼Ìýµ½�ËÂð£¿Õâ¿ÉÊÇ×î¹Ø¼üµÄÒ»²½£¬²»ÄÜÓ�°ëµã²î´í£¡¡±ÄÇÉùÒôÌýÆðÀ´´óÔ¼¶þÊ®À´Ë꣬ËäÈ»²»Ëãµ�³��Ûºñ£¬¿ÉÊÇÌýÔÚ¶úÖ�µ¹Ò²�¦Êæ·þ£¬ºéÎÄÇå�¦µÀ£º¡°�¡±¦Õ⺢×Ó£¬²»ÖªÔÚ¸ãʲô£¬ÎÒÃÇÇÒ�È¿´ËûÒ»¿´£¡¡±

    ¡¡¡¡ÔÆÊ¥�ùº�ÔÆ²ÊóïÒ²ÊÇºÃÆæ£¬Ò¶½úÄþÔò¹´�˹´×ì½Ç£¬³¶³öһ˿²»�¼µÄ�¦Òâ¡£ÈýÈ˸úºéÎÄÇåÒ»ÆðÈÌס³ô棬ÕÒ�˸öÒþ±ÎµÄµØ·½£¬�µ�µ¹Û¿´¡£

    ¡¡¡¡ÕâÀïµØ´¦�þ�ö¸óµÄºóÔº£¬ÖÜΧÂÌÊ÷´�´�£¬�¡ÇÅ�÷Ë®£¬·ç¾°¼«¼Ñ¡£¶ø¾Å»Ê×Óºé�¡±¦£¬ÔòÕýÖ¸»Ó×ÅÆ�ÈËÃÇÔÚµØ�Â�Ú×Åʲô£¬±ßÉ�»¹·Å×Ų»ÉÙµÄÌú¹Ü¡¢ÌÕ´ÉÆ¬¡¢Ä¾�·Ö®ÀàµÄÆ÷Îï¡£×îÆæÃîµÄÊÇ£¬¾�ÔÚºé�¡±¦µÄ²»Ô¶´¦£¬»¹°Ú×ÅÒ»¸öÆæ¹ÖµÄ°×É«ÌÕ´ÉÔì¾�µÄ¶«Î÷£¡

    ¡¡¡¡ÄǸö¶«Î÷Ó�×ÅÒ»¸ö�ÖÔ²�εĿÚ×Ó£¬�ÂÃæÊǵ××ù£¬ºóÃæ»¹´ø×ÅÒ»¸ö·½·½ÕýÕýµÄ�ä×Ó£¬²»ÖªºÎÓá£

    ¡¡¡¡²»¹ýºÜ¿ìËÄÈ˾�ÖªµÀ�Ë¡£Ö»Ìýºé�¡±¦ËµµÀ£º¡°¶¼�¡�Ä�©¹þ£¬Õâ¿ÉÊÇÎÒ�ÂÑ�ÖÆ³öÀ´µÄÂí�°£¬ºÜ½ð¹óµÄ£¬ÌÕ´É×öµÄ£¬±ðÅö»µ�Ë£¡¡±

    ¡¡¡¡Âí�°£¿ÄÇÊÇʲô¶«Î÷£¿

    ¡¡¡¡Ç¿ÈÌס�ÄÖ�ºÃÆæ£¬ºéÎÄÇåËÄÈ˼Ì�ø�µ¿´¡£

    ¡¡¡¡¡°¶Ô¶Ô¶Ô£¬¾�ÕâÑù£¬¼Ì�ø�Ú£¬¶÷£¬ÔÙ�ÚÉîµã£¬´óµã£¬ÒªÖ±É�Ö±�µģ¡¡±ºé�¡±¦¼Ì�øÖ¸»Ó£¬È´²»�ëÆ�ÈËÃÇÕý�Ú×ÅÄØ£¬ºöȻһÃûÆ�ÈË´óÉùµÀ£º¡°¾Å»Ê×Ó£¬ÎÒ�Ú³öÀ´Ò»¸ö¶«Î÷£¡¡±

    ¡¡¡¡ºé�¡±¦ºÃÆæµÀ£º¡°Ê²Ã´¶«Î÷£¿¸øÎÒ¿´¿´£¡¡±

    ¡¡¡¡¡°ÔÚÕâÄØ¡£¡±ÄÇÆ�È˸�½ô°Ñ�Úµ½µÄ¶«Î÷½»µ½ºé�¡±¦Õâλ¾Å»Ê×ÓµÄÊÖÉ�¡£¶«Î÷ºÜÔ࣬É�ÃæÕ´ÂúÄà��£¬ºé�¡±¦Ò²²»½éÒ⣬Éì³ö�ä×ÓÖ±½Ó²��˸ɾ»£¬Ö®ºóÖÕÓÚ¿´µ½Õâµ½µ×ÊǸöʲô¶«Î÷¡£

    ¡¡¡¡ÄÇÊÇÒ»¸ö·Ç³£Æ¯��µÄÊÖïí£¬É�Ãæ�âןì³È»ÆÂÌÇàÀ¶×�ºÚ°×Ò»¹²¾Å¸öÑÕÉ«µÄÓñʯ£¬ÄÇÓñʯÉ�»¹É¢·¢×Åһ˿µ­µ­µÄÎíÆø£¬ÃþÔÚÊÖÀﻹÓ��©Å¯Å¯µÄ£¬·Ç³£ÉñÆæ¡£

    ¡¡¡¡¡°ºÃ¶«Î÷£¡¡±ºé�¡±¦´ó�²£¬Ö±½Ó°ÑÄÇ´®Öé×Ó´÷ÊÖ�óÉ�£¬�¦µÀ£º¡°Ã¿ÈËÉ�¶þ�½£¬»Ø�·×Ô¼ºÈ¥�죡¡±

    ¡¡¡¡ÖÚÆ�È˸�¶÷´÷µÂ£º¡°�»¾Å»Ê×Ó£¡¡±È»ºó¼Ì�ø¿ª�Ú£¡

    ¡¡¡¡ÕâÒ»�Ú¾�×ã×ã�Ú�Ë�¡°ë¸öʱ³½£¬ÖÕÓÚ´ó¿Ó�ں㬾ŻÊ×Ó�Ƚ�È˰ÑÀïÃæÓôóÔ¼�ù³ß×óÓÒ³¤¶ÌµÄÌÕ´ÉÆ¬µæºÃ£¬È»ºó�¹ÜÂñ��£¬ÔÙÖ®ºó°Ñ·à³µ�ÆÀ´£¬ºé�¡±¦ÑÚ×űÇ×Ó½�Æ�È˰ѷ೵ÀïÃæÄǶñ³ôµÄ±ãÄçÖ®ÎïÈ«²¿µ¹�Ë�ÂÈ¥¡ª¡ªÕâÒ»�³¡É�¸ü³ô�Ë¡­¡­

    ¡¡¡¡Õâ�©»î¸É�֮꣬ºó¾�ÊÇÉ�°å¹Ì¶¨£¬×îºóÓÖ°ÑÂí�°¹Ì¶¨ºÃ£¬Ò»Õó΢·ç´µ¹ý£¬×ÜËãÒ»Ç�¸ã¶¨¡£

    ¡¡¡¡¡°�¡±¦£¬ÄãÕâ¹Äµ·Ê²Ã´ÄØ£¿¡±ÑÛ¿´ÕâʱºòÆ�ÈËÒѾ­¿ªÊ¼ÔÚÖÜΧ¸Ç�¡·¿×Ó£¬ºéÎÄÇåËÄÈËÕâ²Å×ß³öÀ´£¬ºéÎÄÇåÖ¸×ÅÄǸöÂí�°£¬ÎʵÀ£º¡°Õâ¶«Î÷¸ÉʲôµÄ£¿¡±

    ¡¡¡¡ÑÛ¼û¸¸»ÊÀ´ÎÊ£¬ºé�¡±¦ºÙºÙ�¦�Ë�¦£¬Ó�µã²»ºÃÒâ˼£¬´ðµÀ£º¡°Õâ¶«Î÷ÎÒ¹ÜËû½�³éË®Âí�°£¬¿ÉÓ�ÓÃ�ËÄØ£¬È¥Ã©²ÞµÄʱºòÖ»Òª×øÔÚÕâÉ�Ãæ·½±ã£¬µÈ·½±ã�ê�Ë£¬Ò»°´Õ⿪¹Ø£¬ÕâºóÃæË®�äÀïµÄË®¾��÷�ÂÀ´£¬È»ºó¾�³å¸É¾»�Ë£¬ºÜʵÓõģ¡¡±

    ¡¡¡¡¡°Äã¹Äµ·Õâô°ëÌ죬¾�ÊÇŪ�˸öé²Þ³öÀ´£¿£¡¡±ºéÎÄÇåËÄÈËÈ«²¿¶¼¾ª´ô�Ë£¡

    ¡¡¡¡ÓÈÆäÊÇÔÆÊ¥�ùº�ÔÆ²Êó¸üÊÇÌýµÄÄ¿µÉ¿Ú´ô£¡Ò¶½úÄþÔòÊÇŤ¹ýÉíÈ¥£¬¼ç°ò²»×¡¶¶¶¯¡£

    ¡¡¡¡Õâ¾Å»Ê×Ó֮ǰºéÎÄÇå¾�˵Ëû²»ÎñÕýÒµ£¬�ÖÔÚ¿´À´£¬»¹ÕæÊÇ£¡

    ¡¡¡¡¡°°¡£¬µ±È»£¬²»¹âÊÇé²Þ£¬¡±ºé�¡±¦·Ç³£µÃÒ⣬µÀ£º¡°ÕâÊÇÎÒµÄÒ»�î�·¢Ã÷£¬ÎÒ¹ÜÕâ¸ö½�ÕÓÆø³Ø£¡¼òµ¥À´Ëµ£¬¾�Êǵ±Õâ�©¡­¡­¶÷¶÷£¬»Æ°×Ö®ÎïÔÚÕâ±ã³ØÀï·¢½�Ö®ºó£¬»á²úÉúÒ»ÖÖÆøÌ壬¿ÉÒÔÓÃÀ´µã»ðÔì·¹Óõ쬷½±ã£¬»·±££¬·Ç³£ºÃÓã¡¡±

    ¡¡¡¡ÔÆÊ¥�ùÒѾ­Ó�µãÌý²»�ÂÈ¥�Ë¡ª¡ª²»ºÃºÃ�·Î䣬¸ãÕâ�©Ó�µÄûµÄµÄÂÒÆß°ËÔãµÄ¶«Î÷£¬Ó�Óã¿

    ¡¡¡¡¡°¶÷£¬�¡±¦°¡£¬¡±ÔÆÊ¥�ùÉ�ǰһ²½£º¡°ÎÒµ±³õ�ô¸øÄãµÄÄDz¿¡¶�÷ÔÆÓ¥×¦¹¦¡·£¬Äã¿ÉÔø�°�·£¿¡±

    ¡¡¡¡¡°ÄúÊÇÔÆ²®²®£¿¡±¿´µ½ÔÆÊ¥�ù£¬ºé�¡±¦¼±Ã¦É�ǰ¼ûÀñ£º¡°ÔƲ®²®£¬ºÃ¾Ãû¼ûÀ²£¡Äú¸øÎÒ�ôµÄ¹¦·¨£¬ÎÒÊÇÒ»¶¨Òª�·µÄѽ£¡¡±

    ¡¡¡¡¡°ÄÇ�·µÄÈçºÎ�Ë£¿¸øÎÒʹÀ´¿´¿´£¿¡±ÔÆÊ¥�ù¼±ÎÊ¡£

    ¡¡¡¡¡°°¡£¬ÄÇÅÂÊDz»��£¬¡±ºé�¡±¦Âú�³²»ºÃÒâ˼µÄ»ØµÀ£º¡°�·ÊÇ�·�Ë£¬¾�ÊÇû�·³É£¡¡±

    ¡¡¡¡ÕâʱºòÒ»ÅÔµÄÔÆ²Êóï¶¼Ó�µãÌý²»�ÂÈ¥�Ë£¬Öå�ËÖåü£¬Å¤¹ýÉí×Ó¡£Ò¶½úÄþÔò¡°à�¡±µÄÒ»Éù�¦�˳öÀ´£¬´Õµ½ÔƲÊóï¶ú±ß£¬�¡ÉùµÀ£º¡°¿´À´ÄãµÄÕâδ»é·ò£¬¹ûÈ»¸ú´«ËµÖ�Ò»Ñù£¬²»Ö»²»ÎñÕýÒµ£¬»¹ÊǸö·�²Ä£¡¡±

    ¡¡¡¡¡ª¡ª¡ª¡ª¡ª¡ª¡ª¡ª¡ª¡ª¡ª¡ª

    ¡¡¡¡�ÂÊé�ÂÆø�ó£¬�ÂÊéÉ�´«À²£¬ºé¼Ò�¡±¦¸ç»ªÀöµÇ³¡£¡

    ¡¡¡¡ºÀÃ˵Ä�ÖµÜÃÇ£¬ÄãÃǿɻ¹ÔÚ·ñ£¿

    ¡¡¡¡�µÄÕ÷³Ì¿ªÆô£¬»¹¿É·ñÅãÎÒÒ»Æð³Ë·çÆÆÀË£¬Åû¾£Õ¶¼¬£¿£¡

    ¡¡¡¡ÇóÊղأ¬Çó�Ƽö£¬Çó´òÉ�£¬ÇóÊ®·ÖÆÀ¼ÛƱ£¡�ÂÊéÇóÒ»Ç�Ö§³Ö£¡£¡

    ¡¡¡¡¡ª¡ª¡ª¡ª¡ª¡ª¡ª¡ª¡ª¡ª¡ª¡ª

    ¡¡¡¡�í�â�ÆÒ»�Â�ê±¾À�Êé¡¶ÖØÉúÖ®Éñ¼¶°Ü¼Ò×Ó¡·£¬¾ù¶©ÆÆ�ò×÷Æ·£¬ÖÊ�¿±£Ö¤£¬Êé»Ä¿É¿´¡£

    ¡¡¡¡ÆðµãÖ�ÎÄ�øwww.qidian.com»¶Ó­¹ã´óÊéÓѹâ�ÙÔĶ�£¬×î�¡¢×î¿ì¡¢×î»ðµÄ�¬ÔØ×÷Æ·¾¡ÔÚÆðµãÔ­´´£¡

    ¡¡¡¡ÆðµãÖÃ�ÎÄÃ�ø www.qidian.com »¶Ó­¹ã´óÊéÓѹâÃ�ÙÔĶÃ�£¬×îÃ�¡¢×î¿ì¡¢×î»ðµÄÃ�¬ÔØ×÷Æ·¾¡ÔÚÆðµãÔ­´´£¡ÊÖ»úÓû§Çëµ½m.qidian.comÃ�¡£');scrapy-scrapy-5569de3/tests/sample_data/compressed/unexpected-eof.gz000066400000000000000000000120161525051012400257100ustar00rootroot00000000000000‹ÙUWdWÛRÛZýœ™§ù‚©ù™™ó0s©©sj¾G(�`G²µå‹$ß´m]¶u—%b’6„p `1vLM·…ÁÔ$U!Æ[½»W¯^«õ·ýõ·üòÏ_ÿôßÿüý×_þøÿˆ^¤õÓ€“¶xÁ1#µz_1Ë|u_aø³ËK=BS1"Ê£Óʾò‚%'C<•têÃÊIuß¾pÒ’ªÍ½ã´JhTs/½ˆŒyïȸ"´áJjËÓê4RõîïûDo³ÁÈ�«÷d·¢sïá¯ôçÿ%O%ÊÛ‰N(…4š}^ð"ÿ¼wÝfÉ0|ðá[BãïÎÂyßÕöŒ+p/R´’ª¾žþÛ¿H˜<éè¼P>Hzµ™êÔ„´˜¼ :d¯~êQó˜Pç´Ò Íãê„9þ9é³ßáNŠTc®¾ñZr$G-YzÇ×9Ñ•ï“^ì9E呞Z›9[ênKn3^ðÓd8X$Ãà1¼#WtH¬ú-ÆâøµZ» ß\{¨ÉIýÑñBUq=?ß¶Œ^`_ë“J×ï8iš öx¡wm_[¤µLê�ô.Rí8Iο4\¢¯]™ƒ¯¨S;[AB†øÙòðB^°g„Vtã«¶B�õæ•) ›7¤¼h3DNžX,J4‘Í í•‡¤Wr&ßN§EwÖâÀ˜Ó3óÐbßøìÜù‘¤�|Û>ÈÌb�ÏŒ8f+‹îªå±ü¦åGJÅ®K‹Y)úŒ>¾jÙúA<‚MËQM�Û,=6´¡õYÏììäà!X/\… >Ño�v¼@ %Èuë¨Y½WO�o*ÐQQ è™æ¶d6œ·äx«j8f€LUl6°z¿Ž¡Êr#N[rvï” ñùÿ‘Ä©ˆN�Ž/$=g3xà¹WŠØt{SÚ¾ô /ò£ñù ü˜èâ®O"Õ¿Õv¼B[«ƒ’ø·-¹§÷®{z6å…¨TR£Ž«·±’1™U -#gk§ÈSÆžg×-â�ƒDBc•iK^¡ÚÜRŸsíƒHä9årúÔ:I“ýÛ´hŠ‘šJƒF2ÌÏ”ÔD‘·P·Œ¹ý±[lÊÖæ ž&þJÞκ8tä}§oïZ~cÕOh§ò(2z¦‚ŠE*Ûa=V$ôrºäÕimd·•_¹³Nè"x!ܳ4p«Y·²W¿ÁèÕ§Ÿº\ùž±œøÆI�bZõ™ŠgZwÞl7Š ¡Þ³HߺïúÇR¿ÓG¼™|çøHu+šþ4X“}­œxß”µ�ÅâØ¾)9nȲµAË¥’´ym±r¦pO›ç(¹sÄ‹F¼‚ uóühcj€?Y�YcZ7…{ûòùK$ÖŠ9®Ø¡ƒží?ÒSdÉòÞB‰›¡ŽR’Ï‘±œy<Òˆ-5 ïË÷ ò×hµîq®Vä2Eð��h”/y^˜2rÜ/e «bùp¥^ÙZ~‰žé5Ñ–[ƒcº#¬D¼È9vŽE3 ü„f¦ÿ ¶×È•gž7ìè'µÙ“ÕŽS¾Ž·žaÈKG ©ÏB÷LòWôË®ê“&�šùBÑ¢�ç•@Öõœ•¾xªýÑo�K¬ï6°$y0Ïé]ƒ‚/®úú�ôÖz�½Â‡Ûòd°pËi1Ry!WéìÁï¬y—�Ü:@Ò–¿C�‰Lé2w~�7´À¤M¤·})fQÇ: ·yAa8/U©ntîüƒ•/°9;²Údèßæy.s�tÌpϾ±d^r‰KˆƒÄç*ºX£(ÚP¼ìÃ>¢Â–Tù2ÿSÍ%CJj~QÓp¯áXÉß@ùÉ/��bV[ ©ÙÜÐÜŠ·ámä–�oõOÍ çë Ö,à™e“ÇmFÆnYp¤€¦�%‹$ï ¦gôL£qö/ªemG !¶ë>¬Oh|ð¥­ä¦Ë¹íZru†[.¸Jc÷›rö©ßô/`í˜0,ÝŽc*pEÖnÉæ«Õ¡nÖÍÕ:ÄIV¡{ù¬Äà&æaĶœýÐ? Â_,5 ŠWåoú�T�Á„1\ÍQ¦«Y•ÆÁ7 X¾_…„%è<-b�¯L¡ws V3å@¹„å/>Ï)MÏÚGþ‘x!ƒUùi¡+‰Å€ö÷à¥è ð§×®•¦Eö%¯ÞÆ€`ùaƒ™ž›Å5Ó?P²)q¸k9fv†Ò–$W/ehÆè]ó Èî+WåÀÒwÌôôt݈=ÃñÝx�Iàj¶Ú¿¬Ôæ‘ÊH¡´ÐõÙ-ä“a êœU€{¨T7Œ3ë^}„yp}Þädë�ôÆÛò@;à‚ÜvU›;F w}™ ?ë-ÈüJ=œ9|–yì7!Ô±{:3xS;PÞ9Óv£“†ÏŒ²¢5Çÿ|3øÞáF_MŽÖµ/ÅÆçx¯b.ÃRöÍßA™,Üã.×ä�öšðfÔ5g9þÃ�‰£9êT¨lë�åü’¤ªAŸ%ƒÁ[S$ú< ¥ß< õe¿øéˆÆth× nÒ�䎘x!‘ò�¶—_n­˜\ sˆnœÓ»­è”ö×Ökº‘°túD·Íè‰a<|*f’›¾$KuIc“®ûTç^]±UÉ÷|ªAÄ(v>u‚Á ÆæH�?µgu À•\q�-»|@üÇpMj7„®SöG+ßæé~ØV Z`²K1ûgÆx!ß ÷±,~@©¼f‡^„ý7‘±Hk’bÁªmÕyÆš‘‡Ž“VW9¤M�C ×ÞsóTš{ ¯¡ÿÌo��’Ÿ± ±XNZ�Gï­�¢H±ƒ«ò¿™Ë4Ž×áSÜÚÂR…ýÖ4O�×xçÑé³{²qKõÚάˆ’Æe¿Ý‚§~ÕSÞD³Âü9;ô/›<‹ q×Ò ÕåÙç€D4©R�Ù[JÄŒ,çaŠcR`R‰†¯Îq¼ôÅXm€`úÍ”òƒ˶ïFtÀõØE¼‡ò††!ñö”Ý'WÞ-XiòY&©CšW~—–i¢JdHå6–°Bûvߟ£fÊkmÚþAÿÆîiž6/¥9Mª�ˆŸ4<-?Zƒì˜f¯-ä o„s)¬ôú]]ƒÅL^‘:Ó2>:^(/u¤j¹Ëh¢õýÁŽŽïgÉÀA"¦¸ãY^õˆ!¨;ÞE�ñâ‡F¢@Ë«uwAFë ‰ÙÒ6UÅAvÎíÖ¤õVµ½õn£’0‰&2¤*{iQÅc²FÂ…4–F†w+ÏÏ{7ÒT<¨kA_úþÑä4þ�=>ˆ©ˆ2âü¾õR/T¾v›Ð5èu¬<ë¶Tü¯'ª‘—} Ä¡ÌKÓÄŽfeŸÝ¿õ)H Mé—}ž¬äŽVÚ)·7‹�wÄí¹ÝH™4zg„±‡˜œKyí]`P8ôQ—„¬jÉe– Sœæ žG]zÚDr›eðkWÝHråñæ¶0ÃÀyÊ^ˆ&ŸR<Ì’”ØYt©,I³¾†&>²ç”ŽÒ¯ÇDÉç:�zœ;›ÈÕ‘ÁLÏ!`jœ6|U†r;åR7}�)Ãeœ¥öq:ñ„eO”\û{+zÚ¹Õè¸G¹Ä¿Z·ý¥!Q=n±R=lºþÐGX«‹Öäï›H 'Ñ}¶&6’úaÉ/qˆ1�ȺÁ[ëj'“YþT~$»bÃ×pã¡�¡/ó�`ÆmÈ'3¢Yöy9�f˜ä4/)‚-�vAdÅ…™G3̇ØBÉR¯yÜ»q.r”m= ŸÊEž oä‘JE'ÒÞÀ†‰­�ÚwÈhq‚l5�Ñv#�5lÜþ˜ÐÚòÉb¥8<û=Vw}4wRÐs÷ë¤ß >ð¦¤a=x‹^„“'¶À‰úK N¼3ÊŸ§ï�k5ˆþb�^ ’æm$¿ƒò×ù„Ú2>ÁQ¡%â=å+µ`êÒåi8Ï ?Uçò»�¼1šuç$ý\IʨÊLLhÐn‡-ýnµcÛÅ ®ú)§˜[y 2ŸóýòjoÓæUªXB.#¢í¨8QìDPÚj Œ„TÕöÄxX‡Q?ÑOÒJ ò…7t&ƒ(ävw¹8ÓNÃçN*| l;3ÈÓܱBPÆà­óžÿƒêƒ.31¨³?¢¸Q'J§ED$ ðwô Iº¶y¨MÁª î°‘|¦^æ†�nd‘E¬<µ�ø _P°•®9J¹ÃhlYwŽˆB5/GWàDò‘‡èœ8N)s;dB �¹Ý=„Û€_†�âù­™ï©¿ ÈN˜R`ÎÄû}®në´Ém|‡Nlmþ õÅÉ2æ‚ 1Ôc¬ÅöîF¡>o�¾S2E\ÔÐ0jÞ�¡‘ø—¨êªY)v\…Ùä ¼ 9 ¶:;èJPJÉuj+{Î(Z;…së¤ãÜ{¦û&¤�$"©¤øòÚ†á5K” rÖš ÓÞ�²×‰Pô»?ùU°"ó4ñ™£ÉŠÜ¡î77n‹+E«rrIJl(­}Æ\º“;ÿÒI@8 6ZSסj¤Ú£zàtÇRâÏò9FϸŽAºÇµ3ïS3,æ¯Âà€Û±L8;bÂìÛ$H0 &Õ¨ã«P©%ü„ksçÿߺs3(“&œØ.®V€ßàø]âutþZ4í–s€�ÚÏl>÷úì‘ît ={ü>ç®Ðô,b"føâ Ͳ×R$÷¸ó;�}|8øªÇøî/µžXuL³¾Y»w�;‡Ö‚¡)Êt {e u“åœ2˜XÊ�¶@!°žB°sFC™Ñ/`V*³×þ=UĈ›/ÅúËœLÝkEpͳˆk…ÁØ„‘”8FàªÕV0yÿÿ¬YKOÂ@þ+ÜŒ¸+ò_�¾BÂïÁšˆ@`+m¡lÓb-JY9Á�ÔWx@¼¸#ᕘÓd·›mf¿îÌÎ|3 ¥«)0}Ð ìŒàð}:ÕþÌ„¹(±p…ĆÐ6aq` Å%‘ØJ­x€èÏÏìj*o8å<[ÄŽá]·HÆ—%N뙎•‡¬–ŽÌ�ß•\(vŽÙfAó(hPE³|onweB¦ª£ô�Eï€KÒù^± É7ê¦K|!™ìVñ3GW91hFóF5Eœž60‘8„žéë&ôF‚ÇHQ’þ­ Œà³4¦Ð~ùYåÕl™b›ù”ÌÉ–áD8\×'8¡÷Dî²)Þ…“Ï}pç™ýÚ‘6)IãS’–9‚*éKTÓK¹üÇ»{[þPèpÃã ‡Ãî£ÀN€Úwo}PÀå[Õ¯N\%žm3éÜe[ʃ­r ÄÎr>rR{“^ º¬ªÄì:@ÈEìȲã a‡^åûãò®Õ×? |&•‚p¦=³×Á%é Œlùhm}ó ÿÿscrapy-scrapy-5569de3/tests/sample_data/feeds/000077500000000000000000000000001525051012400213555ustar00rootroot00000000000000scrapy-scrapy-5569de3/tests/sample_data/feeds/feed-sample1.xml000066400000000000000000000233361525051012400243510ustar00rootroot00000000000000 28.00 Beautiful, open edition giclee print from Agatha's original watercolours, reprinted onto watercolour paper, reproducing all the qualities of the original painting.

    The print is conservation mounted and individually signed by the artist.

    Picture can be personalised!

    An original and delightful gift, perfect as a bedroom or nursery decoration. (Especially pleasing for any cat lover!)

    Picture size: 17cm x 17cm Framed picture size: 30cm x 30cm (12" x 12")

    ]]> 28.00 Beautiful, open edition giclee print from Agatha's original watercolours, reprinted onto watercolour paper, reproducing all the qualities of the original painting.

    The print is conservation mounted and individually signed by the artist.

    Picture can be personalised!

    An original and delightful gift, perfect as a bedroom or nursery decoration. (Especially pleasing for any cat lover!)

    Picture size: 17cm x 17cm Framed picture size: 30cm x 30cm (12" x 12")

    ]]>
    28.00 Beautiful, open edition giclee print from Agatha's original watercolours, reprinted onto watercolour paper, reproducing all the qualities of the original painting.

    The print is conservation mounted and individually signed by the artist.

    Picture can be personalised!

    An original and delightful gift, perfect as a bedroom or nursery decoration. (Especially pleasing for any cat lover!)

    Picture size: 17cm x 17cm Framed picture size: 30cm x 30cm (12" x 12")

    ]]>
    28.00 Beautiful, open edition giclee print from Agatha's original watercolours, reprinted onto watercolour paper, reproducing all the qualities of the original painting.

    The print is conservation mounted and individually signed by the artist.

    Picture can be personalised!

    An original and delightful gift, perfect as a bedroom or nursery decoration. (Especially pleasing for any cat lover!)

    Picture size: 17cm x 17cm Framed picture size: 30cm x 30cm (12" x 12")

    ]]>
    28.00 Beautiful, open edition giclee print from Agatha's original watercolours, reprinted onto watercolour paper, reproducing all the qualities of the original painting.

    The print is conservation mounted and individually signed by the artist.

    Picture can be personalised!

    An original and delightful gift, perfect as a bedroom or nursery decoration. (Especially pleasing for any cat lover!)

    Picture size: 17cm x 17cm Framed picture size: 30cm x 30cm (12" x 12")

    ]]>
    28.00 Beautiful, open edition giclee print from Agatha's original watercolours, reprinted onto watercolour paper, reproducing all the qualities of the original painting.

    The print is conservation mounted and individually signed by the artist.

    An original and delightful christening gift , perfect nursery decoration.

    FREE hand finished, "Quiet night" greeting card included!

    Picture can be personalised!

    Picture size: 17cm x 17cm Framed picture size: 30cm x 30cm (12" x 12").

    ]]>
    28.00 Beautiful, open edition giclee print from Agatha's original watercolours, reprinted onto watercolour paper, reproducing all the qualities of the original painting.

    The print is conservation mounted and individually signed by the artist.

    An original and delightful christening gift , perfect nursery decoration.

    FREE hand finished, "Sunshine lullaby" greeting card included!

    Picture can be personalised!

    Picture size: 17cm x 17cm Framed picture size: 30cm x 30cm (12" x 12").

    ]]>
    28.00 Beautiful, open edition giclee print from Agatha's original watercolours, reprinted onto watercolour paper, reproducing all the qualities of the original painting.

    The print is conservation mounted and individually signed by the artist.

    Picture can be personalised!

    An original and delightful gift, perfect as a bedroom or nursery decoration. (Especially pleasing for any cat lover!)

    Picture size: 17cm x 17cm Framed picture size: 30cm x 30cm (12" x 12")

    ]]>
    scrapy-scrapy-5569de3/tests/sample_data/feeds/feed-sample2.xml000066400000000000000000000143641525051012400243530ustar00rootroot00000000000000 Example.com Example.com http://www.example.com/ Abadeh 7500.924 - 67cm Wide <strong>Price shown per linear metre<br><br>Minimum purchase 3 linear metres</strong><br><br>Quantity discounts available on 30m rolls.<br><br> http://www.example.com/product.asp?P_ID=1602 http://www.example.com/uploads/images_products/1602.jpg 69 Abadeh 7500.924 - 83cm Wide <strong>Price shown per linear metre<br><br>Minimum purchase 3 linear metres</strong><br><br>Quantity discounts available on 30m rolls.<br><br> http://www.example.com/product.asp?P_ID=1602 http://www.example.com/uploads/images_products/1602.jpg 89 Abadeh 7500.929 - 83cm Wide <strong>Price shown per linear metre<br><br>Minimum purchase 3 linear metres</strong><br><br>Quantity discounts available on 30m rolls.<br><br> http://www.example.com/product.asp?P_ID=1603 http://www.example.com/uploads/images_products/1603.jpg 89 Abadeh 7500.929 - 67cm Wide <strong>Price shown per linear metre<br><br>Minimum purchase 3 linear metres</strong><br><br>Quantity discounts available on 30m rolls.<br><br> http://www.example.com/product.asp?P_ID=1603 http://www.example.com/uploads/images_products/1603.jpg 69 Abadeh 7500.938 - 67cm Wide <strong>Price shown per linear metre<br><br>Minimum purchase 3 linear metres</strong><br><br>Quantity discounts available on 30m rolls.<br><br> http://www.example.com/product.asp?P_ID=1604 http://www.example.com/uploads/images_products/1604.jpg 69 Abadeh 7500.938 - 83cm Wide <strong>Price shown per linear metre<br><br>Minimum purchase 3 linear metres</strong><br><br>Quantity discounts available on 30m rolls.<br><br> http://www.example.com/product.asp?P_ID=1604 http://www.example.com/uploads/images_products/1604.jpg 89 Abadeh 7500.944 - 83cm Wide <strong>Price shown per linear metre<br><br>Minimum purchase 3 linear metres</strong><br><br>Quantity discounts available on 30m rolls.<br><br> http://www.example.com/product.asp?P_ID=1605 http://www.example.com/uploads/images_products/1605.jpg 89 Abadeh 7500.944 - 67cm Wide <strong>Price shown per linear metre<br><br>Minimum purchase 3 linear metres</strong><br><br>Quantity discounts available on 30m rolls.<br><br> http://www.example.com/product.asp?P_ID=1605 http://www.example.com/uploads/images_products/1605.jpg 69 Abadeh 7501.938 - 67cm Wide <strong>Price shown per linear metre<br><br>Minimum purchase 3 linear metres</strong><br><br>Quantity discounts available on 30m rolls.<br><br> http://www.example.com/product.asp?P_ID=939 http://www.example.com/uploads/images_products/939.jpg 69 Abadeh 7501.938 - 83cm Wide <strong>Price shown per linear metre<br><br>Minimum purchase 3 linear metres</strong><br><br>Quantity discounts available on 30m rolls.<br><br> http://www.example.com/product.asp?P_ID=939 http://www.example.com/uploads/images_products/939.jpg 89 Abadeh 7501.958 - 83cm Wide <strong>Price shown per linear metre<br><br>Minimum purchase 3 linear metres</strong><br><br>Quantity discounts available on 30m rolls.<br><br> http://www.example.com/product.asp?P_ID=1606 http://www.example.com/uploads/images_products/1606.jpg 89 Abadeh 7501.958 - 67cm Wide <strong>Price shown per linear metre<br><br>Minimum purchase 3 linear metres</strong><br><br>Quantity discounts available on 30m rolls.<br><br> http://www.example.com/product.asp?P_ID=1606 http://www.example.com/uploads/images_products/1606.jpg 69 Abadeh 7502.938 - 67cm Wide <strong>Price shown per linear metre<br><br>Minimum purchase 3 linear metres</strong><br><br>Quantity discounts available on 30m rolls.<br><br> http://www.example.com/product.asp?P_ID=936 http://www.example.com/uploads/images_products/936.jpg 69 scrapy-scrapy-5569de3/tests/sample_data/feeds/feed-sample3.csv000066400000000000000000000001211525051012400243310ustar00rootroot00000000000000id,name,value 1,alpha,foobar 2,unicode,únícódé‽ 3,multi,"foo bar" 4,empty, scrapy-scrapy-5569de3/tests/sample_data/feeds/feed-sample4.csv000066400000000000000000000000551525051012400243400ustar00rootroot00000000000000id,name,value 1,latin1,test 2,something,ñáéó scrapy-scrapy-5569de3/tests/sample_data/feeds/feed-sample5.csv000066400000000000000000000000571525051012400243430ustar00rootroot00000000000000id,name,value 1,cp852,test 2,something,ÈÊÊÊÍÍ» scrapy-scrapy-5569de3/tests/sample_data/feeds/feed-sample6.csv000066400000000000000000000001451525051012400243420ustar00rootroot00000000000000'id','name','value' 1,'alpha','foobar' 2,'unicode','únícódé‽' '3','multi','foo bar' 4,'empty', scrapy-scrapy-5569de3/tests/sample_data/link_extractor/000077500000000000000000000000001525051012400233175ustar00rootroot00000000000000scrapy-scrapy-5569de3/tests/sample_data/link_extractor/linkextractor.html000066400000000000000000000014761525051012400271060ustar00rootroot00000000000000 Sample page with links for testing LinkExtractor scrapy-scrapy-5569de3/tests/sample_data/link_extractor/linkextractor_latin1.html000066400000000000000000000011111525051012400303400ustar00rootroot00000000000000 Sample page with links for testing LinkExtractor scrapy-scrapy-5569de3/tests/sample_data/link_extractor/linkextractor_no_href.html000066400000000000000000000013441525051012400306000ustar00rootroot00000000000000 Sample page with anchor tags containing no href attribute, to test the TextResponse.follow_all method
    “The world as we have created it is a process of our thinking. It cannot be changed without changing our thinking.� by Albert Einstein (about)
    scrapy-scrapy-5569de3/tests/sample_data/link_extractor/linkextractor_noenc.html000066400000000000000000000006061525051012400302620ustar00rootroot00000000000000 Sample page without encoding for testing LinkExtractor scrapy-scrapy-5569de3/tests/sample_data/test_site/000077500000000000000000000000001525051012400222725ustar00rootroot00000000000000scrapy-scrapy-5569de3/tests/sample_data/test_site/files/000077500000000000000000000000001525051012400233745ustar00rootroot00000000000000scrapy-scrapy-5569de3/tests/sample_data/test_site/files/images/000077500000000000000000000000001525051012400246415ustar00rootroot00000000000000python-logo-master-v3-TM-flattened.png000066400000000000000000000256231525051012400336600ustar00rootroot00000000000000scrapy-scrapy-5569de3/tests/sample_data/test_site/files/images‰PNG  IHDRYË]Ɇ&sBIT|dˆ pHYs ð ðB¬4˜tEXtCreation Time06/05/04M¾æq%tEXtSoftwareMacromedia Fireworks MX 2004‡v¬Ï IDATxœíÝŒ×�ð¯Ù2G?,™ÛkkÔ³q\Ò�r’ž)ÔÎET‚ÂÙ½Ü×URàöâ�€¤AWW£@6�rEÕó¢¹¢X]Z;¡€¶WïmRnUäâÝ4u¢q~4"sq�ÌÊ¿"Ú^ÅÙѯ•HY²5ýƒ;Üáp~¼!g†î÷Øä ß¼yÜ]-¿ûÞ›7 Ã0@DDDD¡ÚØï #†,"""¢0dE€!‹ˆˆˆ( YDDDD`È""""ŠCQ²ˆˆˆˆ"À�EDDD†,"""¢0dE€!‹ˆˆˆ( YDDDD`È""""ŠCQ²ˆˆˆˆ"À�EDDD†,"""¢0dE€!‹ˆˆˆ( YDDDD`È""""ŠCQ²ˆˆˆˆ"À�EDDD†,"""¢0dE€!‹ˆˆˆ( YDDC¨^¯CUUÌÌÌ V«õ»9DëÒ¦~7€ˆˆÂ¡ë:*• 4MCµZmí?pà@[E´~1d%˜¬TUe�Ñ€aÈ""J°R©MÓúÝ "rÀ�µNœ9wKX<·ÀÀ™¥¬4®Î]¬ãõ‹ À0,G€±ú`tG wï�ZÛÞ7ÀÀèÎ-ݙžÑØ–º%ÆwDDD4ز†Ð™s+8ýòyœ~å<Μ[ÁÒÅz[`Ð T†¹Ïè| –×–.Ô±tájk»òÒo×ʬֽ-u Þ5z;¼ï�xøwïAæž‘¼?""¢$`Ègέàéþ•W.`éBèO.Ë%|¹ïÑÛµÒ¸ŽÊ‹¿EåÅßâ©ù_ sÏLìÛƒG?ð;½¿A""¢„aÈJ¸Ó¯\À׿÷"N¿rÞ’•º X†ÿñËéøÅ×.à+ýc<õÌóøâ?üö>pgWˆ(‰¸NV‚ýÅ·5>ñÿÄ–aİ Çã—.\Å¡¯}O}çùîÞ(Q1d%ÔŸ}ë˜ýáYøœöí^–ÎòÆÚöSÏ<�¯Ìþ(è[%""J$&Ð׿÷NV_‡oÀiÛö XA—×ñè X¶²'ò_œø=Ñ·KDD”HìÉJ˜¥‹ <õ½,`Ñ,Ëp¤_Ý'òf¿ÿ+á÷LDD”DìÉJ˜¯¯ÙäbFw܆‰‡~™ÑíØºy–.6ðôs/ãôKºCy—€e ðž“pB< _ýÖOñðïîÂè[~ˆˆˆ’�!+aÖ&¹w†˜=woÃñü!l½mmQÐ=£Ûñð{îÂÉÓ5|åü î!È¥· hQ]Oˆï<þ©ïü_<ø�à;'J¾z½I’úÝ ¢È”Ëeß2ããã(—ËA>Ÿw-§i4Mþ}û�N§Ãlfl8\˜ Kík`YÖÖÛ6u,«G÷ÊøÌGö8 ñY·-Ï,Aœüñ X:ÅûM%œ®ë(—ËxüñDZ°°ÐïæEjÆ mÿÍÍÍu쀹¹9”J%èºîZ×ÜÜæææ<Ë :öd%ȹ‹×à6 ÷hî^×€ešÈß�§VïqhB{�óµË7Ÿ}¾†‰‡ßãÙn¢¤©×ëPU•7m¦ugll¬m»\.wì3�ŒŒ Z­bÿþý¯éº>ÿv²dåÚ�Õg�!æáwû/ô¹í¶[�½‹¯_\«èjNUû¶Sy±°Ó/žcÈ¢¡P¯×Q­VQ©TP­VûÝ¢�W(ðÌ3Ï8†¬ùùyäóyÌÏÏ÷¡eáápa‚œ9·Ò9ÄØB’·-›Í\GÀ2¼‡ `¥~]¸íDƒlaa¥R‰‹H�,Ë�$ š¦u¼¦ª* …BZ.†¬$ñ¸MÎâÒe¡**//#¼€e8”<~µì•CÑzU( ªjÛ¾jµ Y–;ÙÝŠ!+q¬“Ö�Õ=Nž~Õ÷È“§Ï"XÀ2Ê»Lˆ÷ Xkh-¾–Ü �DDÔ›\.UUQ¯×[ûTUõ¼ê0I²’Èa÷3ç.á«'ázÈâÒ%|õýÜvüjˆ 2§Ê@gÀ‚µ¼-  ÷–Ñz“J¥�ÏçñÜsÏhNx×4�!‹úÄã69³Ï½„?ýÏ?ÁÒ…µ¿V®ÝÀÉÓgqèÉàʵ½MZ™oeض-­u:3Ñú¶oß><óÌ3†« àÕ… ã°ÌÄòì/—ðì/—°õ¶MÝ!áÌÒ%t†�€Ê|-ïã F,"¢u/“Éh.>zêÔ)>|¸Ï- CVâ–¼âÞËt¥qg—_k>x„ Ð–{˜cÀ"""Ó�pâÄ H’Y–ûÝœÐp¸0aDVûœ(Ûk@眪ÀËp(ßeÀ °ü §|>�ååå¡X¶ÁŠ=Y â°üN[§ò†»<Þpj ¥'Ÿ|Rh*•r,ëv|R0d%‰×ºTr›¯1ç€Å°EDDÉ!+‰º8} X–µ¼ÜÛFDD4|²’¦¯Käøö€ex–µÖMDD4\²ä3Éà3ÉÄr®C_[ÀéÞ€pÀrœåR¶õÀ€EDDË!‹œyõ` /2*Ø[F¡°ßdu�/…Öu}(îKFÉQ«ÕÚnÝbªŸE]×±¼¼Ü±� 3†,òá6ÁÞáµn3V µZ µZ gÏžE­Vƒ®ëÐuÿû?¦ÓiȲ EQ�Ëåúò¡R¯×Q­V¡ª*4MKüUCë‰=Àƒý¡mÞšEÓ´Ö¿?™L¦õoDQH’CK»g¾Çååehš–¨ßëÉÃàµôqºríN¿|‹ç.céB£í8FÇ3§!:û¶W��ϤuÃVÖòü…×/b¥þ¦Ë¹œê XÎÇÿèßÿ“µÝ—N×_i¯ß©�f]›ß Ür' ý=`ã #3”T«UǿĻ¡( öïß�\.J}nÌ`U©TP­VÛ^‹3dÍÍÍ¡\.{–ÇØØ˜o]ÓÓÓŽ¡#LŠ¢`rrR¨¬H{Ž9ÒZUÛK½^o '‹‹‹¾ÇȲŒL&ƒ}ûöõ=t©ªêø³Ö�|>�|>EQBhYïÌïMµZm…ª0(ŠÒz¯>ödÅädå5<ûËßàÙ_Yæ9y܇0ôIëqß&'Ðñ× ¼ù [À²Õa}l<¿¶½õ÷�;>lº³³Þ„Ñ4 ªª†¬ìõkšEQ011ú‡c˜v-¯ ,Âì)ZXX€¢(‹=˜¨ªŠr¹Zð0ëTUµoïÉ×ï‚r¹Œb±80¡rX0dEìô+çñgß|ÞÒcÕbÖr„`ÀršÓ4H·É ÐÚØ{°‚–ù¸ò]àê�ôcÀöýH"MÓ077yO‰õ|G�E±X õ/ÙR©Z] ]×Q.—Cýð6?°÷ïß�ƒ†R§—z½ŽãÇ�GúïÅúžÆÇÇcFŒûw�®ë˜žžŽí{·^0dEäʵ·ðßþNV^õ 1âË%à´Õ×^wÇk} XÇ;�Tw°Ìƒß¾ ¼ñUàÆo€ô§:ëPµZ ³³³±ýBµ+•JÐ4 Åb±/ç§øÔëuÌÎÎBUÕÈα°°€ÅÅELNNFJ4MÃñãÇ#éÝq²°°€jµŠÃ‡G:,w¸²‹ã{·ž0dEàʵ·p¨ôcœ9w¹·€Úmrêv² í69AVˆ=YÖãÏÿ—æ\­íƒ¬r¹Œ¹¹¹®�wš€ìv%•óC—AkxÍÏÏcnn.–`R«Õ0==ɇµªª}é-5{{:ú°š®ë˜��E¥R |¬[[º jQ~ïÖ†¬� X!(†û;¾³­Â˯·¬C�Ë|<÷o�Í÷7ÿ@º®cffFèÊ'«\.EQZŽÝد桪*FFF„&€¯Wn½ËË˾s‚Òé4FFFº>G·–——Q.—»úЕ$©ëPV«ÕpüøqáIü"‚,Y–[ÿfvíÚÕ¬W!Š›ÖëuLOO‡:Ä4üær9d³Y(Šâ{uàââ"*• TU ô}4{×ùGWo²BöÏÿkŰ:CŒc0é) „˜0–ßmrìÇû¶-¢ž,óñ�ÿÈÿƒÆüKQô—žyP6›þËR’¤ÖUCš¦¡T* M .—ËÈd2œëbbbÂq¿ÈŒûöíëK€ %étºui¿,ËàµZ­5!\ôgWÓ4,,,`ÿþÞçI X¹\û÷ï÷ü9N§Ó­#õz ˜ŸŸzo³³³�e¹§@¬ëzk¨Þ�$I( ( H¥RÂçÈd2Èd2Çüü¼ïϨ•ªªÈf³‘_…<̲B4ûÃ_ãôËæ‡XXË£·«ç€0 ݇°›0×^Mh põo�KÏ·è—[ÛàRÞЬì)pO–­M€k/×^Ä ù…\,#¹‚I’$>|Xèìüü|¨ëÑàPGŽé9Ä‹Eá^Ž s‚¬Nœ8áû³hþÖ%¢s®Ì5¢F/¶Y–͵âzwÝcÈ ÁÒÅNV^ƒSÀé-`…ÐKÔ1ÉÜÐÂ\÷Ëðh›ÑöЦ£'Ëž¬�¶ý¯YŽ?ÿM‡­OétZx^P/½4x$IB±XÄää¤Ð ð"ŠÅ¢p¯ÈsÏ=¸~MÓ„†%:úð¶èB½år9ð,ýd®UÒÞÛ aÈ Á³¿ ºŠ»¹O4„,þíP·SÏ�cÝ"Ëè(/&[‡ÚR–S@²¿×¶2¶vtôd­n_>ZS(„þBVU•½YC"—ËáØ±c¡ß>%•J¹^ `×Íò"ó°ü&¸wK’$á÷6;;úù£&úÞ²ºÇ�‚“•W›O„–±öŸWi=¸„�!>ïºE–Cx p|ÛsǶÈìr¸œ½#`ùôRÙÏáÔËõö•�2…‚Øbóóó·„¢vðàA:t(´y=vù|^¨)臵HÈ—$ ããã�ê ¼²×�¹D’˜7‹öׂ¯Ãˆ!«GW®ÝX]²a-¾¡=„ðì% eB¼¹í°às¼×ùÝë¶4[¥Íhóöt,k;¬Ë¡N¯y[ìÍj#Ú£åÊà�8nÜ,:QçºÜ¶;�¿MŽCÛÚ¶½¾M÷ܱ­m×_vX¶«n\áäM+I’„®(35%ò":\'²4M*{à@ô˳¤Ói¡+¢m$\/Z Y=:³tyõ™àmr¼BˆYÆÙ‡Ë>éw|йd«öfî]Ûp X>�Öó{=^ýYÇù×;Ñ_°ÝÌ¥¡õ%È"DzP�L�ŠhÏo҆ףB¦&†¬�~ù<Ö–%ฅ¯"°z�œBŒo@êlk UÜCXð 5d5žï<&¬€eÖÛ0×2#@`‰„IÛ{±ÉìÁ_~á±5uksÇÍ«ÀòSí©Œl_/ëþn–ùøö×ö­G¢½\��â"ò³w/V YdbÈêÁÒ…«¶€4„ø,þíPwëxëëNu‹,ãm>a²u¨{ÀÚ›¹·=`Àò“ks±œÞ«SÀj5ÃÖn¡ fp^– ‡@h�ˆþœõëª8‘9Œüƒ„L›úÝ€$»Ò¸áÒzp 1¡Ì×ò>¾÷UÜ]Îï®¶¥6ã±�¿ÿþöÞø àÒ|{ýÖ ×´ÚÚ¢ìír9žˆV£ÑèwBQ«Õ¸<1dõ¢}NÖžÁBLhË=Ì…s›çºÝVf×>þлñèï½§½÷ ˆ `ùÕcy\©[Ã]8q½à‘˜a ‹Ô†¬žù„À!¸¼RÀÚû®»�¹÷lMÝâ�l;{wŒ¶çe8î6WpÏìé V@sÖÒ¿ê t$—ð)<ïÊ|Mà8ê ?8ˆˆÄ1dõ¯©­ŒSù=HžÇØû®»ñèÀ#õ6‡p3.þÏæR o_Eø+`=Ô2Ȉ‰ˆ’Œ!«g‚ÃhaNˆ·lï}×]xìcïGî�»z{Q¹yX™.–�¿é- ZžÇ‡øþ†‡ÿˆˆ¢Á�Õ‘€$2äçu¼SyÛR·â3û»˜xø=îÍ{ë,`\u †ç¦ãA†Ïëæî·~Üx¨ÿxóEÿ`vÀbOV ¼j�ˆ( Y½êyN•Syïã·¥nÅñÃÅž{vÚÚR®}xóûÀ�³è:€„dŽïKÀbвâåæDác1 Y=ÉܽÝá à +x@s XF¨¸öƒæðœ½¾¨’×ña×vp#¢�$z‚Aÿã •Jõ» 4²z°5uËê3·€dù@÷ X=,Ö[g�•¼½ñ!Ëf½ÙƒUÀ «>· dÝïx¼!Økh²÷z·ç@kD{²E‰¸%DM¢?kq­ååe¡r YdbÈêAs¸p ž kÏÍò®Ûýà¸{ç–µ_ý«–[° �lí·!{@rl‡e¿k;lÇtïPưyÔNôƒŠC…—A½i¹¦iBåø ™²z°gôöö ´‡‡žVóáà#–µ°Þ^n.ÓÐdÜzz|•=Øxõ $‡ é~üÊ8¶Ãákíø5pØÏùXD?¨vïÞqKˆš²Y±{‹Š†ž°ˆ ¦ÓiþAB- Y=Ú{ÿ;,`9�€5zÇì¹ç޵j¯ý�KH±�C´§ÈÞ.¯^ ×ðãTÆqi‡µ}žáÐZÆ#dZ¿R´¦V« O ÎdÂýÚq.yÉåü‡õãY"çc/Y1dõ(sÏíÍ'¶!>÷€‡ònÛyŸ­÷ ñý¶×Ûžî)²µË5ÈØêõ jÖǰR˜óÇv> Z#ú!%IRèóLx³iò"Ò›¥ëzla]×u¡ž¬B¡Ck()²z´÷�;’åy׫iÔ:«u›§bžK´§È)`¹=:,¯€$Z¯ËqAëñ;Þü:måœ,Ñ�%:|„è$bZŸDz²àÔ©S·¤©R©ø–I§ÓœôNm²zԼ X¶°x,2÷Z† ß^ö A{Š,�~A(H °‚¤^Û;­ÑuÕjU¨ì¾}ûÕ-òA÷P%K*•B>Ÿ÷-§ªj ­;Ï�bh % CV�îÞ)Ù† ½Öž·Ê[^³,km€·~�ÈH?VXí jï­íH§Ó�çc‰¬x-ðhý÷-Óh4"Zš¦ùKJ’$ i}aÈ Aû�!Ú?ø±úè°l½]†µ,ÐY6ìâUO?VXíÛ< lÙjª×똟Ÿ*ÛÍ_ç"WWÕëõØz!‡HƒI§ÓBÆår9Ò{–Ëeß2…B�÷+¤ Y!xôÁûÖ6ìÃö€e�ŒîÒÛå°ÐyLXÄëxÑ UÀ «}£Ÿ´!×µR©$4ñ¼Û¿ÎE¯°ŠúÃ1N"ïYtÅpZ311á[F×uá?‚Ò4 ‹‹‹žeÒé4ÆÆØSN�²B°çžÍ ê®Ë@+`µ¬¶òÖªC q 9FÕ3&RÏ�Éú¨ªjdáCUU᡺‰‰‰®þ:�´¬ë:fggׄ¦iB“–{%òuâ²Á¥Ói¡aù¹¹Ð¿¾õz333¾å<êyix0d…ä3}ïê3‡€cn·¸,Ã¥¼µêˆ_=aµ~NžçÇ�wlE’¨ªŠÇ<ôá´Z­†R©$TV–å®ç˜ˆNZšïU´MA躎R©„éééXÂ�,˾sÑêõ:ç¢uallLèbŠ'žx"´?Nêõ:¦§§}{| …B$WßÒp`È É£¸£w¬þ‚õ¼‚Ð+`ÁùøµÂ ¢)Œ@äÙ‡¯MkŸS}†x½»?‹$j4(•J¡…-UUqôèQáòÅb±§ó‰ô>˜TUÅôôt(ÃiõzårG��}ΗȇmTÃZîX,ú†ØF£ÊÏQ½^G©Tò ç²,ú9§õ‡!+DSù,´‡¡€å´¬åz ¢õ™å —z‚�a+ß°àÐk_—ý^AÍ0šÃ„ _Ëì‘ùÜç>‡r¹øD×u<ñÄ�z‹ŠÅbÏëýˆó˜4MÃÑ£G»z�æñ¥R Ÿÿüç177×—¹^"K]hš†¹¹¹Z3\dY þµZ _þò—»^"DÓ4LOOûö8J’„b±ÈÉîäiS¿0Lr܉G?x?NþäÅæŽ^–Ó�¡u¿k°it2öý^õ˜Û=ïÖ‡÷êæö;ÖcÙÞ´ ¸oâÑh`nnsss�e™LŠ¢`÷îÝmWóÕëuÔj5Ôj5¨ªx¨,ŸÏ‡v)úØØ4MþÀ«×ë­÷˜Íf¡( dYvŠÓ4 º®CÓ4T«Õ�˜@ŸÉd�Ëå|瀕ËeÔj5|úÓŸZ²Ù,ŠÅ¢ï f�V&“Áøø¸ÐE •JEx¾¢$I˜œœä£ä‹!+d_øÄpæõóX|õüê�€eŸ¯Õ±Ïú²O ¶ý"õ¶•±× uœ+àñžïÏöø® lJÖ\,QfˆZXXµÞ|>ßó0¡ÝáÇñÄO{Õj5‘ó—ŠÅ"4Mó }•J•Jù|Ùl¶#,“3ó‘žÙÅÅELOO·Vbw EæX¢·ybÀ¢ ²B¶õ¶[püÐGqhæ;X|muÈÃ!̈,[Ðê)`¹ì÷ªÇþØÑ§÷ÖeÀ {xsô“@úÃH*EQb_=Š€4'Á9r333ëb•÷T*…ÉÉILOO õ®©ªÚ6w,�N#›ÍòŠ5ù|’$¡T* }�ÍûöÚÍ!K,Å9YØzÛ-8~ø>üž`Ë€CذqœÏä@b X†C;œÚWÀºïŸ÷'{˜p||SSSÂëMõêàÁƒ‘,“<â¾y®¹Î×±cÇb=¯,˘œœìªgJ×u¼úê«´j¸d³YLMMÅx …B¬ç£áÀž¬ˆl½íVüÓO|�¼o7žüv§Ï,ÁŒXÍ[o•5DØCE›H�@¤ÞA¨çöù³ÍÇ!`~P/..¢\.GÒ ¤( &&&bûà˜˜˜@.—Ã7¾ñ�HæÌårÈf³Èår}›˜,Ë2¾ô¥/áé§Ÿ^w+ÛÇ%�Ncjj ªªbvv6’yyqÿ¡áÂ�±Üwáøá�áÜù+ø¿??‹Ÿ¾°„•úuœyMÇJãÍf¡0VØA&ªàæñÛ÷®>>Üña`K°ûë%E&“Áää$jµæççC™ä­(J¨܃Èd28vìTUíúJB»t: EQ (J×Á*ŠùP©T Åbããã(—Ë3AØäóùÖóó󡬋–Ïç±oß¾À÷í$²Ú`n3¬ià\þoÀ忎'`íþs@z_´ïg�šžžöí•:räˆç/÷jµÚºjOôE–åVÏ ýUn½ R¤·NQH’ÔšÈ,˲P@zì±Ç<_÷ûš‡¡^뾕 ÖIDATÑh´¾gš¦ayyÙ1d*Š‚ÉI±!o‘IÛ²,ÇÒ£7(m1ç_iš†³gÏ yEQZ÷JT%ò6Ê× €ïmƒ0lv‰=YIç� ¬l6Û¶ð¥ùK²V«µzJÒé4FFF�J¥*TÙɲÜq:û/ý^Á‹|ˆÄ!•Jµ¾waÝën�¾·ƒÒ–t:�ýû÷cÿþý­}n?étº/WuÊ× `€ŠCVâİ´’Äü%9,¿,‡å}ÐààÏõCV¢Ä0'Ë,÷›¯¤µòmõ8´§mÎõÂv¼Yß»ýoÀJ5®ÜMDacÈJ’8‡ /ô^O võ× ßÑp`ÈJšÐ‚–µW)Ì f«·uœµ—˶Ÿˆˆh1d%NÉåx‡…S]’c=],f,ŠÁòò²çëìÅ"¢(0d%Jɾ‚ïÖ‡á?ÇUàöEÌï~Þ¤™ˆ¢À�•$½A«ÿ +` HçrxdÀ¢Áž,"ꆬ¤q XA&Ȱ¢ëñ–ý^Çw»z;Q„üz²FFFbj ­' Y‰âhà�V/·ÇÙ–ÞùGÀŽ¿ßÜ÷æ ÿo൯ûO1¿•äÙ“EDQ`ÈJšA¸¡½|úç_´·só(pÏŸ4Ã×/û×C‘jµê[† UQ6ö»@[oÕꎸ–a+ëÝÀ®Ï¹·yÛ^àÞ?aÀ¢¾©T*ž¯+ŠSKˆh½aÈJš~,{}wþðŽ­Þm¾{¢³óø[GÅÞ7QêõºoOV.—‹©5D´Þ0d%ÉÆ-ÍG£�ËÞ“•Úãßîwlköh9µë6†,ŠÎ‰'Z7Ìvc½Ñ6Q˜8'+In½¯û§°V×C}.õøõ‚uIUUß¡Â|>�t:S‹ˆh½aOV’¤ÞÛ|íÉŠ:`P_ôo÷[+À¥ÓÎõlÿ€à›'§ª*J¥’o¹ñññZCDëCVÒlÛ¿ú$¤ž,Ïz ÿúÎÍú·yééÎzÍãÓxÓDâÊå²pÀb/E‰Ã…I³ã€KϬnXŠc@2‹u°à°ŒÖ& ¸~xé+Àý_tnïÕEàÕÿèÜ®;ÇšK=…@Ó4ÌÎ΢V«ù–Uccc1´ŠˆÖ3†¬¤Ù|°ó€ ßê"`Ùlå;–½‡ã X> ¼ù:p÷A`ç#Íýo.o̯>é\ï¦mÀîÏöô¥ šájnnÎwÁQ“,Ë8|øpÄ­""bÈJ¦ô?®þ xó¥ÞRWË^ÏêöåÓÍÿÜê±?Þ?É^,ꚦi¨T*¨V«¾·Ì±’eGŽA*•аuDDM YI´q °ûÏ�³ \{±·€d-ßóÚY.í°?f¾Ü*$ it]ÇÙ³gQ«Õ°¸(p±…ƒ|>�ƒ2`Ql²’Ê Z¯O+j€€„ˆ–Àã¦mÍ,,raöPÕj5躨—Ê�$I(‹\‹ˆbÇ�•d·»¾œÿ&ðÛÿÔ\*? …°nÿ@3`má=âÈ]£ÑÀÂÂBhõ Œ��³÷Šˆú‚!kÜñ‡ÀíÎ.üMs_X©× ¶y´9Á�½W$ ŒûJ’„|>�B¡À%ˆ¨¯²†Å;¶÷îüãfÐZþïÀÛ+Ñ÷d¹ÕsûƒÀ觸–ƒB¡I’|W#_�R©dYZ†ÁN–e är9ö\Ñ@`È6·ÜÕ Zwþ1pùpéÍÿœnÀºýA`燛ÁŠWºÊf³Èf³h4PU§N�ê*T +EQ„¾’$AQ(Š‚l6Ë^+"8 #ðMè(‰/W*ÀJh,ožCOAëÛi°ýÁf¸Ú¾7®w2”t]o.]×qäÈd2ësþZµZÅÌÌLÛ¾t:�‘‘ȲŒ‘‘d2Ȳܧ‰aÈZ¯Þ¾ 4Î4 ½¾Ô O+§[¹Ër)"�Ê›¶®«Í£ì©ŠP­Vƒ$Ië¶g¦Ñh V«µ†‰ˆ’Š!‹ˆˆˆ(¼A4Q²ˆˆˆˆ"À« ׃Õá¸Ç…7À† 1Ÿ•ˆˆh00d%�aˆ…§˜ƒÖÁs1ˆѰbÈJ ¯0%´â¾¾ÁÀj€ò°€±aƒe©Ûë _DD”` YÊ5¹*·ò^á*Ìà刚Kj®¯»ÖÕ< £}ìõ""¢$áƒÂ)< *§o¡è>¯ý"¼Â“Ókö}ÛÍ�¾ûüÎMDDÔo Yý$¬¼B•_àjÛörÄçlùõ*y…)ß e-ëpÇ} \DD4`²ú ãKî¬DCVë¹½>Û¶WÝÝè:@}ÞÜáºí¶�ˆˆ¨²bä®Â�o¨² \neý¶íÇ8m»í�²ïsÛ¶¤ AÌþ\d›ˆˆ( Y�ˆ$LÙ÷‰†« ¡ÌþºÛ•‚nH4\Ù·ýýöìÑ""¢ÁÀu²¢ä0«mÓñ÷õ±ìeü•HØòÚçÆ E†a´ÂËÆ�;Îg-gg=—5ø´ê2ŒÖYí_«Ö¾Õã€Õ…P-ÛDDDýÀ�¡ŽyXnå&¦['¶»Õáµ–HÀºyó¦k30ÙC“tnÞ¼‰�Ýï7¯m°$×ZÜùÇÐEDD1cÈŠ�ýþ}n÷ó³û>û#\nCcï%r{ÍÞ+dÝgï�ò"´¾nXNó¢Ü†ì:†úºÐqœý\ XDD3†¬(Ù‘mÛ)t9.{y§ dkn[{�œ†î¬ç3ݼySxî’Û|,·×„ç\ ÌÉr:'<‚ãõ'¾GÍ~5!‚M~·>n¸/Ý r5a·WŠ.­äjC‘¥¸Œ% CVLü‚ŒhÀrÜçÔ‚„©^~ü–RðÛ'®\Ë57Ööûlʼn!+Nö^-Ÿ^®n‚—HY¿c‚ð YÖ}®ÁHp‰¯s±÷ŠˆˆCV?ø !úl}ÞͶ(Ïž¬æNÇm‘¡¿ õ;•!""ꆬ>ëøò;0û¾n™×>ûù<ù¯ dßRp Vm"""ŠCÖqüV„.·cƒ¬ ? ~=F¾Wºìs,Ó|ÁwÑ aÈTNáÊk?ÜC’ß·8¬‰ïAʸ…$¯ðÄ¡@""J†¬$ñXž¯)¿ž¦Ö+n�Êçx""¢AÇ�5 DÓy¥a„Mñ O夈ˆhHqÅ÷a°aƒðªæÝÞ0ˆ A‹ˆˆh1d­'Âõf£"""" Š!‹ˆˆˆ( YDDDD`È""""ŠCQ²ˆˆˆˆ"À�EDDD†,"""¢0dE€!‹ˆˆˆ( YDDDD`È""""ŠCQ²ˆˆˆˆ"À�EDDD†,"""¢0dE€!‹ˆˆˆ( YDDDD`È""""ŠCQ²ˆˆˆˆ"À�EDDD†,"""¢0dE€!‹ˆˆˆ( YDDDD`È""""ŠCQ²ˆˆˆˆ"ðÿÅ…w¡I¬ÉIEND®B`‚scrapy-scrapy-5569de3/tests/sample_data/test_site/files/images/python-powered-h-50x65.png000066400000000000000000000062531525051012400313530ustar00rootroot00000000000000‰PNG  IHDR2A“‰÷ÞsBIT|dˆtEXtSoftwarewww.inkscape.org›î< =IDATh�ÝZmpS×™~®î•îÕ·,Y’¿ÂF6F�Äà¦À°f %t“lšvÒ¤»ñ²³$ÌÓL'm—ivÓβ4“�d3 MºÙd�aºËÀ´¬w]ʰ$`#ˆñ‡lK²,[ßÒ•î×þpœøC²dW¤KŸ?Òœ{ιïsßó>ï9d$IÝø#€ìm@¡plj$ òĉ%‚ wò>wœˆ×ë¥OŸ>]ÎóüÝE$‹‘ÇŽ«â8îŽ>' å/^4‹¢ø¥¡²]ˆF£doo¯Öçóч#V[[€H$BõôôhšššBÓ}ûûû•Éd’¬¯¯�]¹rEçÏŸ/ª­­�Íœsz¬ÓéŒj4aº=È»»» ©TJæt:#v»=9}ME\¾|Yïv»•EI›6mš0 ü\{3zäµ×^[öÊ+¯ØC¡Åó<ñòË/¯ìèè(€®®.ýéÓ§Ëföïìì4¶··[€eY$“I2�Nî•—^ziÅ›o¾YÕÕÕe|î¹çœJèëëS½ð uCCC*Š¢¤W_}µúÌ™3&¸yó¦êÅ_t|üñÇz½^ÏݸqC÷üóϯŽÅbd^¹zõªñ™gž¹YWWš¦ÅsçÎY¶oßÈÔ&ššš‚eÛ¶m Ð4-Þ¾}[�?þ¸»¼¼<GŽ©>wîœiùòå#–ÆÆÆ‰}ûö�@II ûÞ{ïU577Oôôôè(ŠŸzê)7466†:ÔpåÊýÆ�'szd.l6[Âçó)ÃápÖ¥˜ ÅÅÅÜôÿŠŠŠD  9Ž#z{{ +W®ŒN_«¯¯�E"…ËåRÏ�C«Õ ƒ!=ÓÓ‹"RTTÄÀðð0³4³ASvÄãq’eYræšW("EQâää¤b1sæEdppPI’¤XSS“€t:]µ3 |EEE|``@5Ýæv»Ad ‘ÅÌ•Õ —Ë¥M$d8¦:;;MÕÕÕQš¦E§Ó …BŠ®®.=0¥8cccÊéqEEEEQ¢Çã¡ó1 ¡¡!téÒ%“ ÇqÄÙ³gÍv»}–ªåƒ¬kÞçó1G�­öûýŒÅbI¶¶¶€ÅbIïÙ³gøøñã•§N�*+**J'“I™Á`à@£Ñ»vímkk«liiñ™L&N­Vs2™Lšž›¦iA¥R °{÷î±H$B>|¸–ã8™ÙlfŸ~úé�Ïú‰*•j–ÔªÕj^.—‹sí%2í~[[[×M«– I’ÒÜ>3ñÖ[oUªT*á±Çó,ü܆(Š�É–¶jsŽÊEBEx<¥Õje—dÁLc–HX`iåB8¦\.—úìÙ³£Ñ˜ž™éÿÈH䡇Öétó¶31::Ê ©¶oß>¶nݺE)Ì�@ƹ±ä¥• �H’ê¾åÕŒ…ãò‰hRŠ&)JFH5õ o5¨Ó[Ö,‹P9bo±(‘~oˆþññ‹¶^÷¸’H€Ï~?ÿ/’‹A•úæ§ï‘ë' E¨ KK�‡zªÞˆ1«m&Ä“) øBÈFdº}×ú•þ|kËðïÍ:X Œ…hÏDœYc·àçZð£X¯Ê9îWº,¡XrÞ–|)($)ŽŸÚU¢A˜—|3€€k8 ÌÝ/7 #¢ ÐãžÀ#/žB2•F0–_~„‰ ®ZÃ�èT,|É(‘µ+,ñ·îèù¢e øö?žª/Ä}¢‰ô•Ç~ÓSÚç «¢I–šzøÓê$â ušÑžsÒïÔ"åa I€¼8ã–I”?9䯋"ò›OFô?h»´B%"“œf“Ùœ$D–„$¬[Ï/T�xåË×¶¼U+Ær²ÿòòrQBŽàÌfø"óžÿ¤B*o!È›ÈÉKƒF– ¢ù3¡QÒÄ ¹„Ë1~”ï°Óé�ÕûH»à •#ö¹ÜNI·…@—å~9PðbFøß©Ì˜G$ PÖ$ AäËÿ„£h`þS€(ìFº0!ó|)C—U‡¦þ§=@è< ×D< ãeu¤–ËÙ�R£’ý©æ¡t,*¨³¡pEìØGZx^¶CˆR³b`Þ^K ™€ªïõð± •üÂVã…„ �_Zl·@ŒS‰ cË8¬{Ç@é –“îÜk…”O�ô v„¡A—§ÀT¥ 7ç^‚KÀ]÷~äG¯Ÿ¨|´¹1°zEù¬Øºë¾ òL0™Ê¬w‘lȘGFü“ŠöÎkz×�OeÒ©ùg÷µx•´\Eí®ëÏ]¹¥ß³yíĆÕ+ⷆƘÎëŸjžØùÕüô_]öä×7úÍFÿA·K[QbL—štÜ+ÇÏ”ˆ¢DìÛ±aÜVZœ€AO@qìÔÿZƒÑ˜üŸÿö›ý#þ âç§ÎY-]zïŽõ£n*?µýÏEÓ‡ŸôéªJLl0ÏXqÉè‘oýð ǦµµÑ¿ßÿ°;gɣﶗÀ÷_ýϪ�ÑqæÙ½Û½ïœ¾`yí?>°èµJþí_u–Š¢ˆÏ8}üLwɯÏ\ÿ}áš±Ô¤ãþîõ“•ör ûí�_ñ<Ú¶BâÖÐsàÈ;Õ;p_:¸w@&“áÙŸµÙmn |uÍÊÈÁŸ½g€÷»{umí¬ûÝêݽyݤ”¥r™‘H2Í‘+mV––SÒî�÷L^¾éÖ¦8ž¸píSÃw¿ñ ¿Ä¤çþæ±foû…k&«QÇ—˜t©‹=ýš³Ý.ÝÞí�Þ÷»]Q‘Ls2�’¯Ütë±ùÁG.=E’R×�AU0#5*†orVÇä%ù&ÂòP$!ÿèÆ úÚíaµwžª5u»ÕVÛC°ó�{‚¿»zË “É$†–KM«W„�ü[{å–ûêÂ%#ê5J.•âd[)ë°•²z­j^"tØJYÿdTQe5¦k—•²µËJYZNIëUÑË.·&—�Y=rðhÛ2K‘–û¤oDsàÏ·�ÀwwoòîÿÉÛ5;šœç¯õéÿåž!(38³A›^ã°ÅàOšêƒ]7tu˧ª ßÿÎΡ½qÒ¶õÞÚàx0ªØÛ²Á?÷~¥Åzî/¾±iôñ¾îØ|ompxl’þÞ_ݳùÞÉ'¿áH¦Ò2«QÇ=°¦&l¯0Ï+JdLˆž|a͇o>õæ�—©®´²äŒoD"±$Ù?:N;–•²Jú‹¯t¡(UlÐò Ž'eÓªÏ×ûäé�¨¦*²' ªæ*;ɲƒŸšIKr I¿œ@I¥ÛþlÂr‡@²À¨©FÍtÛT9Y„"#BE£�}ë®mÊ¡ ýÊ"HÉü&ý¬¡š†¾¼ ¦«^Y!�!#TÊ[¢R’ô9°4¿„ª$T†jê¾XKÖ<¤}ÐUÀ!P0±a‰V( aÒÞB Ç%[£#¡% Ï- [Ókj3@OK-°�¶2@UŠÃ!`~ÆÀPµ,/ü‰,9EŽœÔÙð¯"T©¡ ª”¬U?V]uklúœ*‰Y%´%äpx0¨–¯å<­Úò ã ª¢¹Š Ì^V¿¨ŒPWeÀ‚ÄÒô_Áͼ­ŸŽ8tâœAvåð9f>€ñ¡ É9(R"ì½�Ÿ—’i>�AÃëÅëfö>óG8bs�UnF~ɼ~ÀëÆëgEêYà°<“‡Íô#$œæ³=ò ÜÈc~Í=ç°ê÷6~ÎÝðº¢Uo(«é¶šÄÍl_Ut¤„p¢rœ/`&¬ý`¼xÚê��ÕtÛm?úÁ ü…W„Ÿ�©zü\°y¯*ª_Ÿ-Òí«H?Y‹f³ÍÂcë·Á“kKݪè–cލú�·4_ÊÙ!VÀj3;=­5‡¡þðï(˜¬übÐé³é>­6 )wû FyÐÜPX±\CqãXŒšC¡´ÿû¤ãoù^�G¿ó#Ƕ&½·®»ý–ó«”ùëf_ªÁú@­ªÁ0:óñ»T9¼ýí�Ÿ:m[†F 6!Ñ£zÐ?,™¿»¤†U©�ˆW%T *ÆŒ[ç³Ë _χäÅž‡c9ÿJ%…«³�,—ÕU“_Î}~�æ´õ¿Üß}i�Û÷ìö{ŒV§‡—ß8èõxÜÐm–§ªÙSX‰c4jË/gIñÍskía·ï��OR0Ë áù�¿‡ØøDŸÇE?Yh¡ß­þàlR[HyË/®Ív¯ÙvM¼ðëý�»j­¤c£Ÿ ú}Àœ<5)ó †Qnñ t»íô'n!Ô70L–!(Þ¼Öl|Ù¯Z\•Ãûí5ç°&M‡qˆcÂjÊ/ž [¢Ë×nBZîøÍŸþë^|5 0TÄO–wt®Mº«rèNOw •0»ÝÓÓÓ`›�…º#oÃý¡›Ž}¼b †¶D¯íþ#¤-{,¨ó¢¿ìúoô '>%=ä ÀôÔ4Lt�pĘtXpïDGÞwJ¶eqöJØRö,{ü)Y®ýeòþ•£‰‰ „0N\cõeO®‡ÚOŒ� ]095ÝwúA— %ÛÞtë+kœ¿YR*dY Õ0½&³S+#—]¹|ÒÓÓ 333lpœ”Scú1΄¼ÚÈèhHÀ 566ÂsÏ)s›YôWôß#ÓŒ�®Å‰Gþliz:DEEÉ~±gÏ@NÎrøÆŠŠÀýý÷çP¯@�›¡àÓ ’ÓúúúàÂ… °½ìŠ…�è¯è¿·„l4Ùf ­¤¤DºÈaØuøì¯ŸÁîÝ» ..NÑ�b~}¹86&Ð4wH>�\áuªúlÚø¬báÄûÉüö §E„cê”|9«ººôK–@IÉ3жH¢Ÿ-~áT4±}&†36¥&æ`ë¹¹RtÉPVösEÁ è'mÑoâ¿Ï"pÌ~·µÁ:,Ô1쇡 /¬gfI+¡4Þ?ôý–R!oÄÃO Ix!˜1R+… ïó÷¸8†šÁA'Åfò@rO áU÷Ï:HIÖ… ú…þ¡Ÿè/ÓsýÀ©h¢b#õÎï Ôü…Wýézxâ‰Å“/o¢_ÌO#ó[RÇóè±>3¥ŠR êñ^XÇœ¨rVƒñ×�~ ?¼�+GP�•¨§Æ}Aе.ZÓã0=Ò‹–Ì8õGiHaýªý†NûˆjªæGd €JÉ—4�ŽÀº”T°�Ф†÷™ ±p(æÖÙOÁr«…yõÆuøR¸EÔJÀ¼èï÷RØQ~V”$žO„3Ôbb(áuŠ`ðú�=‡%©A‘t¦ó=f¢,zJ‡fgAT†þWPAÚ¸$Õ€Án‚™"áïm;QÍßåƒ#j%€FȧgD@+ã`áý»P¤&g… v&oÔ}v–|÷ÜìäÁì"`*$—"�ù|O„òPÝÐdÆ.€¥Ñ°öüFÍ�Ÿ’¡Z: G¡ïúYÇ0ÄžÎN0Ûƒæ•Ìo9æÜc,[åx ­P— ofgCëlŠO©(1,ìØì,Fo“O“âÀ„þI=ÂqL�G0;~6¤.r|!ádh)×ÏE)B"4¼ït «Klª4&M•s9Gp�N—­r<,þI[ÓÒœ ‰3SV0 ÂÀ¡MþUåPo/˜ffø¯[åŸæe÷\9éùÅÑÂ1�ÛÁifq©°�¾²!…¾¶-Ž@Ã}ØO²Û%øš„0B ¸IK�SÙ€ê858Ÿö÷¹B®¶xëP*ç$§7ð†9©0Yù¤Ÿ¥×ÆÌ½%"=i¼…Ò0luÍ)¼Z°òUÇ \y|Á‡«¢ôZ-•Dõ¨�!ŒvÒ£B.Ž�06O á¡ÐwY0ê{—E �d²�@ -÷¤íõý9sBߟL²UgPEä›—ƒR—öwv±ôøÎ®P„ÊW&“ýW€ÖˆIIEND®B`‚scrapy-scrapy-5569de3/tests/sample_data/test_site/index.html000066400000000000000000000004671525051012400242760ustar00rootroot00000000000000 Scrapy test site

    Scrapy test site

    scrapy-scrapy-5569de3/tests/sample_data/test_site/item1.html000066400000000000000000000003411525051012400241750ustar00rootroot00000000000000 Item 1 - Scrapy test site

    Item 1 name

    • Price: $100
    • Stock: 12
    scrapy-scrapy-5569de3/tests/sample_data/test_site/item2.html000066400000000000000000000003211525051012400241740ustar00rootroot00000000000000 Item 2 - Scrapy test site

    Item 2 name

    • Price: $200
    • Stock: 5
    scrapy-scrapy-5569de3/tests/spiders.py000066400000000000000000000530731525051012400200500ustar00rootroot00000000000000""" Some spiders used for testing and benchmarking """ from __future__ import annotations import asyncio import time from typing import TYPE_CHECKING, Any from urllib.parse import urlencode from twisted.internet import defer from scrapy import signals from scrapy.exceptions import StopDownload from scrapy.http import Request, TextResponse from scrapy.item import Item from scrapy.linkextractors import LinkExtractor from scrapy.spidermiddlewares.httperror import HttpError from scrapy.spiders import Spider from scrapy.spiders.crawl import CrawlSpider, Rule from scrapy.utils.defer import deferred_to_future, maybe_deferred_to_future from scrapy.utils.test import get_from_asyncio_queue if TYPE_CHECKING: from collections.abc import AsyncIterator, Callable, Iterator from twisted.python.failure import Failure from typing_extensions import Self from scrapy.crawler import Crawler from scrapy.http import Headers, Response from scrapy.http.request import CallbackT from tests.mockserver.http import MockServer class MockServerSpider(Spider): def __init__( self, *args: Any, mockserver: MockServer | None = None, is_secure: bool = False, **kwargs: Any, ): super().__init__(*args, **kwargs) self.mockserver = mockserver self.is_secure = is_secure class RawResponseSpider(MockServerSpider): """Base class for spiders that fetch a response built by the test itself. Subclasses return the body from :meth:`raw_body` and request :attr:`raw_url`, which the mock server answers with that body verbatim under :attr:`content_type`. This lets tests reach parsing code that only a specific kind of response triggers while still going through a regular crawl, instead of calling internal parsing methods directly. """ name = "raw_response" content_type = "text/plain" def raw_body(self) -> str: raise NotImplementedError @property def raw_url(self) -> str: assert self.mockserver raw = ( "HTTP/1.1 200 OK\r\n" f"Content-Type: {self.content_type}\r\n" "Connection: close\r\n" "\r\n" f"{self.raw_body()}" ) return self.mockserver.url("/raw?" + urlencode({"raw": raw})) class MetaSpider(MockServerSpider): name = "meta" def __init__(self, *args: Any, **kwargs: Any): super().__init__(*args, **kwargs) self.meta: dict[str, Any] = {} def closed(self, reason: str) -> None: self.meta["close_reason"] = reason class FollowAllSpider(MetaSpider): name = "follow" link_extractor = LinkExtractor() def __init__( self, total: int = 10, show: int = 20, order: str = "rand", maxlatency: float = 0.0, *args: Any, **kwargs: Any, ): super().__init__(*args, **kwargs) self.urls_visited: list[str] = [] self.times: list[float] = [] qargs = {"total": total, "show": show, "order": order, "maxlatency": maxlatency} assert self.mockserver url = self.mockserver.url(f"/follow?{urlencode(qargs, doseq=True)}") self.start_urls = [url] def parse(self, response: Response) -> Iterator[Any]: assert isinstance(response, TextResponse) self.urls_visited.append(response.url) self.times.append(time.time()) for link in self.link_extractor.extract_links(response): yield Request(link.url, callback=self.parse) class DelaySpider(MetaSpider): name = "delay" def __init__(self, n: float = 1, b: float = 0, *args: Any, **kwargs: Any): super().__init__(*args, **kwargs) self.n = n self.b = b self.t1 = self.t2 = self.t2_err = 0.0 async def start(self) -> AsyncIterator[Any]: self.t1 = time.time() assert self.mockserver url = self.mockserver.url(f"/delay?n={self.n}&b={self.b}") yield Request(url, callback=self.parse, errback=self.errback) def parse(self, response: Response) -> Any: self.t2 = time.time() def errback(self, failure: Failure) -> None: self.t2_err = time.time() class LogSpider(MetaSpider): name = "log_spider" def log_debug(self, message: str, extra: dict[str, Any] | None = None) -> None: self.logger.debug(message, extra=extra) def log_info(self, message: str, extra: dict[str, Any] | None = None) -> None: self.logger.info(message, extra=extra) def log_warning(self, message: str, extra: dict[str, Any] | None = None) -> None: self.logger.warning(message, extra=extra) def log_error(self, message: str, extra: dict[str, Any] | None = None) -> None: self.logger.error(message, extra=extra) def log_critical(self, message: str, extra: dict[str, Any] | None = None) -> None: self.logger.critical(message, extra=extra) def parse(self, response: Response) -> None: pass class SlowSpider(DelaySpider): name = "slow" async def start(self) -> AsyncIterator[Any]: assert self.mockserver # 1st response is fast url = self.mockserver.url("/delay?n=0&b=0") yield Request(url, callback=self.parse, errback=self.errback) # 2nd response is slow url = self.mockserver.url(f"/delay?n={self.n}&b={self.b}") yield Request(url, callback=self.parse, errback=self.errback) def parse(self, response: Response) -> Iterator[Any]: yield Item() class SimpleSpider(MetaSpider): name = "simple" def __init__(self, url: str = "http://localhost:8998", *args: Any, **kwargs: Any): super().__init__(*args, **kwargs) self.start_urls = [url] def parse(self, response: Response) -> Any: self.logger.info(f"Got response {response.status}") class AsyncDefSpider(SimpleSpider): name = "asyncdef" async def parse(self, response: Response) -> None: await defer.succeed(42) self.logger.info(f"Got response {response.status}") class AsyncDefAsyncioSpider(SimpleSpider): name = "asyncdef_asyncio" async def parse(self, response: Response) -> None: await asyncio.sleep(0.2) status = await get_from_asyncio_queue(response.status) self.logger.info(f"Got response {status}") class AsyncDefAsyncioReturnSpider(SimpleSpider): name = "asyncdef_asyncio_return" async def parse(self, response: Response) -> Any: await asyncio.sleep(0.2) status = await get_from_asyncio_queue(response.status) self.logger.info(f"Got response {status}") return [{"id": 1}, {"id": 2}] class AsyncDefAsyncioReturnSingleElementSpider(SimpleSpider): name = "asyncdef_asyncio_return_single_element" async def parse(self, response: Response) -> Any: await asyncio.sleep(0.1) status = await get_from_asyncio_queue(response.status) self.logger.info(f"Got response {status}") return {"foo": 42} class AsyncDefAsyncioReqsReturnSpider(SimpleSpider): name = "asyncdef_asyncio_reqs_return" async def parse(self, response: Response) -> Any: await asyncio.sleep(0.2) req_id = response.meta.get("req_id", 0) status = await get_from_asyncio_queue(response.status) self.logger.info(f"Got response {status}, req_id {req_id}") if req_id > 0: return None reqs = [] for i in range(1, 3): req = Request(self.start_urls[0], dont_filter=True, meta={"req_id": i}) reqs.append(req) return reqs class AsyncDefAsyncioGenExcSpider(SimpleSpider): name = "asyncdef_asyncio_gen_exc" async def parse(self, response: Response) -> AsyncIterator[Any]: for i in range(10): await asyncio.sleep(0.1) yield {"foo": i} if i > 5: raise ValueError("Stopping the processing") class AsyncDefDeferredDirectSpider(SimpleSpider): name = "asyncdef_deferred_direct" async def parse(self, response: Response) -> AsyncIterator[Any]: await defer.succeed(None) yield {"code": 200} class AsyncDefDeferredWrappedSpider(SimpleSpider): name = "asyncdef_deferred_wrapped" async def parse(self, response: Response) -> AsyncIterator[Any]: await deferred_to_future(defer.succeed(None)) yield {"code": 200} class AsyncDefDeferredMaybeWrappedSpider(SimpleSpider): name = "asyncdef_deferred_maybe_wrapped" async def parse(self, response: Response) -> AsyncIterator[Any]: await maybe_deferred_to_future(defer.succeed(None)) yield {"code": 200} class AsyncDefAsyncioGenSpider(SimpleSpider): name = "asyncdef_asyncio_gen" async def parse(self, response: Response) -> AsyncIterator[Any]: await asyncio.sleep(0.2) yield {"foo": 42} self.logger.info(f"Got response {response.status}") class AsyncDefAsyncioGenLoopSpider(SimpleSpider): name = "asyncdef_asyncio_gen_loop" async def parse(self, response: Response) -> AsyncIterator[Any]: for i in range(10): await asyncio.sleep(0.1) yield {"foo": i} self.logger.info(f"Got response {response.status}") class AsyncDefAsyncioGenComplexSpider(SimpleSpider): name = "asyncdef_asyncio_gen_complex" initial_reqs = 4 following_reqs = 3 depth = 2 def _get_req(self, index: int, cb: CallbackT | None = None) -> Request: assert self.mockserver return Request( self.mockserver.url(f"/status?n=200&request={index}"), meta={"index": index}, dont_filter=True, callback=cb, ) async def start(self) -> AsyncIterator[Any]: for i in range(1, self.initial_reqs + 1): yield self._get_req(i) async def parse(self, response: Response) -> AsyncIterator[Any]: index = response.meta["index"] yield {"index": index} if index < 10**self.depth: for new_index in range(10 * index, 10 * index + self.following_reqs): yield self._get_req(new_index) yield self._get_req(index, cb=self.parse2) await asyncio.sleep(0.1) yield {"index": index + 5} async def parse2(self, response: Response) -> AsyncIterator[Any]: await asyncio.sleep(0.1) yield {"index2": response.meta["index"]} class ItemSpider(FollowAllSpider): name = "item" def parse(self, response: Response) -> Iterator[Any]: for request in super().parse(response): yield request yield Item() yield {} class MaxItemsAndRequestsSpider(FollowAllSpider): def __init__( self, max_items: int = 10, max_requests: int = 10, *args: Any, **kwargs: Any, ): super().__init__(*args, **kwargs) self.max_items = max_items self.max_requests = max_requests self.items_scraped = 0 self.pages_crawled = 1 # account for the start url def parse(self, response: Response) -> Iterator[Any]: 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 class ErrorSpider(FollowAllSpider): name = "error" exception_cls: type[Exception] = DefaultError def raise_exception(self) -> None: raise self.exception_cls("Expected exception") def parse(self, response: Response) -> Iterator[Any]: for request in super().parse(response): yield request self.raise_exception() class BrokenStartSpider(FollowAllSpider): fail_before_yield = False fail_yielding = False def __init__(self, *a: Any, **kw: Any): super().__init__(*a, **kw) self.seedsseen: list[Any] = [] async def start(self) -> AsyncIterator[Any]: if self.fail_before_yield: 1 / 0 assert self.mockserver for s in range(100): qargs = {"total": 10, "seed": s} url = self.mockserver.url(f"/follow?{urlencode(qargs, doseq=True)}") yield Request(url, meta={"seed": s}) if self.fail_yielding: 2 / 0 assert self.seedsseen, "All seeds consumed before any download happened" def parse(self, response: Response) -> Iterator[Any]: self.seedsseen.append(response.meta.get("seed")) yield from super().parse(response) class StartItemSpider(FollowAllSpider): async def start(self) -> AsyncIterator[Any]: yield {"name": "test item"} class StartGoodAndBadOutput(FollowAllSpider): async def start(self) -> AsyncIterator[Any]: yield {"a": "a"} yield Request("data:,a") yield "data:,b" yield object() class SingleRequestSpider(MetaSpider): seed: Request | str | None = None callback_func: Callable[[Response], Any] | None = None errback_func: Callable[[Failure], Any] | None = None async def start(self) -> AsyncIterator[Any]: if isinstance(self.seed, Request): yield self.seed.replace(callback=self.parse, errback=self.on_error) else: assert self.seed yield Request(self.seed, callback=self.parse, errback=self.on_error) def parse(self, response: Response) -> Any: self.meta.setdefault("responses", []).append(response) if callable(self.callback_func): return self.callback_func(response) if "next" in response.meta: return response.meta["next"] return None def on_error(self, failure: Failure) -> Any: self.meta["failure"] = failure if callable(self.errback_func): return self.errback_func(failure) return None class DuplicateStartSpider(MockServerSpider): dont_filter = True name = "duplicatestartrequests" distinct_urls = 2 dupe_factor = 3 async def start(self) -> AsyncIterator[Any]: assert self.mockserver for i in range(self.distinct_urls): for _ in range(self.dupe_factor): url = self.mockserver.url(f"/echo?headers=1&body=test{i}") yield Request(url, dont_filter=self.dont_filter) def __init__(self, url: str = "http://localhost:8998", *args: Any, **kwargs: Any): super().__init__(*args, **kwargs) self.visited = 0 def parse(self, response: Response) -> None: self.visited += 1 class CrawlSpiderWithParseMethod(MockServerSpider, CrawlSpider): """ A CrawlSpider which overrides the 'parse' method """ name = "crawl_spider_with_parse_method" custom_settings: dict[str, Any] = { "RETRY_HTTP_CODES": [], # no need to retry } rules = (Rule(LinkExtractor(), callback="parse", follow=True),) async def start(self) -> AsyncIterator[Any]: test_body = b""" Page title

    Item 200

    Item 201

    """ assert self.mockserver url = self.mockserver.url("/alpayload") yield Request(url, method="POST", body=test_body) def parse(self, response: Response, foo: str | None = None) -> Iterator[Any]: self.logger.info("[parse] status %i (foo: %s)", response.status, foo) assert self.mockserver yield Request( self.mockserver.url("/status?n=202"), self.parse, cb_kwargs={"foo": "bar"} ) class CrawlSpiderWithAsyncCallback(CrawlSpiderWithParseMethod): """A CrawlSpider with an async def callback""" name = "crawl_spider_with_async_callback" rules = (Rule(LinkExtractor(), callback="parse_async", follow=True),) async def parse_async(self, response: Response, foo: str | None = None) -> Request: self.logger.info("[parse_async] status %i (foo: %s)", response.status, foo) assert self.mockserver return Request( self.mockserver.url("/status?n=202"), self.parse_async, cb_kwargs={"foo": "bar"}, ) class CrawlSpiderWithAsyncGeneratorCallback(CrawlSpiderWithParseMethod): """A CrawlSpider with an async generator callback""" name = "crawl_spider_with_async_generator_callback" rules = (Rule(LinkExtractor(), callback="parse_async_gen", follow=True),) async def parse_async_gen( self, response: Response, foo: str | None = None ) -> AsyncIterator[Any]: self.logger.info("[parse_async_gen] status %i (foo: %s)", response.status, foo) assert self.mockserver yield Request( self.mockserver.url("/status?n=202"), self.parse_async_gen, cb_kwargs={"foo": "bar"}, ) class CrawlSpiderWithErrback(CrawlSpiderWithParseMethod): name = "crawl_spider_with_errback" rules = (Rule(LinkExtractor(), callback="parse", errback="errback", follow=True),) async def start(self) -> AsyncIterator[Any]: test_body = b""" Page title

    Item 200

    Item 201

    Item 404

    Item 500

    Item 501

    """ assert self.mockserver url = self.mockserver.url("/alpayload") yield Request(url, method="POST", body=test_body) def errback(self, failure: Failure) -> None: assert isinstance(failure.value, HttpError) self.logger.info("[errback] status %i", failure.value.response.status) class CrawlSpiderWithoutErrback(CrawlSpiderWithParseMethod): name = "crawl_spider_without_errback" async def start(self) -> AsyncIterator[Any]: test_body = b""" Page title

    Item 200

    Item 404

    """ assert self.mockserver url = self.mockserver.url("/alpayload") yield Request(url, method="POST", body=test_body) class CrawlSpiderWithProcessRequestCallbackKeywordArguments(CrawlSpiderWithParseMethod): name = "crawl_spider_with_process_request_cb_kwargs" rules = ( Rule( LinkExtractor(), callback="parse", follow=True, process_request="process_request", ), ) def process_request(self, request: Request, response: Response) -> Request: request.cb_kwargs["foo"] = "process_request" return request class BytesReceivedCallbackSpider(MetaSpider): full_response_length = 2**18 @classmethod def from_crawler(cls, crawler: Crawler, *args: Any, **kwargs: Any) -> Self: spider = super().from_crawler(crawler, *args, **kwargs) crawler.signals.connect(spider.bytes_received, signals.bytes_received) return spider async def start(self) -> AsyncIterator[Any]: body = b"a" * self.full_response_length assert self.mockserver url = self.mockserver.url("/alpayload", is_secure=self.is_secure) yield Request(url, method="POST", body=body, errback=self.errback) def parse(self, response: Response) -> None: self.meta["response"] = response def errback(self, failure: Failure) -> None: self.meta["failure"] = failure def bytes_received(self, data: bytes, request: Request, spider: Spider) -> None: self.meta["bytes_received"] = data raise StopDownload(fail=False) class BytesReceivedErrbackSpider(BytesReceivedCallbackSpider): def bytes_received(self, data: bytes, request: Request, spider: Spider) -> None: self.meta["bytes_received"] = data raise StopDownload(fail=True) class HeadersReceivedCallbackSpider(MetaSpider): @classmethod def from_crawler(cls, crawler: Crawler, *args: Any, **kwargs: Any) -> Self: spider = super().from_crawler(crawler, *args, **kwargs) crawler.signals.connect(spider.headers_received, signals.headers_received) return spider async def start(self) -> AsyncIterator[Any]: assert self.mockserver yield Request( self.mockserver.url("/status", is_secure=self.is_secure), errback=self.errback, ) def parse(self, response: Response) -> None: self.meta["response"] = response def errback(self, failure: Failure) -> None: self.meta["failure"] = failure def headers_received( self, headers: Headers, body_length: int, request: Request, spider: Spider ) -> None: self.meta["headers_received"] = headers raise StopDownload(fail=False) class HeadersReceivedErrbackSpider(HeadersReceivedCallbackSpider): def headers_received( self, headers: Headers, body_length: int, request: Request, spider: Spider ) -> None: self.meta["headers_received"] = headers raise StopDownload(fail=True) class ExceptionSpider(Spider): name = "exception" @classmethod def from_crawler(cls, crawler: Crawler, *args: Any, **kwargs: Any) -> Self: raise ValueError("Exception in from_crawler method") class NoRequestsSpider(Spider): name = "no_request" async def start(self) -> AsyncIterator[Any]: return yield scrapy-scrapy-5569de3/tests/test_addons.py000066400000000000000000000210531525051012400206770ustar00rootroot00000000000000import itertools from typing import Any from unittest.mock import patch from scrapy import Spider from scrapy.crawler import AsyncCrawlerRunner, Crawler, CrawlerRunner from scrapy.exceptions import NotConfigured from scrapy.settings import BaseSettings, Settings from scrapy.utils.test import get_crawler, get_reactor_settings from tests.utils.decorators import inline_callbacks_test class SimpleAddon: def update_settings(self, settings): pass def get_addon_cls(config: dict[str, Any]) -> type: class AddonWithConfig: def update_settings(self, settings: BaseSettings): settings.update(config, priority="addon") return AddonWithConfig class CreateInstanceAddon: def __init__(self, crawler: Crawler) -> None: super().__init__() self.crawler = crawler self.config = crawler.settings.getdict("MYADDON") @classmethod def from_crawler(cls, crawler: Crawler): return cls(crawler) def update_settings(self, settings): settings.update(self.config, "addon") class TestAddon: def test_update_settings(self): settings = BaseSettings() settings.set("KEY1", "default", priority="default") settings.set("KEY2", "project", priority="project") addon_config = {"KEY1": "addon", "KEY2": "addon", "KEY3": "addon"} testaddon = get_addon_cls(addon_config)() testaddon.update_settings(settings) assert settings["KEY1"] == "addon" assert settings["KEY2"] == "project" assert settings["KEY3"] == "addon" class TestAddonManager: def test_load_settings(self): settings_dict = { "ADDONS": {"tests.test_addons.SimpleAddon": 0}, } crawler = get_crawler(settings_dict=settings_dict) manager = crawler.addons assert isinstance(manager.addons[0], SimpleAddon) def test_notconfigured(self): class NotConfiguredAddon: def update_settings(self, settings): raise NotConfigured settings_dict = { "ADDONS": {NotConfiguredAddon: 0}, } crawler = get_crawler(settings_dict=settings_dict) manager = crawler.addons assert not manager.addons def test_notconfigured_with_args(self): class NotConfiguredAddon: def update_settings(self, settings): raise NotConfigured("addon disabled reason") settings_dict = { "ADDONS": {NotConfiguredAddon: 0}, } with patch("scrapy.addons.logger") as logger_mock: crawler = get_crawler(settings_dict=settings_dict) assert not crawler.addons.addons logger_mock.warning.assert_called_once_with( "Disabled %(clspath)s: %(eargs)s", {"clspath": NotConfiguredAddon, "eargs": "addon disabled reason"}, extra={"crawler": crawler}, ) def test_no_update_settings(self): class PreCrawlerOnlyAddon: @classmethod def update_pre_crawler_settings(cls, settings): settings.set("PRE_CRAWLER_KEY", "value", priority="addon") settings_dict = { "ADDONS": {PreCrawlerOnlyAddon: 0}, } crawler = get_crawler(settings_dict=settings_dict) manager = crawler.addons assert len(manager.addons) == 1 assert isinstance(manager.addons[0], PreCrawlerOnlyAddon) def test_load_settings_order(self): # Get three addons with different settings addonlist = [] for i in range(3): addon = get_addon_cls({"KEY1": i}) addon.number = i # type: ignore[attr-defined] addonlist.append(addon) # Test for every possible ordering for ordered_addons in itertools.permutations(addonlist): expected_order = [a.number for a in ordered_addons] # type: ignore[attr-defined] settings = {"ADDONS": {a: i for i, a in enumerate(ordered_addons)}} crawler = get_crawler(settings_dict=settings) manager = crawler.addons assert [a.number for a in manager.addons] == expected_order assert crawler.settings.getint("KEY1") == expected_order[-1] def test_build_from_crawler(self): settings_dict = { "ADDONS": {"tests.test_addons.CreateInstanceAddon": 0}, "MYADDON": {"MYADDON_KEY": "val"}, } crawler = get_crawler(settings_dict=settings_dict) manager = crawler.addons assert isinstance(manager.addons[0], CreateInstanceAddon) assert crawler.settings.get("MYADDON_KEY") == "val" def test_settings_priority(self): config = { "KEY": 15, # priority=addon } settings_dict = { "ADDONS": {get_addon_cls(config): 1}, **get_reactor_settings(), } crawler = get_crawler(settings_dict=settings_dict) assert crawler.settings.getint("KEY") == 15 runner_cls = ( CrawlerRunner if settings_dict.get("TWISTED_REACTOR_ENABLED", True) else AsyncCrawlerRunner ) settings = Settings(settings_dict) settings.set("KEY", 0, priority="default") runner = runner_cls(settings) crawler = runner.create_crawler(Spider) crawler._apply_settings() assert crawler.settings.getint("KEY") == 15 settings_dict = { "KEY": 20, # priority=project "ADDONS": {get_addon_cls(config): 1}, **get_reactor_settings(), } settings = Settings(settings_dict) settings.set("KEY", 0, priority="default") runner = runner_cls(settings) crawler = runner.create_crawler(Spider) crawler._apply_settings() assert crawler.settings.getint("KEY") == 20 def test_fallback_workflow(self): FALLBACK_SETTING = "MY_FALLBACK_SCHEDULER" class AddonWithFallback: def update_settings(self, settings): if not settings.get(FALLBACK_SETTING): settings.set( FALLBACK_SETTING, settings.get("SCHEDULER"), "addon", ) settings["SCHEDULER"] = "AddonScheduler" settings_dict: dict[str, Any] = { "ADDONS": {AddonWithFallback: 1}, } crawler = get_crawler(settings_dict=settings_dict) assert crawler.settings.get("SCHEDULER") == "AddonScheduler" assert ( crawler.settings.get(FALLBACK_SETTING) == "scrapy.core.scheduler.Scheduler" ) settings_dict = { "ADDONS": {AddonWithFallback: 1}, "SCHEDULER": "UserScheduler", } crawler = get_crawler(settings_dict=settings_dict) assert crawler.settings.get("SCHEDULER") == "AddonScheduler" assert crawler.settings.get(FALLBACK_SETTING) == "UserScheduler" def test_logging_message(self): class LoggedAddon: def update_settings(self, settings): pass with ( patch("scrapy.addons.logger") as logger_mock, patch("scrapy.addons.build_from_crawler") as build_from_crawler_mock, ): settings_dict = { "ADDONS": {LoggedAddon: 1}, } addon = LoggedAddon() build_from_crawler_mock.return_value = addon crawler = get_crawler(settings_dict=settings_dict) logger_mock.info.assert_called_once_with( "Enabled addons:\n%(addons)s", {"addons": [addon]}, extra={"crawler": crawler}, ) @inline_callbacks_test def test_enable_addon_in_spider(self): class MySpider(Spider): name = "myspider" @classmethod def from_crawler(cls, crawler, *args, **kwargs): spider = super().from_crawler(crawler, *args, **kwargs) addon_config = {"KEY": "addon"} addon_cls = get_addon_cls(addon_config) spider.settings.set("ADDONS", {addon_cls: 1}, priority="spider") return spider settings = Settings() settings.setdict(get_reactor_settings()) settings.set("KEY", "default", priority="default") runner_cls = ( CrawlerRunner if settings.getbool("TWISTED_REACTOR_ENABLED", True) else AsyncCrawlerRunner ) runner = runner_cls(settings) crawler = runner.create_crawler(MySpider) assert crawler.settings.get("KEY") == "default" yield crawler.crawl() assert crawler.settings.get("KEY") == "addon" scrapy-scrapy-5569de3/tests/test_closespider.py000066400000000000000000000102231525051012400217400ustar00rootroot00000000000000from scrapy.utils.test import get_crawler from tests.mockserver.http import MockServer from tests.spiders import ( ErrorSpider, FollowAllSpider, ItemSpider, MaxItemsAndRequestsSpider, SlowSpider, ) from tests.utils.decorators import inline_callbacks_test @inline_callbacks_test def test_closespider_itemcount(mockserver: MockServer): close_on = 5 crawler = get_crawler(ItemSpider, {"CLOSESPIDER_ITEMCOUNT": close_on}) yield crawler.crawl(mockserver=mockserver) assert isinstance(crawler.spider, ItemSpider) reason = crawler.spider.meta["close_reason"] assert reason == "closespider_itemcount" itemcount = crawler.stats.get_value("item_scraped_count") assert itemcount >= close_on @inline_callbacks_test def test_closespider_pagecount(mockserver: MockServer): close_on = 5 crawler = get_crawler(FollowAllSpider, {"CLOSESPIDER_PAGECOUNT": close_on}) yield crawler.crawl(mockserver=mockserver) assert isinstance(crawler.spider, FollowAllSpider) reason = crawler.spider.meta["close_reason"] assert reason == "closespider_pagecount" pagecount = crawler.stats.get_value("response_received_count") assert pagecount >= close_on @inline_callbacks_test def test_closespider_pagecount_no_item(mockserver: MockServer): 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=mockserver ) assert isinstance(crawler.spider, MaxItemsAndRequestsSpider) reason = crawler.spider.meta["close_reason"] assert reason == "closespider_pagecount_no_item" pagecount = crawler.stats.get_value("response_received_count") itemcount = crawler.stats.get_value("item_scraped_count") assert pagecount <= close_on + itemcount @inline_callbacks_test def test_closespider_pagecount_no_item_with_pagecount(mockserver: MockServer): 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=mockserver) assert isinstance(crawler.spider, FollowAllSpider) reason = crawler.spider.meta["close_reason"] assert reason == "closespider_pagecount_no_item" pagecount = crawler.stats.get_value("response_received_count") assert pagecount < close_on_pagecount @inline_callbacks_test def test_closespider_errorcount(mockserver: MockServer): close_on = 5 crawler = get_crawler(ErrorSpider, {"CLOSESPIDER_ERRORCOUNT": close_on}) yield crawler.crawl(total=1000000, mockserver=mockserver) assert isinstance(crawler.spider, ErrorSpider) reason = crawler.spider.meta["close_reason"] assert reason == "closespider_errorcount" key = f"spider_exceptions/{crawler.spider.exception_cls.__name__}" errorcount = crawler.stats.get_value(key) assert crawler.stats.get_value("spider_exceptions/count") >= close_on assert errorcount >= close_on @inline_callbacks_test def test_closespider_timeout(mockserver: MockServer): close_on = 0.1 crawler = get_crawler(FollowAllSpider, {"CLOSESPIDER_TIMEOUT": close_on}) yield crawler.crawl(total=1000000, mockserver=mockserver) assert isinstance(crawler.spider, FollowAllSpider) reason = crawler.spider.meta["close_reason"] assert reason == "closespider_timeout" total_seconds = crawler.stats.get_value("elapsed_time_seconds") assert total_seconds >= close_on @inline_callbacks_test def test_closespider_timeout_no_item(mockserver: MockServer): timeout = 1 crawler = get_crawler(SlowSpider, {"CLOSESPIDER_TIMEOUT_NO_ITEM": timeout}) yield crawler.crawl(n=3, mockserver=mockserver) assert isinstance(crawler.spider, SlowSpider) reason = crawler.spider.meta["close_reason"] assert reason == "closespider_timeout_no_item" total_seconds = crawler.stats.get_value("elapsed_time_seconds") assert total_seconds >= timeout scrapy-scrapy-5569de3/tests/test_cmdline/000077500000000000000000000000001525051012400204675ustar00rootroot00000000000000scrapy-scrapy-5569de3/tests/test_cmdline/__init__.py000066400000000000000000000041411525051012400226000ustar00rootroot00000000000000import ast import json import os import pstats import shutil import sys import tempfile from io import StringIO from pathlib import Path from subprocess import PIPE, Popen from typing import Any from scrapy.utils.test import get_testenv def _execute(*new_args: str, **kwargs: Any) -> str: env = get_testenv() tests_path = Path(__file__).parent.parent env["PYTHONPATH"] += os.pathsep + str(tests_path.parent) env["SCRAPY_SETTINGS_MODULE"] = "tests.test_cmdline.settings" encoding = sys.stdout.encoding or "utf-8" args = (sys.executable, "-m", "scrapy.cmdline", *new_args) proc: Popen[bytes] = Popen(args, stdout=PIPE, stderr=PIPE, env=env, **kwargs) comm = proc.communicate()[0].strip() return comm.decode(encoding) def test_default_settings(): assert _execute("settings", "--get", "TEST1") == "default" def test_override_settings_using_set_arg(): assert _execute("settings", "--get", "TEST1", "-s", "TEST1=override") == "override" def test_profiling(): path = Path(tempfile.mkdtemp()) filename = path / "res.prof" try: _execute("version", "--profile", str(filename)) assert filename.exists() out = StringIO() stats = pstats.Stats(str(filename), stream=out) stats.print_stats() out.seek(0) stats_text = out.read() assert str(Path("scrapy", "commands", "version.py")) in stats_text assert "tottime" in stats_text finally: shutil.rmtree(path) def test_override_dict_settings(): EXT_PATH = "tests.test_cmdline.extensions.DummyExtension" EXTENSIONS = {EXT_PATH: 200} settingsstr = _execute( "settings", "--get", "EXTENSIONS", "-s", "EXTENSIONS=" + json.dumps(EXTENSIONS), ) assert "..." not in settingsstr settingsdict = ast.literal_eval(settingsstr) assert set(settingsdict.keys()) == set(EXTENSIONS.keys()) assert settingsdict[EXT_PATH] == 200 def test_pathlib_path_as_feeds_key(): assert _execute("settings", "--get", "FEEDS") == json.dumps( {"items.csv": {"format": "csv", "fields": ["price", "name"]}} ) scrapy-scrapy-5569de3/tests/test_cmdline/extensions.py000066400000000000000000000000371525051012400232400ustar00rootroot00000000000000class DummyExtension: pass scrapy-scrapy-5569de3/tests/test_cmdline/settings.py000066400000000000000000000003401525051012400226760ustar00rootroot00000000000000from pathlib import Path EXTENSIONS = { "tests.test_cmdline.extensions.DummyExtension": 0, } TEST1 = "default" FEEDS = { Path("items.csv"): { "format": "csv", "fields": ["price", "name"], }, } scrapy-scrapy-5569de3/tests/test_cmdline_crawl_with_pipeline/000077500000000000000000000000001525051012400245775ustar00rootroot00000000000000scrapy-scrapy-5569de3/tests/test_cmdline_crawl_with_pipeline/__init__.py000066400000000000000000000012401525051012400267050ustar00rootroot00000000000000from __future__ import annotations import sys from pathlib import Path from subprocess import PIPE, Popen def _execute(spname: str) -> int: args = (sys.executable, "-m", "scrapy.cmdline", "crawl", spname) cwd = Path(__file__).resolve().parent proc = Popen(args, stdout=PIPE, stderr=PIPE, cwd=cwd) proc.communicate() return proc.returncode def test_open_spider_normally_in_pipeline(): returncode = _execute("normal") assert returncode == 0 def test_exception_at_open_spider_in_pipeline(): returncode = _execute("exception") # An exception in pipeline's open_spider should result in a non-zero exit code assert returncode == 1 scrapy-scrapy-5569de3/tests/test_cmdline_crawl_with_pipeline/scrapy.cfg000066400000000000000000000000521525051012400265560ustar00rootroot00000000000000[settings] default = test_spider.settings scrapy-scrapy-5569de3/tests/test_cmdline_crawl_with_pipeline/test_spider/000077500000000000000000000000001525051012400271245ustar00rootroot00000000000000scrapy-scrapy-5569de3/tests/test_cmdline_crawl_with_pipeline/test_spider/__init__.py000066400000000000000000000000001525051012400312230ustar00rootroot00000000000000scrapy-scrapy-5569de3/tests/test_cmdline_crawl_with_pipeline/test_spider/pipelines.py000066400000000000000000000004501525051012400314650ustar00rootroot00000000000000class TestSpiderPipeline: def open_spider(self, spider): pass def process_item(self, item): return item class TestSpiderExceptionPipeline: def open_spider(self, spider): raise RuntimeError("exception") def process_item(self, item): return item scrapy-scrapy-5569de3/tests/test_cmdline_crawl_with_pipeline/test_spider/settings.py000066400000000000000000000001021525051012400313270ustar00rootroot00000000000000BOT_NAME = "test_spider" SPIDER_MODULES = ["test_spider.spiders"] scrapy-scrapy-5569de3/tests/test_cmdline_crawl_with_pipeline/test_spider/spiders/000077500000000000000000000000001525051012400305755ustar00rootroot00000000000000scrapy-scrapy-5569de3/tests/test_cmdline_crawl_with_pipeline/test_spider/spiders/__init__.py000066400000000000000000000000001525051012400326740ustar00rootroot00000000000000scrapy-scrapy-5569de3/tests/test_cmdline_crawl_with_pipeline/test_spider/spiders/exception.py000066400000000000000000000003561525051012400331510ustar00rootroot00000000000000import scrapy class ExceptionSpider(scrapy.Spider): name = "exception" custom_settings = { "ITEM_PIPELINES": {"test_spider.pipelines.TestSpiderExceptionPipeline": 300} } def parse(self, response): pass scrapy-scrapy-5569de3/tests/test_cmdline_crawl_with_pipeline/test_spider/spiders/normal.py000066400000000000000000000003371525051012400324420ustar00rootroot00000000000000import scrapy class NormalSpider(scrapy.Spider): name = "normal" custom_settings = { "ITEM_PIPELINES": {"test_spider.pipelines.TestSpiderPipeline": 300} } def parse(self, response): pass scrapy-scrapy-5569de3/tests/test_command_check.py000066400000000000000000000257611525051012400222140ustar00rootroot00000000000000from __future__ import annotations import sys from io import StringIO from typing import TYPE_CHECKING from unittest import TestCase from unittest.mock import MagicMock, Mock, PropertyMock, call, patch from scrapy.commands.check import Command, TextTestResult from tests.utils.bases.commands import TestProjectBase from tests.utils.cmdline import proc if TYPE_CHECKING: from pathlib import Path class DummyTestCase(TestCase): pass class TestCheckCommand(TestProjectBase): spider_name = "check_spider" def _write_contract( self, proj_path: Path, contracts: str, parse_def: str, custom_settings: str = "", ) -> None: spider = proj_path / self.project_name / "spiders" / "checkspider.py" spider.write_text( f""" import scrapy class CheckSpider(scrapy.Spider): name = '{self.spider_name}' start_urls = ['data:,'] custom_settings = {{ "DOWNLOAD_DELAY": 0, {custom_settings} }} def parse(self, response, **cb_kwargs): \"\"\" @url data:, {contracts} \"\"\" {parse_def} """, encoding="utf-8", ) def _test_contract( self, proj_path: Path, contracts: str = "", parse_def: str = "pass", use_reactor: bool = True, custom_settings: str = "", ) -> None: self._write_contract(proj_path, contracts, parse_def, custom_settings) args = ["check"] if not use_reactor: args += ["-s", "TWISTED_REACTOR_ENABLED=False"] ret, out, err = proc(*args, cwd=proj_path) assert "F" not in out assert "OK" in err assert ret == 0 def test_bootstrap_failure(self, proj_path: Path) -> None: self._write_contract(proj_path, "", "pass") self._break_bootstrap(proj_path / self.project_name) ret, _, _ = proc("check", cwd=proj_path) assert ret == 1 def test_check_returns_requests_contract(self, proj_path: Path) -> None: contracts = """ @returns requests 1 """ parse_def = """ yield scrapy.Request(url='http://next-url.com') """ self._test_contract(proj_path, contracts, parse_def) def test_check_no_reactor(self, proj_path: Path) -> None: contracts = """ @returns requests 1 """ parse_def = """ yield scrapy.Request(url='http://next-url.com') """ self._test_contract(proj_path, contracts, parse_def, use_reactor=False) def test_check_returns_items_contract(self, proj_path: Path) -> None: contracts = """ @returns items 1 """ parse_def = """ yield {'key1': 'val1', 'key2': 'val2'} """ self._test_contract(proj_path, contracts, parse_def) def test_check_cb_kwargs_contract(self, proj_path: Path) -> None: contracts = """ @cb_kwargs {"arg1": "val1", "arg2": "val2"} """ parse_def = """ if len(cb_kwargs.items()) == 0: raise Exception("Callback args not set") """ self._test_contract(proj_path, contracts, parse_def) def test_check_request_contracts(self, proj_path: Path) -> None: contracts = """ @method POST @body field1=value1 @header X-Header header value @cookie name value """ parse_def = """ request = response.request if ( request.method != 'POST' or request.body != b'field1=value1' or request.headers['X-Header'] != b'header value' or request.cookies != {'name': 'value'} ): raise Exception("Request arguments not set") """ self._test_contract(proj_path, contracts, parse_def) def test_check_spider_argument(self, proj_path: Path) -> None: parse_def = """ if self.key != 'val': raise Exception("Spider argument not set") """ self._write_contract(proj_path, "", parse_def) ret, out, err = proc("check", "-a", "key=val", cwd=proj_path) assert "F" not in out assert "OK" in err assert ret == 0 def test_check_invalid_spider_argument(self, proj_path: Path) -> None: self._write_contract(proj_path, "", "pass") ret, _, err = proc("check", "-a", "FOO", cwd=proj_path) assert ret == 2 assert "Invalid -a value, use -a NAME=VALUE" in err def test_check_scrapes_contract(self, proj_path: Path) -> None: contracts = """ @scrapes key1 key2 """ parse_def = """ yield {'key1': 'val1', 'key2': 'val2'} """ self._test_contract(proj_path, contracts, parse_def) def test_check_all_default_contracts(self, proj_path: Path) -> None: contracts = """ @returns items 1 @returns requests 1 @scrapes key1 key2 @cb_kwargs {"arg1": "val1", "arg2": "val2"} """ parse_def = """ yield {'key1': 'val1', 'key2': 'val2'} yield scrapy.Request(url='http://next-url.com') if len(cb_kwargs.items()) == 0: raise Exception("Callback args not set") """ self._test_contract(proj_path, contracts, parse_def) def test_check_no_item_processing(self, proj_path: Path) -> None: (proj_path / self.project_name / "pipelines.py").write_text( """ from pathlib import Path class MarkerPipeline: def open_spider(self, spider): Path("pipeline.txt").touch() def process_item(self, item, spider): return item """, encoding="utf-8", ) self._append_settings( proj_path / self.project_name, """ FEEDS = {"items.jsonl": {"format": "jsonlines"}} """, ) contracts = """ @returns items 1 """ parse_def = """ yield {'key1': 'val1'} """ custom_settings = f'"ITEM_PIPELINES": {{"{self.project_name}.pipelines.MarkerPipeline": 100}},' self._test_contract( proj_path, contracts, parse_def, custom_settings=custom_settings ) assert not (proj_path / "items.jsonl").exists() assert not (proj_path / "pipeline.txt").exists() ret, _, err = proc( "check", "-s", f'ITEM_PIPELINES={{"{self.project_name}.pipelines.MarkerPipeline": 100}}', "-s", 'FEEDS={"items.jsonl": {"format": "jsonlines"}}', cwd=proj_path, ) assert ret == 0, err assert (proj_path / "items.jsonl").exists() assert (proj_path / "pipeline.txt").exists() def test_SCRAPY_CHECK_set(self, proj_path: Path) -> None: parse_def = """ import os if not os.environ.get('SCRAPY_CHECK'): raise Exception('SCRAPY_CHECK not set') """ self._test_contract(proj_path, parse_def=parse_def) def test_printSummary_with_unsuccessful_test_result_without_errors_and_without_failures( self, ) -> None: result = TextTestResult(MagicMock(), descriptions=False, verbosity=1) start_time = 1.0 stop_time = 2.0 result.testsRun = 5 result.failures = [] result.errors = [] result.unexpectedSuccesses = [DummyTestCase(), DummyTestCase()] with patch.object(result.stream, "write") as mock_write: result.printSummary(start_time, stop_time) mock_write.assert_has_calls([call("FAILED"), call("\n")]) def test_printSummary_with_unsuccessful_test_result_with_only_failures( self, ) -> None: result = TextTestResult(MagicMock(), descriptions=False, verbosity=1) start_time = 1.0 stop_time = 2.0 result.testsRun = 5 result.failures = [(DummyTestCase(), "failure")] result.errors = [] with patch.object(result.stream, "writeln") as mock_write: result.printSummary(start_time, stop_time) mock_write.assert_called_with(" (failures=1)") def test_printSummary_with_unsuccessful_test_result_with_only_errors(self) -> None: result = TextTestResult(MagicMock(), descriptions=False, verbosity=1) start_time = 1.0 stop_time = 2.0 result.testsRun = 5 result.failures = [] result.errors = [(DummyTestCase(), "error")] with patch.object(result.stream, "writeln") as mock_write: result.printSummary(start_time, stop_time) mock_write.assert_called_with(" (errors=1)") def test_printSummary_with_unsuccessful_test_result_with_both_failures_and_errors( self, ) -> None: result = TextTestResult(MagicMock(), descriptions=False, verbosity=1) start_time = 1.0 stop_time = 2.0 result.testsRun = 5 result.failures = [(DummyTestCase(), "failure")] result.errors = [(DummyTestCase(), "error")] with patch.object(result.stream, "writeln") as mock_write: result.printSummary(start_time, stop_time) mock_write.assert_called_with(" (failures=1, errors=1)") @patch("scrapy.commands.check.ContractsManager") def test_run_with_opts_list_prints_spider(self, cm_cls_mock) -> None: output = StringIO() sys.stdout = output cmd = Command() cmd.settings = Mock( get_component_priority_dict_with_base=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)) assert output.getvalue() == "FakeSpider\n * fakeMethod1\n * fakeMethod2\n" 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 ) -> None: cmd = Command() cmd.settings = Mock( get_component_priority_dict_with_base=Mock(return_value={}), ) cm_cls_mock.return_value = cm_mock = Mock() spider_loader_mock = Mock() cmd.crawler_process = Mock( spider_loader=spider_loader_mock, bootstrap_failed=False ) spider_name = "FakeSpider" spider_cls_mock = Mock() spider_loader_mock.load.side_effect = lambda x: {spider_name: spider_cls_mock}[ x ] tested_methods: list[str] = [] 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() scrapy-scrapy-5569de3/tests/test_command_crawl.py000066400000000000000000000110461525051012400222360ustar00rootroot00000000000000from __future__ import annotations from typing import TYPE_CHECKING from tests.utils.bases.commands import TestProjectBase from tests.utils.cmdline import proc if TYPE_CHECKING: from collections.abc import Iterable from pathlib import Path class TestCrawlCommand(TestProjectBase): def crawl( self, code: str, proj_path: Path, args: Iterable[str] = () ) -> tuple[int, str, str]: (proj_path / self.project_name / "spiders" / "myspider.py").write_text( code, encoding="utf-8" ) return proc("crawl", "myspider", *args, cwd=proj_path) def get_log(self, code: str, proj_path: Path, args: Iterable[str] = ()) -> str: _, _, stderr = self.crawl(code, proj_path, args=args) return stderr def test_no_spider(self, proj_path: Path) -> None: returncode, out, _ = proc("crawl", cwd=proj_path) assert returncode == 2 assert "Usage" in out def test_multiple_spiders(self, proj_path: Path) -> None: returncode, _, err = proc("crawl", "myspider", "myspider2", cwd=proj_path) assert returncode == 2 assert ( "running 'scrapy crawl' with more than one spider is not supported" in err ) def test_no_output(self, proj_path: Path) -> None: spider_code = """ import scrapy class MySpider(scrapy.Spider): name = 'myspider' async def start(self): self.logger.debug('It works!') return yield """ log = self.get_log(spider_code, proj_path) assert "[myspider] DEBUG: It works!" in log assert ( "Using reactor: twisted.internet.asyncioreactor.AsyncioSelectorReactor" in log ) assert "Spider closed (finished)" in log def test_output(self, proj_path: Path) -> None: spider_code = """ import scrapy class MySpider(scrapy.Spider): name = 'myspider' async def start(self): self.logger.debug('FEEDS: {}'.format(self.settings.getdict('FEEDS'))) return yield """ args = ["-o", "example.json"] log = self.get_log(spider_code, proj_path, args=args) assert "[myspider] DEBUG: FEEDS: {'example.json': {'format': 'json'}}" in log def test_overwrite_output(self, proj_path: Path) -> None: spider_code = """ import json import scrapy class MySpider(scrapy.Spider): name = 'myspider' async def start(self): self.logger.debug( 'FEEDS: {}'.format( json.dumps(self.settings.getdict('FEEDS'), sort_keys=True) ) ) return yield """ j = proj_path / "example.json" j.write_text("not empty", encoding="utf-8") args = ["-O", "example.json"] log = self.get_log(spider_code, proj_path, args=args) assert ( '[myspider] DEBUG: FEEDS: {"example.json": {"format": "json", "overwrite": true}}' in log ) with j.open(encoding="utf-8") as f2: first_line = f2.readline() assert first_line != "not empty" def test_output_and_overwrite_output(self, proj_path: Path) -> None: spider_code = """ import scrapy class MySpider(scrapy.Spider): name = 'myspider' async def start(self): return yield """ args = ["-o", "example1.json", "-O", "example2.json"] log = self.get_log(spider_code, proj_path, args=args) assert ( "error: Please use only one of -o/--output and -O/--overwrite-output" in log ) def test_default_reactor(self, proj_path: Path) -> None: spider_code = """ import scrapy class MySpider(scrapy.Spider): name = 'myspider' async def start(self): self.logger.debug('It works!') return yield """ log = self.get_log(spider_code, proj_path, args=("-s", "TWISTED_REACTOR=")) assert "[myspider] DEBUG: It works!" in log assert ( "Using reactor: twisted.internet.asyncioreactor.AsyncioSelectorReactor" not in log ) assert "Spider closed (finished)" in log def test_no_reactor(self, proj_path: Path) -> None: spider_code = """ import scrapy class MySpider(scrapy.Spider): name = 'myspider' async def start(self): self.logger.debug('It works!') return yield """ log = self.get_log( spider_code, proj_path, args=("-s", "TWISTED_REACTOR_ENABLED=False") ) assert "[myspider] DEBUG: It works!" in log assert "Not using a Twisted reactor" in log assert "Spider closed (finished)" in log scrapy-scrapy-5569de3/tests/test_command_fetch.py000066400000000000000000000052131525051012400222160ustar00rootroot00000000000000from __future__ import annotations from typing import TYPE_CHECKING import pytest from tests.utils.bases.commands import TestProjectBase from tests.utils.cmdline import proc if TYPE_CHECKING: from pathlib import Path from tests.mockserver.http import MockServer class TestFetchCommand: @pytest.mark.parametrize("args", [(), ("not-a-url",), ("a:b", "c:d")]) def test_bad_arguments(self, args: tuple[str, ...]) -> None: returncode, out, _ = proc("fetch", *args) assert returncode == 2 assert "Usage" in out def test_output(self, mockserver: MockServer) -> None: _, out, _ = proc("fetch", mockserver.url("/text")) assert out.strip() == "Works" def test_redirect_default(self, mockserver: MockServer) -> None: _, out, _ = proc("fetch", mockserver.url("/redirect")) assert out.strip() == "Redirected here" def test_redirect_disabled(self, mockserver: MockServer) -> None: _, _, err = proc( "fetch", "--no-redirect", mockserver.url("/redirect-no-meta-refresh") ) err = err.strip() assert "downloader/response_status_count/302" in err assert "downloader/response_status_count/200" not in err def test_headers(self, mockserver: MockServer) -> None: _, out, _ = proc("fetch", mockserver.url("/text"), "--headers") out = out.replace("\r", "") # required on win32 assert "Server: TwistedWeb" in out assert "Content-Type: text/plain" in out def test_no_reactor(self, mockserver: MockServer) -> None: _, out, _ = proc( "fetch", "-s", "TWISTED_REACTOR_ENABLED=False", mockserver.url("/text") ) assert out.strip() == "Works" class TestFetchCommandWithSpider(TestProjectBase): @pytest.fixture(autouse=True) def create_files(self, proj_path: Path) -> None: (proj_path / self.project_name / "spiders" / "myspider.py").write_text( """ import scrapy class MySpider(scrapy.Spider): name = "myspider" custom_settings = {"USER_AGENT": "myspider-user-agent"} """, encoding="utf-8", ) def test_spider(self, proj_path: Path, mockserver: MockServer) -> None: _, out, err = proc( "fetch", "--spider", "myspider", mockserver.url("/echo"), cwd=proj_path ) assert "myspider-user-agent" in out, err def test_bootstrap_failure(self, proj_path: Path, mockserver: MockServer) -> None: self._break_bootstrap(proj_path / self.project_name) returncode, _, _ = proc( "fetch", "--spider", "myspider", mockserver.url("/text"), cwd=proj_path ) assert returncode == 1 scrapy-scrapy-5569de3/tests/test_command_genspider.py000066400000000000000000000301271525051012400231070ustar00rootroot00000000000000from __future__ import annotations import re import sys from pathlib import Path import pytest from tests.utils.bases.commands import TestProjectBase from tests.utils.cmdline import call, proc, write_recording_editor def find_in_file(filename: Path, regex: str) -> re.Match[str] | None: """Find first pattern occurrence in file""" pattern = re.compile(regex) with filename.open("r", encoding="utf-8") as f: for line in f: match = pattern.search(line) if match is not None: return match return None class TestGenspiderCommand(TestProjectBase): def test_arguments(self, proj_path: Path) -> None: spider = proj_path / self.project_name / "spiders" / "test_name.py" # only pass one argument. spider script shouldn't be created assert call("genspider", "test_name", cwd=proj_path) == 2 assert not spider.exists() # pass two arguments . spider script should be created assert call("genspider", "test_name", "test.com", cwd=proj_path) == 0 assert spider.exists() @pytest.mark.parametrize( "tplname", [ "basic", "crawl", "xmlfeed", "csvfeed", ], ) def test_template(self, tplname: str, proj_path: Path) -> None: args = [f"--template={tplname}"] if tplname else [] spname = "test_spider" spmodule = f"{self.project_name}.spiders.{spname}" spfile = proj_path / self.project_name / "spiders" / f"{spname}.py" _, out, _ = proc("genspider", spname, "test.com", *args, cwd=proj_path) assert ( f"Created spider {spname!r} using template {tplname!r} in module:\n {spmodule}" in out ) assert spfile.exists() modify_time_before = spfile.stat().st_mtime _, out, _ = proc("genspider", spname, "test.com", *args, cwd=proj_path) assert f"Spider {spname!r} already exists in module" in out modify_time_after = spfile.stat().st_mtime assert modify_time_after == modify_time_before @pytest.mark.parametrize("suffix", ["", ".tmpl"]) def test_custom_template_path(self, suffix: str, proj_path: Path) -> None: template = proj_path / "custom.tmpl" template.write_text( 'import scrapy\n\n\nclass $classname(scrapy.Spider):\n name = "$name"\n', encoding="utf-8", ) spname = "test_spider" spfile = proj_path / self.project_name / "spiders" / f"{spname}.py" assert ( call( "genspider", "-t", str(template)[: -len(".tmpl")] if suffix == "" else str(template), spname, "test.com", cwd=proj_path, ) == 0 ) assert spfile.exists() assert "class TestSpiderSpider(scrapy.Spider):" in spfile.read_text( encoding="utf-8" ) def test_list(self, proj_path: Path) -> None: assert call("genspider", "--list", cwd=proj_path) == 0 def test_dump(self, proj_path: Path) -> None: assert call("genspider", "--dump=basic", cwd=proj_path) == 0 assert call("genspider", "-d", "basic", cwd=proj_path) == 0 @pytest.mark.parametrize( "args", [("--dump=nonexistent",), ("-t", "nonexistent", "test_name", "test.com")], ) def test_unknown_template(self, args: tuple[str, ...], proj_path: Path) -> None: returncode, out, err = proc("genspider", *args, cwd=proj_path) assert returncode == 0, err assert "Unable to find template: nonexistent" in out assert not (proj_path / self.project_name / "spiders" / "test_name.py").exists() def test_name_not_starting_with_a_letter(self, proj_path: Path) -> None: """The module name, unlike the spider name, is prefixed with a letter.""" _, out, err = proc("genspider", "1st_spider", "test.com", cwd=proj_path) assert "Created spider '1st_spider'" in out, err spider = proj_path / self.project_name / "spiders" / "a1st_spider.py" assert spider.exists() assert find_in_file(spider, r'name\s*=\s*"1st_spider"') is not None @pytest.mark.skipif( sys.platform == "win32", reason="requires a POSIX shell editor script" ) def test_edit(self, proj_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: spider = proj_path / self.project_name / "spiders" / "example2.py" edited = proj_path / "edited.txt" editor = proj_path / "fake-editor.sh" write_recording_editor(editor) # The extra argument exercises shlex-splitting of the EDITOR value. monkeypatch.setenv("EDITOR", f"{editor} {edited}") returncode, _, err = proc( "genspider", "--edit", "example2", "example2.com", cwd=proj_path ) assert returncode == 0, err assert "ModuleNotFoundError" not in err assert spider.exists() assert (proj_path / edited.read_text(encoding="utf-8")).resolve() == ( spider.resolve() ) def test_same_name_as_project(self, proj_path: Path) -> None: _, out, err = proc("genspider", self.project_name, "test.com", cwd=proj_path) assert "Cannot create a spider with the same name as your project" in out, err assert not ( proj_path / self.project_name / "spiders" / f"{self.project_name}.py" ).exists() @pytest.mark.parametrize("force", [True, False]) def test_same_filename_as_existing_spider( self, force: bool, proj_path: Path ) -> None: file_name = "example" file_path = proj_path / self.project_name / "spiders" / f"{file_name}.py" assert call("genspider", file_name, "example.com", cwd=proj_path) == 0 assert file_path.exists() # change name of spider but not its file name with file_path.open("r+", encoding="utf-8") as spider_file: file_data = spider_file.read() file_data = file_data.replace('name = "example"', 'name = "renamed"') spider_file.seek(0) spider_file.write(file_data) spider_file.truncate() modify_time_before = file_path.stat().st_mtime file_contents_before = file_data if force: _, out, _ = proc( "genspider", "--force", file_name, "example.com", cwd=proj_path ) assert ( f"Created spider {file_name!r} using template 'basic' in module" in out ) modify_time_after = file_path.stat().st_mtime assert modify_time_after != modify_time_before file_contents_after = file_path.read_text(encoding="utf-8") assert file_contents_after != file_contents_before else: _, out, _ = proc("genspider", file_name, "example.com", cwd=proj_path) assert f"{file_path.resolve()} already exists" in out modify_time_after = file_path.stat().st_mtime assert modify_time_after == modify_time_before file_contents_after = file_path.read_text(encoding="utf-8") assert file_contents_after == file_contents_before @pytest.mark.parametrize( ("url", "domain"), [ ("test.com", "test.com"), ("https://test.com", "test.com"), ], ) def test_url(self, url: str, domain: str, proj_path: Path) -> None: assert call("genspider", "--force", "test_name", url, cwd=proj_path) == 0 spider = proj_path / self.project_name / "spiders" / "test_name.py" m = find_in_file(spider, r"allowed_domains\s*=\s*\[['\"](.+)['\"]\]") assert m is not None assert m.group(1) == domain m = find_in_file(spider, r"start_urls\s*=\s*\[['\"](.+)['\"]\]") assert m is not None assert m.group(1) == f"https://{domain}" @pytest.mark.parametrize( ("url", "expected", "template"), [ # basic ("https://test.com", "https://test.com", "basic"), ("http://test.com", "http://test.com", "basic"), ("http://test.com/other/path", "http://test.com/other/path", "basic"), ("test.com/other/path", "https://test.com/other/path", "basic"), # crawl ("https://test.com", "https://test.com", "crawl"), ("http://test.com", "http://test.com", "crawl"), ("http://test.com/other/path", "http://test.com/other/path", "crawl"), ("test.com/other/path", "https://test.com/other/path", "crawl"), ("test.com", "https://test.com", "crawl"), # xmlfeed ("https://test.com/feed.xml", "https://test.com/feed.xml", "xmlfeed"), ("http://test.com/feed.xml", "http://test.com/feed.xml", "xmlfeed"), ("test.com/feed.xml", "https://test.com/feed.xml", "xmlfeed"), # csvfeed ("https://test.com/feed.csv", "https://test.com/feed.csv", "csvfeed"), ("http://test.com/feed.xml", "http://test.com/feed.xml", "csvfeed"), ("test.com/feed.csv", "https://test.com/feed.csv", "csvfeed"), ], ) def test_template_start_urls( self, url: str, expected: str, template: str, proj_path: Path ) -> None: assert ( call( "genspider", "-t", template, "--force", "test_name", url, cwd=proj_path ) == 0 ) spider = proj_path / self.project_name / "spiders" / "test_name.py" m = find_in_file(spider, r"start_urls\s*=\s*\[['\"](.+)['\"]\]") assert m is not None assert m.group(1) == expected class TestGenspiderStandaloneCommand: def test_generate_standalone_spider(self, tmp_path: Path) -> None: call("genspider", "example", "example.com", cwd=tmp_path) assert Path(tmp_path, "example.py").exists() @pytest.mark.skipif( sys.platform == "win32", reason="requires a POSIX shell editor script" ) def test_edit(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: spider = tmp_path / "example.py" edited = tmp_path / "edited.txt" editor = tmp_path / "fake-editor.sh" write_recording_editor(editor) monkeypatch.setenv("EDITOR", f"{editor} {edited}") returncode, _, err = proc( "genspider", "--edit", "example", "example.com", cwd=tmp_path ) assert returncode == 0, err assert spider.exists() assert (tmp_path / edited.read_text(encoding="utf-8")).resolve() == ( spider.resolve() ) @pytest.mark.parametrize("force", [True, False]) def test_same_name_as_existing_file(self, force: bool, tmp_path: Path) -> None: file_name = "example" file_path = Path(tmp_path, file_name + ".py") _, out, _ = proc("genspider", file_name, "example.com", cwd=tmp_path) assert f"Created spider {file_name!r} using template 'basic' " in out assert file_path.exists() modify_time_before = file_path.stat().st_mtime file_contents_before = file_path.read_text(encoding="utf-8") if force: # use different template to ensure contents were changed _, out, _ = proc( "genspider", "--force", "-t", "crawl", file_name, "example.com", cwd=tmp_path, ) assert f"Created spider {file_name!r} using template 'crawl' " in out modify_time_after = file_path.stat().st_mtime assert modify_time_after != modify_time_before file_contents_after = file_path.read_text(encoding="utf-8") assert file_contents_after != file_contents_before else: _, out, _ = proc("genspider", file_name, "example.com", cwd=tmp_path) assert ( f"{Path(tmp_path, file_name + '.py').resolve()} already exists" in out ) modify_time_after = file_path.stat().st_mtime assert modify_time_after == modify_time_before file_contents_after = file_path.read_text(encoding="utf-8") assert file_contents_after == file_contents_before scrapy-scrapy-5569de3/tests/test_command_parse.py000066400000000000000000000520541525051012400222440ustar00rootroot00000000000000from __future__ import annotations import argparse import re from typing import TYPE_CHECKING from urllib.parse import urlparse import pytest from scrapy.commands import parse from scrapy.settings import Settings from tests.utils.bases.commands import TestProjectBase from tests.utils.cmdline import call, proc if TYPE_CHECKING: from pathlib import Path from tests.mockserver.http import MockServer class TestParseCommand(TestProjectBase): spider_name = "parse_spider" @pytest.fixture(autouse=True) def create_files(self, proj_path: Path) -> None: proj_mod_path = proj_path / self.project_name (proj_mod_path / "spiders" / "myspider.py").write_text( f""" import scrapy from scrapy.linkextractors import LinkExtractor from scrapy.spiders import CrawlSpider, Rule from scrapy.utils.test import get_from_asyncio_queue import asyncio class BaseSpider(scrapy.Spider): custom_settings = {{ "DOWNLOAD_DELAY": 0, }} class AsyncDefAsyncioReturnSpider(BaseSpider): name = "asyncdef_asyncio_return" async def parse(self, response): await asyncio.sleep(0.2) status = await get_from_asyncio_queue(response.status) self.logger.info(f"Got response {{status}}") return [{{'id': 1}}, {{'id': 2}}] class AsyncDefAsyncioReturnSingleElementSpider(BaseSpider): name = "asyncdef_asyncio_return_single_element" async def parse(self, response): await asyncio.sleep(0.1) status = await get_from_asyncio_queue(response.status) self.logger.info(f"Got response {{status}}") return {{'foo': 42}} class AsyncDefAsyncioGenLoopSpider(BaseSpider): name = "asyncdef_asyncio_gen_loop" async def parse(self, response): for i in range(10): await asyncio.sleep(0.1) yield {{'foo': i}} self.logger.info(f"Got response {{response.status}}") class AsyncDefAsyncioSpider(BaseSpider): name = "asyncdef_asyncio" async def parse(self, response): await asyncio.sleep(0.2) status = await get_from_asyncio_queue(response.status) self.logger.debug(f"Got response {{status}}") class AsyncDefAsyncioGenExcSpider(BaseSpider): name = "asyncdef_asyncio_gen_exc" async def parse(self, response): for i in range(10): await asyncio.sleep(0.1) yield {{'foo': i}} if i > 5: raise ValueError("Stopping the processing") class CallbackSignatureDownloaderMiddleware: def process_request(self, request, spider): from inspect import signature spider.logger.debug(f"request.callback signature: {{signature(request.callback)}}") class MySpider(scrapy.Spider): name = '{self.spider_name}' custom_settings = {{ "DOWNLOADER_MIDDLEWARES": {{ CallbackSignatureDownloaderMiddleware: 0, }}, "DOWNLOAD_DELAY": 0, }} def parse(self, response): if getattr(self, 'test_arg', None): self.logger.debug('It Works!') return [scrapy.Item(), dict(foo='bar')] def parse_request_with_meta(self, response): foo = response.meta.get('foo', 'bar') if foo == 'bar': self.logger.debug('It Does Not Work :(') else: self.logger.debug('It Works!') def parse_request_with_cb_kwargs(self, response, foo=None, key=None): if foo == 'bar' and key == 'value': self.logger.debug('It Works!') else: self.logger.debug('It Does Not Work :(') def parse_request_without_meta(self, response): foo = response.meta.get('foo', 'bar') if foo == 'bar': self.logger.debug('It Works!') else: self.logger.debug('It Does Not Work :(') class RetryRequestSpider(BaseSpider): name = 'retry_request' def parse(self, response): if response.meta.get('retried'): yield {{'retried': True}} return response.meta['retried'] = True yield response.request.replace(dont_filter=True) class CustomCallbackRetryRequestSpider(BaseSpider): name = 'retry_request_custom_callback' def parse(self, response): yield response.request.replace( callback=self.parse_retry, dont_filter=True, ) def parse_retry(self, response): if response.meta.get('retried'): yield {{'retried_with_custom_callback': True}} return response.meta['retried'] = True yield response.request.replace(dont_filter=True) class MyGoodCrawlSpider(CrawlSpider): name = 'goodcrawl{self.spider_name}' custom_settings = {{ "DOWNLOAD_DELAY": 0, }} rules = ( Rule(LinkExtractor(allow=r'/html'), callback='parse_item', follow=True), Rule(LinkExtractor(allow=r'/text'), follow=True), ) def parse_item(self, response): return [scrapy.Item(), dict(foo='bar')] def parse(self, response): return [scrapy.Item(), dict(nomatch='default')] class MyBadCrawlSpider(CrawlSpider): '''Spider which doesn't define a parse_item callback while using it in a rule.''' name = 'badcrawl{self.spider_name}' custom_settings = {{ "DOWNLOAD_DELAY": 0, }} rules = ( Rule(LinkExtractor(allow=r'/html'), callback='parse_item', follow=True), ) def parse(self, response): return [scrapy.Item(), dict(foo='bar')] """, encoding="utf-8", ) (proj_mod_path / "pipelines.py").write_text( """ import logging class MyPipeline: component_name = 'my_pipeline' def process_item(self, item): logging.info('It Works!') return item """, encoding="utf-8", ) with (proj_mod_path / "settings.py").open("a", encoding="utf-8") as f: f.write( f""" ITEM_PIPELINES = {{'{self.project_name}.pipelines.MyPipeline': 1}} """ ) def test_bootstrap_failure(self, proj_path: Path, mockserver: MockServer) -> None: self._break_bootstrap(proj_path / self.project_name) returncode, _, _ = proc( "parse", "--spider", self.spider_name, "-c", "parse", mockserver.url("/html"), cwd=proj_path, ) assert returncode == 1 def test_spider_arguments(self, proj_path: Path, mockserver: MockServer) -> None: _, _, stderr = proc( "parse", "--spider", self.spider_name, "-a", "test_arg=1", "-c", "parse", "--verbose", mockserver.url("/html"), cwd=proj_path, ) assert "DEBUG: It Works!" in stderr def test_request_with_meta(self, proj_path: Path, mockserver: MockServer) -> None: raw_json_string = '{"foo" : "baz"}' _, _, stderr = proc( "parse", "--spider", self.spider_name, "--meta", raw_json_string, "-c", "parse_request_with_meta", "--verbose", mockserver.url("/html"), cwd=proj_path, ) assert "DEBUG: It Works!" in stderr _, _, stderr = proc( "parse", "--spider", self.spider_name, "-m", raw_json_string, "-c", "parse_request_with_meta", "--verbose", mockserver.url("/html"), cwd=proj_path, ) assert "DEBUG: It Works!" in stderr def test_request_with_cb_kwargs( self, proj_path: Path, mockserver: MockServer ) -> None: raw_json_string = '{"foo" : "bar", "key": "value"}' _, _, stderr = proc( "parse", "--spider", self.spider_name, "--cbkwargs", raw_json_string, "-c", "parse_request_with_cb_kwargs", "--verbose", mockserver.url("/html"), cwd=proj_path, ) assert "DEBUG: It Works!" in stderr assert ( "DEBUG: request.callback signature: (response, foo=None, key=None)" in stderr ) def test_request_without_meta( self, proj_path: Path, mockserver: MockServer ) -> None: _, _, stderr = proc( "parse", "--spider", self.spider_name, "-c", "parse_request_without_meta", "--nolinks", mockserver.url("/html"), cwd=proj_path, ) assert "DEBUG: It Works!" in stderr def test_pipelines(self, proj_path: Path, mockserver: MockServer) -> None: _, _, stderr = proc( "parse", "--spider", self.spider_name, "--pipelines", "-c", "parse", "--verbose", mockserver.url("/html"), cwd=proj_path, ) assert "INFO: It Works!" in stderr def test_async_def_asyncio_parse_items_list( self, proj_path: Path, mockserver: MockServer ) -> None: _, out, stderr = proc( "parse", "--spider", "asyncdef_asyncio_return", "-c", "parse", mockserver.url("/html"), cwd=proj_path, ) assert "INFO: Got response 200" in stderr assert "{'id': 1}" in out assert "{'id': 2}" in out def test_async_def_asyncio_parse_items_single_element( self, proj_path: Path, mockserver: MockServer ) -> None: _, out, stderr = proc( "parse", "--spider", "asyncdef_asyncio_return_single_element", "-c", "parse", mockserver.url("/html"), cwd=proj_path, ) assert "INFO: Got response 200" in stderr assert "{'foo': 42}" in out def test_async_def_asyncgen_parse_loop( self, proj_path: Path, mockserver: MockServer ) -> None: _, out, stderr = proc( "parse", "--spider", "asyncdef_asyncio_gen_loop", "-c", "parse", mockserver.url("/html"), cwd=proj_path, ) assert "INFO: Got response 200" in stderr for i in range(10): assert f"{{'foo': {i}}}" in out def test_async_def_asyncgen_parse_exc( self, proj_path: Path, mockserver: MockServer ) -> None: _, out, stderr = proc( "parse", "--spider", "asyncdef_asyncio_gen_exc", "-c", "parse", mockserver.url("/html"), cwd=proj_path, ) assert "ValueError" in stderr for i in range(7): assert f"{{'foo': {i}}}" in out def test_async_def_asyncio_parse( self, proj_path: Path, mockserver: MockServer ) -> None: _, _, stderr = proc( "parse", "--spider", "asyncdef_asyncio", "-c", "parse", mockserver.url("/html"), cwd=proj_path, ) assert "DEBUG: Got response 200" in stderr def test_parse_items(self, proj_path: Path, mockserver: MockServer) -> None: _, out, _ = proc( "parse", "--spider", self.spider_name, "-c", "parse", mockserver.url("/html"), cwd=proj_path, ) assert "[{}, {'foo': 'bar'}]" in out def test_parse_items_no_callback_passed( self, proj_path: Path, mockserver: MockServer ) -> None: _, out, _ = proc( "parse", "--spider", self.spider_name, mockserver.url("/html"), cwd=proj_path, ) assert "[{}, {'foo': 'bar'}]" in out def test_retry_response_request( self, proj_path: Path, mockserver: MockServer ) -> None: _, out, stderr = proc( "parse", "--spider", "retry_request", "-d", "2", mockserver.url("/html"), cwd=proj_path, ) assert "RecursionError" not in stderr assert "{'retried': True}" in out def test_retry_response_request_with_custom_callback( self, proj_path: Path, mockserver: MockServer ) -> None: _, out, stderr = proc( "parse", "--spider", "retry_request_custom_callback", "-d", "3", mockserver.url("/html"), cwd=proj_path, ) assert "RecursionError" not in stderr assert "{'retried_with_custom_callback': True}" in out def test_wrong_callback_passed( self, proj_path: Path, mockserver: MockServer ) -> None: _, out, stderr = proc( "parse", "--spider", self.spider_name, "-c", "dummy", mockserver.url("/html"), cwd=proj_path, ) assert re.search(r"# Scraped Items -+\r?\n\[\]", out) assert "Cannot find callback" in stderr def test_crawlspider_matching_rule_callback_set( self, proj_path: Path, mockserver: MockServer ) -> None: """If a rule matches the URL, use it's defined callback.""" _, out, _ = proc( "parse", "--spider", "goodcrawl" + self.spider_name, "-r", mockserver.url("/html"), cwd=proj_path, ) assert "[{}, {'foo': 'bar'}]" in out def test_crawlspider_matching_rule_default_callback( self, proj_path: Path, mockserver: MockServer ) -> None: """If a rule match but it has no callback set, use the 'parse' callback.""" _, out, _ = proc( "parse", "--spider", "goodcrawl" + self.spider_name, "-r", mockserver.url("/text"), cwd=proj_path, ) assert "[{}, {'nomatch': 'default'}]" in out def test_spider_with_no_rules_attribute( self, proj_path: Path, mockserver: MockServer ) -> None: """Using -r with a spider with no rule should not produce items.""" _, out, stderr = proc( "parse", "--spider", self.spider_name, "-r", mockserver.url("/html"), cwd=proj_path, ) assert re.search(r"# Scraped Items -+\r?\n\[\]", out) assert "No CrawlSpider rules found" in stderr def test_crawlspider_missing_callback( self, proj_path: Path, mockserver: MockServer ) -> None: _, out, _ = proc( "parse", "--spider", "badcrawl" + self.spider_name, "-r", mockserver.url("/html"), cwd=proj_path, ) assert re.search(r"# Scraped Items -+\r?\n\[\]", out) def test_crawlspider_no_matching_rule( self, proj_path: Path, mockserver: MockServer ) -> None: """The requested URL has no matching rule, so no items should be scraped""" _, out, stderr = proc( "parse", "--spider", "badcrawl" + self.spider_name, "-r", mockserver.url("/enc-gb18030"), cwd=proj_path, ) assert re.search(r"# Scraped Items -+\r?\n\[\]", out) assert "Cannot find a rule that matches" in stderr def test_crawlspider_not_exists_with_not_matched_url( self, proj_path: Path, mockserver: MockServer ) -> None: assert call("parse", mockserver.url("/invalid_url"), cwd=proj_path) == 0 def test_output_flag(self, proj_path: Path, mockserver: MockServer) -> None: """Checks if a file was created successfully having correct format containing correct data in it. """ file_name = "data.json" file_path = proj_path / file_name proc( "parse", "--spider", self.spider_name, "-c", "parse", "-o", file_name, mockserver.url("/html"), cwd=proj_path, ) assert file_path.exists() assert file_path.is_file() content = '[\n{},\n{"foo": "bar"}\n]' assert file_path.read_text(encoding="utf-8") == content @pytest.mark.parametrize("args", [(), ("not-a-url",), ("a:b", "c:d")]) def test_bad_arguments(self, args: tuple[str, ...], proj_path: Path) -> None: returncode, out, _ = proc("parse", *args, cwd=proj_path) assert returncode == 2 assert "Usage" in out @pytest.mark.parametrize( ("option", "message"), [ ("--meta", "Invalid -m/--meta value"), ("-m", "Invalid -m/--meta value"), ("--cbkwargs", "Invalid --cbkwargs value"), ], ) def test_invalid_json( self, option: str, message: str, proj_path: Path, mockserver: MockServer ) -> None: returncode, _, err = proc( "parse", "--spider", self.spider_name, option, "{invalid", mockserver.url("/html"), cwd=proj_path, ) assert returncode == 2 assert message in err def test_unknown_spider(self, proj_path: Path, mockserver: MockServer) -> None: returncode, _, err = proc( "parse", "--spider", "nonexistent", mockserver.url("/html"), cwd=proj_path, ) assert returncode == 0, err assert "Unable to find spider: nonexistent" in err def test_spider_found_by_url(self, proj_path: Path, mockserver: MockServer) -> None: """Without --spider, the spider is chosen based on the URL.""" url = mockserver.url("/html") # The spider name doubles as a domain of the spider, and it is matched # against the netloc of the URL, hence the port. (proj_path / self.project_name / "spiders" / "urlspider.py").write_text( f""" import scrapy class UrlSpider(scrapy.Spider): name = "{urlparse(url).netloc}" def parse(self, response): return [{{"found_by_url": True}}] """, encoding="utf-8", ) returncode, out, err = proc("parse", url, cwd=proj_path) assert returncode == 0, err assert "Unable to find spider for" not in err assert "{'found_by_url': True}" in out def test_legacy_item_processor( self, proj_path: Path, mockserver: MockServer ) -> None: """--pipelines supports an ITEM_PROCESSOR without process_item_async().""" (proj_path / self.project_name / "legacy.py").write_text( """ import logging from twisted.internet.defer import succeed class LegacyItemProcessor: @classmethod def from_crawler(cls, crawler): return cls() def open_spider(self, spider): return succeed(None) def close_spider(self, spider): return succeed(None) def process_item(self, item, spider): logging.info("Legacy item processor!") return succeed(item) """, encoding="utf-8", ) _, _, stderr = proc( "parse", "--spider", self.spider_name, "--pipelines", "-c", "parse", "-s", f"ITEM_PROCESSOR={self.project_name}.legacy.LegacyItemProcessor", mockserver.url("/html"), cwd=proj_path, ) assert "INFO: Legacy item processor!" in stderr @pytest.mark.parametrize("verbose", [True, False]) def test_no_items_no_links( self, verbose: bool, proj_path: Path, mockserver: MockServer ) -> None: args = ["--verbose"] if verbose else [] _, out, err = proc( "parse", "--spider", self.spider_name, "-c", "parse", "--noitems", "--nolinks", *args, mockserver.url("/html"), cwd=proj_path, ) assert "# Scraped Items" not in out, err assert "# Requests" not in out def test_parse_add_options(self): command = parse.Command() command.settings = Settings() parser = argparse.ArgumentParser( prog="scrapy", formatter_class=argparse.HelpFormatter, conflict_handler="resolve", prefix_chars="-", ) command.add_options(parser) namespace = parser.parse_args( ["--verbose", "--nolinks", "-d", "2", "--spider", self.spider_name] ) assert namespace.nolinks assert namespace.depth == 2 assert namespace.spider == self.spider_name assert namespace.verbose def test_no_reactor(self, proj_path: Path, mockserver: MockServer) -> None: _, out, stderr = proc( "parse", "--spider", "asyncdef_asyncio_return", "-c", "parse", mockserver.url("/html"), "-s", "TWISTED_REACTOR_ENABLED=False", cwd=proj_path, ) assert "INFO: Got response 200" in stderr assert "{'id': 1}" in out assert "{'id': 2}" in out scrapy-scrapy-5569de3/tests/test_command_runspider.py000066400000000000000000000320501525051012400231370ustar00rootroot00000000000000from __future__ import annotations import asyncio import inspect import platform import sys from typing import TYPE_CHECKING import pytest from tests.spiders import ExceptionSpider, NoRequestsSpider from tests.utils.cmdline import proc if TYPE_CHECKING: from collections.abc import Iterable from pathlib import Path # Header for spider classes copied with inspect.getsource(): the future import # keeps their annotations from being evaluated in the generated module. SPIDER_HEADER = "from __future__ import annotations\nfrom scrapy import Spider\n" class TestRunSpiderCommand: spider_filename = "myspider.py" debug_log_spider = """ import scrapy class MySpider(scrapy.Spider): name = 'myspider' async def start(self): self.logger.debug("It Works!") return yield """ badspider = """ import scrapy class BadSpider(scrapy.Spider): name = "bad" async def start(self): raise Exception("oops!") yield """ def runspider( self, cwd: Path, code: str, name: str | None = None, args: Iterable[str] = () ) -> tuple[int, str, str]: fname = cwd / (name or self.spider_filename) fname.write_text(code, encoding="utf-8") return proc("runspider", str(fname), *args, cwd=cwd) def get_log( self, cwd: Path, code: str, name: str | None = None, args: Iterable[str] = () ) -> str: _, _, stderr = self.runspider(cwd, code, name, args=args) return stderr def test_runspider(self, tmp_path: Path) -> None: log = self.get_log(tmp_path, self.debug_log_spider) assert "DEBUG: It Works!" in log assert ( "Using reactor: twisted.internet.asyncioreactor.AsyncioSelectorReactor" in log ) assert "INFO: Spider closed (finished)" in log def test_run_fail_spider(self, tmp_path: Path) -> None: ret, _, _ = self.runspider( tmp_path, SPIDER_HEADER + inspect.getsource(ExceptionSpider) ) assert ret != 0 def test_run_good_spider(self, tmp_path: Path) -> None: ret, _, _ = self.runspider( tmp_path, SPIDER_HEADER + inspect.getsource(NoRequestsSpider), ) assert ret == 0 def test_runspider_log_level(self, tmp_path: Path) -> None: log = self.get_log( tmp_path, self.debug_log_spider, args=("-s", "LOG_LEVEL=INFO") ) assert "DEBUG: It Works!" not in log assert "INFO: Spider opened" in log def test_runspider_default_reactor(self, tmp_path: Path) -> None: log = self.get_log( tmp_path, self.debug_log_spider, args=("-s", "TWISTED_REACTOR=") ) assert "DEBUG: It Works!" in log assert ( "Using reactor: twisted.internet.asyncioreactor.AsyncioSelectorReactor" not in log ) assert "INFO: Spider opened" in log assert "INFO: Closing spider (finished)" in log assert "INFO: Spider closed (finished)" in log def test_runspider_dnscache_disabled(self, tmp_path: Path) -> None: # see https://github.com/scrapy/scrapy/issues/2811 # The spider below should not be able to connect to localhost:12345, # which is intended, # but this should not be because of DNS lookup error # assumption: localhost will resolve in all cases (true?) dnscache_spider = """ import scrapy class MySpider(scrapy.Spider): name = 'myspider' start_urls = ['http://localhost:12345'] custom_settings = { "ROBOTSTXT_OBEY": False, "RETRY_ENABLED": False, } def parse(self, response): return {'test': 'value'} """ log = self.get_log( tmp_path, dnscache_spider, args=("-s", "DNSCACHE_ENABLED=False") ) assert "CannotResolveHostError" not in log assert "INFO: Spider opened" in log @pytest.mark.parametrize("value", [False, True]) def test_runspider_log_short_names(self, tmp_path: Path, value: bool) -> None: log1 = self.get_log( tmp_path, self.debug_log_spider, args=("-s", f"LOG_SHORT_NAMES={value}") ) assert "[myspider] DEBUG: It Works!" in log1 assert ("[scrapy]" in log1) is value assert ("[scrapy.core.engine]" in log1) is not value def test_runspider_no_spider_found(self, tmp_path: Path) -> None: log = self.get_log(tmp_path, "from scrapy.spiders import Spider\n") assert "No spider found in file" in log @pytest.mark.parametrize("args", [(), ("a.py", "b.py")]) def test_runspider_bad_arguments(self, args: tuple[str, ...]) -> None: returncode, out, _ = proc("runspider", *args) assert returncode == 2 assert "Usage" in out def test_runspider_file_not_found(self) -> None: _, _, log = proc("runspider", "some_non_existent_file") assert "File not found: some_non_existent_file" in log def test_runspider_unable_to_load(self, tmp_path: Path) -> None: log = self.get_log(tmp_path, "", name="myspider.txt") assert "Unable to load" in log def test_start_errors(self, tmp_path: Path) -> None: log = self.get_log(tmp_path, self.badspider, name="badspider.py") assert "start" in log assert "badspider.py" in log, log def test_asyncio_enabled_true(self, tmp_path: Path) -> None: log = self.get_log( tmp_path, self.debug_log_spider, args=[ "-s", "TWISTED_REACTOR=twisted.internet.asyncioreactor.AsyncioSelectorReactor", ], ) assert ( "Using reactor: twisted.internet.asyncioreactor.AsyncioSelectorReactor" in log ) def test_asyncio_enabled_default(self, tmp_path: Path) -> None: log = self.get_log(tmp_path, self.debug_log_spider) assert ( "Using reactor: twisted.internet.asyncioreactor.AsyncioSelectorReactor" in log ) def test_asyncio_enabled_false(self, tmp_path: Path) -> None: log = self.get_log( tmp_path, self.debug_log_spider, args=["-s", "TWISTED_REACTOR=twisted.internet.selectreactor.SelectReactor"], ) assert "Using reactor: twisted.internet.selectreactor.SelectReactor" in log assert ( "Using reactor: twisted.internet.asyncioreactor.AsyncioSelectorReactor" not in log ) @pytest.mark.requires_uvloop def test_custom_asyncio_loop_enabled_true(self, tmp_path: Path) -> None: log = self.get_log( tmp_path, self.debug_log_spider, args=[ "-s", "TWISTED_REACTOR=twisted.internet.asyncioreactor.AsyncioSelectorReactor", "-s", "ASYNCIO_EVENT_LOOP=uvloop.Loop", ], ) assert "Using asyncio event loop: uvloop.Loop" in log def test_custom_asyncio_loop_enabled_false(self, tmp_path: Path) -> None: log = self.get_log( tmp_path, self.debug_log_spider, args=[ "-s", "TWISTED_REACTOR=twisted.internet.asyncioreactor.AsyncioSelectorReactor", ], ) if sys.platform != "win32": loop = asyncio.new_event_loop() else: loop = asyncio.SelectorEventLoop() assert ( f"Using asyncio event loop: {loop.__module__}.{loop.__class__.__name__}" in log ) loop.close() def test_no_reactor(self, tmp_path: Path) -> None: log = self.get_log( tmp_path, self.debug_log_spider, args=[ "-s", "TWISTED_REACTOR_ENABLED=False", ], ) assert "Not using a Twisted reactor" in log def test_output(self, tmp_path: Path) -> None: spider_code = """ import scrapy class MySpider(scrapy.Spider): name = 'myspider' async def start(self): self.logger.debug('FEEDS: {}'.format(self.settings.getdict('FEEDS'))) return yield """ args = ["-o", "example.json"] log = self.get_log(tmp_path, spider_code, args=args) assert "[myspider] DEBUG: FEEDS: {'example.json': {'format': 'json'}}" in log def test_overwrite_output(self, tmp_path: Path) -> None: spider_code = """ import json import scrapy class MySpider(scrapy.Spider): name = 'myspider' async def start(self): self.logger.debug( 'FEEDS: {}'.format( json.dumps(self.settings.getdict('FEEDS'), sort_keys=True) ) ) return yield """ (tmp_path / "example.json").write_text("not empty", encoding="utf-8") args = ["-O", "example.json"] log = self.get_log(tmp_path, spider_code, args=args) assert ( '[myspider] DEBUG: FEEDS: {"example.json": {"format": "json", "overwrite": true}}' in log ) with (tmp_path / "example.json").open(encoding="utf-8") as f2: first_line = f2.readline() assert first_line != "not empty" def test_output_and_overwrite_output(self, tmp_path: Path) -> None: spider_code = """ import scrapy class MySpider(scrapy.Spider): name = 'myspider' async def start(self): return yield """ args = ["-o", "example1.json", "-O", "example2.json"] log = self.get_log(tmp_path, spider_code, args=args) assert ( "error: Please use only one of -o/--output and -O/--overwrite-output" in log ) def test_output_stdout(self, tmp_path: Path) -> None: spider_code = """ import scrapy class MySpider(scrapy.Spider): name = 'myspider' async def start(self): self.logger.debug('FEEDS: {}'.format(self.settings.getdict('FEEDS'))) return yield """ args = ["-o", "-:json"] log = self.get_log(tmp_path, spider_code, args=args) assert "[myspider] DEBUG: FEEDS: {'stdout:': {'format': 'json'}}" in log def test_output_feed_exporter_disabled(self, tmp_path: Path) -> None: spider_code = """ import scrapy class MySpider(scrapy.Spider): name = 'myspider' custom_settings = { "EXTENSIONS": {"scrapy.extensions.feedexport.FeedExporter": None}, } start_urls = ["data:,"] def parse(self, response): yield {"hello": "world"} """ args = ["-o", "example.json"] log = self.get_log(tmp_path, spider_code, args=args) assert "FeedExporter extension is not enabled" in log assert not (tmp_path / "example.json").exists() def test_output_feed_exporter_subclass(self, tmp_path: Path) -> None: spider_code = """ import scrapy from scrapy.extensions.feedexport import FeedExporter class MyFeedExporter(FeedExporter): pass class MySpider(scrapy.Spider): name = 'myspider' custom_settings = { "EXTENSIONS": { "scrapy.extensions.feedexport.FeedExporter": None, MyFeedExporter: 0, }, } start_urls = ["data:,"] def parse(self, response): yield {"hello": "world"} """ args = ["-o", "example.json"] log = self.get_log(tmp_path, spider_code, args=args) assert "FeedExporter extension is not enabled" not in log assert (tmp_path / "example.json").exists() @pytest.mark.parametrize("arg", ["output.json:json", "output.json"]) def test_absolute_path(self, tmp_path: Path, arg: str) -> None: spider_code = """ import scrapy class MySpider(scrapy.Spider): name = 'myspider' start_urls = ["data:,"] def parse(self, response): yield {"hello": "world"} """ args = ["-o", str(tmp_path / arg)] log = self.get_log(tmp_path, spider_code, args=args) assert ( f"[scrapy.extensions.feedexport] INFO: Stored json feed (1 items) in: {tmp_path / 'output.json'}" in log ) def test_args_change_settings(self, tmp_path: Path) -> None: spider_code = """ import scrapy class MySpider(scrapy.Spider): name = 'myspider' @classmethod def from_crawler(cls, crawler, *args, **kwargs): spider = super().from_crawler(crawler, *args, **kwargs) spider.settings.set("FOO", kwargs.get("foo")) return spider async def start(self): self.logger.info(f"The value of FOO is {self.settings.getint('FOO')}") return yield """ args = ["-a", "foo=42"] log = self.get_log(tmp_path, spider_code, args=args) assert "Spider closed (finished)" in log assert "The value of FOO is 42" in log @pytest.mark.skipif( platform.system() != "Windows", reason="Windows required for .pyw files" ) class TestWindowsRunSpiderCommand(TestRunSpiderCommand): spider_filename = "myspider.pyw" def test_start_errors(self, tmp_path: Path) -> None: log = self.get_log(tmp_path, self.badspider, name="badspider.pyw") assert "start" in log assert "badspider.pyw" in log def test_runspider_unable_to_load(self, tmp_path: Path) -> None: pytest.skip("Already Tested in 'RunSpiderCommandTest'") scrapy-scrapy-5569de3/tests/test_command_shell.py000066400000000000000000000364041525051012400222420ustar00rootroot00000000000000from __future__ import annotations import importlib.util import os import signal import sys from io import BytesIO from pathlib import Path from typing import TYPE_CHECKING, Any from unittest.mock import AsyncMock, MagicMock, patch import pytest from pexpect.popen_spawn import PopenSpawn from scrapy import Spider from scrapy.http import Request, Response from scrapy.shell import Shell, inspect_response from scrapy.utils.reactor import _asyncio_reactor_path from scrapy.utils.test import get_crawler from tests import NON_EXISTING_RESOLVABLE, tests_datadir from tests.utils.bases.commands import TestProjectBase from tests.utils.cmdline import proc, stop_spawn from tests.utils.decorators import coroutine_test if TYPE_CHECKING: from scrapy.crawler import Crawler from tests.mockserver.http import MockServer class TestShellCommand: def test_empty(self) -> None: _, out, _ = proc("shell", "-c", "item") assert "{}" in out def test_empty_no_reactor(self) -> None: _, out, _ = proc( "shell", "-c", "item", "--set", "TWISTED_REACTOR_ENABLED=False" ) assert "{}" in out def test_response_body(self, mockserver: MockServer) -> None: _, out, _ = proc("shell", mockserver.url("/text"), "-c", "response.body") assert "Works" in out def test_response_type_text(self, mockserver: MockServer) -> None: _, out, _ = proc("shell", mockserver.url("/text"), "-c", "type(response)") assert "TextResponse" in out def test_response_type_html(self, mockserver: MockServer) -> None: _, out, _ = proc("shell", mockserver.url("/html"), "-c", "type(response)") assert "HtmlResponse" in out def test_response_selector_html(self, mockserver: MockServer) -> None: xpath = "response.xpath(\"//p[@class='one']/text()\").get()" _, out, _ = proc("shell", mockserver.url("/html"), "-c", xpath) assert out.strip() == "Works" def test_response_encoding_gb18030(self, mockserver: MockServer) -> None: _, out, _ = proc( "shell", mockserver.url("/enc-gb18030"), "-c", "response.encoding" ) assert out.strip() == "gb18030" def test_redirect(self, mockserver: MockServer) -> None: _, out, _ = proc("shell", mockserver.url("/redirect"), "-c", "response.url") assert out.strip().endswith("/redirected") def test_redirect_follow_302(self, mockserver: MockServer) -> None: _, out, _ = proc( "shell", mockserver.url("/redirect-no-meta-refresh"), "-c", "response.status", ) assert out.strip().endswith("200") def test_redirect_not_follow_302(self, mockserver: MockServer) -> None: _, out, _ = proc( "shell", "--no-redirect", mockserver.url("/redirect-no-meta-refresh"), "-c", "response.status", ) assert out.strip().endswith("302") def test_fetch_redirect_follow_302(self, mockserver: MockServer) -> None: """Test that calling ``fetch(url)`` follows HTTP redirects by default.""" url = mockserver.url("/redirect-no-meta-refresh") code = f"fetch('{url}')" ret, out, err = proc("shell", "-c", code) assert ret == 0, out assert "Redirecting (302)" in err assert "Crawled (200)" in err def test_fetch_redirect_not_follow_302(self, mockserver: MockServer) -> None: """Test that calling ``fetch(url, redirect=False)`` disables automatic redirects.""" url = mockserver.url("/redirect-no-meta-refresh") code = f"fetch('{url}', redirect=False)" ret, out, err = proc("shell", "-c", code) assert ret == 0, out assert "Crawled (302)" in err def test_request_replace(self, mockserver: MockServer) -> None: url = mockserver.url("/text") code = f"fetch('{url}') or fetch(response.request.replace(method='POST'))" ret, out, _ = proc("shell", "-c", code) assert ret == 0, out def test_scrapy_import(self, mockserver: MockServer) -> None: url = mockserver.url("/text") code = f"fetch(scrapy.Request('{url}'))" ret, out, _ = proc("shell", "-c", code) assert ret == 0, out def test_local_file(self) -> None: filepath = Path(tests_datadir, "test_site", "index.html") _, out, _ = proc("shell", str(filepath), "-c", "item") assert "{}" in out def test_local_nofile(self) -> None: filepath = "file:///tests/sample_data/test_site/nothinghere.html" ret, out, err = proc("shell", filepath, "-c", "item") assert ret == 1, out or err assert "No such file or directory" in err def test_dns_failures(self, mockserver: MockServer) -> None: if NON_EXISTING_RESOLVABLE: pytest.skip("Non-existing hosts are resolvable") url = "www.somedomainthatdoesntexi.st" ret, out, err = proc("shell", url, "-c", "item") assert ret == 1, out or err assert "CannotResolveHostError" in err def test_shell_fetch_async(self, mockserver: MockServer) -> None: url = mockserver.url("/html") code = f"fetch('{url}')" ret, _, err = proc( "shell", "-c", code, "--set", f"TWISTED_REACTOR={_asyncio_reactor_path}" ) assert ret == 0, err assert "RuntimeError: There is no current event loop in thread" not in err def test_shell_fetch_no_reactor(self, mockserver: MockServer) -> None: url = mockserver.url("/html") code = f"fetch('{url}')" ret, _, err = proc( "shell", "-c", code, "--set", "TWISTED_REACTOR_ENABLED=False" ) assert ret == 0, err def test_shelp(self) -> None: ret, out, _ = proc("shell", "-c", "shelp()") assert ret == 0, out assert "Available Scrapy objects" in out def test_fetch_request_with_callbacks(self, mockserver: MockServer) -> None: url = mockserver.url("/text") code = ( f"fetch(scrapy.Request('{url}', callback=lambda r: r, errback=lambda f: f))" ) ret, out, _ = proc("shell", "-c", code) assert ret == 0, out class TestShellCommandWithSpider(TestProjectBase): @pytest.fixture(autouse=True) def create_files(self, proj_path: Path) -> None: (proj_path / self.project_name / "spiders" / "myspider.py").write_text( """ import scrapy class MySpider(scrapy.Spider): name = "myspider" """, encoding="utf-8", ) def test_spider(self, proj_path: Path, mockserver: MockServer) -> None: ret, out, err = proc( "shell", "--spider", "myspider", mockserver.url("/text"), "-c", "spider.name", cwd=proj_path, ) assert ret == 0, err assert out.strip() == "myspider" class TestInteractiveShell: # Starting an interactive shell involves an interpreter start-up, Scrapy # imports and shell imports, which on PyPy with coverage enabled can take # well over 10 seconds. TIMEOUT = 60 def test_fetch(self, mockserver: MockServer) -> None: args = ( sys.executable, "-m", "scrapy.cmdline", "shell", ) env = os.environ.copy() env["SCRAPY_PYTHON_SHELL"] = "python" logfile = BytesIO() p = PopenSpawn(args, env=env, timeout=self.TIMEOUT) p.logfile_read = logfile p.expect_exact("Available Scrapy objects") p.sendline(f"fetch('{mockserver.url('/')}')") p.sendline("type(response)") p.expect_exact("HtmlResponse") stop_spawn(p) logfile.seek(0) assert "Traceback" not in logfile.read().decode() @staticmethod def _isolate_config(env: dict[str, str], config_home: Path) -> None: """Point every scrapy.cfg location (see :func:`scrapy.utils.conf.get_sources`) at ``config_home``. ``XDG_CONFIG_HOME`` is read by Scrapy on all platforms, while ``~/.scrapy.cfg`` goes through :func:`os.path.expanduser`, which uses ``HOME`` on POSIX and ``USERPROFILE`` on Windows. The working directory stays at the repository root (no scrapy.cfg) so subprocess coverage data is still collected there. """ env.pop("SCRAPY_PYTHON_SHELL", None) env["HOME"] = str(config_home) env["USERPROFILE"] = str(config_home) env["XDG_CONFIG_HOME"] = str(config_home) def _run_interactive_shell(self, env: dict[str, str]) -> str: args = (sys.executable, "-m", "scrapy.cmdline", "shell") logfile = BytesIO() p = PopenSpawn(args, env=env, timeout=self.TIMEOUT) p.logfile_read = logfile p.expect_exact("Available Scrapy objects") stop_spawn(p) logfile.seek(0) return logfile.read().decode() @pytest.mark.skipif( importlib.util.find_spec("IPython") is None, reason="Without IPython installed, shell=python and the default both " "select the standard Python shell, so the setting has no observable effect.", ) def test_shell_from_cfg(self, tmp_path: Path) -> None: config_home = tmp_path / "config" config_home.mkdir() (config_home / "scrapy.cfg").write_text("[settings]\nshell = python\n") env = os.environ.copy() self._isolate_config(env, config_home) args = (sys.executable, "-m", "scrapy.cmdline", "shell") logfile = BytesIO() p = PopenSpawn(args, env=env, timeout=self.TIMEOUT) p.logfile_read = logfile p.expect_exact("Available Scrapy objects") # The standard Python shell never imports IPython, whereas the IPython # shell (the default when installed) does; this confirms the configured # shell=python was honored, regardless of platform-specific prompts. p.sendline("import sys; print('IPYMODULE', 'IPython' in sys.modules)") p.expect_exact("IPYMODULE False") stop_spawn(p) logfile.seek(0) assert "Traceback" not in logfile.read().decode() def test_shell_default_shells(self, tmp_path: Path) -> None: config_home = tmp_path / "config" config_home.mkdir() env = os.environ.copy() self._isolate_config(env, config_home) assert "Traceback" not in self._run_interactive_shell(env) @pytest.mark.skipif( importlib.util.find_spec("IPython") is None, reason="IPython is not installed" ) def test_shell_ipython(self, tmp_path: Path) -> None: # Reaching the embedded IPython shell requires selecting it explicitly, # since ptpython takes precedence when both are installed. config_home = tmp_path / "config" config_home.mkdir() env = os.environ.copy() self._isolate_config(env, config_home) env["SCRAPY_PYTHON_SHELL"] = "ipython" args = (sys.executable, "-m", "scrapy.cmdline", "shell") logfile = BytesIO() p = PopenSpawn(args, env=env, timeout=self.TIMEOUT) p.logfile_read = logfile p.expect_exact("Available Scrapy objects") p.sendline("import sys; print('IPYMODULE', 'IPython' in sys.modules)") p.expect_exact("IPYMODULE True") stop_spawn(p) logfile.seek(0) assert "Traceback" not in logfile.read().decode() @pytest.fixture def restore_sigint(): """Shell.start() installs SIG_IGN as the SIGINT handler; restore it.""" handler = signal.getsignal(signal.SIGINT) try: yield finally: signal.signal(signal.SIGINT, handler) def _no_reactor_crawler(monkeypatch: pytest.MonkeyPatch) -> Crawler: """Return a crawler that reports ``TWISTED_REACTOR_ENABLED=False``. A genuine no-reactor crawler cannot be built while a Twisted reactor is installed (as it is during the test run), so we build a normal crawler and make its settings report the reactor as disabled, which is all the shell code looks at. """ crawler = get_crawler() real_getbool = crawler.settings.getbool def fake_getbool(name: str, *args: Any, **kwargs: Any) -> bool: if name == "TWISTED_REACTOR_ENABLED": return False return real_getbool(name, *args, **kwargs) monkeypatch.setattr(crawler.settings, "getbool", fake_getbool) return crawler @pytest.mark.requires_reactor class TestShell: """Tests for :class:`~scrapy.shell.Shell` paths with no ``scrapy shell`` command-line route: those reached through :func:`scrapy.shell.inspect_response` (called from spider callbacks) or only through direct API use, hence not covered by the subprocess tests above. """ def test_populate_vars_fetch_not_available(self) -> None: shell = Shell(get_crawler()) shell._inthread = False shell.populate_vars() assert "fetch" not in shell.vars def test_get_help_fetch_not_available(self) -> None: shell = Shell(get_crawler()) shell._inthread = False shell.populate_vars() help_text = shell.get_help() assert "fetch(url" not in help_text assert "shelp()" in help_text def test_start_with_request(self, restore_sigint: None) -> None: shell = Shell(get_crawler(), code="1") shell.fetch = MagicMock() # type: ignore[method-assign] request = Request("data:,") shell.start(request=request) shell.fetch.assert_called_once_with(request, None) def test_start_with_response( self, restore_sigint: None, capsys: pytest.CaptureFixture[str] ) -> None: shell = Shell(get_crawler(), code="response.url") request = Request("data:,") response = Response("data:,", request=request) shell.start(response=response) assert "data:," in capsys.readouterr().out assert shell.vars["response"] is response assert shell.vars["request"] is request @patch("scrapy.shell.start_python_console") def test_inspect_response( self, mock_console: MagicMock, restore_sigint: None ) -> None: crawler = get_crawler() spider = crawler._create_spider() response = Response("data:,", request=Request("data:,")) sigint_handler = signal.getsignal(signal.SIGINT) inspect_response(response, spider) mock_console.assert_called_once() assert signal.getsignal(signal.SIGINT) is sigint_handler @coroutine_test async def test_open_spider_explicit_spider(self) -> None: crawler = get_crawler() crawler.engine = MagicMock() crawler.engine.open_spider_async = AsyncMock() shell = Shell(crawler) spider = Spider.from_crawler(crawler, "test") await shell._open_spider(spider) assert shell.spider is spider assert crawler.spider is spider crawler.engine.open_spider_async.assert_called_once_with(close_if_idle=False) @pytest.mark.only_asyncio class TestShellNoReactor: @coroutine_test @patch("scrapy.shell.start_python_console") async def test_inspect_response_no_reactor( self, mock_console: MagicMock, restore_sigint: None, monkeypatch: pytest.MonkeyPatch, ) -> None: crawler = _no_reactor_crawler(monkeypatch) spider = crawler._create_spider() response = Response("data:,", request=Request("data:,")) inspect_response(response, spider) mock_console.assert_called_once() scrapy-scrapy-5569de3/tests/test_command_startproject.py000066400000000000000000000240641525051012400236560ustar00rootroot00000000000000from __future__ import annotations import os import subprocess import sys from contextlib import contextmanager from itertools import chain from pathlib import Path from shutil import copytree from stat import S_IWRITE as ANYONE_WRITE_PERMISSION import scrapy from scrapy.commands.startproject import IGNORE from scrapy.utils.test import get_testenv from tests.utils.cmdline import call, proc class TestStartprojectCommand: project_name = "testproject" @staticmethod def _assert_files_exist(project_dir: Path, project_name: str) -> None: assert (project_dir / "scrapy.cfg").exists() assert (project_dir / project_name).exists() assert (project_dir / project_name / "__init__.py").exists() assert (project_dir / project_name / "items.py").exists() assert (project_dir / project_name / "pipelines.py").exists() assert (project_dir / project_name / "settings.py").exists() assert (project_dir / project_name / "spiders" / "__init__.py").exists() def test_startproject(self, tmp_path: Path) -> None: # with no dir argument creates the project in the "self.project_name" subdir of cwd assert call("startproject", self.project_name, cwd=tmp_path) == 0 self._assert_files_exist(tmp_path / self.project_name, self.project_name) assert call("startproject", self.project_name, cwd=tmp_path) == 1 assert call("startproject", "wrong---project---name") == 1 assert call("startproject", "sys") == 1 def test_startproject_with_project_dir(self, tmp_path: Path) -> None: # with a dir arg creates the project in the specified dir project_dir = tmp_path / "project" assert ( call("startproject", self.project_name, str(project_dir), cwd=tmp_path) == 0 ) self._assert_files_exist(project_dir, self.project_name) assert ( call( "startproject", self.project_name, str(project_dir) + "2", cwd=tmp_path ) == 0 ) assert ( call("startproject", self.project_name, str(project_dir), cwd=tmp_path) == 1 ) assert ( call( "startproject", self.project_name + "2", str(project_dir), cwd=tmp_path ) == 1 ) assert call("startproject", "wrong---project---name") == 1 assert call("startproject", "sys") == 1 assert call("startproject") == 2 assert ( call("startproject", self.project_name, str(project_dir), "another_params") == 2 ) def test_existing_project_dir(self, tmp_path: Path) -> None: project_name = self.project_name + "_existing" project_path = tmp_path / project_name project_path.mkdir() assert call("startproject", project_name, cwd=tmp_path) == 0 self._assert_files_exist(project_path, project_name) def get_permissions_dict( path: str | os.PathLike[str], 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 () permissions_dict = { ".": get_permissions(path_obj), } for root, dirs, files in os.walk(path_obj): nodes = list(chain(dirs, files)) if ignore: ignored_names = ignore(root, nodes) nodes = [node for node in nodes if node not in ignored_names] for node in nodes: absolute_path = Path(root, node) relative_path = str(absolute_path.relative_to(path)) for search_string, replacement in renamings: relative_path = relative_path.replace(search_string, replacement) permissions = get_permissions(absolute_path) permissions_dict[relative_path] = permissions return permissions_dict class TestStartprojectTemplates: def test_startproject_template_override(self, tmp_path: Path) -> None: tmpl = tmp_path / "templates" tmpl_proj = tmpl / "project" project_name = "testproject" copytree(Path(scrapy.__path__[0], "templates"), tmpl) (tmpl_proj / "root_template").write_bytes(b"") args = ["--set", f"TEMPLATES_DIR={tmpl}"] _, out, _ = proc("startproject", project_name, *args, cwd=tmp_path) assert f"New Scrapy project '{project_name}', using template directory" in out assert str(tmpl_proj) in out assert (tmp_path / project_name / "root_template").exists() def test_startproject_permissions_from_writable(self, tmp_path: Path) -> None: """Check that generated files have the right permissions when the template folder has the same permissions as in the project, i.e. everything is writable.""" scrapy_path = scrapy.__path__[0] project_template = Path(scrapy_path, "templates", "project") project_name = "startproject1" renamings = ( ("module", project_name), (".tmpl", ""), ) expected_permissions = get_permissions_dict( project_template, renamings, IGNORE, ) destination = tmp_path / "proj" destination.mkdir() process = subprocess.Popen( ( sys.executable, "-m", "scrapy.cmdline", "startproject", project_name, ), cwd=destination, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, env=get_testenv(), ) process.wait() project_dir = destination / project_name actual_permissions = get_permissions_dict(project_dir) assert actual_permissions == expected_permissions def test_startproject_permissions_from_read_only(self, tmp_path: Path) -> None: """Check that generated files have the right permissions when the template folder has been made read-only, which is something that some systems do. See https://github.com/scrapy/scrapy/pull/4604 """ scrapy_path = scrapy.__path__[0] templates_dir = Path(scrapy_path, "templates") project_template = Path(templates_dir, "project") project_name = "startproject2" renamings = ( ("module", project_name), (".tmpl", ""), ) expected_permissions = get_permissions_dict( project_template, renamings, IGNORE, ) def _make_read_only(path: Path): current_permissions = path.stat().st_mode path.chmod(current_permissions & ~ANYONE_WRITE_PERMISSION) read_only_templates_dir = tmp_path / "templates" copytree(templates_dir, read_only_templates_dir) for root, dirs, files in os.walk(read_only_templates_dir): for node in chain(dirs, files): _make_read_only(Path(root, node)) destination = tmp_path / "proj" destination.mkdir() assert ( call( "startproject", project_name, "--set", f"TEMPLATES_DIR={read_only_templates_dir}", cwd=destination, ) == 0 ) project_dir = destination / project_name actual_permissions = get_permissions_dict(project_dir) assert actual_permissions == expected_permissions def test_startproject_permissions_unchanged_in_destination( self, tmp_path: Path ) -> None: """Check that preexisting folders and files in the destination folder do not see their permissions modified.""" scrapy_path = scrapy.__path__[0] project_template = Path(scrapy_path, "templates", "project") project_name = "startproject3" renamings = ( ("module", project_name), (".tmpl", ""), ) expected_permissions = get_permissions_dict( project_template, renamings, IGNORE, ) destination = tmp_path / "proj" project_dir = destination / project_name project_dir.mkdir(parents=True) existing_nodes = { f"{permissions:o}{extension}": permissions for extension in ("", ".d") for permissions in ( 0o444, 0o555, 0o644, 0o666, 0o755, 0o777, ) } for node, permissions in existing_nodes.items(): path = project_dir / node if node.endswith(".d"): path.mkdir(mode=permissions) else: path.touch(mode=permissions) expected_permissions[node] = oct(path.stat().st_mode) assert call("startproject", project_name, ".", cwd=project_dir) == 0 actual_permissions = get_permissions_dict(project_dir) assert actual_permissions == expected_permissions def test_startproject_permissions_umask_002(self, tmp_path: Path) -> None: """Check that generated files have the right permissions when the system uses a umask value that causes new files to have different permissions than those from the template folder.""" @contextmanager def umask(new_mask): cur_mask = os.umask(new_mask) yield os.umask(cur_mask) scrapy_path = scrapy.__path__[0] project_template = Path(scrapy_path, "templates", "project") project_name = "umaskproject" renamings = ( ("module", project_name), (".tmpl", ""), ) expected_permissions = get_permissions_dict( project_template, renamings, IGNORE, ) with umask(0o002): destination = tmp_path / "proj" destination.mkdir() assert call("startproject", project_name, cwd=destination) == 0 project_dir = destination / project_name actual_permissions = get_permissions_dict(project_dir) assert actual_permissions == expected_permissions scrapy-scrapy-5569de3/tests/test_command_version.py000066400000000000000000000010711525051012400226100ustar00rootroot00000000000000import scrapy from tests.utils.cmdline import proc def test_output() -> None: _, out, _ = proc("version") assert out.strip() == f"Scrapy {scrapy.__version__}" def test_verbose_output() -> None: _, out, _ = proc("version", "-v") headers = [line.partition(":")[0].strip() for line in out.strip().splitlines()] assert headers == [ "Scrapy", "lxml", "libxml2", "cssselect", "parsel", "w3lib", "Twisted", "Python", "pyOpenSSL", "cryptography", "Platform", ] scrapy-scrapy-5569de3/tests/test_commands.py000066400000000000000000000764351525051012400212460ustar00rootroot00000000000000from __future__ import annotations import argparse import json import logging import pdb # noqa: T100 import sys from pathlib import Path from typing import TYPE_CHECKING, Any import pytest import scrapy from scrapy.cmdline import _pop_command_name, execute from scrapy.commands import ScrapyCommand, ScrapyHelpFormatter from scrapy.exceptions import ScrapyDeprecationWarning from scrapy.settings import Settings from scrapy.utils.reactor import _asyncio_reactor_path from tests.utils.bases.commands import TestProjectBase from tests.utils.cmdline import ( call, proc, write_recording_browser, write_recording_editor, ) if TYPE_CHECKING: from tests.mockserver.http import MockServer class EmptyCommand(ScrapyCommand): def short_desc(self) -> str: return "" def run(self, args: list[str], opts: argparse.Namespace) -> None: pass class TestHelpDeprecation: def test_calling_help_is_deprecated(self) -> None: command = EmptyCommand() with pytest.warns( ScrapyDeprecationWarning, match=r"ScrapyCommand\.help\(\) is deprecated, use long_desc\(\) instead\.", ): result = command.help() # help() still delegates to long_desc() for backward compatibility. assert result == command.long_desc() def test_overriding_help_is_deprecated(self) -> None: class HelpCommand(ScrapyCommand): def short_desc(self) -> str: return "" def run(self, args: list[str], opts: argparse.Namespace) -> None: pass def help(self) -> str: return "custom help" with pytest.warns( ScrapyDeprecationWarning, match=r"The ScrapyCommand\.help\(\) method is deprecated and " r"overriding it, as the .*HelpCommand class does, has no effect; " r"override long_desc\(\) instead\.", ): HelpCommand() def test_not_overriding_help_does_not_warn(self, recwarn) -> None: # Commands that do not override help() must not emit the # override-deprecation warning when instantiated, including subclasses # several levels below ScrapyCommand (as the built-in commands are). class SubCommand(EmptyCommand): pass EmptyCommand() SubCommand() assert not [ w for w in recwarn.list if issubclass(w.category, ScrapyDeprecationWarning) ] class TestCommandSettings: def setup_method(self): self.command = EmptyCommand() self.command.settings = Settings() self.parser = argparse.ArgumentParser( formatter_class=ScrapyHelpFormatter, conflict_handler="resolve" ) self.command.add_options(self.parser) def test_settings_json_string(self): feeds_json = '{"data.json": {"format": "json"}, "data.xml": {"format": "xml"}}' opts, args = self.parser.parse_known_args( args=["-s", f"FEEDS={feeds_json}", "spider.py"] ) self.command.process_options(args, opts) assert self.command.settings is not None assert isinstance(self.command.settings["FEEDS"], scrapy.settings.BaseSettings) assert dict(self.command.settings["FEEDS"]) == json.loads(feeds_json) def test_pdb_uses_ipdb_if_installed(self, monkeypatch): fake_ipdb: Any = argparse.Namespace(post_mortem=lambda tb: None) monkeypatch.setitem(sys.modules, "pdb", pdb) monkeypatch.setitem(sys.modules, "ipdb", fake_ipdb) opts, args = self.parser.parse_known_args(args=["--pdb", "spider.py"]) handlers = logging.root.handlers[:] try: self.command.process_options(args, opts) assert sys.modules["pdb"] is fake_ipdb finally: logging.root.handlers[:] = handlers def test_help_formatter(self): formatter = ScrapyHelpFormatter(prog="scrapy") part_strings = [ "usage: scrapy genspider [options] \n\n", "\n", "optional arguments:\n", "\n", "Global Options:\n", ] assert formatter._join_parts(part_strings) == ( "Usage\n=====\n scrapy genspider [options] \n\n\n" "Optional Arguments\n==================\n\n" "Global Options\n--------------\n" ) class TestGlobalOptions: """Tests for the options that every command supports.""" spider_code = """ import scrapy class MySpider(scrapy.Spider): name = "myspider" async def start(self): self.logger.debug("It works!") return yield """ @pytest.fixture def spider_path(self, tmp_path: Path) -> Path: path = tmp_path / "myspider.py" path.write_text(self.spider_code, encoding="utf-8") return path def test_invalid_set(self, spider_path: Path) -> None: returncode, _, err = proc("runspider", str(spider_path), "-s", "FOO") assert returncode == 2 assert "Invalid -s value, use -s NAME=VALUE" in err def test_invalid_spider_argument(self, spider_path: Path) -> None: returncode, _, err = proc("runspider", str(spider_path), "-a", "FOO") assert returncode == 2 assert "Invalid -a value, use -a NAME=VALUE" in err def test_logfile(self, tmp_path: Path, spider_path: Path) -> None: logfile = tmp_path / "scrapy.log" returncode, _, err = proc( "runspider", str(spider_path), "--logfile", str(logfile) ) assert returncode == 0, err assert "It works!" in logfile.read_text(encoding="utf-8") assert "It works!" not in err def test_loglevel(self, spider_path: Path) -> None: returncode, _, err = proc("runspider", str(spider_path), "--loglevel", "INFO") assert returncode == 0, err assert "It works!" not in err assert "Spider closed (finished)" in err def test_nolog(self, spider_path: Path) -> None: returncode, _, err = proc("runspider", str(spider_path), "--nolog") assert returncode == 0, err assert not err def test_pidfile(self, tmp_path: Path, spider_path: Path) -> None: pidfile = tmp_path / "scrapy.pid" returncode, _, err = proc( "runspider", str(spider_path), "--pidfile", str(pidfile) ) assert returncode == 0, err assert pidfile.read_text(encoding="utf-8").strip().isdigit() def test_pdb(self, spider_path: Path) -> None: returncode, out, err = proc("runspider", str(spider_path), "--pdb", input="") assert returncode == 0, err assert "It works!" in err assert "(Pdb)" not in out def test_pdb_on_logged_error(self, tmp_path: Path) -> None: path = tmp_path / "myspider.py" path.write_text( """ import scrapy class MySpider(scrapy.Spider): name = "myspider" async def start(self): raise ValueError("boom") yield """, encoding="utf-8", ) _, out, err = proc("runspider", str(path), "--pdb", input="") assert "(Pdb)" in out, err def test_pdb_on_logged_error_without_exception(self, tmp_path: Path) -> None: path = tmp_path / "myspider.py" path.write_text( """ import logging import scrapy logger = logging.getLogger(__name__) class MySpider(scrapy.Spider): name = "myspider" async def start(self): logger.error("boom") return yield """, encoding="utf-8", ) returncode, out, err = proc("runspider", str(path), "--pdb", input="") assert returncode == 0, err assert "(Pdb)" not in out def test_pdb_on_handled_failure(self, tmp_path: Path) -> None: path = tmp_path / "myspider.py" path.write_text( """ import scrapy from scrapy.exceptions import IgnoreRequest class IgnoreEverything: def process_request(self, request, spider): raise IgnoreRequest class MySpider(scrapy.Spider): name = "myspider" custom_settings = {"DOWNLOADER_MIDDLEWARES": {IgnoreEverything: 500}} async def start(self): yield scrapy.Request("https://example.com") """, encoding="utf-8", ) returncode, out, err = proc("runspider", str(path), "--pdb", input="") assert returncode == 0, err assert "(Pdb)" not in out class TestSettingsCommand: @pytest.mark.parametrize( ("option", "setting", "expected"), [ ("--get", "BOT_NAME", "scrapybot"), ("--getbool", "COOKIES_ENABLED", "True"), ("--getint", "CONCURRENT_REQUESTS", "16"), ("--getfloat", "DOWNLOAD_DELAY", "0.0"), ("--getlist", "SPIDER_MODULES", "[]"), ], ) def test_get(self, option: str, setting: str, expected: str) -> None: returncode, out, err = proc("settings", option, setting) assert returncode == 0, err assert out.startswith(expected) def test_no_option(self) -> None: returncode, out, err = proc("settings") assert returncode == 0, err assert not out class TestCommandCrawlerProcess(TestProjectBase): """Test that the command uses the expected kind of *CrawlerProcess and produces expected errors when needed.""" name = "crawl" NORMAL_MSG = "Using CrawlerProcess" ASYNC_MSG = "Using AsyncCrawlerProcess" @pytest.fixture(autouse=True) def create_files(self, proj_path: Path) -> None: proj_mod_path = proj_path / self.project_name (proj_mod_path / "spiders" / "sp.py").write_text(""" import scrapy class MySpider(scrapy.Spider): name = 'sp' custom_settings = {} async def start(self): self.logger.debug('It works!') return yield """) (proj_mod_path / "spiders" / "aiosp.py").write_text(""" import asyncio import scrapy class MySpider(scrapy.Spider): name = 'aiosp' custom_settings = {} async def start(self): await asyncio.sleep(0.01) self.logger.debug('It works!') return yield """) self._append_settings(proj_mod_path, "LOG_LEVEL = 'DEBUG'\n") @staticmethod def _replace_custom_settings( proj_mod_path: Path, spider_name: str, text: str ) -> None: """Replace custom_settings in the given spider file with the given text.""" spider_path = proj_mod_path / "spiders" / f"{spider_name}.py" with spider_path.open("r+", encoding="utf-8") as f: content = f.read() content = content.replace( "custom_settings = {}", f"custom_settings = {text}" ) f.seek(0) f.write(content) f.truncate() def _assert_spider_works(self, msg: str, proj_path: Path, *args: str) -> None: """The command uses the expected *CrawlerProcess, the spider works.""" _, _, err = proc(self.name, *args, cwd=proj_path) assert msg in err assert "It works!" in err assert "Spider closed (finished)" in err def _assert_spider_asyncio_fail( self, msg: str, proj_path: Path, *args: str ) -> None: """The command uses the expected *CrawlerProcess, the spider fails to use asyncio.""" _, _, err = proc(self.name, *args, cwd=proj_path) assert msg in err assert "no running event loop" in err def test_project_settings(self, proj_path: Path) -> None: """The reactor is set via the project default settings (to the asyncio value). AsyncCrawlerProcess, the asyncio reactor, both spiders work.""" for spider in ["sp", "aiosp"]: self._assert_spider_works(self.ASYNC_MSG, proj_path, spider) def test_cmdline_asyncio(self, proj_path: Path) -> None: """The reactor is set via the command line to the asyncio value. AsyncCrawlerProcess, the asyncio reactor, both spiders work.""" for spider in ["sp", "aiosp"]: self._assert_spider_works( self.ASYNC_MSG, proj_path, spider, "-s", f"TWISTED_REACTOR={_asyncio_reactor_path}", ) def test_project_settings_explicit_asyncio(self, proj_path: Path) -> None: """The reactor explicitly is set via the project settings to the asyncio value. AsyncCrawlerProcess, the asyncio reactor, both spiders work.""" self._append_settings( proj_path / self.project_name, f"TWISTED_REACTOR = '{_asyncio_reactor_path}'\n", ) for spider in ["sp", "aiosp"]: self._assert_spider_works(self.ASYNC_MSG, proj_path, spider) def test_cmdline_empty(self, proj_path: Path) -> None: """The reactor is set via the command line to the empty value. CrawlerProcess, the default reactor, only the normal spider works.""" self._assert_spider_works( self.NORMAL_MSG, proj_path, "sp", "-s", "TWISTED_REACTOR=" ) self._assert_spider_asyncio_fail( self.NORMAL_MSG, proj_path, "aiosp", "-s", "TWISTED_REACTOR=" ) def test_project_settings_empty(self, proj_path: Path) -> None: """The reactor is set via the project settings to the empty value. CrawlerProcess, the default reactor, only the normal spider works.""" self._append_settings(proj_path / self.project_name, "TWISTED_REACTOR = None\n") self._assert_spider_works(self.NORMAL_MSG, proj_path, "sp") self._assert_spider_asyncio_fail(self.NORMAL_MSG, proj_path, "aiosp") def test_spider_settings_asyncio(self, proj_path: Path) -> None: """The reactor is set via the spider settings to the asyncio value. AsyncCrawlerProcess, the asyncio reactor, both spiders work.""" for spider in ["sp", "aiosp"]: self._replace_custom_settings( proj_path / self.project_name, spider, f"{{'TWISTED_REACTOR': '{_asyncio_reactor_path}'}}", ) self._assert_spider_works(self.ASYNC_MSG, proj_path, spider) def test_spider_settings_asyncio_cmdline_empty(self, proj_path: Path) -> None: """The reactor is set via the spider settings to the asyncio value and via command line to the empty value. The command line value takes precedence so the spider settings don't matter. CrawlerProcess, the default reactor, only the normal spider works.""" for spider in ["sp", "aiosp"]: self._replace_custom_settings( proj_path / self.project_name, spider, f"{{'TWISTED_REACTOR': '{_asyncio_reactor_path}'}}", ) self._assert_spider_works( self.NORMAL_MSG, proj_path, "sp", "-s", "TWISTED_REACTOR=" ) self._assert_spider_asyncio_fail( self.NORMAL_MSG, proj_path, "aiosp", "-s", "TWISTED_REACTOR=" ) def test_project_empty_spider_settings_asyncio(self, proj_path: Path) -> None: """The reactor is set via the project settings to the empty value and via the spider settings to the asyncio value. CrawlerProcess is chosen based on the project settings, but the asyncio reactor is chosen based on the spider settings. CrawlerProcess, the asyncio reactor, both spiders work.""" self._append_settings(proj_path / self.project_name, "TWISTED_REACTOR = None\n") for spider in ["sp", "aiosp"]: self._replace_custom_settings( proj_path / self.project_name, spider, f"{{'TWISTED_REACTOR': '{_asyncio_reactor_path}'}}", ) self._assert_spider_works(self.NORMAL_MSG, proj_path, spider) def test_project_asyncio_spider_settings_select(self, proj_path: Path) -> None: """The reactor is set via the project settings to the asyncio value and via the spider settings to the select value. AsyncCrawlerProcess is chosen based on the project settings, and the conflicting reactor setting in the spider settings causes an exception. AsyncCrawlerProcess, the asyncio reactor, both spiders produce a mismatched reactor exception.""" self._append_settings( proj_path / self.project_name, f"TWISTED_REACTOR = '{_asyncio_reactor_path}'\n", ) for spider in ["sp", "aiosp"]: self._replace_custom_settings( proj_path / self.project_name, spider, "{'TWISTED_REACTOR': 'twisted.internet.selectreactor.SelectReactor'}", ) _, _, err = proc(self.name, spider, cwd=proj_path) assert self.ASYNC_MSG in err assert ( "The installed reactor (twisted.internet.asyncioreactor.AsyncioSelectorReactor)" " does not match the requested one" " (twisted.internet.selectreactor.SelectReactor)" ) in err def test_project_asyncio_spider_settings_select_forced( self, proj_path: Path ) -> None: """The reactor is set via the project settings to the asyncio value and via the spider settings to the select value, CrawlerProcess is forced via the project settings. The reactor is chosen based on the spider settings. CrawlerProcess, the select reactor, only the normal spider works.""" self._append_settings( proj_path / self.project_name, "FORCE_CRAWLER_PROCESS = True\n" ) for spider in ["sp", "aiosp"]: self._replace_custom_settings( proj_path / self.project_name, spider, "{'TWISTED_REACTOR': 'twisted.internet.selectreactor.SelectReactor'}", ) self._assert_spider_works(self.NORMAL_MSG, proj_path, "sp") self._assert_spider_asyncio_fail(self.NORMAL_MSG, proj_path, "aiosp") class TestLogInstallRootHandler(TestProjectBase): """LOG_INSTALL_ROOT_HANDLER=False must let a user-configured root handler be the only one in effect, instead of Scrapy adding its own alongside it.""" @pytest.fixture(autouse=True) def create_files(self, proj_path: Path) -> None: proj_mod_path = proj_path / self.project_name (proj_mod_path / "spiders" / "sp.py").write_text(""" import scrapy class MySpider(scrapy.Spider): name = 'sp' async def start(self): self.logger.info('It works!') return yield """) self._append_settings( proj_mod_path, "\nimport logging\n" "logging.basicConfig(level=logging.INFO, format='CUSTOM: %(message)s')\n", ) def test_default(self, proj_path: Path) -> None: """Scrapy installs its own root handler in addition to the user-configured one, so the message is logged twice, once in each format.""" _, _, err = proc("crawl", "sp", cwd=proj_path) assert "CUSTOM: It works!" in err assert "[sp] INFO: It works!" in err def test_disabled(self, proj_path: Path) -> None: """Scrapy leaves the user-configured root handler alone, so the message is only logged once, in the user's format.""" _, _, err = proc( "crawl", "sp", "-s", "LOG_INSTALL_ROOT_HANDLER=False", cwd=proj_path ) assert "CUSTOM: It works!" in err assert "[sp] INFO: It works!" not in err class TestMiscCommands(TestProjectBase): def test_list(self, proj_path: Path) -> None: assert call("list", cwd=proj_path) == 0 def test_list_subdir(self, proj_path: Path) -> None: """Test that commands work in a subdirectory of the project.""" subdir = proj_path / "subdir" subdir.mkdir(exist_ok=True) assert call("list", cwd=subdir) == 0 class TestCommandListing(TestProjectBase): """Tests for the command list that ``scrapy`` prints when called without a command name.""" def test_outside_project(self) -> None: returncode, out, err = proc() assert returncode == 0, err assert f"Scrapy {scrapy.__version__} - no active project" in out assert "Available commands:" in out assert "Create new project" in out assert "More commands available when run from project directory" in out assert 'Use "scrapy -h" to see more info about a command' in out def test_inside_project(self, proj_path: Path) -> None: returncode, out, err = proc(cwd=proj_path) assert returncode == 0, err assert ( f"Scrapy {scrapy.__version__} - active project: {self.project_name}" in out ) assert "List available spiders" in out assert "More commands available when run from project directory" not in out class TestUnknownCommand(TestProjectBase): def test_outside_project(self) -> None: returncode, out, err = proc("abc") assert returncode == 2, err assert f"Scrapy {scrapy.__version__} - no active project" in out assert "Unknown command: abc" in out assert 'Use "scrapy" to see available commands' in out def test_inside_project(self, proj_path: Path) -> None: returncode, out, err = proc("abc", cwd=proj_path) assert returncode == 2, err assert ( f"Scrapy {scrapy.__version__} - active project: {self.project_name}" in out ) assert "Unknown command: abc" in out def test_project_only_command_outside_project(self) -> None: returncode, out, err = proc("list") assert returncode == 2, err assert "The list command is not available from this location." in out assert ( "These commands are only available from within a project: " "check, crawl, edit, list, parse." in out ) class TestCommandsModule(TestProjectBase): """Tests for commands defined in the module of the COMMANDS_MODULE setting.""" @pytest.fixture def proj_path_with_commands(self, proj_path: Path) -> Path: commands_path = proj_path / self.project_name / "commands" commands_path.mkdir() (commands_path / "__init__.py").touch() (commands_path / "mycmd.py").write_text( """ from scrapy.commands import ScrapyCommand class Command(ScrapyCommand): requires_crawler_process = False def short_desc(self): return "My custom command" def run(self, args, opts): print("My custom command ran") """, encoding="utf-8", ) (commands_path / "helpcmd.py").write_text( """ from scrapy.commands import ScrapyCommand from scrapy.exceptions import UsageError class Command(ScrapyCommand): requires_crawler_process = False def short_desc(self): return "My command that asks for its help message" def run(self, args, opts): raise UsageError """, encoding="utf-8", ) (commands_path / "silentcmd.py").write_text( """ from scrapy.commands import ScrapyCommand from scrapy.exceptions import UsageError class Command(ScrapyCommand): requires_crawler_process = False def short_desc(self): return "My command that fails silently" def run(self, args, opts): raise UsageError(print_help=False) """, encoding="utf-8", ) self._append_settings( proj_path / self.project_name, f'\nCOMMANDS_MODULE = "{self.project_name}.commands"\n', ) return proj_path def test_listed(self, proj_path_with_commands: Path) -> None: returncode, out, err = proc(cwd=proj_path_with_commands) assert returncode == 0, err assert "My custom command" in out def test_run(self, proj_path_with_commands: Path) -> None: returncode, out, err = proc("mycmd", cwd=proj_path_with_commands) assert returncode == 0, err assert "My custom command ran" in out def test_usage_error(self, proj_path_with_commands: Path) -> None: """A message-less UsageError makes the help message be printed.""" returncode, out, err = proc("helpcmd", cwd=proj_path_with_commands) assert returncode == 2, err assert "scrapy helpcmd" in out def test_usage_error_without_help(self, proj_path_with_commands: Path) -> None: """A message-less UsageError with print_help disabled prints nothing.""" returncode, out, err = proc("silentcmd", cwd=proj_path_with_commands) assert returncode == 2, err assert not out class TestEntryPointCommands: """Tests for commands defined in the scrapy.commands entry point group.""" @staticmethod def _write_dist(path: Path, entry_point: str) -> None: """Write into *path* a package with a command and a function, and the metadata of an installed distribution that declares *entry_point* in the scrapy.commands entry point group. Since ``python -m scrapy.cmdline`` puts the current working directory in the import path, running it with *path* as the working directory makes Scrapy find that entry point. """ package_path = path / "mycmds" package_path.mkdir() (package_path / "__init__.py").touch() (package_path / "mycmd.py").write_text( """ from scrapy.commands import ScrapyCommand class Command(ScrapyCommand): requires_crawler_process = False def short_desc(self): return "My entry point command" def run(self, args, opts): print("My entry point command ran") def not_a_command(): pass """, encoding="utf-8", ) dist_info_path = path / "mycmds-1.0.dist-info" dist_info_path.mkdir() (dist_info_path / "METADATA").write_text( "Metadata-Version: 2.1\nName: mycmds\nVersion: 1.0\n", encoding="utf-8" ) (dist_info_path / "entry_points.txt").write_text( f"[scrapy.commands]\n{entry_point}\n", encoding="utf-8" ) def test_listed(self, tmp_path: Path) -> None: self._write_dist(tmp_path, "mycmd = mycmds.mycmd:Command") returncode, out, err = proc(cwd=tmp_path) assert returncode == 0, err assert "My entry point command" in out def test_run(self, tmp_path: Path) -> None: self._write_dist(tmp_path, "mycmd = mycmds.mycmd:Command") returncode, out, err = proc("mycmd", cwd=tmp_path) assert returncode == 0, err assert "My entry point command ran" in out def test_not_a_class(self, tmp_path: Path) -> None: self._write_dist(tmp_path, "mycmd = mycmds.mycmd:not_a_command") returncode, _, err = proc("version", cwd=tmp_path) assert returncode == 1 assert "ValueError: Invalid entry point mycmd" in err class TestExecute: """Tests for calls to scrapy.cmdline.execute() from Python code, which the command line does not cover.""" def test_argv(self, capsys: pytest.CaptureFixture[str]) -> None: with pytest.raises(SystemExit) as exc_info: execute(["scrapy", "version"]) assert exc_info.value.code == 0 assert scrapy.__version__ in capsys.readouterr().out def test_settings(self, capsys: pytest.CaptureFixture[str]) -> None: settings = Settings() with pytest.raises(SystemExit) as exc_info: execute(["scrapy", "settings", "--get", "BOT_NAME"], settings=settings) assert exc_info.value.code == 0 assert capsys.readouterr().out.strip() == "scrapybot" class TestBenchCommand: @pytest.mark.parametrize("use_reactor", [True, False]) def test_run(self, use_reactor: bool) -> None: args: list[str] = [ "bench", "-s", "LOGSTATS_INTERVAL=0.001", "-s", "CLOSESPIDER_TIMEOUT=0.01", ] if not use_reactor: args += ["-s", "TWISTED_REACTOR_ENABLED=False"] _, _, err = proc(*args) assert "INFO: Crawled" in err assert "Unhandled Error" not in err assert "log_count/ERROR" not in err class TestViewCommand: @pytest.mark.skipif( sys.platform == "win32", reason="requires a POSIX shell browser script" ) def test_view( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, mockserver: MockServer ) -> None: opened = tmp_path / "opened.txt" browser = tmp_path / "fake-browser.sh" write_recording_browser(browser, opened) monkeypatch.setenv("BROWSER", str(browser)) returncode, _, err = proc("view", mockserver.url("/html"), cwd=tmp_path) assert returncode == 0, err url = opened.read_text(encoding="utf-8") assert url.startswith("file://") body = Path(url.removeprefix("file://")).read_text(encoding="utf-8") assert "

    Works

    " in body def test_non_text_response(self, mockserver: MockServer) -> None: returncode, _, err = proc( "view", mockserver.url("/static/files/images/scrapy.png") ) assert returncode == 0, err assert "Cannot view a non-text response." in err class TestEditCommand(TestProjectBase): @pytest.mark.skipif( sys.platform == "win32", reason="requires a POSIX shell editor script" ) def test_edit(self, proj_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: spider = proj_path / self.project_name / "spiders" / "example.py" edited = proj_path / "edited.txt" editor = proj_path / "fake-editor.sh" write_recording_editor(editor) monkeypatch.setenv("EDITOR", f"{editor} {edited}") assert call("genspider", "example", "example.com", cwd=proj_path) == 0 returncode, _, err = proc("edit", "example", cwd=proj_path) assert returncode == 0, err assert (proj_path / edited.read_text(encoding="utf-8")).resolve() == ( spider.resolve() ) def test_edit_spider_not_found(self, proj_path: Path) -> None: returncode, _, err = proc("edit", "nonexistent", cwd=proj_path) assert returncode == 1 assert "Spider not found: nonexistent" in err def test_edit_no_spider(self, proj_path: Path) -> None: returncode, out, _ = proc("edit", cwd=proj_path) assert returncode == 2 assert "Usage" in out class TestHelpMessage(TestProjectBase): @pytest.mark.parametrize( "command", [ "parse", "startproject", "view", "crawl", "edit", "list", "fetch", "settings", "shell", "runspider", "version", "genspider", "check", "bench", ], ) def test_help_messages(self, proj_path: Path, command: str) -> None: _, out, _ = proc(command, "-h", cwd=proj_path) assert "Usage" in out class TestPopCommandName: def test_valid_command(self) -> None: argv = ["scrapy", "crawl", "my_spider"] command = _pop_command_name(argv) assert command == "crawl" assert argv == ["scrapy", "my_spider"] def test_no_command(self) -> None: argv = ["scrapy"] command = _pop_command_name(argv) assert command is None assert argv == ["scrapy"] def test_option_before_command(self) -> None: argv = ["scrapy", "-h", "crawl"] command = _pop_command_name(argv) assert command == "crawl" assert argv == ["scrapy", "-h"] def test_option_after_command(self) -> None: argv = ["scrapy", "crawl", "-h"] command = _pop_command_name(argv) assert command == "crawl" assert argv == ["scrapy", "-h"] scrapy-scrapy-5569de3/tests/test_contracts.py000066400000000000000000000707111525051012400214340ustar00rootroot00000000000000from __future__ import annotations from typing import TYPE_CHECKING, Any from unittest import TextTestResult import pytest from twisted.python import failure from scrapy.contracts import Contract, ContractsManager from scrapy.contracts.default import ( CallbackKeywordArgumentsContract, MetadataContract, ReturnsContract, ScrapesContract, UrlContract, ) from scrapy.exceptions import ScrapyDeprecationWarning from scrapy.http import Request, Response from scrapy.item import Field, Item from scrapy.spidermiddlewares.httperror import HttpError from scrapy.spiders import Spider from scrapy.utils.test import get_crawler from tests.mockserver.http import MockServer from tests.utils.decorators import coroutine_test, inline_callbacks_test if TYPE_CHECKING: from collections.abc import AsyncIterator from unittest import TestResult from scrapy.http.request import CallbackT def _request( conman: ContractsManager, method: CallbackT, results: TestResult ) -> Request: request = conman.from_method(method, results) assert request return request def _call(request: Request, response: Any) -> Any: assert request.callback return request.callback(response, **request.cb_kwargs) class DemoItem(Item): name = Field() url = Field() class ResponseMock: url = "http://scrapy.org" class ResponseMetaMock(ResponseMock): meta: Any = None class TaggedRequest(Request): def __init__(self, url, contract_tag=None, **kwargs): super().__init__(url, **kwargs) self.contract_tag = contract_tag class CustomSuccessContract(Contract): name = "custom_success_contract" def adjust_request_args(self, args): args["url"] = "http://scrapy.org" return args class CustomFailContract(Contract): name = "custom_fail_contract" def adjust_request_args(self, args): raise TypeError("Error in adjust_request_args") class CustomTaggedRequestContract(Contract): name = "custom_tagged_request" request_cls = TaggedRequest def adjust_request_args(self, args): args["contract_tag"] = "custom" args["method"] = "POST" return args class DemoSpider(Spider): name = "demo_spider" def returns_request(self, response): """method which returns request @url http://scrapy.org @returns requests 1 """ return Request("http://scrapy.org", callback=self.returns_item) async def returns_request_async(self, response: Response) -> Request: """async method which returns request @url http://scrapy.org @returns requests 1 """ return Request("http://scrapy.org", callback=self.returns_item) def returns_item(self, response): """method which returns item @url http://scrapy.org @returns items 1 1 """ return DemoItem(url=response.url) def returns_request_cb_kwargs(self, response, url): """method which returns request @url https://example.org @cb_kwargs {"url": "http://scrapy.org"} @returns requests 1 """ return Request(url, callback=self.returns_item_cb_kwargs) def returns_item_cb_kwargs(self, response, name): """method which returns item @url http://scrapy.org @cb_kwargs {"name": "Scrapy"} @returns items 1 1 """ return DemoItem(name=name, url=response.url) def returns_item_cb_kwargs_error_unexpected_keyword(self, response): """method which returns item @url http://scrapy.org @cb_kwargs {"arg": "value"} @returns items 1 1 """ return DemoItem(url=response.url) def returns_item_cb_kwargs_error_missing_argument(self, response, arg): """method which returns item @url http://scrapy.org @returns items 1 1 """ return DemoItem(url=response.url) def returns_dict_item(self, response): """method which returns item @url http://scrapy.org @returns items 1 1 """ return {"url": response.url} def returns_fail(self, response): """method which returns item @url http://scrapy.org @returns items 0 0 """ return DemoItem(url=response.url) def returns_request_range_fail(self, response): """method which returns fewer requests than the expected range @url http://scrapy.org @returns requests 2 3 """ return Request("http://scrapy.org", callback=self.returns_item) def yields_item_and_request(self, response): """yields one item and one request @url http://scrapy.org @returns items 1 1 @scrapes name url """ yield DemoItem(name="test", url=response.url) yield Request("http://scrapy.org", callback=self.returns_item) async def returns_async_gen(self, response: Response) -> AsyncIterator[DemoItem]: """async generator callback @url http://scrapy.org @returns items 1 1 """ yield DemoItem(url=response.url) def returns_coroutine(self, response): """method which returns a coroutine without being defined with async def @url http://scrapy.org @returns requests 1 """ return self.returns_request_async(response) def returns_async_gen_sync(self, response): """method which returns an async generator without being defined with async def @url http://scrapy.org @returns items 1 1 """ return self.returns_async_gen(response) async def raises_async(self, response): """async method which raises an exception @url http://scrapy.org @returns items 1 1 """ raise ValueError("async callback error") def returns_dict_fail(self, response): """method which returns item @url http://scrapy.org @returns items 0 0 """ return {"url": response.url} def scrapes_item_ok(self, response): """returns item with name and url @url http://scrapy.org @returns items 1 1 @scrapes name url """ return DemoItem(name="test", url=response.url) def scrapes_dict_item_ok(self, response): """returns item with name and url @url http://scrapy.org @returns items 1 1 @scrapes name url """ return {"name": "test", "url": response.url} def scrapes_item_fail(self, response): """returns item with no name @url http://scrapy.org @returns items 1 1 @scrapes name url """ return DemoItem(url=response.url) def scrapes_dict_item_fail(self, response): """returns item with no name @url http://scrapy.org @returns items 1 1 @scrapes name url """ return {"url": response.url} def scrapes_multiple_missing_fields(self, response): """returns item with no name @url http://scrapy.org @returns items 1 1 @scrapes name url """ return {} def parse_no_url(self, response): """method with no url @returns items 1 1 """ def custom_tagged_request(self, response): """ @url http://scrapy.org @custom_tagged_request """ def invalid_regex(self, response): """method with invalid regex @ Scrapy is awsome """ def invalid_regex_with_valid_contract(self, response): """method with invalid regex @ scrapy is awsome @url http://scrapy.org """ 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 DemoItem(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" def parse(self, response): """ @custom_success_contract """ class CustomContractFailSpider(Spider): name = "custom_contract_fail_spider" def parse(self, response): """ @custom_fail_contract """ class InheritsDemoSpider(DemoSpider): name = "inherits_demo_spider" class TestContractsManager: contracts = [ UrlContract, CallbackKeywordArgumentsContract, MetadataContract, ReturnsContract, ScrapesContract, CustomTaggedRequestContract, CustomSuccessContract, CustomFailContract, ] def setup_method(self): self.conman = ContractsManager(self.contracts) self.results = TextTestResult( # type: ignore[type-var] stream=None, descriptions=False, verbosity=0 ) def should_succeed(self) -> None: assert not self.results.failures assert not self.results.errors def should_fail(self) -> None: assert self.results.failures assert not self.results.errors def should_error(self) -> None: assert self.results.errors def test_contracts(self): spider = DemoSpider() # extract contracts correctly contracts = self.conman.extract_contracts(spider.returns_request) assert len(contracts) == 2 assert frozenset(type(x) for x in contracts) == frozenset( [UrlContract, ReturnsContract] ) # returns request for valid method assert self.conman.from_method(spider.returns_request, self.results) is not None # no request for missing url assert self.conman.from_method(spider.parse_no_url, self.results) is None def test_cb_kwargs(self): spider = DemoSpider() response = ResponseMock() # extract contracts correctly contracts = self.conman.extract_contracts(spider.returns_request_cb_kwargs) assert len(contracts) == 3 assert frozenset(type(x) for x in contracts) == frozenset( [UrlContract, CallbackKeywordArgumentsContract, ReturnsContract] ) contracts = self.conman.extract_contracts(spider.returns_item_cb_kwargs) assert len(contracts) == 3 assert frozenset(type(x) for x in contracts) == frozenset( [UrlContract, CallbackKeywordArgumentsContract, ReturnsContract] ) contracts = self.conman.extract_contracts( spider.returns_item_cb_kwargs_error_unexpected_keyword ) assert len(contracts) == 3 assert frozenset(type(x) for x in contracts) == frozenset( [UrlContract, CallbackKeywordArgumentsContract, ReturnsContract] ) contracts = self.conman.extract_contracts( spider.returns_item_cb_kwargs_error_missing_argument ) assert len(contracts) == 2 assert frozenset(type(x) for x in contracts) == frozenset( [UrlContract, ReturnsContract] ) # returns_request request = _request(self.conman, spider.returns_request_cb_kwargs, self.results) _call(request, response) self.should_succeed() # returns_item request = _request(self.conman, spider.returns_item_cb_kwargs, self.results) _call(request, response) self.should_succeed() # returns_item (error, callback doesn't take keyword arguments) request = _request( self.conman, spider.returns_item_cb_kwargs_error_unexpected_keyword, self.results, ) _call(request, response) self.should_error() # returns_item (error, contract doesn't provide keyword arguments) request = _request( self.conman, spider.returns_item_cb_kwargs_error_missing_argument, self.results, ) _call(request, response) self.should_error() def test_meta(self): spider = DemoSpider() # extract contracts correctly contracts = self.conman.extract_contracts(spider.returns_request_meta) assert len(contracts) == 3 assert frozenset(type(x) for x in contracts) == frozenset( [UrlContract, MetadataContract, ReturnsContract] ) contracts = self.conman.extract_contracts(spider.returns_item_meta) assert len(contracts) == 3 assert frozenset(type(x) for x in contracts) == frozenset( [UrlContract, MetadataContract, ReturnsContract] ) response = ResponseMetaMock() # returns_request request = _request(self.conman, spider.returns_request_meta, self.results) assert request.meta["cookiejar"] == "session1" response.meta = request.meta _call(request, response) assert response.meta["cookiejar"] == "session1" self.should_succeed() response = ResponseMetaMock() # returns_item request = _request(self.conman, spider.returns_item_meta, self.results) assert request.meta["key"] == "example" response.meta = request.meta _call(request, response) assert response.meta["key"] == "example" self.should_succeed() response = ResponseMetaMock() request = _request(self.conman, spider.returns_error_missing_meta, self.results) _call(request, response) self.should_error() def test_returns(self): spider = DemoSpider() response = ResponseMock() # returns_item request = _request(self.conman, spider.returns_item, self.results) _call(request, response) self.should_succeed() # returns_dict_item request = _request(self.conman, spider.returns_dict_item, self.results) _call(request, response) self.should_succeed() # returns_request request = _request(self.conman, spider.returns_request, self.results) _call(request, response) self.should_succeed() # returns_fail request = _request(self.conman, spider.returns_fail, self.results) _call(request, response) self.should_fail() # returns_dict_fail request = _request(self.conman, spider.returns_dict_fail, self.results) _call(request, response) self.should_fail() @coroutine_test async def test_returns_async(self): spider = DemoSpider() response = ResponseMock() request = _request(self.conman, spider.returns_request_async, self.results) await _call(request, response) self.should_succeed() @coroutine_test async def test_returns_async_gen(self): spider = DemoSpider() response = ResponseMock() request = _request(self.conman, spider.returns_async_gen, self.results) await _call(request, response) self.should_succeed() def test_returns_coroutine(self): spider = DemoSpider() response = ResponseMock() request = _request(self.conman, spider.returns_coroutine, self.results) _call(request, response) self.should_error() def test_returns_async_gen_sync(self): spider = DemoSpider() response = ResponseMock() request = _request(self.conman, spider.returns_async_gen_sync, self.results) _call(request, response) self.should_error() @coroutine_test async def test_raises_async(self): spider = DemoSpider() response = ResponseMock() request = _request(self.conman, spider.raises_async, self.results) await _call(request, response) self.should_error() def test_returns_invalid_argument_count(self): spider = DemoSpider() with pytest.raises(ValueError, match="expected 1, 2 or 3, got 0"): ReturnsContract(spider.returns_item) with pytest.raises(ValueError, match="expected 1, 2 or 3, got 4"): ReturnsContract(spider.returns_item, "items", "1", "2", "3") def test_returns_default_bounds(self): spider = DemoSpider() contract = ReturnsContract(spider.returns_item, "items") assert contract.min_bound == 1 assert contract.max_bound == float("inf") def test_returns_range_fail(self): spider = DemoSpider() response = ResponseMock() request = _request(self.conman, spider.returns_request_range_fail, self.results) _call(request, response) self.should_fail() assert "expected 2..3" in self.results.failures[-1][-1] def test_returns_and_scrapes_ignore_other_types(self): spider = DemoSpider() response = ResponseMock() # @returns and @scrapes only count matching output objects and skip # the request that is also yielded. request = _request(self.conman, spider.yields_item_and_request, self.results) _call(request, response) self.should_succeed() def test_testcase_str(self): spider = DemoSpider() contract = UrlContract(spider.returns_request, "http://scrapy.org") assert ( str(contract.testcase_pre) == "[demo_spider] returns_request (@url pre-hook)" ) def test_scrapes(self): spider = DemoSpider() response = ResponseMock() # scrapes_item_ok request = _request(self.conman, spider.scrapes_item_ok, self.results) _call(request, response) self.should_succeed() # scrapes_dict_item_ok request = _request(self.conman, spider.scrapes_dict_item_ok, self.results) _call(request, response) self.should_succeed() # scrapes_item_fail request = _request(self.conman, spider.scrapes_item_fail, self.results) _call(request, response) self.should_fail() # scrapes_dict_item_fail request = _request(self.conman, spider.scrapes_dict_item_fail, self.results) _call(request, response) self.should_fail() # scrapes_multiple_missing_fields request = _request( self.conman, spider.scrapes_multiple_missing_fields, self.results ) _call(request, response) self.should_fail() message = "ContractFail: Missing fields: name, url" assert message in self.results.failures[-1][-1] def test_regex(self): spider = DemoSpider() response = ResponseMock() # invalid regex assert self.conman.from_method(spider.invalid_regex, self.results) is None # invalid regex with valid contract request = _request( self.conman, spider.invalid_regex_with_valid_contract, self.results ) _call(request, response) self.should_succeed() def test_custom_contracts(self): self.conman.from_spider(CustomContractSuccessSpider(), self.results) self.should_succeed() self.conman.from_spider(CustomContractFailSpider(), self.results) self.should_error() def test_errback(self): spider = DemoSpider() try: raise HttpError(Response("http://scrapy.org"), "Ignoring non-200 response") except HttpError: failure_mock = failure.Failure() request = _request(self.conman, spider.returns_request, self.results) assert request.errback request.errback(failure_mock) assert not self.results.failures assert self.results.errors @inline_callbacks_test def test_same_url(self): class TestSameUrlSpider(Spider): name = "test_same_url" def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.visited = 0 async def start(self_): # pylint: disable=no-self-argument for item_or_request in self.conman.from_spider(self_, self.results): yield item_or_request def parse_first(self, response): self.visited += 1 return DemoItem() def parse_second(self, response): self.visited += 1 return DemoItem() with MockServer() as mockserver: contract_doc = f"@url {mockserver.url('/status?n=200')}" TestSameUrlSpider.parse_first.__doc__ = contract_doc TestSameUrlSpider.parse_second.__doc__ = contract_doc crawler = get_crawler(TestSameUrlSpider) yield crawler.crawl() assert isinstance(crawler.spider, TestSameUrlSpider) assert crawler.spider.visited == 2 @inline_callbacks_test def test_async_callbacks(self): class AsyncCallbackSpider(Spider): name = "async_callbacks" async def start(self_): # pylint: disable=no-self-argument for item_or_request in self.conman.from_spider(self_, self.results): yield item_or_request async def parse_coroutine(self, response): return DemoItem(name="coroutine", url=response.url) async def parse_async_gen(self, response): yield DemoItem(name="async gen", url=response.url) with MockServer() as mockserver: contract_doc = ( f"@url {mockserver.url('/status?n=200')}\n" "@returns items 1 1\n" "@scrapes name url" ) AsyncCallbackSpider.parse_coroutine.__doc__ = contract_doc AsyncCallbackSpider.parse_async_gen.__doc__ = contract_doc crawler = get_crawler(AsyncCallbackSpider) yield crawler.crawl() self.should_succeed() assert self.results.testsRun == 4 def test_custom_tagged_request_contract(self): spider = DemoSpider() request = _request(self.conman, spider.custom_tagged_request, self.results) assert request.method == "POST" assert isinstance(request, TaggedRequest) assert request.contract_tag == "custom" def test_inherited_contracts(self): spider = InheritsDemoSpider() requests = self.conman.from_spider(spider, self.results) assert requests assert any( isinstance(request, TaggedRequest) for request in requests if request ) class CustomFailContractPreProcess(Contract): name = "test_contract" def pre_process(self, response): raise KeyboardInterrupt("Pre-process exception") class CustomFailContractPostProcess(Contract): name = "test_contract" def post_process(self, response): raise KeyboardInterrupt("Post-process exception") class PreProcessSuccessContract(Contract): name = "pre_success" def pre_process(self, response): return class PreProcessAssertionFailContract(Contract): name = "pre_assertion_fail" def pre_process(self, response): raise AssertionError("pre-process assertion") class PreProcessErrorContract(Contract): name = "pre_error" def pre_process(self, response): raise ValueError("pre-process error") class PostProcessSuccessContract(Contract): name = "post_success" def post_process(self, output): return class PostProcessErrorContract(Contract): name = "post_error" def post_process(self, output): raise ValueError("post-process error") class TestCustomContractPrePostProcess: def setup_method(self): self.results = TextTestResult( # type: ignore[type-var] stream=None, descriptions=False, verbosity=0 ) def test_pre_hook_keyboard_interrupt(self): spider = DemoSpider() response = ResponseMock() contract = CustomFailContractPreProcess(spider.returns_request) conman = ContractsManager([UrlContract, ReturnsContract]) request = _request(conman, spider.returns_request, self.results) contract.add_pre_hook(request, self.results) with pytest.raises(KeyboardInterrupt, match="Pre-process exception"): _call(request, response) assert not self.results.failures assert not self.results.errors def test_post_hook_keyboard_interrupt(self): spider = DemoSpider() response = ResponseMock() contract = CustomFailContractPostProcess(spider.returns_request) conman = ContractsManager([UrlContract, ReturnsContract]) request = _request(conman, spider.returns_request, self.results) contract.add_post_hook(request, self.results) with pytest.raises(KeyboardInterrupt, match="Post-process exception"): _call(request, response) assert not self.results.failures assert not self.results.errors def test_pre_hook_success(self): spider = DemoSpider() response = ResponseMock() contract = PreProcessSuccessContract(spider.returns_request) conman = ContractsManager([UrlContract, ReturnsContract]) request = _request(conman, spider.returns_request, self.results) contract.add_pre_hook(request, self.results) _call(request, response) assert not self.results.failures assert not self.results.errors def test_pre_hook_assertion_failure(self): spider = DemoSpider() response = ResponseMock() contract = PreProcessAssertionFailContract(spider.returns_request) conman = ContractsManager([UrlContract, ReturnsContract]) request = _request(conman, spider.returns_request, self.results) contract.add_pre_hook(request, self.results) _call(request, response) assert self.results.failures assert not self.results.errors def test_pre_hook_error(self): spider = DemoSpider() response = ResponseMock() contract = PreProcessErrorContract(spider.returns_request) conman = ContractsManager([UrlContract, ReturnsContract]) request = _request(conman, spider.returns_request, self.results) contract.add_pre_hook(request, self.results) _call(request, response) assert self.results.errors @coroutine_test async def test_pre_hook_async_callback(self): spider = DemoSpider() response = ResponseMock() contract = PreProcessSuccessContract(spider.returns_request_async) request = Request("http://scrapy.org", callback=spider.returns_request_async) contract.add_pre_hook(request, self.results) output = await _call(request, response) assert len(output) == 1 assert not self.results.failures assert not self.results.errors @coroutine_test async def test_pre_hook_async_generator(self): spider = DemoSpider() response = ResponseMock() contract = PreProcessSuccessContract(spider.returns_async_gen) request = Request("http://scrapy.org", callback=spider.returns_async_gen) contract.add_pre_hook(request, self.results) output = await _call(request, response) assert len(output) == 1 assert not self.results.failures assert not self.results.errors @coroutine_test async def test_post_hook_async_generator(self): spider = DemoSpider() response = ResponseMock() contract = PostProcessSuccessContract(spider.returns_async_gen) request = Request("http://scrapy.org", callback=spider.returns_async_gen) contract.add_post_hook(request, self.results) output = await _call(request, response) assert len(output) == 1 assert not self.results.failures assert not self.results.errors def test_post_hook_error(self): spider = DemoSpider() response = ResponseMock() contract = PostProcessErrorContract(spider.returns_request) conman = ContractsManager([UrlContract, ReturnsContract]) request = _request(conman, spider.returns_request, self.results) contract.add_post_hook(request, self.results) _call(request, response) assert self.results.errors class LegacyHookContract(Contract): name = "legacy_hook" def add_pre_hook(self, request, results): return request def test_hook_override_deprecation(): with pytest.warns(ScrapyDeprecationWarning, match="add_pre_hook"): ContractsManager([LegacyHookContract]) def test_no_hook_override_deprecation(recwarn): ContractsManager([UrlContract, ReturnsContract]) assert not [w for w in recwarn if w.category is ScrapyDeprecationWarning] scrapy-scrapy-5569de3/tests/test_core_downloader.py000066400000000000000000000634601525051012400226050ustar00rootroot00000000000000from __future__ import annotations import warnings from typing import TYPE_CHECKING, Any, cast import OpenSSL.SSL import pytest from pytest_twisted import async_yield_fixture from twisted.internet.endpoints import HostnameEndpoint from twisted.internet.protocol import Factory from twisted.internet.protocol import Protocol as TxProtocol from twisted.internet.ssl import AcceptableCiphers, optionsForClientTLS from twisted.protocols.tls import TLSMemoryBIOFactory, TLSMemoryBIOProtocol from twisted.web import server, static from twisted.web.client import ( URI, Agent, BrowserLikePolicyForHTTPS, _StandardEndpointFactory, readBody, ) from twisted.web.client import Response as TxResponse from scrapy import Request, Spider from scrapy.core.downloader import Downloader, Slot, tls from scrapy.core.downloader._idna_patch import ( _install_twisted_idna_fallbacks, _safe_hostname_bytes, ) from scrapy.core.downloader.contextfactory import ( _get_creator_context, _load_context_factory_from_settings, _ScrapyClientContextFactory, ) from scrapy.core.downloader.handlers.http11 import _RequestBodyProducer from scrapy.exceptions import ScrapyDeprecationWarning from scrapy.resolver import dnscache from scrapy.utils._deps_compat import PYOPENSSL_SET_CIPHER_LIST_TMP_CONN from scrapy.utils.defer import maybe_deferred_to_future from scrapy.utils.misc import build_from_crawler from scrapy.utils.python import to_bytes from scrapy.utils.spider import DefaultSpider from scrapy.utils.test import get_crawler from tests import IDNA_REJECTED_HOSTNAMES from tests.mockserver.http_resources import PayloadResource, put_child from tests.mockserver.utils import ssl_context_factory from tests.utils.cmdline import proc from tests.utils.decorators import coroutine_test if TYPE_CHECKING: from pathlib import Path from twisted.internet.defer import Deferred from twisted.internet.interfaces import IConsumer, IListeningPort from twisted.web.iweb import IBodyProducer from scrapy.http import Response from tests.mockserver.http import MockServer @pytest.fixture(autouse=True, scope="module") def _twisted_idna_fallbacks() -> None: """The download handlers install these when built; the tests below exercise the patched Twisted helpers directly, so they need them installed too.""" _install_twisted_idna_fallbacks() class TestSlot: def test_repr(self): slot = Slot(concurrency=8, delay=0.1, jitter=0.5) assert repr(slot) == "Slot(concurrency=8, delay=0.1, jitter=0.5)" def test_download_delay_without_jitter(self): slot = Slot(concurrency=8, delay=2.0, jitter=0) assert slot.download_delay() == 2.0 def test_download_delay_with_jitter(self): slot = Slot(concurrency=8, delay=2.0, jitter=0.25) assert all(1.5 <= slot.download_delay() <= 2.5 for _ in range(100)) def test_download_delay_with_jitter_above_one(self): slot = Slot(concurrency=8, delay=2.0, jitter=3.0) assert all(slot.download_delay() >= 0 for _ in range(100)) @pytest.mark.parametrize(("value", "expected"), [(True, 0.5), (False, 0.0)]) @pytest.mark.parametrize("positional", [False, True]) def test_deprecated_randomize_delay_param( self, positional: bool, value: bool, expected: float ): with pytest.warns( ScrapyDeprecationWarning, match="The randomize_delay parameter of Slot is deprecated", ): slot = ( Slot(8, 2.0, value) if positional else Slot(concurrency=8, delay=2.0, randomize_delay=value) ) assert slot.jitter == expected @pytest.mark.parametrize(("jitter", "expected"), [(0, False), (0.2, True)]) def test_deprecated_randomize_delay(self, jitter: float, expected: bool): slot = Slot(concurrency=8, delay=2.0, jitter=jitter) with pytest.warns( ScrapyDeprecationWarning, match="Slot.randomize_delay is deprecated" ): assert slot.randomize_delay is expected @pytest.mark.parametrize(("value", "expected"), [(True, 0.5), (False, 0.0)]) def test_deprecated_randomize_delay_setter(self, value: bool, expected: float): slot = Slot(concurrency=8, delay=2.0, jitter=0.2) with pytest.warns( ScrapyDeprecationWarning, match="Slot.randomize_delay is deprecated" ): slot.randomize_delay = value assert slot.jitter == expected class TestJitterSetting: @staticmethod def _jitter(**settings: Any) -> float: crawler = get_crawler(settings_dict=settings) downloader = Downloader(crawler) downloader.close() return downloader._jitter def test_default(self): assert self._jitter() == 0.5 @pytest.mark.parametrize(("value", "expected"), [(0, 0.0), ("0.2", 0.2), (1, 1.0)]) def test_value(self, value: Any, expected: float): assert self._jitter(DOWNLOAD_DELAY_JITTER=value) == pytest.approx(expected) @pytest.mark.parametrize(("value", "expected"), [(True, 0.5), (False, 0.0)]) def test_deprecated_setting(self, value: bool, expected: float): with pytest.warns( ScrapyDeprecationWarning, match="The RANDOMIZE_DOWNLOAD_DELAY setting is deprecated", ): assert self._jitter(RANDOMIZE_DOWNLOAD_DELAY=value) == expected @pytest.mark.parametrize(("jitter", "expected"), [(0, False), (0.2, True)]) def test_deprecated_downloader_attribute(self, jitter: float, expected: bool): crawler = get_crawler(settings_dict={"DOWNLOAD_DELAY_JITTER": jitter}) downloader = Downloader(crawler) downloader.close() with pytest.warns( ScrapyDeprecationWarning, match="Downloader.randomize_delay is deprecated" ): assert downloader.randomize_delay is expected def test_deprecated_setting_loses_on_tie(self): with pytest.warns(ScrapyDeprecationWarning): jitter = self._jitter( RANDOMIZE_DOWNLOAD_DELAY=False, DOWNLOAD_DELAY_JITTER=0.2 ) assert jitter == pytest.approx(0.2) @pytest.mark.requires_reactor # this test is related to the Twisted HTTP code class TestContextFactoryBase: @async_yield_fixture async def server_url(self, tmp_path): (tmp_path / "file").write_bytes(b"0123456789") r = static.File(str(tmp_path)) put_child(r, b"payload", PayloadResource()) site = server.Site(r, timeout=None) port = self._listen(site) portno = port.getHost().port yield f"https://127.0.0.1:{portno}/" await port.stopListening() def _listen(self, site: server.Site) -> IListeningPort: from twisted.internet import reactor return reactor.listenSSL( 0, site, contextFactory=ssl_context_factory(), interface="127.0.0.1", ) @staticmethod async def get_page( url: str, client_context_factory: BrowserLikePolicyForHTTPS, body: str | None = None, ) -> bytes: from twisted.internet import reactor agent = Agent(reactor, contextFactory=client_context_factory) body_producer = _RequestBodyProducer(body.encode()) if body else None response: TxResponse = cast( "TxResponse", await maybe_deferred_to_future( agent.request( b"GET", url.encode(), bodyProducer=cast("IBodyProducer", body_producer), ) ), ) with warnings.catch_warnings(): # https://github.com/twisted/twisted/issues/8227 warnings.filterwarnings( "ignore", category=DeprecationWarning, message=r".*does not have an abortConnection method", ) d: Deferred[bytes] = readBody(response) # type: ignore[arg-type] return await maybe_deferred_to_future(d) class TestContextFactory(TestContextFactoryBase): @pytest.fixture def factory(self) -> _ScrapyClientContextFactory: crawler = get_crawler() return _load_context_factory_from_settings(crawler) @staticmethod def _get_dummy_protocol() -> TLSMemoryBIOProtocol: # from Twisted src/twisted/web/test/test_agent.py::dummyTLSProtocol() factory = TLSMemoryBIOFactory( optionsForClientTLS("example.com"), True, Factory.forProtocol(TxProtocol) ) return factory.buildProtocol(None) @coroutine_test async def test_payload( self, factory: _ScrapyClientContextFactory, server_url: str ) -> None: s = "0123456789" * 10 body = await self.get_page(server_url + "payload", factory, body=s) assert body == to_bytes(s) def test_no_context_sharing(self, factory: _ScrapyClientContextFactory) -> None: """Every call to creatorForNetloc() should give a fresh context.""" creator1 = factory.creatorForNetloc(b"website1.tld", 443) assert creator1._hostnameBytes == b"website1.tld" creator2 = factory.creatorForNetloc(b"website2.tld", 443) assert creator2._hostnameBytes == b"website2.tld" assert _get_creator_context(creator1) is not _get_creator_context(creator2) def test_no_context_sharing_with_conn( self, factory: _ScrapyClientContextFactory ) -> None: """Like test_no_context_sharing() but get the context from a connection.""" creator1 = factory.creatorForNetloc(b"website1.tld", 443) assert creator1._hostnameBytes == b"website1.tld" conn1 = creator1.clientConnectionForTLS(self._get_dummy_protocol()) creator2 = factory.creatorForNetloc(b"website2.tld", 443) assert creator2._hostnameBytes == b"website2.tld" conn2 = creator2.clientConnectionForTLS(self._get_dummy_protocol()) assert conn1.get_context() is not conn2.get_context() @pytest.mark.skipif( PYOPENSSL_SET_CIPHER_LIST_TMP_CONN, reason="Fails or doesn't make sense on this pyOpenSSL version", ) def test_no_immutable_ctx_warning( self, factory: _ScrapyClientContextFactory ) -> None: """There should be no pyOpenSSL context modification warning. pyOpenSSL < 25.1.0 doesn't produce this warning, and on 25.1.0 it's always produced due to https://github.com/scrapy/scrapy/issues/6859#issuecomment-4294917851. """ with warnings.catch_warnings(): warnings.filterwarnings( "error", category=DeprecationWarning, message="Attempting to mutate a Context after a Connection was created", ) factory.creatorForNetloc(b"website.tld", 443) @pytest.mark.parametrize("hostname", [h.encode() for h in IDNA_REJECTED_HOSTNAMES]) def test_idna_rejected_hostname( self, factory: _ScrapyClientContextFactory, hostname: bytes ) -> None: """Hostnames rejected by the idna package, such as punycode emoji domains or domains with underscores, should work.""" creator = factory.creatorForNetloc(hostname, 443) assert creator._hostnameBytes == hostname assert creator._hostnameASCII == hostname.decode("ascii") assert creator._hostnameIsDnsName is True conn = creator.clientConnectionForTLS(self._get_dummy_protocol()) assert conn is not None @pytest.mark.parametrize("hostname", [h.encode() for h in IDNA_REJECTED_HOSTNAMES]) def test_idna_rejected_hostname_verify_certificates(self, hostname: bytes) -> None: """Same as test_idna_rejected_hostname() but with certificate verification enabled, which uses plain optionsForClientTLS().""" crawler = get_crawler(settings_dict={"DOWNLOAD_VERIFY_CERTIFICATES": True}) factory = cast( "_ScrapyClientContextFactory", _load_context_factory_from_settings(crawler) ) creator = factory.creatorForNetloc(hostname, 443) assert creator._hostnameBytes == hostname assert creator._hostnameASCII == hostname.decode("ascii") @pytest.mark.parametrize("verify_certs", [False, True]) def test_keylog( self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path, verify_certs: bool, ) -> None: """The key log callback should be set with and without verification.""" monkeypatch.setenv("SSLKEYLOGFILE", str(tmp_path / "keylog")) crawler = get_crawler( settings_dict={"DOWNLOAD_VERIFY_CERTIFICATES": verify_certs} ) factory: _ScrapyClientContextFactory = _load_context_factory_from_settings( crawler ) creator = factory.creatorForNetloc(b"website.tld", 443) conn = creator.clientConnectionForTLS(self._get_dummy_protocol()) assert conn.get_context()._keylog_callback is not None def test_keylog_ignore_environment( self, monkeypatch: pytest.MonkeyPatch, mockserver: MockServer, tmp_path: Path, ) -> None: keylog_file = tmp_path / "keylog" monkeypatch.setenv("SSLKEYLOGFILE", str(keylog_file)) _, out, err = proc( "fetch", mockserver.url("/text", is_secure=True), python_args=("-E",) ) assert out.strip() == "Works", err assert not keylog_file.exists() def test_ctx_flags(self, factory: _ScrapyClientContextFactory) -> None: """The context should have the expected flags set.""" creator = factory.creatorForNetloc(b"website.tld", 443) conn = creator.clientConnectionForTLS(self._get_dummy_protocol()) ctx = conn.get_context() # fragile but pyOpenSSL doesn't have Context.get_options() options = OpenSSL.SSL._lib.SSL_CTX_get_options(ctx._context) # type: ignore[attr-defined] assert options & 0x4 # OP_LEGACY_SERVER_CONNECT class TestContextFactoryCiphers(TestContextFactoryBase): async def _assert_factory_works( self, server_url: str, client_context_factory: _ScrapyClientContextFactory ) -> None: s = "0123456789" * 10 body = await self.get_page( server_url + "payload", client_context_factory, body=s ) assert body == to_bytes(s) def test_default(self) -> None: """The default 'DEFAULT' value is passed to Twisted as is.""" crawler = get_crawler() factory = build_from_crawler(_ScrapyClientContextFactory, crawler) assert factory.tls_ciphers is not None # OpenSSLAcceptableCiphers has no __eq__, so compare the parsed ciphers. assert ( factory.tls_ciphers._ciphers == AcceptableCiphers.fromOpenSSLCipherString("DEFAULT")._ciphers ) assert factory._get_cert_options_kwargs()["acceptableCiphers"] is not None def test_custom(self) -> None: crawler = get_crawler( settings_dict={"DOWNLOADER_CLIENT_TLS_CIPHERS": "CAMELLIA256-SHA"} ) factory = build_from_crawler(_ScrapyClientContextFactory, crawler) assert factory.tls_ciphers is not None assert ( factory.tls_ciphers._ciphers == AcceptableCiphers.fromOpenSSLCipherString("CAMELLIA256-SHA")._ciphers ) @coroutine_test async def test_none(self, server_url: str) -> None: """A None value enables the Twisted default ciphers.""" crawler = get_crawler(settings_dict={"DOWNLOADER_CLIENT_TLS_CIPHERS": None}) factory = build_from_crawler(_ScrapyClientContextFactory, crawler) assert factory.tls_ciphers is None assert factory._get_cert_options_kwargs()["acceptableCiphers"] is None await self._assert_factory_works(server_url, factory) class TestContextFactoryTLSMethod(TestContextFactoryBase): async def _assert_factory_works( self, server_url: str, client_context_factory: _ScrapyClientContextFactory ) -> None: s = "0123456789" * 10 body = await self.get_page( server_url + "payload", client_context_factory, body=s ) assert body == to_bytes(s) @coroutine_test async def test_setting_default(self, server_url: str) -> None: crawler = get_crawler() client_context_factory = _load_context_factory_from_settings(crawler) assert client_context_factory._ssl_method == OpenSSL.SSL.SSLv23_METHOD await self._assert_factory_works(server_url, client_context_factory) def test_setting_none(self): crawler = get_crawler(settings_dict={"DOWNLOADER_CLIENT_TLS_METHOD": None}) with ( pytest.warns( ScrapyDeprecationWarning, match="Setting DOWNLOADER_CLIENT_TLS_METHOD to a non-default value is deprecated", ), pytest.raises(KeyError), ): _load_context_factory_from_settings(crawler) def test_setting_bad(self): crawler = get_crawler(settings_dict={"DOWNLOADER_CLIENT_TLS_METHOD": "bad"}) with ( pytest.warns( ScrapyDeprecationWarning, match="Setting DOWNLOADER_CLIENT_TLS_METHOD to a non-default value is deprecated", ), pytest.raises(KeyError), ): _load_context_factory_from_settings(crawler) @pytest.mark.filterwarnings( r"ignore:Passing method to twisted\.internet\.ssl\.CertificateOptions:DeprecationWarning" ) @coroutine_test async def test_setting_explicit(self, server_url: str) -> None: crawler = get_crawler(settings_dict={"DOWNLOADER_CLIENT_TLS_METHOD": "TLSv1.2"}) with pytest.warns( ScrapyDeprecationWarning, match="Setting DOWNLOADER_CLIENT_TLS_METHOD to a non-default value is deprecated", ): client_context_factory = _load_context_factory_from_settings(crawler) assert client_context_factory._ssl_method == OpenSSL.SSL.TLSv1_2_METHOD await self._assert_factory_works(server_url, client_context_factory) @coroutine_test async def test_direct_from_crawler(self, server_url: str) -> None: # the setting is ignored crawler = get_crawler(settings_dict={"DOWNLOADER_CLIENT_TLS_METHOD": "bad"}) client_context_factory = build_from_crawler( _ScrapyClientContextFactory, crawler ) assert client_context_factory._ssl_method == OpenSSL.SSL.SSLv23_METHOD await self._assert_factory_works(server_url, client_context_factory) @pytest.mark.filterwarnings( r"ignore:Passing method to twisted\.internet\.ssl\.CertificateOptions:DeprecationWarning" ) @coroutine_test async def test_direct_init(self, server_url: str) -> None: client_context_factory = _ScrapyClientContextFactory(OpenSSL.SSL.TLSv1_2_METHOD) assert client_context_factory._ssl_method == OpenSSL.SSL.TLSv1_2_METHOD await self._assert_factory_works(server_url, client_context_factory) @pytest.mark.parametrize( ("concurrency", "active", "expected"), [ (2, 1, False), (2, 2, True), (0, 0, False), (0, 2, False), ], ) def test_needs_backout(concurrency: int, active: int, expected: bool) -> None: crawler = get_crawler(settings_dict={"CONCURRENT_REQUESTS": concurrency}) downloader = Downloader(crawler) downloader.active = {Request(f"https://example.com/{i}") for i in range(active)} assert downloader.needs_backout() is expected downloader.close() class TestSafeHostnameBytes: """Tests for the workarounds for hostnames rejected by the idna package.""" @pytest.mark.parametrize( ("hostname", "expected"), [ ("example.com", b"example.com"), ("xn--i-7iq.ws", b"xn--i-7iq.ws"), # i�.ws ("i�.ws", b"xn--i-7iq.ws"), ("ü.example", b"xn--tda.example"), ("foo_bar.example", b"foo_bar.example"), # overlong ASCII label, rejected by the stdlib codec ("a" * 64 + ".com", b"a" * 64 + b".com"), ], ) def test_valid(self, hostname: str, expected: bytes) -> None: assert _safe_hostname_bytes(hostname) == expected @pytest.mark.parametrize( "hostname", [ "�" * 200 + ".ws", # not hostnames at all: Twisted parses ":" out of a bracketless # IPv6 netloc on old versions ":", "[::1]", ], ) def test_invalid(self, hostname: str) -> None: with pytest.raises(UnicodeError): _safe_hostname_bytes(hostname) @pytest.mark.parametrize( ("host", "expected"), [ ("xn--i-7iq.ws", (False, b"xn--i-7iq.ws", "xn--i-7iq.ws")), ("i�.ws", (False, b"xn--i-7iq.ws", "i�.ws")), ("foo_bar.example", (False, b"foo_bar.example", "foo_bar.example")), ("example.com", (False, b"example.com", "example.com")), # bytes hostnames are decoded instead of encoded (b"xn--i-7iq.ws", (False, b"xn--i-7iq.ws", "i�.ws")), (b"foo_bar.example", (False, b"foo_bar.example", "foo_bar.example")), (b"example.com", (False, b"example.com", "example.com")), # invalid hostnames must stay invalid, or Twisted resolves them # instead of failing early (":", (True, b":", ":")), ("[::1]", (True, b"[::1]", "[::1]")), (b":", (True, b":", ":")), ], ) def test_host_as_bytes_and_text( self, host: bytes | str, expected: tuple[bool, bytes, str] ) -> None: """HostnameEndpoint should consider only invalid hostnames invalid.""" assert HostnameEndpoint._hostAsBytesAndText(host) == expected @pytest.mark.requires_reactor class TestIdnaRejectedEndpoints: """Endpoints for hostnames rejected by the idna package, such as emoji domains or domains with underscores, should be connectable.""" @pytest.mark.parametrize("scheme", ["http", "https"]) @pytest.mark.parametrize("hostname", IDNA_REJECTED_HOSTNAMES) def test_endpoint_for_uri(self, scheme: str, hostname: str) -> None: from twisted.internet import reactor crawler = get_crawler() endpoint_factory = _StandardEndpointFactory( reactor, _load_context_factory_from_settings(crawler), 10, None ) endpoint = endpoint_factory.endpointForURI( URI.fromBytes(f"{scheme}://{hostname}/".encode()) ) # https endpoints are wrapped by wrapClientTLS() hostname_endpoint = getattr(endpoint, "_wrappedEndpoint", endpoint) assert hostname_endpoint._badHostname is False assert hostname_endpoint._hostBytes == hostname.encode("ascii") assert hostname_endpoint._hostText == hostname @coroutine_test async def test_fetch_deprecated_spider_arg(): class CustomDownloader(Downloader): # requiring the spider argument is what triggers the deprecation def fetch( # type: ignore[override] # pylint: disable=signature-differs self, request: Request, spider: Spider ) -> Deferred[Response | Request]: return super().fetch(request, spider) crawler = get_crawler(DefaultSpider, {"DOWNLOADER": CustomDownloader}) with pytest.warns( ScrapyDeprecationWarning, match=r"The fetch\(\) method of .+\.CustomDownloader requires a spider argument", ): await crawler.crawl_async() def test_deprecated_tls_module_names() -> None: with pytest.warns( ScrapyDeprecationWarning, match="scrapy.core.downloader.tls.METHOD_TLS is deprecated", ): assert tls.METHOD_TLS == "TLS" with pytest.warns( ScrapyDeprecationWarning, match="scrapy.core.downloader.tls.openssl_methods is deprecated", ): assert isinstance(tls.openssl_methods, dict) with pytest.warns( ScrapyDeprecationWarning, match="scrapy.core.downloader.tls.DEFAULT_CIPHERS is deprecated", ): assert tls.DEFAULT_CIPHERS._ciphers == ( AcceptableCiphers.fromOpenSSLCipherString("DEFAULT")._ciphers ) def test_slot_str() -> None: slot = Slot(concurrency=2, delay=0.5, jitter=0) assert str(slot).startswith( " None: """Slots that have been idle for long enough are discarded.""" crawler = get_crawler(DefaultSpider) crawler.spider = crawler._create_spider() downloader = Downloader(crawler) try: await maybe_deferred_to_future(downloader.fetch(Request("data:,"))) assert set(downloader.slots) == {""} downloader._slot_gc() assert set(downloader.slots) == {""} downloader._slot_gc(age=0) assert not downloader.slots finally: downloader.close() await downloader.handlers._close() @coroutine_test async def test_request_body_producer() -> None: class Consumer: def __init__(self) -> None: self.written: list[bytes] = [] def write(self, data: bytes) -> None: self.written.append(data) producer = _RequestBodyProducer(b"body") assert producer.length == 4 consumer = Consumer() await maybe_deferred_to_future(producer.startProducing(cast("IConsumer", consumer))) assert consumer.written == [b"body"] # The body is written in a single call, so there is nothing to pause or stop. producer.pauseProducing() producer.stopProducing() scrapy-scrapy-5569de3/tests/test_core_scraper.py000066400000000000000000000110121525051012400220700ustar00rootroot00000000000000from __future__ import annotations import logging from typing import TYPE_CHECKING, Any, cast import pytest from scrapy import Request, Spider from scrapy.core.scraper import Scraper from scrapy.exceptions import IgnoreRequest from scrapy.http import Response from scrapy.utils.defer import maybe_deferred_to_future from scrapy.utils.spider import DefaultSpider from scrapy.utils.test import get_crawler from tests.spiders import SimpleSpider from tests.utils.decorators import coroutine_test, inline_callbacks_test if TYPE_CHECKING: from collections.abc import AsyncIterator, Generator, Iterable from twisted.internet.defer import Deferred from tests.mockserver.http import MockServer @coroutine_test async def test_scraper_exception( mockserver: MockServer, caplog: pytest.LogCaptureFixture, monkeypatch: pytest.MonkeyPatch, ) -> None: crawler = get_crawler(SimpleSpider) monkeypatch.setattr( "scrapy.core.engine.Scraper.handle_spider_output_async", lambda *args, **kwargs: 1 / 0, ) await crawler.crawl_async(url=mockserver.url("/")) assert "Scraper bug processing" in caplog.text @coroutine_test async def test_open_spider_without_spider() -> None: scraper = Scraper(get_crawler(DefaultSpider)) with pytest.raises(RuntimeError, match=r"called before Crawler\.spider is set"): await scraper.open_spider_async() @inline_callbacks_test def test_enqueue_scrape_without_slot() -> Generator[Deferred[Any], Any, None]: scraper = Scraper(get_crawler(DefaultSpider)) with pytest.raises(RuntimeError, match="Scraper slot not assigned"): yield scraper.enqueue_scrape(Response("data:,"), Request("data:,")) @coroutine_test async def test_enqueue_scrape_wrong_type(caplog: pytest.LogCaptureFixture) -> None: crawler = get_crawler(DefaultSpider) crawler.spider = crawler._create_spider() scraper = Scraper(crawler) await scraper.open_spider_async() try: await maybe_deferred_to_future( scraper.enqueue_scrape("not a response", Request("data:,")) # type: ignore[arg-type] ) finally: await scraper.close_spider_async() assert "expected Response or Failure, got " in caplog.text @coroutine_test async def test_call_spider_sets_request_on_response() -> None: class ParsingSpider(Spider): name = "parsing" def parse(self, response: Response) -> Iterable[Any]: return [response.url] crawler = get_crawler(ParsingSpider) crawler.spider = crawler._create_spider() scraper = Scraper(crawler) response = Response("data:,") request = Request("data:,") output = await scraper.call_spider_async(response, request) assert response.request is request assert list(cast("Iterable[Any]", output)) == ["data:,"] @coroutine_test async def test_errback_error_on_ignored_request( caplog: pytest.LogCaptureFixture, ) -> None: """An errback failing on an ignored request does not log a download error.""" class IgnoreMiddleware: def process_request(self, request: Request) -> None: raise IgnoreRequest("nope") class ErrbackSpider(Spider): name = "errback" async def start(self) -> AsyncIterator[Any]: yield Request("data:,", errback=self.errback) def errback(self, failure: Any) -> None: raise ValueError("errback error") crawler = get_crawler( ErrbackSpider, {"DOWNLOADER_MIDDLEWARES": {IgnoreMiddleware: 1}} ) with caplog.at_level(logging.DEBUG): await crawler.crawl_async() assert "Error downloading" not in caplog.text assert crawler.stats.get_value("spider_exceptions/ValueError") == 1 @coroutine_test async def test_none_in_callback_output() -> None: """``None`` in the callback output is dropped, without dropping later items.""" class NoneSpider(Spider): name = "none" async def start(self) -> AsyncIterator[Any]: yield Request("data:,") def parse(self, response: Response) -> Iterable[Any]: yield {"index": 1} yield None yield {"index": 2} crawler = get_crawler( NoneSpider, # The built-in spider middlewares would drop the None before the # scraper gets to see it. A single concurrent item makes sure that the # None and the item that follows it are handled by the same worker. {"CONCURRENT_ITEMS": 1, "SPIDER_MIDDLEWARES_BASE": {}}, ) await crawler.crawl_async() assert crawler.stats.get_value("item_scraped_count") == 2 scrapy-scrapy-5569de3/tests/test_crawl.py000066400000000000000000001157631525051012400205530ustar00rootroot00000000000000from __future__ import annotations import json import logging import re from ipaddress import IPv4Address from socket import gethostbyname from typing import TYPE_CHECKING, Any from urllib.parse import urlencode, urlparse import pytest from cryptography.x509 import load_der_x509_certificate from twisted.internet.defer import succeed from twisted.internet.ssl import Certificate from twisted.python.failure import Failure from scrapy import Spider, signals from scrapy.exceptions import CloseSpider, ScrapyDeprecationWarning, StopDownload from scrapy.http import Request from scrapy.http.response import Response from scrapy.utils.defer import maybe_deferred_to_future from scrapy.utils.engine import ( format_engine_status, get_engine_status, print_engine_status, ) from scrapy.utils.python import to_unicode from scrapy.utils.test import get_crawler from tests import NON_EXISTING_RESOLVABLE from tests.spiders import ( AsyncDefAsyncioGenComplexSpider, AsyncDefAsyncioGenExcSpider, AsyncDefAsyncioGenLoopSpider, AsyncDefAsyncioGenSpider, AsyncDefAsyncioReqsReturnSpider, AsyncDefAsyncioReturnSingleElementSpider, AsyncDefAsyncioReturnSpider, AsyncDefAsyncioSpider, AsyncDefDeferredDirectSpider, AsyncDefDeferredMaybeWrappedSpider, AsyncDefDeferredWrappedSpider, AsyncDefSpider, BrokenStartSpider, BytesReceivedCallbackSpider, BytesReceivedErrbackSpider, CrawlSpiderWithAsyncCallback, CrawlSpiderWithAsyncGeneratorCallback, CrawlSpiderWithErrback, CrawlSpiderWithoutErrback, CrawlSpiderWithParseMethod, CrawlSpiderWithProcessRequestCallbackKeywordArguments, DelaySpider, DuplicateStartSpider, FollowAllSpider, HeadersReceivedCallbackSpider, HeadersReceivedErrbackSpider, SimpleSpider, SingleRequestSpider, StartGoodAndBadOutput, StartItemSpider, ) from tests.utils.decorators import coroutine_test if TYPE_CHECKING: from scrapy.statscollectors import StatsCollector from tests.mockserver.http import MockServer class TestCrawl: @coroutine_test async def test_follow_all(self, mockserver: MockServer) -> None: crawler = get_crawler(FollowAllSpider) await crawler.crawl_async(mockserver=mockserver) assert isinstance(crawler.spider, FollowAllSpider) assert len(crawler.spider.urls_visited) == 11 # 10 + start_url @coroutine_test async def test_fixed_delay(self, mockserver: MockServer) -> None: await self._test_delay(mockserver, total=10, delay=0.2) @coroutine_test async def test_randomized_delay(self, mockserver: MockServer) -> None: await self._test_delay(mockserver, total=10, delay=0.1, randomize=True) @staticmethod async def _test_delay( mockserver: MockServer, total: int, delay: float, randomize: bool = False ) -> None: crawl_kwargs = { "maxlatency": delay * 2, "mockserver": mockserver, "total": total, } tolerance = 1 - (0.6 if randomize else 0.2) settings = { "DOWNLOAD_DELAY": delay, "DOWNLOAD_DELAY_JITTER": 0.5 if randomize else 0, } crawler = get_crawler(FollowAllSpider, settings) await crawler.crawl_async(**crawl_kwargs) assert crawler.spider assert isinstance(crawler.spider, FollowAllSpider) times = crawler.spider.times total_time = times[-1] - times[0] average = total_time / (len(times) - 1) assert average > delay * tolerance, f"download delay too small: {average}" # Ensure that the same test parameters would cause a failure if no # download delay is set. Otherwise, it means we are using a combination # of ``total`` and ``delay`` values that are too small for the test # code above to have any meaning. settings["DOWNLOAD_DELAY"] = 0 crawler = get_crawler(FollowAllSpider, settings) await crawler.crawl_async(**crawl_kwargs) assert crawler.spider assert isinstance(crawler.spider, FollowAllSpider) times = crawler.spider.times total_time = times[-1] - times[0] average = total_time / (len(times) - 1) assert average <= delay / tolerance, "test total or delay values are too small" @coroutine_test async def test_timeout_success(self, mockserver: MockServer) -> None: crawler = get_crawler(DelaySpider) await crawler.crawl_async(n=0.5, mockserver=mockserver) assert isinstance(crawler.spider, DelaySpider) assert crawler.spider.t1 > 0 assert crawler.spider.t2 > 0 assert crawler.spider.t2 > crawler.spider.t1 @coroutine_test async def test_timeout_failure(self, mockserver: MockServer) -> None: crawler = get_crawler(DelaySpider, {"DOWNLOAD_TIMEOUT": 0.35}) await crawler.crawl_async(n=0.5, mockserver=mockserver) assert isinstance(crawler.spider, DelaySpider) assert crawler.spider.t1 > 0 assert crawler.spider.t2 == 0 assert crawler.spider.t2_err > 0 assert crawler.spider.t2_err > crawler.spider.t1 # server hangs after receiving response headers crawler = get_crawler(DelaySpider, {"DOWNLOAD_TIMEOUT": 0.35}) await crawler.crawl_async(n=0.5, b=1, mockserver=mockserver) assert isinstance(crawler.spider, DelaySpider) assert crawler.spider.t1 > 0 assert crawler.spider.t2 == 0 assert crawler.spider.t2_err > 0 assert crawler.spider.t2_err > crawler.spider.t1 @coroutine_test async def test_retry_503( self, caplog: pytest.LogCaptureFixture, mockserver: MockServer ) -> None: crawler = get_crawler(SimpleSpider) with caplog.at_level(logging.DEBUG): await crawler.crawl_async( mockserver.url("/status?n=503"), mockserver=mockserver ) self._assert_retried(caplog.text) @coroutine_test async def test_retry_conn_failed( self, caplog: pytest.LogCaptureFixture, mockserver: MockServer ) -> None: crawler = get_crawler(SimpleSpider) with caplog.at_level(logging.DEBUG): await crawler.crawl_async( "http://localhost:65432/status?n=503", mockserver=mockserver ) self._assert_retried(caplog.text) @coroutine_test async def test_retry_dns_error( self, caplog: pytest.LogCaptureFixture, mockserver: MockServer ) -> None: if NON_EXISTING_RESOLVABLE: pytest.skip("Non-existing hosts are resolvable") crawler = get_crawler(SimpleSpider) with caplog.at_level(logging.DEBUG): # try to fetch the homepage of a nonexistent domain await crawler.crawl_async( "http://dns.resolution.invalid./", mockserver=mockserver ) self._assert_retried(caplog.text) @coroutine_test async def test_start_bug_before_yield( self, caplog: pytest.LogCaptureFixture, mockserver: MockServer ) -> None: with caplog.at_level(logging.ERROR): crawler = get_crawler(BrokenStartSpider) await crawler.crawl_async(fail_before_yield=1, mockserver=mockserver) assert len(caplog.records) == 1 record = caplog.records[0] assert record.exc_info is not None assert record.exc_info[0] is ZeroDivisionError @coroutine_test async def test_start_bug_yielding( self, caplog: pytest.LogCaptureFixture, mockserver: MockServer ) -> None: with caplog.at_level(logging.ERROR): crawler = get_crawler(BrokenStartSpider) await crawler.crawl_async(fail_yielding=1, mockserver=mockserver) assert len(caplog.records) == 1 record = caplog.records[0] assert record.exc_info is not None assert record.exc_info[0] is ZeroDivisionError @coroutine_test async def test_start_items( self, caplog: pytest.LogCaptureFixture, mockserver: MockServer ) -> None: items = [] def _on_item_scraped(item): items.append(item) with caplog.at_level(logging.ERROR): crawler = get_crawler(StartItemSpider) crawler.signals.connect(_on_item_scraped, signals.item_scraped) await crawler.crawl_async(mockserver=mockserver) assert len(caplog.records) == 0 assert items == [{"name": "test item"}] @coroutine_test async def test_start_unsupported_output( self, caplog: pytest.LogCaptureFixture, mockserver: MockServer ) -> None: """Anything that is not a request is assumed to be an item, avoiding a potentially expensive call to itemadapter.is_item(), and letting instead things fail when ItemAdapter is actually used on the corresponding non-item object.""" items = [] def _on_item_scraped(item): items.append(item) with caplog.at_level(logging.ERROR): crawler = get_crawler(StartGoodAndBadOutput) crawler.signals.connect(_on_item_scraped, signals.item_scraped) await crawler.crawl_async(mockserver=mockserver) assert len(caplog.records) == 0 assert len(items) == 3 assert not any(isinstance(item, Request) for item in items) @coroutine_test async def test_start_dupes(self, mockserver: MockServer) -> None: settings = {"CONCURRENT_REQUESTS": 1} crawler = get_crawler(DuplicateStartSpider, settings) await crawler.crawl_async( dont_filter=True, distinct_urls=2, dupe_factor=3, mockserver=mockserver ) assert isinstance(crawler.spider, DuplicateStartSpider) assert crawler.spider.visited == 6 crawler = get_crawler(DuplicateStartSpider, settings) await crawler.crawl_async( dont_filter=False, distinct_urls=3, dupe_factor=4, mockserver=mockserver, ) assert isinstance(crawler.spider, DuplicateStartSpider) assert crawler.spider.visited == 3 @coroutine_test async def test_unbounded_response( self, caplog: pytest.LogCaptureFixture, mockserver: MockServer ) -> None: # Completeness of responses without Content-Length or Transfer-Encoding # can not be determined, we treat them as valid but flagged as "partial" query = urlencode( { "raw": """\ HTTP/1.1 200 OK Server: Apache-Coyote/1.1 X-Powered-By: Servlet 2.4; JBoss-4.2.3.GA (build: SVNTag=JBoss_4_2_3_GA date=200807181417)/JBossWeb-2.0 Set-Cookie: JSESSIONID=08515F572832D0E659FD2B0D8031D75F; Path=/ Pragma: no-cache Expires: Thu, 01 Jan 1970 00:00:00 GMT Cache-Control: no-cache Cache-Control: no-store Content-Type: text/html;charset=UTF-8 Content-Language: en Date: Tue, 27 Aug 2013 13:05:05 GMT Connection: close foo body with multiples lines """ } ) crawler = get_crawler(SimpleSpider) with caplog.at_level(logging.INFO): await crawler.crawl_async( mockserver.url(f"/raw?{query}"), mockserver=mockserver ) assert caplog.text.count("Got response 200") == 1 @coroutine_test async def test_retry_conn_lost( self, caplog: pytest.LogCaptureFixture, mockserver: MockServer ) -> None: # connection lost after receiving data crawler = get_crawler(SimpleSpider) with caplog.at_level(logging.DEBUG): await crawler.crawl_async( mockserver.url("/drop?abort=0"), mockserver=mockserver ) self._assert_retried(caplog.text) @coroutine_test async def test_retry_conn_aborted( self, caplog: pytest.LogCaptureFixture, mockserver: MockServer ) -> None: # connection lost before receiving data crawler = get_crawler(SimpleSpider) with caplog.at_level(logging.DEBUG): await crawler.crawl_async( mockserver.url("/drop?abort=1"), mockserver=mockserver ) self._assert_retried(caplog.text) @staticmethod def _assert_retried(log: str) -> None: assert str(log).count("Retrying") == 2 assert str(log).count("Gave up retrying") == 1 @coroutine_test async def test_referer_header(self, mockserver: MockServer) -> None: """Referer header is set by RefererMiddleware unless it is already set""" req0 = Request(mockserver.url("/echo?headers=1&body=0"), dont_filter=True) req1 = req0.replace() req2 = req0.replace(headers={"Referer": None}) req3 = req0.replace(headers={"Referer": "http://example.com"}) req0.meta["next"] = req1 req1.meta["next"] = req2 req2.meta["next"] = req3 crawler = get_crawler(SingleRequestSpider) await crawler.crawl_async(seed=req0, mockserver=mockserver) assert isinstance(crawler.spider, SingleRequestSpider) # basic asserts in case of weird communication errors assert "responses" in crawler.spider.meta assert "failures" not in crawler.spider.meta # start() doesn't set Referer header echo0 = json.loads(to_unicode(crawler.spider.meta["responses"][0].body)) assert "Referer" not in echo0["headers"] # following request sets Referer to the source request url echo1 = json.loads(to_unicode(crawler.spider.meta["responses"][1].body)) assert echo1["headers"].get("Referer") == [req0.url] # next request avoids Referer header echo2 = json.loads(to_unicode(crawler.spider.meta["responses"][2].body)) assert "Referer" not in echo2["headers"] # last request explicitly sets a Referer header echo3 = json.loads(to_unicode(crawler.spider.meta["responses"][3].body)) assert echo3["headers"].get("Referer") == ["http://example.com"] @coroutine_test async def test_engine_status(self, mockserver: MockServer) -> None: est: list[list[tuple[str, Any]]] = [] def cb(response): est.append(get_engine_status(crawler.engine)) crawler = get_crawler(SingleRequestSpider) await crawler.crawl_async( seed=mockserver.url("/"), callback_func=cb, mockserver=mockserver ) assert isinstance(crawler.spider, SingleRequestSpider) assert len(est) == 1, est s = dict(est[0]) assert s["engine.spider.name"] == crawler.spider.name assert s["len(engine.scraper.slot.active)"] == 1 @coroutine_test async def test_print_engine_status( self, mockserver: MockServer, capsys: pytest.CaptureFixture[str] ) -> None: def cb(response): assert crawler.engine print_engine_status(crawler.engine) crawler = get_crawler(SingleRequestSpider) await crawler.crawl_async( seed=mockserver.url("/"), callback_func=cb, mockserver=mockserver ) assert isinstance(crawler.spider, SingleRequestSpider) out = capsys.readouterr().out assert out.startswith("Execution engine status") assert re.search(rf"engine\.spider\.name +: {crawler.spider.name}\n", out) @coroutine_test async def test_format_engine_status(self, mockserver: MockServer) -> None: est: list[str] = [] def cb(response): est.append(format_engine_status(crawler.engine)) crawler = get_crawler(SingleRequestSpider) await crawler.crawl_async( seed=mockserver.url("/"), callback_func=cb, mockserver=mockserver ) assert isinstance(crawler.spider, SingleRequestSpider) assert len(est) == 1, est est = est[0].split("\n")[2:-2] # remove header & footer # convert to dict est_split = [x.split(":") for x in est] est = [x for sublist in est_split for x in sublist] # flatten est = [x.lstrip().rstrip() for x in est] it = iter(est) s = dict(zip(it, it, strict=True)) assert s["engine.spider.name"] == crawler.spider.name assert s["len(engine.scraper.slot.active)"] == "1" @coroutine_test async def test_open_spider_error_on_faulty_pipeline( self, mockserver: MockServer ) -> None: settings = { "ITEM_PIPELINES": { "tests.pipelines.ZeroDivisionErrorPipeline": 300, } } crawler = get_crawler(SimpleSpider, settings) with pytest.raises(ZeroDivisionError): await crawler.crawl_async( mockserver.url("/status?n=200"), mockserver=mockserver ) assert not crawler.crawling @coroutine_test async def test_open_spider_error_on_faulty_pipeline_crawl( self, mockserver: MockServer ) -> None: # cover the except block in Crawler.crawl() settings = { "ITEM_PIPELINES": { "tests.pipelines.ZeroDivisionErrorPipeline": 300, } } crawler = get_crawler(SimpleSpider, settings) with pytest.raises(ZeroDivisionError): await maybe_deferred_to_future( crawler.crawl(mockserver.url("/status?n=200"), mockserver=mockserver) ) assert not crawler.crawling @coroutine_test async def test_unknown_url_scheme(self, caplog: pytest.LogCaptureFixture) -> None: crawler = get_crawler(SimpleSpider) await crawler.crawl_async("foo://bar") assert "NotSupported: Unsupported URL scheme 'foo'" in caplog.text class TestCrawlSpider: @staticmethod async def _run_spider( spider_cls: type[Spider], mockserver: MockServer ) -> tuple[list[Any], StatsCollector]: items = [] def _on_item_scraped(item): items.append(item) crawler = get_crawler(spider_cls) crawler.signals.connect(_on_item_scraped, signals.item_scraped) await crawler.crawl_async( mockserver.url("/status?n=200"), mockserver=mockserver ) return items, crawler.stats @coroutine_test async def test_crawlspider_with_parse( self, caplog: pytest.LogCaptureFixture, mockserver: MockServer ) -> None: crawler = get_crawler(CrawlSpiderWithParseMethod) with caplog.at_level(logging.INFO): await crawler.crawl_async(mockserver=mockserver) assert "[parse] status 200 (foo: None)" in caplog.text assert "[parse] status 201 (foo: None)" in caplog.text assert "[parse] status 202 (foo: bar)" in caplog.text @coroutine_test async def test_crawlspider_with_async_callback( self, caplog: pytest.LogCaptureFixture, mockserver: MockServer ) -> None: crawler = get_crawler(CrawlSpiderWithAsyncCallback) with caplog.at_level(logging.INFO): await crawler.crawl_async(mockserver=mockserver) assert "[parse_async] status 200 (foo: None)" in caplog.text assert "[parse_async] status 201 (foo: None)" in caplog.text assert "[parse_async] status 202 (foo: bar)" in caplog.text @coroutine_test async def test_crawlspider_with_async_generator_callback( self, caplog: pytest.LogCaptureFixture, mockserver: MockServer ) -> None: crawler = get_crawler(CrawlSpiderWithAsyncGeneratorCallback) with caplog.at_level(logging.INFO): await crawler.crawl_async(mockserver=mockserver) assert "[parse_async_gen] status 200 (foo: None)" in caplog.text assert "[parse_async_gen] status 201 (foo: None)" in caplog.text assert "[parse_async_gen] status 202 (foo: bar)" in caplog.text @coroutine_test async def test_crawlspider_with_errback( self, caplog: pytest.LogCaptureFixture, mockserver: MockServer ) -> None: crawler = get_crawler(CrawlSpiderWithErrback) with caplog.at_level(logging.INFO): await crawler.crawl_async(mockserver=mockserver) assert "[parse] status 200 (foo: None)" in caplog.text assert "[parse] status 201 (foo: None)" in caplog.text assert "[parse] status 202 (foo: bar)" in caplog.text assert "[errback] status 404" in caplog.text assert "[errback] status 500" in caplog.text assert "[errback] status 501" in caplog.text @coroutine_test async def test_crawlspider_without_errback( self, caplog: pytest.LogCaptureFixture, mockserver: MockServer ) -> None: crawler = get_crawler(CrawlSpiderWithoutErrback) with caplog.at_level(logging.INFO): await crawler.crawl_async(mockserver=mockserver) # The failing request (404) is followed by a rule without an errback, # so the failure is dropped silently and the crawl finishes normally. assert "[parse] status 200 (foo: None)" in caplog.text assert "[errback]" not in caplog.text assert crawler.stats.get_value("downloader/response_status_count/404") == 1 @coroutine_test async def test_crawlspider_process_request_cb_kwargs( self, caplog: pytest.LogCaptureFixture, mockserver: MockServer ) -> None: crawler = get_crawler(CrawlSpiderWithProcessRequestCallbackKeywordArguments) with caplog.at_level(logging.INFO): await crawler.crawl_async(mockserver=mockserver) assert "[parse] status 200 (foo: process_request)" in caplog.text assert "[parse] status 201 (foo: process_request)" in caplog.text assert "[parse] status 202 (foo: bar)" in caplog.text @coroutine_test async def test_async_def_parse( self, caplog: pytest.LogCaptureFixture, mockserver: MockServer ) -> None: crawler = get_crawler(AsyncDefSpider) with caplog.at_level(logging.INFO): await crawler.crawl_async( mockserver.url("/status?n=200"), mockserver=mockserver ) assert "Got response 200" in caplog.text @pytest.mark.only_asyncio @coroutine_test async def test_async_def_asyncio_parse( self, caplog: pytest.LogCaptureFixture, mockserver: MockServer ) -> None: crawler = get_crawler( AsyncDefAsyncioSpider, { "TWISTED_REACTOR": "twisted.internet.asyncioreactor.AsyncioSelectorReactor" }, ) with caplog.at_level(logging.INFO): await crawler.crawl_async( mockserver.url("/status?n=200"), mockserver=mockserver ) assert "Got response 200" in caplog.text @pytest.mark.only_asyncio @coroutine_test async def test_async_def_asyncio_parse_items_list( self, caplog: pytest.LogCaptureFixture, mockserver: MockServer ) -> None: with caplog.at_level(logging.INFO): items, _ = await self._run_spider(AsyncDefAsyncioReturnSpider, mockserver) assert "Got response 200" in caplog.text assert {"id": 1} in items assert {"id": 2} in items @pytest.mark.only_asyncio @coroutine_test async def test_async_def_asyncio_parse_items_single_element( self, caplog: pytest.LogCaptureFixture, mockserver: MockServer ) -> None: items = [] def _on_item_scraped(item): items.append(item) crawler = get_crawler(AsyncDefAsyncioReturnSingleElementSpider) crawler.signals.connect(_on_item_scraped, signals.item_scraped) with caplog.at_level(logging.INFO): await crawler.crawl_async( mockserver.url("/status?n=200"), mockserver=mockserver ) assert "Got response 200" in caplog.text assert {"foo": 42} in items @pytest.mark.only_asyncio @coroutine_test async def test_async_def_asyncgen_parse( self, caplog: pytest.LogCaptureFixture, mockserver: MockServer ) -> None: with caplog.at_level(logging.INFO): _, stats = await self._run_spider(AsyncDefAsyncioGenSpider, mockserver) assert "Got response 200" in caplog.text itemcount = stats.get_value("item_scraped_count") assert itemcount == 1 @pytest.mark.only_asyncio @coroutine_test async def test_async_def_asyncgen_parse_loop( self, caplog: pytest.LogCaptureFixture, mockserver: MockServer ) -> None: with caplog.at_level(logging.INFO): items, stats = await self._run_spider( AsyncDefAsyncioGenLoopSpider, mockserver ) assert "Got response 200" in caplog.text itemcount = stats.get_value("item_scraped_count") assert itemcount == 10 for i in range(10): assert {"foo": i} in items @pytest.mark.only_asyncio @coroutine_test async def test_async_def_asyncgen_parse_exc( self, caplog: pytest.LogCaptureFixture, mockserver: MockServer ) -> None: with caplog.at_level(logging.INFO): items, stats = await self._run_spider( AsyncDefAsyncioGenExcSpider, mockserver ) assert "Spider error processing" in caplog.text assert "ValueError" in caplog.text itemcount = stats.get_value("item_scraped_count") assert itemcount == 7 for i in range(7): assert {"foo": i} in items @pytest.mark.only_asyncio @coroutine_test async def test_async_def_asyncgen_parse_complex( self, mockserver: MockServer ) -> None: items, stats = await self._run_spider( AsyncDefAsyncioGenComplexSpider, mockserver ) itemcount = stats.get_value("item_scraped_count") assert itemcount == 156 # some random items for i in [1, 4, 21, 22, 207, 311]: assert {"index": i} in items for i in [10, 30, 122]: assert {"index2": i} in items @pytest.mark.only_asyncio @coroutine_test async def test_async_def_asyncio_parse_reqs_list( self, caplog: pytest.LogCaptureFixture, mockserver: MockServer ) -> None: with caplog.at_level(logging.INFO): await self._run_spider(AsyncDefAsyncioReqsReturnSpider, mockserver) for req_id in range(3): assert f"Got response 200, req_id {req_id}" in caplog.text @pytest.mark.only_not_asyncio @coroutine_test async def test_async_def_deferred_direct(self, mockserver: MockServer) -> None: items, _ = await self._run_spider(AsyncDefDeferredDirectSpider, mockserver) assert items == [{"code": 200}] @pytest.mark.only_asyncio @coroutine_test async def test_async_def_deferred_wrapped(self, mockserver: MockServer) -> None: items, _ = await self._run_spider(AsyncDefDeferredWrappedSpider, mockserver) assert items == [{"code": 200}] @coroutine_test async def test_async_def_deferred_maybe_wrapped( self, mockserver: MockServer ) -> None: items, _ = await self._run_spider( AsyncDefDeferredMaybeWrappedSpider, mockserver ) assert items == [{"code": 200}] @coroutine_test async def test_response_ssl_certificate_none(self, mockserver: MockServer) -> None: crawler = get_crawler(SingleRequestSpider) url = mockserver.url("/echo?body=test", is_secure=False) await crawler.crawl_async(seed=url, mockserver=mockserver) assert isinstance(crawler.spider, SingleRequestSpider) assert crawler.spider.meta["responses"][0].certificate is None @pytest.mark.filterwarnings( r"ignore:.*You should use cryptography's X\.509 APIs:DeprecationWarning" ) @pytest.mark.parametrize("url", ["/echo?body=test", "/status?n=200"]) @coroutine_test async def test_response_ssl_certificate( self, mockserver: MockServer, url: str ) -> None: crawler = get_crawler(SingleRequestSpider) url = mockserver.url(url, is_secure=True) await crawler.crawl_async(seed=url, mockserver=mockserver) assert isinstance(crawler.spider, SingleRequestSpider) cert = crawler.spider.meta["responses"][0].certificate assert cert is not None if isinstance(cert, Certificate): # Twisted assert cert.getSubject().commonName == b"localhost" assert cert.getIssuer().commonName == b"localhost" elif isinstance(cert, bytes): # DER bytes cert_x509 = load_der_x509_certificate(cert) assert cert_x509.subject.rfc4514_string() == "CN=localhost,O=Scrapy,C=IE" assert cert_x509.issuer.rfc4514_string() == "CN=localhost,O=Scrapy,C=IE" @pytest.mark.parametrize("url", ["/echo?body=test", "/status?n=200"]) @coroutine_test async def test_response_ip_address(self, mockserver: MockServer, url: str) -> None: crawler = get_crawler(SingleRequestSpider) url = mockserver.url(url) expected_netloc, _ = urlparse(url).netloc.split(":") await crawler.crawl_async(seed=url, mockserver=mockserver) assert isinstance(crawler.spider, SingleRequestSpider) ip_address = crawler.spider.meta["responses"][0].ip_address assert isinstance(ip_address, IPv4Address) assert str(ip_address) == gethostbyname(expected_netloc) @coroutine_test async def test_bytes_received_stop_download_callback( self, mockserver: MockServer ) -> None: crawler = get_crawler(BytesReceivedCallbackSpider) await crawler.crawl_async(mockserver=mockserver) assert isinstance(crawler.spider, BytesReceivedCallbackSpider) assert crawler.spider.meta.get("failure") is None assert isinstance(crawler.spider.meta["response"], Response) assert crawler.spider.meta["response"].body == crawler.spider.meta.get( "bytes_received" ) assert ( len(crawler.spider.meta["response"].body) < crawler.spider.full_response_length ) @coroutine_test async def test_bytes_received_stop_download_errback( self, mockserver: MockServer ) -> None: crawler = get_crawler(BytesReceivedErrbackSpider) await crawler.crawl_async(mockserver=mockserver) assert isinstance(crawler.spider, BytesReceivedErrbackSpider) assert crawler.spider.meta.get("response") is None assert isinstance(crawler.spider.meta["failure"], Failure) assert isinstance(crawler.spider.meta["failure"].value, StopDownload) assert isinstance(crawler.spider.meta["failure"].value.response, Response) assert crawler.spider.meta[ "failure" ].value.response.body == crawler.spider.meta.get("bytes_received") assert ( len(crawler.spider.meta["failure"].value.response.body) < crawler.spider.full_response_length ) @coroutine_test async def test_headers_received_stop_download_callback( self, mockserver: MockServer ) -> None: crawler = get_crawler(HeadersReceivedCallbackSpider) await crawler.crawl_async(mockserver=mockserver) assert isinstance(crawler.spider, HeadersReceivedCallbackSpider) assert crawler.spider.meta.get("failure") is None assert isinstance(crawler.spider.meta["response"], Response) assert crawler.spider.meta["response"].headers == crawler.spider.meta.get( "headers_received" ) @coroutine_test async def test_headers_received_stop_download_errback( self, mockserver: MockServer ) -> None: crawler = get_crawler(HeadersReceivedErrbackSpider) await crawler.crawl_async(mockserver=mockserver) assert isinstance(crawler.spider, HeadersReceivedErrbackSpider) assert crawler.spider.meta.get("response") is None assert isinstance(crawler.spider.meta["failure"], Failure) assert isinstance(crawler.spider.meta["failure"].value, StopDownload) assert isinstance(crawler.spider.meta["failure"].value.response, Response) assert crawler.spider.meta[ "failure" ].value.response.headers == crawler.spider.meta.get("headers_received") @coroutine_test async def test_spider_callback_deferred_deprecated( self, mockserver: MockServer ) -> None: def cb(response: Response) -> Any: return succeed(None) crawler = get_crawler(SingleRequestSpider) with pytest.warns( ScrapyDeprecationWarning, match="Returning Deferreds from spider callbacks is deprecated", ): await crawler.crawl_async(seed=mockserver.url("/"), callback_func=cb) @coroutine_test async def test_spider_errback( self, caplog: pytest.LogCaptureFixture, mockserver: MockServer ) -> None: failures = [] def eb(failure: Failure) -> Failure: failures.append(failure) return failure crawler = get_crawler(SingleRequestSpider) with caplog.at_level(logging.INFO): await crawler.crawl_async( seed=mockserver.url("/status?n=400"), errback_func=eb ) assert len(failures) == 1 assert "HTTP status code is not handled or not allowed" in caplog.text assert "Spider error processing" not in caplog.text @coroutine_test async def test_spider_errback_silence( self, caplog: pytest.LogCaptureFixture, mockserver: MockServer ) -> None: failures = [] def eb(failure: Failure) -> None: failures.append(failure) crawler = get_crawler(SingleRequestSpider) with caplog.at_level(logging.INFO): await crawler.crawl_async( seed=mockserver.url("/status?n=400"), errback_func=eb ) assert len(failures) == 1 assert "HTTP status code is not handled or not allowed" not in caplog.text assert "Spider error processing" not in caplog.text @coroutine_test async def test_spider_errback_exception( self, caplog: pytest.LogCaptureFixture, mockserver: MockServer ) -> None: def eb(failure: Failure) -> None: raise ValueError("foo") crawler = get_crawler(SingleRequestSpider) with caplog.at_level(logging.INFO): await crawler.crawl_async( seed=mockserver.url("/status?n=400"), errback_func=eb ) assert "Spider error processing" in caplog.text @coroutine_test async def test_spider_errback_item( self, caplog: pytest.LogCaptureFixture, mockserver: MockServer ) -> None: def eb(failure: Failure) -> Any: return {"foo": "bar"} crawler = get_crawler(SingleRequestSpider) with caplog.at_level(logging.INFO): await crawler.crawl_async( seed=mockserver.url("/status?n=400"), errback_func=eb ) assert "HTTP status code is not handled or not allowed" not in caplog.text assert "Spider error processing" not in caplog.text assert "'item_scraped_count': 1" in caplog.text @coroutine_test async def test_spider_errback_request( self, caplog: pytest.LogCaptureFixture, mockserver: MockServer ) -> None: def eb(failure: Failure) -> Request: return Request(mockserver.url("/")) crawler = get_crawler(SingleRequestSpider) with caplog.at_level(logging.DEBUG): await crawler.crawl_async( seed=mockserver.url("/status?n=400"), errback_func=eb ) assert "HTTP status code is not handled or not allowed" not in caplog.text assert "Spider error processing" not in caplog.text assert "Crawled (200)" in caplog.text @coroutine_test async def test_spider_errback_downloader_error( self, caplog: pytest.LogCaptureFixture, mockserver: MockServer ) -> None: failures = [] def eb(failure: Failure) -> Failure: failures.append(failure) return failure crawler = get_crawler(SingleRequestSpider) with caplog.at_level(logging.INFO): await crawler.crawl_async( seed=mockserver.url("/drop?abort=1"), errback_func=eb ) assert len(failures) == 1 assert "Error downloading" in caplog.text assert "Spider error processing" not in caplog.text @coroutine_test async def test_spider_errback_downloader_error_exception( self, caplog: pytest.LogCaptureFixture, mockserver: MockServer ) -> None: def eb(failure: Failure) -> None: raise ValueError("foo") crawler = get_crawler(SingleRequestSpider) with caplog.at_level(logging.INFO): await crawler.crawl_async( seed=mockserver.url("/drop?abort=1"), errback_func=eb ) assert "Error downloading" in caplog.text assert "Spider error processing" in caplog.text @coroutine_test async def test_spider_errback_downloader_error_item( self, caplog: pytest.LogCaptureFixture, mockserver: MockServer ) -> None: def eb(failure: Failure) -> Any: return {"foo": "bar"} crawler = get_crawler(SingleRequestSpider) with caplog.at_level(logging.INFO): await crawler.crawl_async( seed=mockserver.url("/drop?abort=1"), errback_func=eb ) assert "HTTP status code is not handled or not allowed" not in caplog.text assert "Spider error processing" not in caplog.text assert "'item_scraped_count': 1" in caplog.text @coroutine_test async def test_spider_errback_downloader_error_request( self, caplog: pytest.LogCaptureFixture, mockserver: MockServer ) -> None: def eb(failure: Failure) -> Request: return Request(mockserver.url("/")) crawler = get_crawler(SingleRequestSpider) with caplog.at_level(logging.DEBUG): await crawler.crawl_async( seed=mockserver.url("/drop?abort=1"), errback_func=eb ) assert "HTTP status code is not handled or not allowed" not in caplog.text assert "Spider error processing" not in caplog.text assert "Crawled (200)" in caplog.text @coroutine_test async def test_spider_errback_deferred_deprecated( self, mockserver: MockServer ) -> None: def eb(failure: Failure) -> Any: return succeed(None) crawler = get_crawler(SingleRequestSpider) with pytest.warns( ScrapyDeprecationWarning, match="Returning Deferreds from spider errbacks is deprecated", ): await crawler.crawl_async( seed=mockserver.url("/status?n=400"), errback_func=eb ) @coroutine_test async def test_raise_closespider( self, caplog: pytest.LogCaptureFixture, mockserver: MockServer ) -> None: def cb(response): raise CloseSpider crawler = get_crawler(SingleRequestSpider) with caplog.at_level(logging.INFO): await crawler.crawl_async(seed=mockserver.url("/"), callback_func=cb) assert "Closing spider (cancelled)" in caplog.text assert "Spider error processing" not in caplog.text @coroutine_test async def test_raise_closespider_reason( self, caplog: pytest.LogCaptureFixture, mockserver: MockServer ) -> None: def cb(response): raise CloseSpider("my_reason") crawler = get_crawler(SingleRequestSpider) with caplog.at_level(logging.INFO): await crawler.crawl_async(seed=mockserver.url("/"), callback_func=cb) assert "Closing spider (my_reason)" in caplog.text assert "Spider error processing" not in caplog.text scrapy-scrapy-5569de3/tests/test_crawler.py000066400000000000000000000767671525051012400211140ustar00rootroot00000000000000from __future__ import annotations import asyncio import logging import re import signal import threading from pathlib import Path from typing import TYPE_CHECKING, Any, ClassVar from unittest.mock import MagicMock import pytest import scrapy from scrapy import Spider from scrapy.crawler import AsyncCrawlerProcess, Crawler, CrawlerProcess from scrapy.exceptions import ScrapyDeprecationWarning from scrapy.extensions.throttle import AutoThrottle from scrapy.settings import Settings, default_settings from scrapy.utils.defer import maybe_deferred_to_future from scrapy.utils.log import ( _uninstall_scrapy_root_handler, configure_logging, get_scrapy_root_handler, ) from scrapy.utils.spider import DefaultSpider from scrapy.utils.test import get_crawler, get_reactor_settings from tests.spiders import NoRequestsSpider from tests.utils import assert_option_is_default from tests.utils.decorators import coroutine_test if TYPE_CHECKING: from collections.abc import Callable BASE_SETTINGS: dict[str, Any] = {} def get_raw_crawler( spidercls: type[Spider] | None = None, settings_dict: dict[str, Any] | None = None ) -> Crawler: """get_crawler alternative that only calls the __init__ method of the crawler.""" settings = Settings() settings.setdict(get_reactor_settings()) settings.setdict(settings_dict or {}) return Crawler(spidercls or DefaultSpider, settings) class TestCrawler: def test_populate_spidercls_settings(self) -> None: spider_settings: dict[str, Any] = { "TEST1": "spider", "TEST2": "spider", } project_settings = { **BASE_SETTINGS, "TEST1": "project", "TEST3": "project", **get_reactor_settings(), } class CustomSettingsSpider(DefaultSpider): custom_settings = spider_settings settings = Settings() settings.setdict(project_settings, priority="project") crawler = Crawler(CustomSettingsSpider, settings) crawler._apply_settings() assert crawler.settings.get("TEST1") == "spider" assert crawler.settings.get("TEST2") == "spider" assert crawler.settings.get("TEST3") == "project" assert not settings.frozen assert crawler.settings.frozen @pytest.mark.parametrize( "attr", ["extensions", "logformatter", "request_fingerprinter", "stats"], ) def test_late_attr_before_apply_settings(self, attr: str) -> None: crawler = get_raw_crawler(DefaultSpider) with pytest.raises(RuntimeError, match=rf"Crawler\.{attr} is not set yet"): getattr(crawler, attr) crawler._apply_settings() assert getattr(crawler, attr) is not None @pytest.mark.parametrize( "attr", ["engine", "extensions", "logformatter", "request_fingerprinter", "stats"], ) def test_late_attr_on_class(self, attr: str) -> None: # Introspection tools such as help() read these off the class. assert getattr(Crawler, attr) is getattr(Crawler, attr) def test_late_attr_engine_before_crawl(self) -> None: crawler = get_raw_crawler(DefaultSpider) crawler._apply_settings() with pytest.raises(RuntimeError, match=r"Crawler\.engine is not set yet"): _ = crawler.engine @pytest.mark.parametrize( ("attr", "setting"), [ ("download_delay", "DOWNLOAD_DELAY"), ("max_concurrent_requests", "CONCURRENT_REQUESTS_PER_DOMAIN"), ], ) def test_deprecated_spider_attr(self, attr: str, setting: str) -> None: crawler = get_raw_crawler(type("_Spider", (DefaultSpider,), {attr: 2})) with pytest.warns( ScrapyDeprecationWarning, match=f"The {attr!r} spider attribute is deprecated. Use the {setting} ", ): crawler._apply_settings() assert crawler.settings.getint(setting) == 2 @pytest.mark.parametrize( ("attr", "setting"), [ ("download_delay", "DOWNLOAD_DELAY"), ("max_concurrent_requests", "CONCURRENT_REQUESTS_PER_DOMAIN"), ], ) def test_deprecated_spider_attr_ignored(self, attr: str, setting: str) -> None: crawler = get_raw_crawler(type("_Spider", (DefaultSpider,), {attr: 2})) crawler.settings.set(setting, 3, priority="spider") with pytest.warns( ScrapyDeprecationWarning, match=f"The {attr!r} spider attribute is deprecated. It is also being ", ): crawler._apply_settings() assert crawler.settings.getint(setting) == 3 def test_crawler_accepts_dict(self) -> None: crawler = get_crawler(DefaultSpider, {"foo": "bar"}) assert crawler.settings["foo"] == "bar" assert_option_is_default(crawler.settings, "RETRY_ENABLED") def test_crawler_accepts_None(self) -> None: crawler = Crawler(DefaultSpider) assert_option_is_default(crawler.settings, "RETRY_ENABLED") def test_crawler_rejects_spider_objects(self) -> None: with pytest.raises(ValueError, match="spidercls argument must be a class"): Crawler(DefaultSpider()) # type: ignore[arg-type] @coroutine_test async def test_crawler_crawl_twice_seq_unsupported(self) -> None: crawler = get_raw_crawler(NoRequestsSpider, BASE_SETTINGS) await maybe_deferred_to_future(crawler.crawl()) with pytest.raises(RuntimeError, match="more than once on the same instance"): await maybe_deferred_to_future(crawler.crawl()) @coroutine_test async def test_crawler_crawl_async_twice_seq_unsupported(self) -> None: crawler = get_raw_crawler(NoRequestsSpider, BASE_SETTINGS) await crawler.crawl_async() with pytest.raises(RuntimeError, match="more than once on the same instance"): await crawler.crawl_async() @coroutine_test async def test_crawler_crawl_twice_parallel_unsupported(self) -> None: crawler = get_raw_crawler(NoRequestsSpider, BASE_SETTINGS) d1 = crawler.crawl() d2 = crawler.crawl() await maybe_deferred_to_future(d1) with pytest.raises(RuntimeError, match="Crawling already taking place"): await maybe_deferred_to_future(d2) @pytest.mark.only_asyncio @coroutine_test async def test_crawler_crawl_async_twice_parallel_unsupported(self) -> None: crawler = get_raw_crawler(NoRequestsSpider, BASE_SETTINGS) t1 = asyncio.create_task(crawler.crawl_async()) t2 = asyncio.create_task(crawler.crawl_async()) await t1 with pytest.raises(RuntimeError, match="Crawling already taking place"): await t2 def test_get_addon(self) -> None: class ParentAddon: pass class TrackingAddon(ParentAddon): instances: ClassVar[list[TrackingAddon]] = [] def __init__(self): TrackingAddon.instances.append(self) def update_settings(self, settings): pass settings = { **BASE_SETTINGS, "ADDONS": { TrackingAddon: 0, }, } crawler = get_crawler(settings_dict=settings) assert len(TrackingAddon.instances) == 1 expected = TrackingAddon.instances[-1] addon = crawler.get_addon(TrackingAddon) assert addon == expected addon = crawler.get_addon(DefaultSpider) # type: ignore[assignment] assert addon is None addon = crawler.get_addon(ParentAddon) assert addon == expected class ChildAddon(TrackingAddon): pass addon = crawler.get_addon(ChildAddon) assert addon is None @coroutine_test async def test_get_downloader_middleware(self) -> None: class ParentDownloaderMiddleware: pass class TrackingDownloaderMiddleware(ParentDownloaderMiddleware): instances: ClassVar[list[TrackingDownloaderMiddleware]] = [] def __init__(self): TrackingDownloaderMiddleware.instances.append(self) class MySpider(Spider): name = "myspider" cls: ClassVar[type[Any]] result: ClassVar[Any] @classmethod def from_crawler(cls, crawler): return cls(crawler=crawler) def __init__(self, crawler, **kwargs: Any): super().__init__(**kwargs) self.crawler = crawler async def start(self): MySpider.result = crawler.get_downloader_middleware(MySpider.cls) return yield settings = { **BASE_SETTINGS, "DOWNLOADER_MIDDLEWARES": { TrackingDownloaderMiddleware: 0, }, } crawler = get_raw_crawler(MySpider, settings) MySpider.cls = TrackingDownloaderMiddleware await crawler.crawl_async() assert len(TrackingDownloaderMiddleware.instances) == 1 assert MySpider.result == TrackingDownloaderMiddleware.instances[-1] crawler = get_raw_crawler(MySpider, settings) MySpider.cls = DefaultSpider await crawler.crawl_async() assert MySpider.result is None crawler = get_raw_crawler(MySpider, settings) MySpider.cls = ParentDownloaderMiddleware await crawler.crawl_async() assert MySpider.result == TrackingDownloaderMiddleware.instances[-1] class ChildDownloaderMiddleware(TrackingDownloaderMiddleware): pass crawler = get_raw_crawler(MySpider, settings) MySpider.cls = ChildDownloaderMiddleware await crawler.crawl_async() assert MySpider.result is None def test_get_downloader_middleware_not_crawling(self) -> None: crawler = get_raw_crawler(settings_dict=BASE_SETTINGS) with pytest.raises(RuntimeError): crawler.get_downloader_middleware(DefaultSpider) @coroutine_test async def test_get_downloader_middleware_no_engine(self) -> None: class MySpider(Spider): name = "myspider" @classmethod def from_crawler(cls, crawler): crawler.get_downloader_middleware(DefaultSpider) crawler = get_raw_crawler(MySpider, BASE_SETTINGS) with pytest.raises(RuntimeError): await crawler.crawl_async() @coroutine_test async def test_get_extension(self) -> None: class ParentExtension: pass class TrackingExtension(ParentExtension): instances: ClassVar[list[TrackingExtension]] = [] def __init__(self): TrackingExtension.instances.append(self) class MySpider(Spider): name = "myspider" cls: ClassVar[type[Any]] result: ClassVar[Any] @classmethod def from_crawler(cls, crawler): return cls(crawler=crawler) def __init__(self, crawler, **kwargs: Any): super().__init__(**kwargs) self.crawler = crawler async def start(self): MySpider.result = crawler.get_extension(MySpider.cls) return yield settings = { **BASE_SETTINGS, "EXTENSIONS": { TrackingExtension: 0, }, } crawler = get_raw_crawler(MySpider, settings) MySpider.cls = TrackingExtension await crawler.crawl_async() assert len(TrackingExtension.instances) == 1 assert MySpider.result == TrackingExtension.instances[-1] crawler = get_raw_crawler(MySpider, settings) MySpider.cls = DefaultSpider await crawler.crawl_async() assert MySpider.result is None crawler = get_raw_crawler(MySpider, settings) MySpider.cls = ParentExtension await crawler.crawl_async() assert MySpider.result == TrackingExtension.instances[-1] class ChildExtension(TrackingExtension): pass crawler = get_raw_crawler(MySpider, settings) MySpider.cls = ChildExtension await crawler.crawl_async() assert MySpider.result is None def test_get_extension_not_crawling(self) -> None: crawler = get_raw_crawler(settings_dict=BASE_SETTINGS) with pytest.raises(RuntimeError): crawler.get_extension(DefaultSpider) @coroutine_test async def test_get_extension_no_engine(self) -> None: class MySpider(Spider): name = "myspider" @classmethod def from_crawler(cls, crawler): crawler.get_extension(DefaultSpider) crawler = get_raw_crawler(MySpider, BASE_SETTINGS) with pytest.raises(RuntimeError): await crawler.crawl_async() @coroutine_test async def test_get_item_pipeline(self) -> None: class ParentItemPipeline: pass class TrackingItemPipeline(ParentItemPipeline): instances: ClassVar[list[TrackingItemPipeline]] = [] def __init__(self): TrackingItemPipeline.instances.append(self) class MySpider(Spider): name = "myspider" cls: ClassVar[type[Any]] result: ClassVar[Any] @classmethod def from_crawler(cls, crawler): return cls(crawler=crawler) def __init__(self, crawler, **kwargs: Any): super().__init__(**kwargs) self.crawler = crawler async def start(self): MySpider.result = crawler.get_item_pipeline(MySpider.cls) return yield settings = { **BASE_SETTINGS, "ITEM_PIPELINES": { TrackingItemPipeline: 0, }, } crawler = get_raw_crawler(MySpider, settings) MySpider.cls = TrackingItemPipeline await crawler.crawl_async() assert len(TrackingItemPipeline.instances) == 1 assert MySpider.result == TrackingItemPipeline.instances[-1] crawler = get_raw_crawler(MySpider, settings) MySpider.cls = DefaultSpider await crawler.crawl_async() assert MySpider.result is None crawler = get_raw_crawler(MySpider, settings) MySpider.cls = ParentItemPipeline await crawler.crawl_async() assert MySpider.result == TrackingItemPipeline.instances[-1] class ChildItemPipeline(TrackingItemPipeline): pass crawler = get_raw_crawler(MySpider, settings) MySpider.cls = ChildItemPipeline await crawler.crawl_async() assert MySpider.result is None def test_get_item_pipeline_not_crawling(self) -> None: crawler = get_raw_crawler(settings_dict=BASE_SETTINGS) with pytest.raises(RuntimeError): crawler.get_item_pipeline(DefaultSpider) @coroutine_test async def test_get_item_pipeline_no_engine(self) -> None: class MySpider(Spider): name = "myspider" @classmethod def from_crawler(cls, crawler): crawler.get_item_pipeline(DefaultSpider) crawler = get_raw_crawler(MySpider, BASE_SETTINGS) with pytest.raises(RuntimeError): await crawler.crawl_async() @coroutine_test async def test_get_spider_middleware(self) -> None: class ParentSpiderMiddleware: pass class TrackingSpiderMiddleware(ParentSpiderMiddleware): instances: ClassVar[list[TrackingSpiderMiddleware]] = [] def __init__(self): TrackingSpiderMiddleware.instances.append(self) class MySpider(Spider): name = "myspider" cls: ClassVar[type[Any]] result: ClassVar[Any] @classmethod def from_crawler(cls, crawler): return cls(crawler=crawler) def __init__(self, crawler, **kwargs: Any): super().__init__(**kwargs) self.crawler = crawler async def start(self): MySpider.result = crawler.get_spider_middleware(MySpider.cls) return yield settings = { **BASE_SETTINGS, "SPIDER_MIDDLEWARES": { TrackingSpiderMiddleware: 0, }, } crawler = get_raw_crawler(MySpider, settings) MySpider.cls = TrackingSpiderMiddleware await crawler.crawl_async() assert len(TrackingSpiderMiddleware.instances) == 1 assert MySpider.result == TrackingSpiderMiddleware.instances[-1] crawler = get_raw_crawler(MySpider, settings) MySpider.cls = DefaultSpider await crawler.crawl_async() assert MySpider.result is None crawler = get_raw_crawler(MySpider, settings) MySpider.cls = ParentSpiderMiddleware await crawler.crawl_async() assert MySpider.result == TrackingSpiderMiddleware.instances[-1] class ChildSpiderMiddleware(TrackingSpiderMiddleware): pass crawler = get_raw_crawler(MySpider, settings) MySpider.cls = ChildSpiderMiddleware await crawler.crawl_async() assert MySpider.result is None def test_get_spider_middleware_not_crawling(self) -> None: crawler = get_raw_crawler(settings_dict=BASE_SETTINGS) with pytest.raises(RuntimeError): crawler.get_spider_middleware(DefaultSpider) @coroutine_test async def test_get_spider_middleware_no_engine(self) -> None: class MySpider(Spider): name = "myspider" @classmethod def from_crawler(cls, crawler): crawler.get_spider_middleware(DefaultSpider) crawler = get_raw_crawler(MySpider, BASE_SETTINGS) with pytest.raises(RuntimeError): await crawler.crawl_async() class TestSpiderSettings: def test_spider_custom_settings(self) -> None: class MySpider(scrapy.Spider): name = "spider" custom_settings = {"AUTOTHROTTLE_ENABLED": True} crawler = get_crawler(MySpider) enabled_exts = [e.__class__ for e in crawler.extensions.middlewares] assert AutoThrottle in enabled_exts class TestCrawlerLogging: def test_no_root_handler_installed(self) -> None: handler = get_scrapy_root_handler() if handler is not None: logging.root.removeHandler(handler) class MySpider(scrapy.Spider): name = "spider" get_crawler(MySpider) assert get_scrapy_root_handler() is None @coroutine_test async def test_spider_custom_settings_log_level(self, tmp_path: Path) -> None: log_file = Path(tmp_path, "log.txt") log_file.write_text("previous message\n", encoding="utf-8") info_count = None class MySpider(scrapy.Spider): name = "spider" custom_settings = { "LOG_LEVEL": "INFO", "LOG_FILE": str(log_file), } async def start(self): info_count_start = crawler.stats.get_value("log_count/INFO") logging.debug("debug message") # noqa: LOG015 logging.info("info message") # noqa: LOG015 logging.warning("warning message") # noqa: LOG015 logging.error("error message") # noqa: LOG015 nonlocal info_count info_count = ( crawler.stats.get_value("log_count/INFO") - info_count_start ) return yield try: configure_logging() handler = get_scrapy_root_handler() assert handler is not None assert handler.level == logging.DEBUG crawler = get_crawler(MySpider) handler = get_scrapy_root_handler() assert handler is not None assert handler.level == logging.INFO await crawler.crawl_async() finally: _uninstall_scrapy_root_handler() logged = log_file.read_text(encoding="utf-8") assert "previous message" in logged assert "debug message" not in logged assert "info message" in logged assert "warning message" in logged assert "error message" in logged assert crawler.stats.get_value("log_count/ERROR") == 1 assert crawler.stats.get_value("log_count/WARNING") == 1 assert info_count == 1 assert crawler.stats.get_value("log_count/DEBUG", 0) == 0 def test_spider_custom_settings_log_append(self, tmp_path: Path) -> None: log_file = Path(tmp_path, "log.txt") log_file.write_text("previous message\n", encoding="utf-8") class MySpider(scrapy.Spider): name = "spider" custom_settings = { "LOG_FILE": str(log_file), "LOG_FILE_APPEND": False, } try: configure_logging() get_crawler(MySpider) logging.debug("debug message") # noqa: LOG015 finally: _uninstall_scrapy_root_handler() logged = log_file.read_text(encoding="utf-8") assert "previous message" not in logged assert "debug message" in logged class TestAsyncCrawlerProcessReactorlessHelpers: """Unit tests for the reactorless shutdown helpers of AsyncCrawlerProcess. These cover defensive branches that guard against shutdown races and that are not reachable through a full process run. """ @staticmethod def _bare_process( monkeypatch: pytest.MonkeyPatch, ) -> tuple[AsyncCrawlerProcess, list[Any]]: # AsyncCrawlerProcess.__init__ has global side effects (it installs a # reactor import hook and an asyncio event loop), so build a bare # instance and set only the attributes these helpers read. The shutdown # handlers installed by these helpers are recorded for assertions # instead of touching the real process-wide signal handlers. installed_handlers: list[Any] = [] monkeypatch.setattr( "scrapy.crawler.install_shutdown_handlers", lambda handler, *args, **kwargs: installed_handlers.append(handler), ) return AsyncCrawlerProcess.__new__(AsyncCrawlerProcess), installed_handlers @staticmethod def _run_in_thread(target: Callable[[], None]) -> None: # Run target in a dedicated thread so its event loop is not nested # inside the event loop that may already be running the test session. thread = threading.Thread(target=target) thread.start() thread.join() def test_signal_shutdown_reactorless_without_loop( self, monkeypatch: pytest.MonkeyPatch ) -> None: process, installed_handlers = self._bare_process(monkeypatch) process._reactorless_loop = None # No loop to schedule the shutdown task on, so it returns early, but it # must still escalate the handler so a second signal forces a kill. process._signal_shutdown_reactorless(signal.SIGINT, None) assert installed_handlers == [process._signal_kill_reactorless] def test_signal_kill_reactorless_without_loop( self, monkeypatch: pytest.MonkeyPatch ) -> None: process, installed_handlers = self._bare_process(monkeypatch) process._reactorless_loop = None process._reactorless_main_task = None # No loop to cancel the main task on, so it returns early, but it must # still ignore any further signals. process._signal_kill_reactorless(signal.SIGINT, None) assert installed_handlers == [signal.SIG_IGN] def test_signal_kill_reactorless_without_main_task( self, monkeypatch: pytest.MonkeyPatch ) -> None: process, installed_handlers = self._bare_process(monkeypatch) loop = MagicMock() process._reactorless_loop = loop process._reactorless_main_task = None # No main task to cancel, only the log message is scheduled. process._signal_kill_reactorless(signal.SIGINT, None) assert installed_handlers == [signal.SIG_IGN] loop.call_soon_threadsafe.assert_called_once_with( process._log_kill, signal.SIGINT ) def test_signal_shutdown_reactorless_does_not_log_inline( self, monkeypatch: pytest.MonkeyPatch ) -> None: process, _ = self._bare_process(monkeypatch) loop = MagicMock() process._reactorless_loop = loop log_calls: list[int] = [] monkeypatch.setattr(process, "_log_shutdown", log_calls.append) # A signal can land while the interrupted code is itself writing to # the log stream, so logging from the handler would reenter that # write; it must be scheduled on the loop instead of called directly. process._signal_shutdown_reactorless(signal.SIGINT, None) assert log_calls == [] loop.call_soon_threadsafe.assert_any_call(process._log_shutdown, signal.SIGINT) def test_signal_kill_reactorless_does_not_log_inline( self, monkeypatch: pytest.MonkeyPatch ) -> None: process, _ = self._bare_process(monkeypatch) loop = MagicMock() process._reactorless_loop = loop process._reactorless_main_task = MagicMock() log_calls: list[int] = [] monkeypatch.setattr(process, "_log_kill", log_calls.append) process._signal_kill_reactorless(signal.SIGINT, None) assert log_calls == [] loop.call_soon_threadsafe.assert_any_call(process._log_kill, signal.SIGINT) def test_shutdown_graceful_reactorless_main_task_already_done( self, monkeypatch: pytest.MonkeyPatch ) -> None: process, _ = self._bare_process(monkeypatch) process._stop_after_crawl = False async def noop() -> None: return None monkeypatch.setattr(process, "stop", noop) monkeypatch.setattr(process, "join", noop) def run() -> None: loop = asyncio.new_event_loop() try: main_task: asyncio.Future[None] = loop.create_future() main_task.set_result(None) process._reactorless_main_task = main_task # The main task is already done, so it is not cancelled. loop.run_until_complete(process._shutdown_graceful_reactorless()) assert not main_task.cancelled() finally: loop.close() self._run_in_thread(run) def test_create_shutdown_task_closed_loop( self, monkeypatch: pytest.MonkeyPatch ) -> None: process, _ = self._bare_process(monkeypatch) loop = asyncio.new_event_loop() loop.close() process._reactorless_loop = loop process._stop_after_crawl = True # create_task() raises RuntimeError on a closed loop; the coroutine # must be closed instead of leaking. process._create_shutdown_task() def test_cancel_all_tasks_logs_task_exception(self) -> None: contexts: list[dict[str, Any]] = [] task_was_cancelled: list[bool] = [] def run() -> None: loop = asyncio.new_event_loop() loop.set_exception_handler(lambda _loop, context: contexts.append(context)) async def fail_on_cancel() -> None: try: await asyncio.sleep(10) except asyncio.CancelledError: raise RuntimeError("boom") try: task = loop.create_task(fail_on_cancel()) # Let the task start and suspend on the sleep so the # cancellation is raised inside its body and turned into a # RuntimeError rather than cancelling the task cleanly. loop.run_until_complete(asyncio.sleep(0)) AsyncCrawlerProcess._cancel_all_tasks(loop) task_was_cancelled.append(task.cancelled()) finally: loop.close() self._run_in_thread(run) # The task raised instead of being cancelled, so its exception is # reported to the loop exception handler. assert task_was_cancelled == [False] assert any( context.get("message") == "unhandled exception during AsyncCrawlerProcess shutdown" for context in contexts ) class TestCrawlerProcessBaseSignalHandlers: """Unit tests for the Twisted-reactor shutdown handlers shared by CrawlerProcess and AsyncCrawlerProcess. """ @staticmethod def _bare_process( monkeypatch: pytest.MonkeyPatch, ) -> tuple[CrawlerProcess, list[Any]]: installed_handlers: list[Any] = [] monkeypatch.setattr( "scrapy.crawler.install_shutdown_handlers", lambda handler, *args, **kwargs: installed_handlers.append(handler), ) return CrawlerProcess.__new__(CrawlerProcess), installed_handlers def test_signal_shutdown_does_not_log_inline( self, monkeypatch: pytest.MonkeyPatch ) -> None: process, _ = self._bare_process(monkeypatch) reactor = MagicMock() # No reactor may be installed yet in the test process, so # twisted.internet may not have a "reactor" attribute to patch over. monkeypatch.setattr("twisted.internet.reactor", reactor, raising=False) log_calls: list[int] = [] monkeypatch.setattr(process, "_log_shutdown", log_calls.append) # A signal can land while the interrupted code is itself writing to # the log stream, so logging from the handler would reenter that # write and raise, aborting the handler before it schedules the # graceful stop; it must be scheduled on the reactor instead. process._signal_shutdown(signal.SIGINT, None) assert log_calls == [] reactor.callFromThread.assert_any_call(process._log_shutdown, signal.SIGINT) reactor.callFromThread.assert_any_call(process._graceful_stop_reactor) def test_signal_kill_does_not_log_inline( self, monkeypatch: pytest.MonkeyPatch ) -> None: process, _ = self._bare_process(monkeypatch) reactor = MagicMock() monkeypatch.setattr("twisted.internet.reactor", reactor, raising=False) log_calls: list[int] = [] monkeypatch.setattr(process, "_log_kill", log_calls.append) process._signal_kill(signal.SIGINT, None) assert log_calls == [] reactor.callFromThread.assert_any_call(process._log_kill, signal.SIGINT) reactor.callFromThread.assert_any_call(process._stop_reactor) @pytest.mark.parametrize( ("settings", "items"), [ ({}, default_settings.LOG_VERSIONS), ({"LOG_VERSIONS": ["itemadapter"]}, ["itemadapter"]), ({"LOG_VERSIONS": []}, None), ], ) def test_log_scrapy_info( settings: dict[str, Any], items: list[str] | None, caplog: pytest.LogCaptureFixture ) -> None: with caplog.at_level("INFO"): CrawlerProcess({**settings, "LOG_INSTALL_ROOT_HANDLER": False}) assert ( caplog.records[0].getMessage() == f"Scrapy {scrapy.__version__} started (bot: scrapybot)" ), repr(caplog.records[0].msg) if not items: assert len(caplog.records) == 1 return version_string = caplog.records[1].getMessage() expected_items_pattern = "',\n '".join( f"{item}': '[^']+('\n +'[^']+)*" for item in items ) assert re.search(r"^Versions:\n{'" + expected_items_pattern + "'}$", version_string) @coroutine_test async def test_deprecated_crawler_stop() -> None: crawler = get_crawler(DefaultSpider) d = crawler.crawl() await maybe_deferred_to_future(d) with pytest.warns( ScrapyDeprecationWarning, match=r"Crawler.stop\(\) is deprecated" ): await maybe_deferred_to_future(crawler.stop()) scrapy-scrapy-5569de3/tests/test_crawler_runners.py000066400000000000000000000215331525051012400226450ustar00rootroot00000000000000from __future__ import annotations import logging from typing import TYPE_CHECKING import pytest from scrapy.crawler import ( AsyncCrawlerProcess, AsyncCrawlerRunner, Crawler, CrawlerProcess, CrawlerRunner, CrawlerRunnerBase, ) from scrapy.exceptions import ScrapyDeprecationWarning from scrapy.utils.defer import ensure_awaitable, maybe_deferred_to_future from scrapy.utils.spider import DefaultSpider from scrapy.utils.test import get_reactor_settings from tests.spiders import ExceptionSpider, NoRequestsSpider, SimpleSpider from tests.utils import assert_option_is_default from tests.utils.decorators import coroutine_test if TYPE_CHECKING: from scrapy import Spider from tests.mockserver.http import MockServer class SpiderLoaderWithWrongInterface: def unneeded_method(self) -> None: pass class TestCrawlerRunner: def test_crawler_runner_accepts_dict(self) -> None: runner = CrawlerRunner({"foo": "bar"}) assert runner.settings["foo"] == "bar" assert_option_is_default(runner.settings, "RETRY_ENABLED") def test_crawler_runner_accepts_None(self) -> None: runner = CrawlerRunner() assert_option_is_default(runner.settings, "RETRY_ENABLED") class TestAsyncCrawlerRunner: def test_crawler_runner_accepts_dict(self) -> None: runner = AsyncCrawlerRunner({"foo": "bar"}) assert runner.settings["foo"] == "bar" assert_option_is_default(runner.settings, "RETRY_ENABLED") def test_crawler_runner_accepts_None(self) -> None: runner = AsyncCrawlerRunner() assert_option_is_default(runner.settings, "RETRY_ENABLED") class TestCrawlerProcess: def test_crawler_process_accepts_dict(self) -> None: runner = CrawlerProcess({"foo": "bar", "LOG_INSTALL_ROOT_HANDLER": False}) assert runner.settings["foo"] == "bar" assert_option_is_default(runner.settings, "RETRY_ENABLED") def test_crawler_process_accepts_None(self) -> None: with pytest.warns(ScrapyDeprecationWarning, match="install_root_handler"): runner = CrawlerProcess(install_root_handler=False) assert_option_is_default(runner.settings, "RETRY_ENABLED") @pytest.mark.only_asyncio class TestAsyncCrawlerProcess: def test_crawler_process_accepts_dict(self, reactor_pytest: str) -> None: runner = AsyncCrawlerProcess( { "foo": "bar", "TWISTED_REACTOR_ENABLED": reactor_pytest != "none", "LOG_INSTALL_ROOT_HANDLER": False, } ) assert runner.settings["foo"] == "bar" assert_option_is_default(runner.settings, "RETRY_ENABLED") @pytest.mark.requires_reactor # can't pass TWISTED_REACTOR_ENABLED=False def test_crawler_process_accepts_None(self) -> None: with pytest.warns(ScrapyDeprecationWarning, match="install_root_handler"): runner = AsyncCrawlerProcess(install_root_handler=False) assert_option_is_default(runner.settings, "RETRY_ENABLED") @pytest.mark.requires_reactor # CrawlerRunner requires a reactor class TestCrawlerRunnerHasSpider: @pytest.fixture def runner(self) -> CrawlerRunnerBase: return CrawlerRunner(get_reactor_settings()) @staticmethod async def _crawl(runner: CrawlerRunnerBase, spider: type[Spider]) -> None: await ensure_awaitable(runner.crawl(spider)) @coroutine_test async def test_crawler_runner_bootstrap_successful( self, runner: CrawlerRunnerBase ) -> None: await self._crawl(runner, NoRequestsSpider) assert not runner.bootstrap_failed @coroutine_test async def test_crawler_runner_bootstrap_successful_for_several( self, runner: CrawlerRunnerBase ) -> None: await self._crawl(runner, NoRequestsSpider) await self._crawl(runner, NoRequestsSpider) assert not runner.bootstrap_failed @coroutine_test async def test_crawler_runner_bootstrap_failed( self, runner: CrawlerRunnerBase ) -> None: try: await self._crawl(runner, ExceptionSpider) except ValueError: pass else: pytest.fail("Exception should be raised from spider") assert runner.bootstrap_failed @coroutine_test async def test_crawler_runner_bootstrap_failed_for_several( self, runner: CrawlerRunnerBase ) -> None: try: await self._crawl(runner, ExceptionSpider) except ValueError: pass else: pytest.fail("Exception should be raised from spider") await self._crawl(runner, NoRequestsSpider) assert runner.bootstrap_failed @coroutine_test async def test_crawler_runner_asyncio_enabled_true( self, reactor_pytest: str ) -> None: if reactor_pytest != "asyncio": runner = CrawlerRunner( settings={ "TWISTED_REACTOR": "twisted.internet.asyncioreactor.AsyncioSelectorReactor", } ) with pytest.raises( Exception, match=r"The installed reactor \(.*?\) does not match the requested one \(.*?\)", ): await self._crawl(runner, NoRequestsSpider) else: runner = CrawlerRunner( settings={ "TWISTED_REACTOR": "twisted.internet.asyncioreactor.AsyncioSelectorReactor", } ) await self._crawl(runner, NoRequestsSpider) @pytest.mark.only_asyncio class TestAsyncCrawlerRunnerHasSpider(TestCrawlerRunnerHasSpider): @pytest.fixture def runner(self) -> CrawlerRunnerBase: return AsyncCrawlerRunner(get_reactor_settings()) def test_crawler_runner_asyncio_enabled_true(self) -> None: # type: ignore[override] pytest.skip("This test is only for CrawlerRunner") @pytest.mark.parametrize("runner_cls", [AsyncCrawlerRunner, CrawlerRunner]) def test_runner_settings_applied_to_crawler_instance( runner_cls: type[CrawlerRunnerBase], ) -> None: runner = runner_cls({"FOO": "runner"}) crawler = Crawler(DefaultSpider) result = runner.create_crawler(crawler) assert result is crawler assert result.settings["FOO"] == "runner" @pytest.mark.parametrize("runner_cls", [AsyncCrawlerRunner, CrawlerRunner]) def test_spider_custom_settings_override_runner( runner_cls: type[CrawlerRunnerBase], ) -> None: class MySpider(DefaultSpider): custom_settings = {"FOO": "spider"} runner = runner_cls({"FOO": "runner"}) crawler = Crawler(MySpider) runner.create_crawler(crawler) assert crawler.settings["FOO"] == "spider" def test_create_crawler_instance_consistent_with_spider_class() -> None: runner = AsyncCrawlerRunner({"FOO": "runner"}) crawler_from_class = runner.create_crawler(DefaultSpider) pre_built = Crawler(DefaultSpider) runner.create_crawler(pre_built) assert crawler_from_class.settings["FOO"] == "runner" assert pre_built.settings["FOO"] == "runner" @pytest.mark.parametrize("runner_cls", [AsyncCrawlerRunner, CrawlerRunner]) def test_create_crawler_rejects_spider_object( runner_cls: type[CrawlerRunnerBase], ) -> None: runner = runner_cls() with pytest.raises(ValueError, match="cannot be a spider object"): runner.create_crawler(DefaultSpider()) # type: ignore[arg-type] @pytest.mark.parametrize("runner_cls", [AsyncCrawlerRunner, CrawlerRunner]) def test_crawl_rejects_spider_object(runner_cls: type[CrawlerRunnerBase]) -> None: runner = runner_cls() with pytest.raises(ValueError, match="cannot be a spider object"): runner.crawl(DefaultSpider()) # type: ignore[arg-type] @coroutine_test async def test_crawlerrunner_accepts_crawler( caplog: pytest.LogCaptureFixture, mockserver: MockServer ) -> None: crawler = Crawler(SimpleSpider, get_reactor_settings()) runner = CrawlerRunner() with caplog.at_level(logging.DEBUG): await maybe_deferred_to_future( runner.crawl( crawler, mockserver.url("/status?n=200"), mockserver=mockserver, ) ) assert "Got response 200" in caplog.text @coroutine_test async def test_crawl_multiple( caplog: pytest.LogCaptureFixture, mockserver: MockServer ) -> None: settings_dict = get_reactor_settings() runner_cls = ( CrawlerRunner if settings_dict.get("TWISTED_REACTOR_ENABLED", True) else AsyncCrawlerRunner ) runner = runner_cls(settings_dict) runner.crawl( SimpleSpider, mockserver.url("/status?n=200"), mockserver=mockserver, ) runner.crawl( SimpleSpider, mockserver.url("/status?n=503"), mockserver=mockserver, ) with caplog.at_level(logging.DEBUG): await ensure_awaitable(runner.join()) assert "Got response 200" in caplog.text assert "Gave up retrying" in caplog.text scrapy-scrapy-5569de3/tests/test_crawler_subprocess.py000066400000000000000000000617751525051012400233550ustar00rootroot00000000000000from __future__ import annotations import platform import re import signal import subprocess import sys from abc import ABC, abstractmethod from pathlib import Path from typing import TYPE_CHECKING import pytest from pexpect.popen_spawn import PopenSpawn from scrapy.utils.asyncio import sleep from tests.utils import get_script_run_env from tests.utils.cmdline import stop_spawn from tests.utils.decorators import coroutine_test if TYPE_CHECKING: from tests.mockserver.http import MockServer # Guards against a hung subprocess. Generous, because starting a script is # slow on PyPy, slower still with coverage measurement on. SCRIPT_TIMEOUT = 60 class ScriptRunnerMixin(ABC): @property @abstractmethod def script_dir(self) -> Path: raise NotImplementedError @staticmethod def get_script_dir(name: str) -> Path: return Path(__file__).parent.resolve() / name 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), *script_args] def run_script(self, script_name: str, *script_args: str) -> str: args = self.get_script_args(script_name, *script_args) p = subprocess.Popen( args, env=get_script_run_env(), stdout=subprocess.PIPE, stderr=subprocess.PIPE, ) _, stderr = p.communicate() return stderr.decode("utf-8") class TestCrawlerProcessSubprocessBase(ScriptRunnerMixin): """Common tests between CrawlerProcess and AsyncCrawlerProcess, with the same file names and expectations. """ def test_simple(self) -> None: log = self.run_script("simple.py") assert "Spider closed (finished)" in log assert ( "Using reactor: twisted.internet.asyncioreactor.AsyncioSelectorReactor" in log ) assert "is_reactorless(): False" in log def test_multi(self) -> None: log = self.run_script("multi.py") assert "Spider closed (finished)" in log assert ( "Using reactor: twisted.internet.asyncioreactor.AsyncioSelectorReactor" in log ) assert "ReactorAlreadyInstalledError" not in log def test_reactor_default(self) -> None: log = self.run_script("reactor_default.py") assert "Spider closed (finished)" not in log assert ( "does not match the requested one " "(twisted.internet.asyncioreactor.AsyncioSelectorReactor)" ) in log def test_asyncio_enabled_no_reactor(self) -> None: log = self.run_script("asyncio_enabled_no_reactor.py") assert "Spider closed (finished)" in log assert ( "Using reactor: twisted.internet.asyncioreactor.AsyncioSelectorReactor" in log ) assert "RuntimeError" not in log def test_asyncio_enabled_reactor(self) -> None: log = self.run_script("asyncio_enabled_reactor.py") assert "Spider closed (finished)" in log assert ( "Using reactor: twisted.internet.asyncioreactor.AsyncioSelectorReactor" in log ) assert "RuntimeError" not in log def test_ipv6_default_name_resolver(self) -> None: log = self.run_script("default_name_resolver.py") assert "Spider closed (finished)" in log assert ( "'downloader/exception_type_count/scrapy.exceptions.CannotResolveHostError': 1," in log ) assert ( "scrapy.exceptions.CannotResolveHostError: DNS lookup failed: no results for hostname lookup: ::1." in log ) def test_caching_hostname_resolver_ipv6(self) -> None: log = self.run_script("caching_hostname_resolver_ipv6.py") assert "Spider closed (finished)" in log assert "http://::1" not in log assert "scrapy.exceptions.CannotResolveHostError" not in log def test_caching_hostname_resolver_finite_execution( self, mockserver: MockServer ) -> None: log = self.run_script("caching_hostname_resolver.py", mockserver.url("/")) assert "Spider closed (finished)" in log assert "ERROR: Error downloading" not in log assert "TimeoutError" not in log assert "scrapy.exceptions.CannotResolveHostError" not in log def test_dns_resolver_deprecated(self) -> None: log = self.run_script("dns_resolver_deprecated.py") assert "Spider closed (finished)" in log assert "The DNS_RESOLVER setting is deprecated" in log def test_dns_resolver_deprecated_twisted_dns_resolver(self) -> None: log = self.run_script("dns_resolver_deprecated.py", "twisted-wins") assert "Spider closed (finished)" in log assert "The DNS_RESOLVER setting is deprecated" in log def test_twisted_reactor_asyncio(self) -> None: log = self.run_script("twisted_reactor_asyncio.py") assert "Spider closed (finished)" in log assert ( "Using reactor: twisted.internet.asyncioreactor.AsyncioSelectorReactor" in log ) def test_twisted_reactor_asyncio_custom_settings(self) -> None: log = self.run_script("twisted_reactor_custom_settings.py") assert "Spider closed (finished)" in log assert ( "Using reactor: twisted.internet.asyncioreactor.AsyncioSelectorReactor" in log ) def test_twisted_reactor_asyncio_custom_settings_same(self) -> None: log = self.run_script("twisted_reactor_custom_settings_same.py") assert "Spider closed (finished)" in log assert ( "Using reactor: twisted.internet.asyncioreactor.AsyncioSelectorReactor" in log ) @pytest.mark.requires_uvloop def test_custom_loop_asyncio(self) -> None: log = self.run_script("asyncio_custom_loop.py") assert "Spider closed (finished)" in log assert ( "Using reactor: twisted.internet.asyncioreactor.AsyncioSelectorReactor" in log ) assert "Using asyncio event loop: uvloop.Loop" in log @pytest.mark.requires_uvloop def test_custom_loop_asyncio_deferred_signal(self) -> None: log = self.run_script("asyncio_deferred_signal.py", "uvloop.Loop") assert "Spider closed (finished)" in log assert ( "Using reactor: twisted.internet.asyncioreactor.AsyncioSelectorReactor" in log ) assert "Using asyncio event loop: uvloop.Loop" in log assert "async pipeline opened!" in log @pytest.mark.requires_uvloop def test_asyncio_enabled_reactor_same_loop(self) -> None: log = self.run_script("asyncio_enabled_reactor_same_loop.py") assert "Spider closed (finished)" in log assert ( "Using reactor: twisted.internet.asyncioreactor.AsyncioSelectorReactor" in log ) assert "Using asyncio event loop: uvloop.Loop" in log @pytest.mark.requires_uvloop def test_asyncio_enabled_reactor_different_loop(self) -> None: log = self.run_script("asyncio_enabled_reactor_different_loop.py") assert "Spider closed (finished)" not in log assert ( "does not match the one specified in the ASYNCIO_EVENT_LOOP " "setting (uvloop.Loop)" ) in log def test_default_loop_asyncio_deferred_signal(self) -> None: log = self.run_script("asyncio_deferred_signal.py") assert "Spider closed (finished)" in log assert ( "Using reactor: twisted.internet.asyncioreactor.AsyncioSelectorReactor" in log ) assert "Using asyncio event loop: uvloop.Loop" not in log assert "async pipeline opened!" in log def test_args_change_settings(self) -> None: log = self.run_script("args_settings.py") assert "Spider closed (finished)" in log assert "The value of FOO is 42" in log def _test_shutdown_graceful( self, script: str = "sleeping.py", *extra_args: str ) -> None: sig = signal.SIGINT if sys.platform != "win32" else signal.SIGBREAK # type: ignore[attr-defined] args = self.get_script_args(script, "3", *extra_args) p = PopenSpawn(args, timeout=SCRIPT_TIMEOUT, env=get_script_run_env()) p.expect_exact("Spider opened") p.expect_exact("Crawled (200)") p.kill(sig) p.expect_exact("shutting down gracefully") p.expect_exact("Spider closed (shutdown)") stop_spawn(p) def test_shutdown_graceful(self) -> None: self._test_shutdown_graceful() async def _test_shutdown_forced(self, script: str = "sleeping.py") -> None: sig = signal.SIGINT if sys.platform != "win32" else signal.SIGBREAK # type: ignore[attr-defined] args = self.get_script_args(script, "10") p = PopenSpawn(args, timeout=SCRIPT_TIMEOUT, env=get_script_run_env()) p.expect_exact("Spider opened") p.expect_exact("Crawled (200)") p.kill(sig) p.expect_exact("shutting down gracefully") # sending the second signal too fast often causes problems await sleep(0.01) p.kill(sig) p.expect_exact("forcing unclean shutdown") stop_spawn(p) @coroutine_test async def test_shutdown_forced(self) -> None: await self._test_shutdown_forced() def test_shutdown_graceful_no_stop(self) -> None: self._test_shutdown_graceful("sleeping.py", "--no-stop") class TestCrawlerProcessSubprocess(TestCrawlerProcessSubprocessBase): @property def script_dir(self) -> Path: return self.get_script_dir("CrawlerProcess") def test_reactor_default_twisted_reactor_select(self) -> None: log = self.run_script("reactor_default_twisted_reactor_select.py") if platform.system() in ["Windows", "Darwin"]: # The goal of this test function is to test that, when a reactor is # installed (the default one here) and a different reactor is # configured (select here), an error raises. # # In Windows the default reactor is the select reactor, so that # error does not raise. # # If that ever becomes the case on more platforms (i.e. if Linux # also starts using the select reactor by default in a future # version of Twisted), then we will need to rethink this test. assert "Spider closed (finished)" in log else: assert "Spider closed (finished)" not in log assert ( "does not match the requested one " "(twisted.internet.selectreactor.SelectReactor)" ) in log def test_reactor_select(self) -> None: log = self.run_script("reactor_select.py") assert "Spider closed (finished)" not in log assert ( "does not match the requested one " "(twisted.internet.asyncioreactor.AsyncioSelectorReactor)" ) in log def test_reactor_select_twisted_reactor_select(self) -> None: log = self.run_script("reactor_select_twisted_reactor_select.py") assert "Spider closed (finished)" in log assert "ReactorAlreadyInstalledError" not in log def test_reactor_select_subclass_twisted_reactor_select(self) -> None: log = self.run_script("reactor_select_subclass_twisted_reactor_select.py") assert "Spider closed (finished)" not in log assert ( "does not match the requested one " "(twisted.internet.selectreactor.SelectReactor)" ) in log def test_twisted_reactor_select(self) -> None: log = self.run_script("twisted_reactor_select.py") assert "Spider closed (finished)" in log assert "Using reactor: twisted.internet.selectreactor.SelectReactor" in log @pytest.mark.skipif( platform.system() == "Windows", reason="PollReactor is not supported on Windows" ) def test_twisted_reactor_poll(self) -> None: log = self.run_script("twisted_reactor_poll.py") assert "Spider closed (finished)" in log assert "Using reactor: twisted.internet.pollreactor.PollReactor" in log def test_twisted_reactor_asyncio_custom_settings_conflict(self) -> None: log = self.run_script("twisted_reactor_custom_settings_conflict.py") assert "Using reactor: twisted.internet.selectreactor.SelectReactor" in log assert ( "(twisted.internet.selectreactor.SelectReactor) does not match the requested one" in log ) def test_reactorless(self) -> None: log = self.run_script("reactorless.py") assert ( "RuntimeError: CrawlerProcess doesn't support TWISTED_REACTOR_ENABLED=False" in log ) class TestAsyncCrawlerProcessSubprocess(TestCrawlerProcessSubprocessBase): @property def script_dir(self) -> Path: return self.get_script_dir("AsyncCrawlerProcess") def test_twisted_reactor_custom_settings_select(self) -> None: log = self.run_script("twisted_reactor_custom_settings_select.py") assert "Spider closed (finished)" not in log assert ( "(twisted.internet.asyncioreactor.AsyncioSelectorReactor) " "does not match the requested one " "(twisted.internet.selectreactor.SelectReactor)" ) in log @pytest.mark.requires_uvloop def test_asyncio_custom_loop_custom_settings_same(self) -> None: log = self.run_script("asyncio_custom_loop_custom_settings_same.py") assert "Spider closed (finished)" in log assert ( "Using reactor: twisted.internet.asyncioreactor.AsyncioSelectorReactor" in log ) assert "Using asyncio event loop: uvloop.Loop" in log @pytest.mark.requires_uvloop def test_asyncio_custom_loop_custom_settings_different(self) -> None: log = self.run_script("asyncio_custom_loop_custom_settings_different.py") assert "Spider closed (finished)" not in log assert ( "does not match the one specified in the ASYNCIO_EVENT_LOOP " "setting (uvloop.Loop)" ) in log def test_reactorless_simple(self) -> None: log = self.run_script("reactorless_simple.py") assert "Not using a Twisted reactor" in log assert "Spider closed (finished)" in log assert "is_reactorless(): True" in log assert "ERROR: " not in log assert log.count("WARNING: AiohttpDownloadHandler is experimental") == 2 assert log.count("WARNING: ") == 2 def test_reactorless_custom_settings(self) -> None: """Setting TWISTED_REACTOR_ENABLED=False in spider settings is not currently supported, AsyncCrawlerProcess will install a reactor in this case. """ log = self.run_script("reactorless_custom_settings.py") assert "Spider closed (finished)" not in log assert ( "TWISTED_REACTOR_ENABLED is False but a Twisted reactor is installed." in log ) def test_reactorless_datauri(self) -> None: log = self.run_script("reactorless_datauri.py") assert "Not using a Twisted reactor" in log assert "Spider closed (finished)" in log assert "{'data': 'foo'}" in log assert "'item_scraped_count': 1" in log assert "ERROR: " not in log assert log.count("WARNING: AiohttpDownloadHandler is experimental") == 2 assert log.count("WARNING: ") == 2 def test_reactorless_import_hook(self) -> None: log = self.run_script("reactorless_import_hook.py") assert "Not using a Twisted reactor" in log assert "Spider closed (start_error)" in log assert "ImportError: Import of twisted.internet.reactor is forbidden" in log def test_reactorless_import_hook_uninstall(self) -> None: """The import hook is removed when start() returns, so importing twisted.internet.reactor becomes possible again.""" log = self.run_script("reactorless_import_hook_uninstall.py") assert "Not using a Twisted reactor" in log assert "Spider closed (finished)" in log assert "Hooks in sys.meta_path after start(): 0" in log assert "Reactor imported after start()" in log assert "ImportError" not in log def test_reactorless_import_hook_multiple(self) -> None: """Sequential AsyncCrawlerProcess instances don't accumulate import hooks: there is exactly one during each run and none afterwards.""" log = self.run_script("reactorless_import_hook_multiple.py") assert log.count("Spider closed (finished)") == 2 assert log.count("Hooks during run: 1") == 2 assert "Hooks after runs: 0" in log assert "ERROR: " not in log def test_reactorless_then_reactor(self) -> None: """After a reactorless run finishes, a reactor-based run is possible in the same process.""" log = self.run_script("reactorless_then_reactor.py") assert log.count("Spider closed (finished)") == 2 assert "Not using a Twisted reactor" in log assert ( "Using reactor: twisted.internet.asyncioreactor.AsyncioSelectorReactor" in log ) assert "ImportError" not in log assert "ERROR: " not in log def test_reactorless_telnetconsole_default(self) -> None: """By default TWISTED_REACTOR_ENABLED=False silently sets TELNETCONSOLE_ENABLED=False.""" log = self.run_script("reactorless_simple.py") # no need for a separate script assert "Not using a Twisted reactor" in log assert "Spider closed (finished)" in log assert "The TelnetConsole extension requires a Twisted reactor" not in log assert "scrapy.extensions.telnet.TelnetConsole" not in log def test_reactorless_telnetconsole_disabled(self) -> None: """Explicit TELNETCONSOLE_ENABLED=False, there are no warnings.""" log = self.run_script("reactorless_telnetconsole_disabled.py") assert "Not using a Twisted reactor" in log assert "Spider closed (finished)" in log assert "The TelnetConsole extension requires a Twisted reactor" not in log assert "scrapy.extensions.telnet.TelnetConsole" not in log def test_reactorless_telnetconsole_enabled(self) -> None: """Explicit TELNETCONSOLE_ENABLED=True, the user gets a warning.""" log = self.run_script("reactorless_telnetconsole_enabled.py") assert "Not using a Twisted reactor" in log assert "Spider closed (finished)" in log assert "The TelnetConsole extension requires a Twisted reactor" in log def test_reactorless_reactor(self) -> None: log = self.run_script("reactorless_reactor.py") assert ( "RuntimeError: TWISTED_REACTOR_ENABLED is False but a Twisted reactor is installed" in log ) def test_reactorless_shutdown_graceful(self) -> None: self._test_shutdown_graceful("reactorless_sleeping.py") @coroutine_test async def test_reactorless_shutdown_forced(self) -> None: await self._test_shutdown_forced("reactorless_sleeping.py") def test_reactorless_shutdown_graceful_no_stop(self) -> None: self._test_shutdown_graceful("reactorless_sleeping.py", "--no-stop") def test_asyncio_enabled_reactor_same_loop_default(self) -> None: log = self.run_script("asyncio_enabled_reactor_same_loop_default.py") assert "Spider closed (finished)" in log assert ( "Using reactor: twisted.internet.asyncioreactor.AsyncioSelectorReactor" in log ) class TestCrawlerRunnerSubprocessBase(ScriptRunnerMixin): """Common tests between CrawlerRunner and AsyncCrawlerRunner, with the same file names and expectations. """ def test_simple(self) -> None: log = self.run_script("simple.py") assert "Spider closed (finished)" in log assert ( "Using reactor: twisted.internet.asyncioreactor.AsyncioSelectorReactor" in log ) assert "is_reactorless(): False" in log def test_multi_parallel(self) -> None: log = self.run_script("multi_parallel.py") assert "Spider closed (finished)" in log assert ( "Using reactor: twisted.internet.asyncioreactor.AsyncioSelectorReactor" in log ) assert re.search( r"Spider opened.+Spider opened.+Closing spider.+Closing spider", log, re.DOTALL, ) def test_multi_seq(self) -> None: log = self.run_script("multi_seq.py") assert "Spider closed (finished)" in log assert ( "Using reactor: twisted.internet.asyncioreactor.AsyncioSelectorReactor" in log ) assert re.search( r"Spider opened.+Closing spider.+Spider opened.+Closing spider", log, re.DOTALL, ) @pytest.mark.requires_uvloop def test_custom_loop_same(self) -> None: log = self.run_script("custom_loop_same.py") assert "Spider closed (finished)" in log assert ( "Using reactor: twisted.internet.asyncioreactor.AsyncioSelectorReactor" in log ) assert "Using asyncio event loop: uvloop.Loop" in log @pytest.mark.requires_uvloop def test_custom_loop_different(self) -> None: log = self.run_script("custom_loop_different.py") assert "Spider closed (finished)" not in log assert ( "does not match the one specified in the ASYNCIO_EVENT_LOOP " "setting (uvloop.Loop)" ) in log def test_no_reactor(self) -> None: log = self.run_script("no_reactor.py") assert "Spider closed (finished)" not in log assert ( "RuntimeError: We expected a Twisted reactor to be installed but it isn't." in log ) class TestCrawlerRunnerSubprocess(TestCrawlerRunnerSubprocessBase): @property def script_dir(self) -> Path: return self.get_script_dir("CrawlerRunner") def test_explicit_default_reactor(self) -> None: log = self.run_script("explicit_default_reactor.py") assert "Spider closed (finished)" in log assert ( "Using reactor: twisted.internet.asyncioreactor.AsyncioSelectorReactor" not in log ) def test_response_ip_address(self) -> None: log = self.run_script("ip_address.py") assert "INFO: Spider closed (finished)" in log assert "INFO: Host: not.a.real.domain" in log assert "INFO: Type: " in log assert "INFO: IP address: 127.0.0.1" in log def test_change_default_reactor(self) -> None: log = self.run_script("change_reactor.py") assert ( "DEBUG: Using reactor: twisted.internet.asyncioreactor.AsyncioSelectorReactor" in log ) assert "DEBUG: Using asyncio event loop" in log def test_reactorless(self) -> None: log = self.run_script("reactorless.py") assert ( "RuntimeError: CrawlerRunner doesn't support TWISTED_REACTOR_ENABLED=False" in log ) class TestAsyncCrawlerRunnerSubprocess(TestCrawlerRunnerSubprocessBase): @property def script_dir(self) -> Path: return self.get_script_dir("AsyncCrawlerRunner") def test_simple_default_reactor(self) -> None: log = self.run_script("simple_default_reactor.py") assert "Spider closed (finished)" not in log assert ( "RuntimeError: When TWISTED_REACTOR_ENABLED is True, " "AsyncCrawlerRunner requires that the installed Twisted reactor" ) in log def test_reactorless_simple(self) -> None: log = self.run_script("reactorless_simple.py") assert "Not using a Twisted reactor" in log assert "Spider closed (finished)" in log assert "is_reactorless(): True" in log assert "ERROR: " not in log assert log.count("WARNING: AiohttpDownloadHandler is experimental") == 2 assert log.count("WARNING: ") == 2 def test_reactorless_custom_settings(self) -> None: """Setting TWISTED_REACTOR_ENABLED=False in spider settings is not currently supported, AsyncCrawlerRunner will expect a reactor installed by the user. """ log = self.run_script("reactorless_custom_settings.py") assert "Spider closed (finished)" not in log assert "We expected a Twisted reactor to be installed but it isn't." in log def test_reactorless_datauri(self) -> None: log = self.run_script("reactorless_datauri.py") assert "Not using a Twisted reactor" in log assert "Spider closed (finished)" in log assert "{'data': 'foo'}" in log assert "'item_scraped_count': 1" in log assert "ERROR: " not in log assert log.count("WARNING: AiohttpDownloadHandler is experimental") == 2 assert log.count("WARNING: ") == 2 def test_reactorless_reactor(self) -> None: log = self.run_script("reactorless_reactor.py") assert ( "RuntimeError: TWISTED_REACTOR_ENABLED is False but a Twisted reactor is installed" in log ) scrapy-scrapy-5569de3/tests/test_dependencies.py000066400000000000000000000016371525051012400220630ustar00rootroot00000000000000from __future__ import annotations import os import re from configparser import ConfigParser from pathlib import Path import pytest from twisted import version as twisted_version def test_pinned_twisted_version() -> None: """When running tests within a Tox environment with pinned dependencies, make sure that the version of Twisted is the pinned version. See https://github.com/scrapy/scrapy/pull/4814#issuecomment-706230011 """ if not os.environ.get("_SCRAPY_MIN", None): pytest.skip("Not in a min environment") tox_config_file_path = Path(__file__).parent / ".." / "tox.ini" config_parser = ConfigParser() config_parser.read(tox_config_file_path) pattern = r"Twisted==([\d.]+)" match = re.search(pattern, config_parser["min"]["deps"]) assert match pinned_twisted_version_string = match[1] assert twisted_version.short() == pinned_twisted_version_string scrapy-scrapy-5569de3/tests/test_downloader_handler_aiohttp.py000066400000000000000000000062421525051012400250150ustar00rootroot00000000000000from __future__ import annotations import sys from typing import TYPE_CHECKING, Any import pytest from scrapy.core.downloader.handlers._aiohttp import AiohttpDownloadHandler from tests.utils.bases.download_handlers_http import ( TestHttpBase, TestHttpProxyBase, TestHttpsBase, TestHttpsCustomCiphersBase, TestHttpsDefaultCiphersBase, TestHttpsInvalidDNSIdBase, TestHttpsInvalidDNSPatternBase, TestHttpsTLSVersionBase, TestHttpsWrongHostnameBase, TestHttpWithCrawlerBase, TestMitmProxyBase, TestRealWebsiteBase, TestSimpleHttpsBase, ) if TYPE_CHECKING: from scrapy.core.downloader.handlers import DownloadHandlerProtocol pytestmark = pytest.mark.only_asyncio class AiohttpDownloadHandlerMixin: @property def download_handler_cls(self) -> type[DownloadHandlerProtocol]: return AiohttpDownloadHandler @property def settings_dict(self) -> dict[str, Any] | None: return { "DOWNLOAD_HANDLERS": { "http": "scrapy.core.downloader.handlers._aiohttp.AiohttpDownloadHandler", "https": "scrapy.core.downloader.handlers._aiohttp.AiohttpDownloadHandler", } } class TestHttp(AiohttpDownloadHandlerMixin, TestHttpBase): handler_supports_bindaddress_meta = False handler_bad_header_handling = "fail" class TestHttps(AiohttpDownloadHandlerMixin, TestHttpsBase): handler_supports_bindaddress_meta = False handler_bad_header_handling = "fail" tls_log_message = "SSL connection to 127.0.0.1 using protocol TLSv1.3, cipher" @pytest.mark.skip(reason="The check is Twisted-specific") def test_verify_certs_deprecated(self) -> None: # type: ignore[override] pass class TestSimpleHttps(AiohttpDownloadHandlerMixin, TestSimpleHttpsBase): pass class TestHttpsWrongHostname(AiohttpDownloadHandlerMixin, TestHttpsWrongHostnameBase): pass class TestHttpsInvalidDNSId(AiohttpDownloadHandlerMixin, TestHttpsInvalidDNSIdBase): pass class TestHttpsInvalidDNSPattern( AiohttpDownloadHandlerMixin, TestHttpsInvalidDNSPatternBase ): pass class TestHttpsCustomCiphers(AiohttpDownloadHandlerMixin, TestHttpsCustomCiphersBase): pass class TestHttpsDefaultCiphers(AiohttpDownloadHandlerMixin, TestHttpsDefaultCiphersBase): pass class TestHttpsTLSVersion(AiohttpDownloadHandlerMixin, TestHttpsTLSVersionBase): pass class TestHttpWithCrawler(AiohttpDownloadHandlerMixin, TestHttpWithCrawlerBase): pass class TestHttpsWithCrawler(TestHttpWithCrawler): is_secure = True class TestHttpProxy(AiohttpDownloadHandlerMixin, TestHttpProxyBase): pass class TestHttpsProxy(AiohttpDownloadHandlerMixin, TestHttpProxyBase): is_secure = True @property def handler_supports_tls_in_tls(self) -> bool: return sys.version_info >= (3, 11) @pytest.mark.requires_mitmproxy class TestMitmProxy(AiohttpDownloadHandlerMixin, TestMitmProxyBase): handler_supports_socks: bool = False @property def handler_supports_tls_in_tls(self) -> bool: return sys.version_info >= (3, 11) @pytest.mark.requires_internet class TestRealWebsite(AiohttpDownloadHandlerMixin, TestRealWebsiteBase): pass scrapy-scrapy-5569de3/tests/test_downloader_handler_httpx.py000066400000000000000000000210421525051012400245070ustar00rootroot00000000000000"""Tests for scrapy.core.downloader.handlers._httpx.HttpxDownloadHandler.""" from __future__ import annotations import sys from importlib.util import find_spec from typing import TYPE_CHECKING, Any, ClassVar import pytest from scrapy import Request from scrapy.core.downloader.handlers._httpx import ( # type: ignore[attr-defined] HAS_HTTP2, HAS_SOCKS, HttpxDownloadHandler, httpx, ) from scrapy.exceptions import DownloadFailedError from scrapy.utils.misc import build_from_crawler from scrapy.utils.test import get_crawler from tests import IDNA_REJECTED_HOSTNAMES from tests.utils.bases.download_handlers_http import ( TestHttpBase, TestHttpProxyBase, TestHttpsBase, TestHttpsCustomCiphersBase, TestHttpsDefaultCiphersBase, TestHttpsInvalidDNSIdBase, TestHttpsInvalidDNSPatternBase, TestHttpsTLSVersionBase, TestHttpsWrongHostnameBase, TestHttpWithCrawlerBase, TestMitmProxyBase, TestRealWebsiteBase, TestSimpleHttpsBase, ) from tests.utils.decorators import coroutine_test if TYPE_CHECKING: from pathlib import Path from scrapy.core.downloader.handlers import DownloadHandlerProtocol from tests.mockserver.http import MockServer from tests.mockserver.proxy_echo import ProxyEchoMockServer pytestmark = pytest.mark.only_asyncio if find_spec("httpx2") is None and find_spec("httpx") is None: pytest.skip("Neither httpx2 nor httpx are installed", allow_module_level=True) # httpx2 < 2.4.0 reads URL.host through idna.decode() without display=True when # building the Host header, which raises for punycode labels that IDNA 2008 # rejects. https://github.com/pydantic/httpx2/pull/1018 # This check can be dropped once the httpx2 requirement is bumped to >= 2.4.0. try: for _hostname in IDNA_REJECTED_HOSTNAMES: _ = httpx.URL(f"http://{_hostname}").host except Exception: HTTPX_SUPPORTS_IDNA_REJECTED_HOSTNAMES = False else: HTTPX_SUPPORTS_IDNA_REJECTED_HOSTNAMES = True class HttpxDownloadHandlerMixin: @property def download_handler_cls(self) -> type[DownloadHandlerProtocol]: return HttpxDownloadHandler @property def settings_dict(self) -> dict[str, Any] | None: return { "DOWNLOAD_HANDLERS": { "http": "scrapy.core.downloader.handlers._httpx.HttpxDownloadHandler", "https": "scrapy.core.downloader.handlers._httpx.HttpxDownloadHandler", } } class TestHttp(HttpxDownloadHandlerMixin, TestHttpBase): handler_supports_bindaddress_meta = False handler_bad_header_handling = "fail" handler_supports_idna_rejected_hostnames = HTTPX_SUPPORTS_IDNA_REJECTED_HOSTNAMES @pytest.mark.skipif( sys.platform == "darwin", reason="127.0.0.2 is not available on macOS by default", ) @coroutine_test async def test_bind_address_port_warning( self, caplog: pytest.LogCaptureFixture, mockserver: MockServer ) -> None: request = Request(mockserver.url("/client-ip")) async with self.get_dh( {"DOWNLOAD_BIND_ADDRESS": ("127.0.0.2", 12345)} ) as download_handler: response = await download_handler.download_request(request) assert response.body == b"127.0.0.2" assert "DOWNLOAD_BIND_ADDRESS specifies a port (12345)" in caplog.text assert "Ignoring the port" in caplog.text class TestHttps(HttpxDownloadHandlerMixin, TestHttpsBase): handler_supports_bindaddress_meta = False handler_bad_header_handling = "fail" handler_supports_idna_rejected_hostnames = HTTPX_SUPPORTS_IDNA_REJECTED_HOSTNAMES tls_log_message = "SSL connection to 127.0.0.1 using protocol TLSv1.3, cipher" @pytest.mark.skip(reason="The check is Twisted-specific") def test_verify_certs_deprecated(self) -> None: # type: ignore[override] pass @pytest.mark.skipif(not HAS_HTTP2, reason="No HTTP/2 support in HttpxDownloadHandler") class TestHttp2(TestHttps): http2 = True handler_supports_http2_dataloss = False default_handler_settings: ClassVar[dict[str, Any]] = { "HTTPX_HTTP2_ENABLED": True, } @coroutine_test async def test_protocol(self, mockserver: MockServer) -> None: request = Request(mockserver.url("/host", is_secure=self.is_secure)) async with self.get_dh() as download_handler: response = await download_handler.download_request(request) assert response.protocol == "HTTP/2" @coroutine_test async def test_data_loss_handling(self, mockserver: MockServer) -> None: request = Request(mockserver.url("/broken", is_secure=self.is_secure)) async with self.get_dh() as download_handler: with pytest.raises(DownloadFailedError): await download_handler.download_request(request) class TestSimpleHttps(HttpxDownloadHandlerMixin, TestSimpleHttpsBase): pass class TestHttpsWrongHostname(HttpxDownloadHandlerMixin, TestHttpsWrongHostnameBase): pass class TestHttpsInvalidDNSId(HttpxDownloadHandlerMixin, TestHttpsInvalidDNSIdBase): pass class TestHttpsInvalidDNSPattern( HttpxDownloadHandlerMixin, TestHttpsInvalidDNSPatternBase ): pass class TestHttpsCustomCiphers(HttpxDownloadHandlerMixin, TestHttpsCustomCiphersBase): pass class TestHttpsDefaultCiphers(HttpxDownloadHandlerMixin, TestHttpsDefaultCiphersBase): pass class TestHttpsTLSVersion(HttpxDownloadHandlerMixin, TestHttpsTLSVersionBase): pass class TestHttpWithCrawler(HttpxDownloadHandlerMixin, TestHttpWithCrawlerBase): pass class TestHttpsWithCrawler(TestHttpWithCrawler): is_secure = True class TestHttpProxy(HttpxDownloadHandlerMixin, TestHttpProxyBase): expected_http_proxy_request_body = b"http://example.com/" class TestHttpsProxy(TestHttpProxy): is_secure = True @coroutine_test async def test_keylog( self, monkeypatch: pytest.MonkeyPatch, proxy_mockserver: ProxyEchoMockServer, tmp_path: Path, ) -> None: keylog_file = tmp_path / "keylog" monkeypatch.setenv("SSLKEYLOGFILE", str(keylog_file)) http_proxy = proxy_mockserver.url("", is_secure=True) request = Request("http://example.com", meta={"proxy": http_proxy}) async with self.get_dh() as download_handler: response = await download_handler.download_request(request) assert response.body == self.expected_http_proxy_request_body assert keylog_file.read_text() @pytest.mark.requires_mitmproxy class TestMitmProxy(HttpxDownloadHandlerMixin, TestMitmProxyBase): handler_supports_socks = HAS_SOCKS @pytest.mark.requires_internet class TestRealWebsite(HttpxDownloadHandlerMixin, TestRealWebsiteBase): pass @pytest.mark.parametrize(("concurrency", "expected"), [(16, 16), (0, None)]) @coroutine_test async def test_pool_limits(concurrency: int, expected: int | None) -> None: crawler = get_crawler(settings_dict={"CONCURRENT_REQUESTS": concurrency}) handler = build_from_crawler(HttpxDownloadHandler, crawler) try: assert handler._limits.max_connections == expected assert handler._limits.max_keepalive_connections == expected finally: await handler.close() @coroutine_test async def test_experimental_warning(caplog: pytest.LogCaptureFixture) -> None: class StableHandler(HttpxDownloadHandler): experimental = False crawler = get_crawler() for handler_cls, expected in ((HttpxDownloadHandler, True), (StableHandler, False)): caplog.clear() handler = build_from_crawler(handler_cls, crawler) await handler.close() assert ("is experimental" in caplog.text) is expected @coroutine_test async def test_proxies_not_supported() -> None: class NoProxyHandler(HttpxDownloadHandler): supports_proxies = False crawler = get_crawler() handler = build_from_crawler(NoProxyHandler, crawler) request = Request("http://example.com", meta={"proxy": "http://proxy.example:8080"}) try: with pytest.raises(NotImplementedError, match="doesn't support proxies"): await handler.download_request(request) finally: await handler.close() @coroutine_test async def test_non_basic_proxy_auth() -> None: crawler = get_crawler() handler = build_from_crawler(HttpxDownloadHandler, crawler) request = Request( "http://example.com", meta={"proxy": "http://proxy.example:8080"}, headers={"Proxy-Authorization": "Digest deadbeef"}, ) try: with pytest.raises(ValueError, match="got Digest"): await handler.download_request(request) finally: await handler.close() scrapy-scrapy-5569de3/tests/test_downloader_handler_twisted_ftp.py000066400000000000000000000164511525051012400257040ustar00rootroot00000000000000from __future__ import annotations import os from abc import ABC, abstractmethod from pathlib import Path from tempfile import mkstemp from typing import TYPE_CHECKING, Any import pytest from pytest_twisted import async_yield_fixture from twisted.cred import checkers, credentials, portal from scrapy import Spider from scrapy.core.downloader.handlers.ftp import FTPDownloadHandler from scrapy.crawler import Crawler from scrapy.exceptions import NotConfigured from scrapy.http import HtmlResponse, Request, Response from scrapy.http.response.text import TextResponse from scrapy.utils.defer import deferred_f_from_coro_f from scrapy.utils.misc import build_from_crawler from scrapy.utils.python import to_bytes from scrapy.utils.test import get_crawler if TYPE_CHECKING: from collections.abc import AsyncGenerator, Generator from twisted.protocols.ftp import FTPFactory pytestmark = pytest.mark.requires_reactor # FTPDownloadHandler requires a reactor class TestFTPBase(ABC): username = "scrapy" password = "passwd" req_meta: dict[str, Any] = {"ftp_user": username, "ftp_password": password} test_files = ( ("file.txt", b"I have the power!"), ("file with spaces.txt", b"Moooooooooo power!"), ("html-file-without-extension", b"\n."), ) @abstractmethod def _create_files(self, root: Path) -> None: raise NotImplementedError @abstractmethod def _get_factory(self, tmp_path: Path) -> FTPFactory: raise NotImplementedError @async_yield_fixture # type: ignore[untyped-decorator] async def server_url(self, tmp_path: Path) -> AsyncGenerator[str]: from twisted.internet import reactor self._create_files(tmp_path) factory = self._get_factory(tmp_path) port = reactor.listenTCP(0, factory, interface="127.0.0.1") portno = port.getHost().port yield f"ftp://127.0.0.1:{portno}/" await port.stopListening() @staticmethod @pytest.fixture def dh() -> Generator[FTPDownloadHandler]: crawler = get_crawler() dh = build_from_crawler(FTPDownloadHandler, crawler) yield dh # if the test was skipped, there will be no client attribute if hasattr(dh, "client"): assert dh.client.transport dh.client.transport.loseConnection() @deferred_f_from_coro_f async def test_ftp_download_success( self, server_url: str, dh: FTPDownloadHandler ) -> None: request = Request(url=server_url + "file.txt", meta=self.req_meta) r = await dh.download_request(request) assert r.status == 200 assert r.body == b"I have the power!" assert r.headers == {b"Local Filename": [b""], b"Size": [b"17"]} assert r.protocol is None @deferred_f_from_coro_f async def test_ftp_download_path_with_spaces( self, server_url: str, dh: FTPDownloadHandler ) -> None: request = Request( url=server_url + "file with spaces.txt", meta=self.req_meta, ) r = await dh.download_request(request) assert r.status == 200 assert r.body == b"Moooooooooo power!" assert r.headers == {b"Local Filename": [b""], b"Size": [b"18"]} @deferred_f_from_coro_f async def test_ftp_download_nonexistent( self, server_url: str, dh: FTPDownloadHandler ) -> None: request = Request(url=server_url + "nonexistent.txt", meta=self.req_meta) r = await dh.download_request(request) assert r.status == 404 assert r.body == b"['550 nonexistent.txt: No such file or directory.']" @deferred_f_from_coro_f async def test_ftp_local_filename( self, server_url: str, dh: FTPDownloadHandler ) -> None: f, local_fname = mkstemp() fname_bytes = to_bytes(local_fname) local_path = Path(local_fname) os.close(f) meta = {"ftp_local_filename": fname_bytes} meta.update(self.req_meta) request = Request(url=server_url + "file.txt", meta=meta) r = await dh.download_request(request) assert r.body == fname_bytes assert r.headers == {b"Local Filename": [fname_bytes], b"Size": [b"17"]} assert local_path.exists() assert local_path.read_bytes() == b"I have the power!" local_path.unlink() @pytest.mark.parametrize( ("filename", "response_class"), [ ("file.txt", TextResponse), ("html-file-without-extension", HtmlResponse), ], ) @deferred_f_from_coro_f async def test_response_class( self, filename: str, response_class: type[Response], server_url: str, dh: FTPDownloadHandler, ) -> None: meta = {} meta.update(self.req_meta) request = Request(url=server_url + filename, meta=meta) r = await dh.download_request(request) assert type(r) is response_class class TestFTP(TestFTPBase): def _create_files(self, root: Path) -> None: userdir = root / self.username userdir.mkdir() for filename, content in self.test_files: (userdir / filename).write_bytes(content) def _get_factory(self, root: Path) -> FTPFactory: from twisted.protocols.ftp import FTPFactory, FTPRealm realm = FTPRealm(anonymousRoot=str(root), userHome=str(root)) # zope.interface has no type hints, so mypy cannot tell that these # objects provide the interfaces that Portal expects. p = portal.Portal(realm) # type: ignore[arg-type] users_checker = checkers.InMemoryUsernamePasswordDatabaseDontUse() # the FTP protocol authenticates with str credentials users_checker.addUser(self.username, self.password) # type: ignore[arg-type] p.registerChecker(users_checker, credentials.IUsernamePassword) # type: ignore[arg-type] return FTPFactory(portal=p) @deferred_f_from_coro_f async def test_invalid_credentials( self, server_url: str, dh: FTPDownloadHandler ) -> None: from twisted.protocols.ftp import ConnectionLost meta = dict(self.req_meta) meta.update({"ftp_password": "invalid"}) request = Request(url=server_url + "file.txt", meta=meta) with pytest.raises(ConnectionLost): await dh.download_request(request) class TestAnonymousFTP(TestFTPBase): username = "anonymous" req_meta = {} def _create_files(self, root: Path) -> None: for filename, content in self.test_files: (root / filename).write_bytes(content) def _get_factory(self, tmp_path: Path) -> FTPFactory: from twisted.protocols.ftp import FTPFactory, FTPRealm realm = FTPRealm(anonymousRoot=str(tmp_path)) # zope.interface has no type hints, so mypy cannot tell that these # objects provide the interfaces that Portal expects. p = portal.Portal(realm) # type: ignore[arg-type] p.registerChecker( checkers.AllowAnonymousAccess(), # type: ignore[arg-type] credentials.IAnonymous, ) return FTPFactory(portal=p, userAnonymous=self.username) def test_not_configured_without_reactor() -> None: crawler = Crawler(Spider, {"TWISTED_REACTOR_ENABLED": False}) with pytest.raises(NotConfigured): build_from_crawler(FTPDownloadHandler, crawler) scrapy-scrapy-5569de3/tests/test_downloader_handler_twisted_http11.py000066400000000000000000000153161525051012400262330ustar00rootroot00000000000000"""Tests for scrapy.core.downloader.handlers.http11.HTTP11DownloadHandler.""" from __future__ import annotations import sys from contextlib import asynccontextmanager from typing import TYPE_CHECKING, Any import pytest from twisted.internet.protocol import Factory, Protocol from scrapy import Request, Spider from scrapy.core.downloader.handlers.http11 import HTTP11DownloadHandler, TunnelError from scrapy.crawler import Crawler from scrapy.exceptions import ( CannotResolveHostError, DownloadConnectionRefusedError, NotConfigured, ) from scrapy.utils.defer import maybe_deferred_to_future from scrapy.utils.misc import build_from_crawler from scrapy.utils.spider import DefaultSpider from scrapy.utils.test import get_crawler from tests import NON_EXISTING_RESOLVABLE from tests.utils.bases.download_handlers_http import ( TestHttpBase, TestHttpProxyBase, TestHttpsBase, TestHttpsCustomCiphersBase, TestHttpsInvalidDNSIdBase, TestHttpsInvalidDNSPatternBase, TestHttpsTLSVersionBase, TestHttpsWrongHostnameBase, TestHttpWithCrawlerBase, TestMitmProxyBase, TestRealWebsiteBase, TestSimpleHttpsBase, ) from tests.utils.decorators import coroutine_test if TYPE_CHECKING: from collections.abc import AsyncGenerator from scrapy.core.downloader.handlers import DownloadHandlerProtocol pytestmark = pytest.mark.requires_reactor # HTTP11DownloadHandler requires a reactor class HTTP11DownloadHandlerMixin: @property def download_handler_cls(self) -> type[DownloadHandlerProtocol]: return HTTP11DownloadHandler @property def settings_dict(self) -> dict[str, Any] | None: return { "DOWNLOAD_HANDLERS": { "http": "scrapy.core.downloader.handlers.http11.HTTP11DownloadHandler", "https": "scrapy.core.downloader.handlers.http11.HTTP11DownloadHandler", } } def test_not_configured_without_reactor() -> None: crawler = Crawler(Spider, {"TWISTED_REACTOR_ENABLED": False}) with pytest.raises(NotConfigured): build_from_crawler(HTTP11DownloadHandler, crawler) class TestHttp(HTTP11DownloadHandlerMixin, TestHttpBase): pass class TestHttps(HTTP11DownloadHandlerMixin, TestHttpsBase): pass class TestSimpleHttps(HTTP11DownloadHandlerMixin, TestSimpleHttpsBase): pass class TestHttpsWrongHostname(HTTP11DownloadHandlerMixin, TestHttpsWrongHostnameBase): pass class TestHttpsInvalidDNSId(HTTP11DownloadHandlerMixin, TestHttpsInvalidDNSIdBase): pass class TestHttpsInvalidDNSPattern( HTTP11DownloadHandlerMixin, TestHttpsInvalidDNSPatternBase ): pass class TestHttpsCustomCiphers(HTTP11DownloadHandlerMixin, TestHttpsCustomCiphersBase): pass class TestHttpsTLSVersion(HTTP11DownloadHandlerMixin, TestHttpsTLSVersionBase): pass class TestHttpWithCrawler(HTTP11DownloadHandlerMixin, TestHttpWithCrawlerBase): pass class TestHttpsWithCrawler(TestHttpWithCrawler): is_secure = True class TestHttpProxy(HTTP11DownloadHandlerMixin, TestHttpProxyBase): pass class TestHttpsProxy(HTTP11DownloadHandlerMixin, TestHttpProxyBase): is_secure = True # not implemented handler_supports_tls_in_tls = False @pytest.mark.requires_mitmproxy class TestMitmProxy(HTTP11DownloadHandlerMixin, TestMitmProxyBase): # not implemented handler_supports_tls_in_tls = False @pytest.mark.requires_internet class TestRealWebsite(HTTP11DownloadHandlerMixin, TestRealWebsiteBase): @property def platform_cert_store_works(self) -> bool: return sys.platform != "win32" class _CannedConnectProxy(Protocol): """Answers any request with a canned response.""" factory: _CannedConnectProxyFactory def dataReceived(self, data: bytes) -> None: from twisted.internet import reactor assert self.transport response = self.factory.response if not self.factory.split_at: self.transport.write(response) return self.transport.write(response[: self.factory.split_at]) reactor.callLater(0.1, self.transport.write, response[self.factory.split_at :]) class _CannedConnectProxyFactory(Factory): protocol = _CannedConnectProxy # type: ignore[assignment] def __init__(self, response: bytes, split_at: int = 0): self.response = response self.split_at = split_at @asynccontextmanager async def _canned_proxy(response: bytes, split_at: int = 0) -> AsyncGenerator[str]: from twisted.internet import reactor port = reactor.listenTCP( 0, _CannedConnectProxyFactory(response, split_at), interface="127.0.0.1" ) try: yield f"http://127.0.0.1:{port.getHost().port}" finally: await maybe_deferred_to_future(port.stopListening()) @asynccontextmanager async def _get_dh() -> AsyncGenerator[HTTP11DownloadHandler]: crawler = get_crawler(DefaultSpider) crawler.spider = crawler._create_spider() dh = build_from_crawler(HTTP11DownloadHandler, crawler) try: yield dh finally: await dh.close() class TestTunnelingErrors: @coroutine_test async def test_response_in_two_packets(self) -> None: """A CONNECT response split across packets is buffered until complete.""" response = b"HTTP/1.1 407 Proxy Authentication Required\r\n\r\n" async with _canned_proxy(response, split_at=len(response) - 2) as proxy: request = Request("https://example.com", meta={"proxy": proxy}) async with _get_dh() as dh: with pytest.raises(TunnelError, match="407"): await dh.download_request(request) @coroutine_test async def test_unparsable_response(self) -> None: async with _canned_proxy(b"not an HTTP response\r\n\r\n") as proxy: request = Request("https://example.com", meta={"proxy": proxy}) async with _get_dh() as dh: with pytest.raises(TunnelError, match="not an HTTP response"): await dh.download_request(request) @coroutine_test async def test_proxy_connection_refused(self) -> None: request = Request( "https://example.com", meta={"proxy": "http://127.0.0.1:65432"} ) async with _get_dh() as dh: with pytest.raises(DownloadConnectionRefusedError): await dh.download_request(request) @coroutine_test async def test_proxy_without_port(self) -> None: if NON_EXISTING_RESOLVABLE: pytest.skip("Non-existing hosts are resolvable") request = Request( "https://example.com", meta={"proxy": "http://no-such-domain.nosuch"} ) async with _get_dh() as dh: with pytest.raises(CannotResolveHostError): await dh.download_request(request) scrapy-scrapy-5569de3/tests/test_downloader_handler_twisted_http2.py000066400000000000000000000221631525051012400261510ustar00rootroot00000000000000"""Tests for scrapy.core.downloader.handlers.http2.H2DownloadHandler.""" from __future__ import annotations import logging import sys from typing import TYPE_CHECKING, Any import pytest from twisted.web.http import H2_ENABLED from scrapy import Spider from scrapy.crawler import Crawler from scrapy.exceptions import ( DownloadFailedError, NotConfigured, UnsupportedURLSchemeError, ) from scrapy.http import Request from scrapy.utils.misc import build_from_crawler from tests.utils.bases.download_handlers_http import ( TestHttpProxyBase, TestHttpsBase, TestHttpsCustomCiphersBase, TestHttpsInvalidDNSIdBase, TestHttpsInvalidDNSPatternBase, TestHttpsTLSVersionBase, TestHttpsWrongHostnameBase, TestHttpWithCrawlerBase, TestMitmProxyBase, TestRealWebsiteBase, TestSimpleHttpsBase, ) from tests.utils.decorators import coroutine_test if TYPE_CHECKING: from scrapy.core.downloader.handlers import DownloadHandlerProtocol from tests.mockserver.http import MockServer pytestmark = [ pytest.mark.requires_reactor, # H2DownloadHandler requires a reactor pytest.mark.skipif( not H2_ENABLED, reason="HTTP/2 support in Twisted is not enabled" ), ] class H2DownloadHandlerMixin: @property def download_handler_cls(self) -> type[DownloadHandlerProtocol]: # the import can fail when H2_ENABLED is False from scrapy.core.downloader.handlers.http2 import ( # noqa: PLC0415 H2DownloadHandler, ) return H2DownloadHandler @property def settings_dict(self) -> dict[str, Any] | None: return { "DOWNLOAD_HANDLERS": { "http": None, "https": "scrapy.core.downloader.handlers.http2.H2DownloadHandler", } } def test_not_configured_without_reactor() -> None: from scrapy.core.downloader.handlers.http2 import H2DownloadHandler # noqa: PLC0415 crawler = Crawler(Spider, {"TWISTED_REACTOR_ENABLED": False}) with pytest.raises(NotConfigured): build_from_crawler(H2DownloadHandler, crawler) class TestHttp2(H2DownloadHandlerMixin, TestHttpsBase): http2 = True handler_supports_http2_dataloss = False @coroutine_test async def test_protocol(self, mockserver: MockServer) -> None: request = Request(mockserver.url("/host", is_secure=self.is_secure)) async with self.get_dh() as download_handler: response = await download_handler.download_request(request) assert response.protocol == "h2" def test_download_conn_failed(self) -> None: # type: ignore[override] # Unlike HTTP11DownloadHandler which raises it from download_request() # (without any special handling), here ConnectionRefusedError (raised in # twisted.internet.endpoints.startConnectionAttempts()) bubbles up as # an unhandled exception in a Deferred and the handler waits until # DOWNLOAD_TIMEOUT. pytest.skip("The handler doesn't properly reraise ConnectionRefusedError") def test_download_dns_error(self) -> None: # type: ignore[override] # Unlike HTTP11DownloadHandler which raises it from download_request() # (without any special handling), here DNSLookupError (raised in # twisted.internet.endpoints.startConnectionAttempts()) bubbles up as # an unhandled exception in a Deferred and the handler waits until # DOWNLOAD_TIMEOUT. pytest.skip("The handler doesn't properly reraise DNSLookupError") @coroutine_test async def test_concurrent_requests_same_domain( self, mockserver: MockServer ) -> None: request1 = Request(mockserver.url("/text", is_secure=self.is_secure)) request2 = Request( mockserver.url("/echo", is_secure=self.is_secure), method="POST" ) async with self.get_dh() as download_handler: response1 = await download_handler.download_request(request1) assert response1.body == b"Works" response2 = await download_handler.download_request(request2) assert response2.headers["Content-Length"] == b"79" @pytest.mark.xfail(reason="https://github.com/python-hyper/h2/issues/1247") @coroutine_test async def test_connect_request(self, mockserver: MockServer) -> None: request = Request( mockserver.url("/file", is_secure=self.is_secure), method="CONNECT" ) async with self.get_dh() as download_handler: response = await download_handler.download_request(request) assert response.body == b"" @coroutine_test async def test_custom_content_length_good(self, mockserver: MockServer) -> None: request = Request(mockserver.url("/contentlength", is_secure=self.is_secure)) custom_content_length = str(len(request.body)) request.headers["Content-Length"] = custom_content_length async with self.get_dh() as download_handler: response = await download_handler.download_request(request) assert response.text == custom_content_length @coroutine_test async def test_custom_content_length_bad( self, caplog: pytest.LogCaptureFixture, mockserver: MockServer ) -> None: request = Request(mockserver.url("/contentlength", is_secure=self.is_secure)) actual_content_length = str(len(request.body)) bad_content_length = str(len(request.body) + 1) request.headers["Content-Length"] = bad_content_length async with self.get_dh() as download_handler: with caplog.at_level(logging.DEBUG): response = await download_handler.download_request(request) assert response.text == actual_content_length assert ( "scrapy.core._http2.stream", logging.WARNING, f"Ignoring bad Content-Length header " f"{bad_content_length!r} of request {request}, sending " f"{actual_content_length!r} instead", ) in caplog.record_tuples @coroutine_test async def test_data_loss_handling(self, mockserver: MockServer) -> None: request = Request(mockserver.url("/broken", is_secure=self.is_secure)) async with self.get_dh() as download_handler: with pytest.raises(DownloadFailedError): await download_handler.download_request(request) @coroutine_test async def test_download_405_data(self, mockserver: MockServer) -> None: """Servers without HTTP/2 support answer the connection preface with a 405 status line, which this handler reports as a download failure instead of waiting for frames that never arrive.""" request = Request(mockserver.url("/h2-no-support", is_secure=self.is_secure)) async with self.get_dh() as download_handler: with pytest.raises(DownloadFailedError, match="405 Method Not Allowed"): await download_handler.download_request(request) @coroutine_test async def test_download_plain_http(self, mockserver: MockServer) -> None: request = Request(mockserver.url("/text")) async with self.get_dh() as download_handler: with pytest.raises(UnsupportedURLSchemeError): await download_handler.download_request(request) @coroutine_test async def test_download_proxy(self, mockserver: MockServer) -> None: request = Request( mockserver.url("/text", is_secure=self.is_secure), meta={"proxy": "https://example.com:8080"}, ) async with self.get_dh() as download_handler: with pytest.raises(NotImplementedError): await download_handler.download_request(request) @coroutine_test async def test_download_pushed_stream(self, mockserver: MockServer) -> None: """Pushed responses are ignored.""" request = Request(mockserver.url("/h2-push", is_secure=self.is_secure)) async with self.get_dh() as download_handler: response = await download_handler.download_request(request) assert response.body == b"Works" class TestSimpleHttp2(H2DownloadHandlerMixin, TestSimpleHttpsBase): pass class TestHttp2WrongHostname(H2DownloadHandlerMixin, TestHttpsWrongHostnameBase): pass class TestHttp2InvalidDNSId(H2DownloadHandlerMixin, TestHttpsInvalidDNSIdBase): pass class TestHttp2InvalidDNSPattern( H2DownloadHandlerMixin, TestHttpsInvalidDNSPatternBase ): pass class TestHttp2CustomCiphers(H2DownloadHandlerMixin, TestHttpsCustomCiphersBase): pass class TestHttp2TLSVersion(H2DownloadHandlerMixin, TestHttpsTLSVersionBase): pass class TestHttp2WithCrawler(H2DownloadHandlerMixin, TestHttpWithCrawlerBase): is_secure = True @pytest.mark.skip(reason="Proxy support is not implemented yet") class TestHttp2Proxy(H2DownloadHandlerMixin, TestHttpProxyBase): is_secure = True @pytest.mark.skip(reason="Proxy support is not implemented yet") @pytest.mark.requires_mitmproxy class TestMitmProxy(H2DownloadHandlerMixin, TestMitmProxyBase): pass @pytest.mark.requires_internet class TestRealWebsite(H2DownloadHandlerMixin, TestRealWebsiteBase): @property def platform_cert_store_works(self) -> bool: return sys.platform != "win32" scrapy-scrapy-5569de3/tests/test_downloader_handlers.py000066400000000000000000000420651525051012400234530ustar00rootroot00000000000000"""Tests for DownloadHandlers and for specific non-HTTP download handlers.""" from __future__ import annotations import contextlib import os from pathlib import Path from tempfile import mkdtemp, mkstemp from unittest import mock import pytest from twisted.internet.defer import CancelledError from twisted.internet.error import ConnectionRefusedError as TxConnectionRefusedError from twisted.internet.error import DNSLookupError from twisted.internet.error import TimeoutError as TxTimeoutError from twisted.web.client import ResponseFailed from twisted.web.error import SchemeNotSupported from w3lib.url import path_to_file_uri from scrapy.core.downloader.handlers import DownloadHandlers from scrapy.core.downloader.handlers.datauri import DataURIDownloadHandler from scrapy.core.downloader.handlers.file import FileDownloadHandler from scrapy.core.downloader.handlers.s3 import S3DownloadHandler from scrapy.exceptions import ( CannotResolveHostError, DownloadCancelledError, DownloadConnectionRefusedError, DownloadFailedError, DownloadTimeoutError, NotConfigured, ScrapyDeprecationWarning, UnsupportedURLSchemeError, ) from scrapy.http import Request, TextResponse from scrapy.responsetypes import responsetypes from scrapy.utils._download_handlers import wrap_twisted_exceptions from scrapy.utils.boto import is_botocore_available from scrapy.utils.misc import build_from_crawler from scrapy.utils.test import get_crawler from tests.utils.decorators import coroutine_test class DummyDH: lazy = False async def download_request(self, request): pass class DummyLazyDH: # Default (but deprecated) is lazy for backward compatibility async def download_request(self, request): pass class OffDH: lazy = False def __init__(self, crawler): raise NotConfigured @classmethod def from_crawler(cls, crawler): return cls(crawler) class BuggyDH: lazy = False def __init__(self, crawler): raise ValueError @classmethod def from_crawler(cls, crawler): return cls(crawler) class TestLoad: def test_enabled_handler(self): handlers = {"scheme": DummyDH} crawler = get_crawler(settings_dict={"DOWNLOAD_HANDLERS": handlers}) dh = DownloadHandlers(crawler) assert "scheme" in dh._schemes assert "scheme" in dh._handlers assert "scheme" not in dh._notconfigured def test_not_configured_handler(self): handlers = {"scheme": OffDH} crawler = get_crawler(settings_dict={"DOWNLOAD_HANDLERS": handlers}) dh = DownloadHandlers(crawler) assert "scheme" in dh._schemes assert "scheme" not in dh._handlers assert "scheme" in dh._notconfigured def test_buggy_handler(self, caplog: pytest.LogCaptureFixture) -> None: handlers = {"scheme": BuggyDH} crawler = get_crawler(settings_dict={"DOWNLOAD_HANDLERS": handlers}) dh = DownloadHandlers(crawler) assert "scheme" in dh._schemes assert "scheme" not in dh._handlers assert "scheme" in dh._notconfigured assert ( 'Loading "" for scheme "scheme"' in caplog.text ) def test_disabled_handler(self): handlers = {"scheme": None} crawler = get_crawler(settings_dict={"DOWNLOAD_HANDLERS": handlers}) dh = DownloadHandlers(crawler) assert "scheme" not in dh._schemes assert dh._get_handler("scheme") is None assert "scheme" not in dh._handlers assert "scheme" in dh._notconfigured # get the handler again to cover the code that gets it from dh._notconfigured assert dh._get_handler("scheme") is None def test_lazy_handlers(self): handlers = {"scheme": DummyLazyDH} crawler = get_crawler(settings_dict={"DOWNLOAD_HANDLERS": handlers}) with pytest.warns( ScrapyDeprecationWarning, match="DummyLazyDH doesn't define a 'lazy' attribute", ): dh = DownloadHandlers(crawler) assert "scheme" in dh._schemes assert "scheme" not in dh._handlers handler = dh._get_handler("scheme") # force load lazy handler assert handler assert "scheme" in dh._handlers assert "scheme" not in dh._notconfigured class TestFile: def setup_method(self): # add a special char to check that they are handled correctly self.fd, self.tmpname = mkstemp(suffix="^") Path(self.tmpname).write_text("0123456789", encoding="utf-8") download_handler = build_from_crawler(FileDownloadHandler, get_crawler()) self.download_request = download_handler.download_request def teardown_method(self): os.close(self.fd) Path(self.tmpname).unlink() @coroutine_test async def test_download(self): request = Request(path_to_file_uri(self.tmpname)) assert request.url.upper().endswith("%5E") response = await self.download_request(request) assert response.url == request.url assert response.status == 200 assert response.body == b"0123456789" assert response.protocol is None @coroutine_test async def test_non_existent(self): request = Request(path_to_file_uri(mkdtemp())) # the specific exception differs between platforms with pytest.raises(OSError): # noqa: PT011 await self.download_request(request) class HttpDownloadHandlerMock: def __init__(self, *args, **kwargs): pass async def download_request(self, request): return request @pytest.mark.requires_botocore class TestS3Anon: def setup_method(self): crawler = get_crawler( settings_dict={ "DOWNLOAD_HANDLERS": {"https": HttpDownloadHandlerMock}, } ) self.s3reqh = build_from_crawler(S3DownloadHandler, crawler) self.download_request = self.s3reqh.download_request @coroutine_test async def test_anon_request(self): req = Request("s3://aws-publicdatasets/") httpreq = await self.download_request(req) assert hasattr(self.s3reqh, "anon") assert self.s3reqh.anon assert httpreq.url == "https://aws-publicdatasets.s3.amazonaws.com/" @coroutine_test async def test_anon_request_insecure(self): req = Request("s3://aws-publicdatasets/", meta={"is_secure": False}) with pytest.warns( ScrapyDeprecationWarning, match="Passing is_secure=False for s3:// requests is deprecated", ): httpreq = await self.download_request(req) assert httpreq.url == "http://aws-publicdatasets.s3.amazonaws.com/" @pytest.mark.requires_botocore class TestS3: def setup_method(self): # test use same example keys than amazon developer guide # http://s3.amazonaws.com/awsdocs/S3/20060301/s3-dg-20060301.pdf # and the tests described here are the examples from that manual crawler = get_crawler( settings_dict={ "AWS_ACCESS_KEY_ID": "0PN5J17HBGZHT7JJ3X82", "AWS_SECRET_ACCESS_KEY": "uV3F3YluFJax1cknvbcGwgjvx4QpvB+leU8dUj2o", "DOWNLOAD_HANDLERS": {"https": HttpDownloadHandlerMock}, } ) s3reqh = build_from_crawler(S3DownloadHandler, crawler) self.download_request = s3reqh.download_request @contextlib.contextmanager def _mocked_date(self, date): import botocore.auth # noqa: F401,PLC0415 # We need to mock botocore.auth.formatdate, because otherwise # botocore overrides Date header with current date and time # and Authorization header is different each time with mock.patch("botocore.auth.formatdate") as mock_formatdate: mock_formatdate.return_value = date yield @coroutine_test async def test_secure_by_default(self): req = Request("s3://johnsmith/photos/puppy.jpg") httpreq = await self.download_request(req) assert httpreq.url == "https://johnsmith.s3.amazonaws.com/photos/puppy.jpg" @coroutine_test async def test_insecure_opt_out(self): req = Request("s3://johnsmith/photos/puppy.jpg", meta={"is_secure": False}) with pytest.warns( ScrapyDeprecationWarning, match="Passing is_secure=False for s3:// requests is deprecated", ): httpreq = await self.download_request(req) assert httpreq.url == "http://johnsmith.s3.amazonaws.com/photos/puppy.jpg" @coroutine_test async def test_request_signing1(self): # gets an object from the johnsmith bucket. date = "Tue, 27 Mar 2007 19:36:42 +0000" req = Request("s3://johnsmith/photos/puppy.jpg", headers={"Date": date}) with self._mocked_date(date): httpreq = await self.download_request(req) assert ( httpreq.headers["Authorization"] == b"AWS 0PN5J17HBGZHT7JJ3X82:xXjDGYUmKxnwqr5KXNPGldn5LbA=" ) @coroutine_test async def test_request_signing2(self): # puts an object into the johnsmith bucket. date = "Tue, 27 Mar 2007 21:15:45 +0000" req = Request( "s3://johnsmith/photos/puppy.jpg", method="PUT", headers={ "Content-Type": "image/jpeg", "Date": date, "Content-Length": "94328", }, ) with self._mocked_date(date): httpreq = await self.download_request(req) assert ( httpreq.headers["Authorization"] == b"AWS 0PN5J17HBGZHT7JJ3X82:hcicpDDvL9SsO6AkvxqmIWkmOuQ=" ) @coroutine_test async def test_request_signing3(self): # lists the content of the johnsmith bucket. date = "Tue, 27 Mar 2007 19:42:41 +0000" req = Request( "s3://johnsmith/?prefix=photos&max-keys=50&marker=puppy", method="GET", headers={ "User-Agent": "Mozilla/5.0", "Date": date, }, ) with self._mocked_date(date): httpreq = await self.download_request(req) assert ( httpreq.headers["Authorization"] == b"AWS 0PN5J17HBGZHT7JJ3X82:jsRt/rhG+Vtp88HrYL706QhE4w4=" ) @coroutine_test async def test_request_signing4(self): # fetches the access control policy sub-resource for the 'johnsmith' bucket. date = "Tue, 27 Mar 2007 19:44:46 +0000" req = Request("s3://johnsmith/?acl", method="GET", headers={"Date": date}) with self._mocked_date(date): httpreq = await self.download_request(req) assert ( httpreq.headers["Authorization"] == b"AWS 0PN5J17HBGZHT7JJ3X82:thdUi9VAkzhkniLj96JIrOPGi0g=" ) @coroutine_test async def test_request_signing6(self): # uploads an object to a CNAME style virtual hosted bucket with metadata. date = "Tue, 27 Mar 2007 21:06:08 +0000" req = Request( "s3://static.johnsmith.net:8080/db-backup.dat.gz", method="PUT", headers={ "User-Agent": "curl/7.15.5", "Host": "static.johnsmith.net:8080", "Date": date, "x-amz-acl": "public-read", "content-type": "application/x-download", "Content-MD5": "4gJE4saaMU4BqNR0kLY+lw==", "X-Amz-Meta-ReviewedBy": "joe@johnsmith.net,jane@johnsmith.net", "X-Amz-Meta-FileChecksum": "0x02661779", "X-Amz-Meta-ChecksumAlgorithm": "crc32", "Content-Disposition": "attachment; filename=database.dat", "Content-Encoding": "gzip", "Content-Length": "5913339", }, ) with self._mocked_date(date): httpreq = await self.download_request(req) assert ( httpreq.headers["Authorization"] == b"AWS 0PN5J17HBGZHT7JJ3X82:C0FlOtU8Ylb9KDTpZqYkZPX91iI=" ) @coroutine_test async def test_request_signing7(self): # ensure that spaces are quoted properly before signing date = "Tue, 27 Mar 2007 19:42:41 +0000" req = Request( "s3://johnsmith/photos/my puppy.jpg?response-content-disposition=my puppy.jpg", method="GET", headers={"Date": date}, ) with self._mocked_date(date): httpreq = await self.download_request(req) assert ( httpreq.headers["Authorization"] == b"AWS 0PN5J17HBGZHT7JJ3X82:+CfvG8EZ3YccOrRVMXNaK2eKZmM=" ) @pytest.mark.skipif(is_botocore_available(), reason="Requires not having botocore") def test_s3_no_botocore() -> None: crawler = get_crawler() with pytest.raises(NotConfigured, match="missing botocore library"): build_from_crawler(S3DownloadHandler, crawler) class TestDataURI: def setup_method(self): crawler = get_crawler() download_handler = build_from_crawler(DataURIDownloadHandler, crawler) self.download_request = download_handler.download_request @coroutine_test async def test_response_attrs(self): uri = "data:,A%20brief%20note" request = Request(uri) response = await self.download_request(request) assert response.url == uri assert not response.headers @coroutine_test async def test_default_mediatype_encoding(self): request = Request("data:,A%20brief%20note") response = await self.download_request(request) assert response.text == "A brief note" assert type(response) is responsetypes.from_mimetype("text/plain") assert isinstance(response, TextResponse) assert response.encoding == "US-ASCII" @coroutine_test async def test_default_mediatype(self): request = Request("data:;charset=iso-8859-7,%be%d3%be") response = await self.download_request(request) assert response.text == "\u038e\u03a3\u038e" assert type(response) is responsetypes.from_mimetype("text/plain") assert isinstance(response, TextResponse) assert response.encoding == "iso-8859-7" @coroutine_test async def test_text_charset(self): request = Request("data:text/plain;charset=iso-8859-7,%be%d3%be") response = await self.download_request(request) assert response.text == "\u038e\u03a3\u038e" assert response.body == b"\xbe\xd3\xbe" assert isinstance(response, TextResponse) assert response.encoding == "iso-8859-7" @coroutine_test async def test_mediatype_parameters(self): request = Request( "data:text/plain;foo=%22foo;bar%5C%22%22;" "charset=utf-8;bar=%22foo;%5C%22 foo ;/,%22" ",%CE%8E%CE%A3%CE%8E" ) response = await self.download_request(request) assert response.text == "\u038e\u03a3\u038e" assert type(response) is responsetypes.from_mimetype("text/plain") assert isinstance(response, TextResponse) assert response.encoding == "utf-8" @coroutine_test async def test_base64(self): request = Request("data:text/plain;base64,SGVsbG8sIHdvcmxkLg%3D%3D") response = await self.download_request(request) assert response.text == "Hello, world." @coroutine_test async def test_binary_mediatype(self): request = Request("data:image/png;base64,iVBORw0KGgo%3D") response = await self.download_request(request) assert response.body == b"\x89PNG\r\n\x1a\n" assert type(response) is responsetypes.from_mimetype("image/png") assert not isinstance(response, TextResponse) @coroutine_test async def test_protocol(self): request = Request("data:,") response = await self.download_request(request) assert response.protocol is None class TestWrapTwistedExceptions: """Every Twisted exception the HTTP handlers can raise has a Scrapy equivalent. Most mappings are also covered end to end by the HTTP handler tests, but a Twisted ``TimeoutError`` needs a connection attempt that is silently dropped rather than refused, which no mock server can offer reliably. """ @pytest.mark.parametrize( ("twisted_exception", "scrapy_exception"), [ (SchemeNotSupported, UnsupportedURLSchemeError), (CancelledError, DownloadCancelledError), (TxConnectionRefusedError, DownloadConnectionRefusedError), (DNSLookupError, CannotResolveHostError), (ResponseFailed, DownloadFailedError), (TxTimeoutError, DownloadTimeoutError), ], ) def test_mapping( self, twisted_exception: type[Exception], scrapy_exception: type[Exception], ) -> None: original = twisted_exception("some message") with pytest.raises(scrapy_exception) as exc_info, wrap_twisted_exceptions(): raise original assert exc_info.value.__cause__ is original assert str(exc_info.value) == str(original) def test_other_exceptions_pass_through(self) -> None: with pytest.raises(ZeroDivisionError), wrap_twisted_exceptions(): 1 / 0 scrapy-scrapy-5569de3/tests/test_downloadermiddleware.py000066400000000000000000000313231525051012400236240ustar00rootroot00000000000000from __future__ import annotations import asyncio from contextlib import asynccontextmanager from gzip import BadGzipFile from typing import TYPE_CHECKING from unittest import mock import pytest from twisted.internet.defer import Deferred, succeed from scrapy.core.downloader.middleware import DownloaderMiddlewareManager from scrapy.exceptions import ScrapyDeprecationWarning, _InvalidOutput from scrapy.http import Request, Response from scrapy.spiders import Spider from scrapy.utils.defer import maybe_deferred_to_future from scrapy.utils.misc import build_from_crawler from scrapy.utils.python import to_bytes from scrapy.utils.test import get_crawler, get_from_asyncio_queue from tests.utils.decorators import coroutine_test if TYPE_CHECKING: from collections.abc import AsyncGenerator class TestManagerBase: settings_dict = None # should be a fixture but async fixtures that use Futures are problematic with pytest-twisted @asynccontextmanager async def get_mwman(self) -> AsyncGenerator[DownloaderMiddlewareManager]: crawler = get_crawler(Spider, self.settings_dict) crawler.spider = crawler._create_spider("foo") mwman = build_from_crawler(DownloaderMiddlewareManager, crawler) crawler.engine = crawler._create_engine() await crawler.engine.open_spider_async() try: yield mwman finally: await crawler.engine.close_spider_async() @staticmethod async def _download( mwman: DownloaderMiddlewareManager, request: Request, response: Response | None = None, ) -> Response | Request: """Executes downloader mw manager's download method and returns the result (Request or Response) or raises exception in case of failure. """ if not response: response = Response(request.url) async def download_func(request: Request) -> Response: return response return await mwman.download_async(download_func, request) class TestDefaults(TestManagerBase): """Tests default behavior with default settings""" @coroutine_test async def test_request_response(self): req = Request("http://example.com/index.html") resp = Response(req.url, status=200) async with self.get_mwman() as mwman: ret = await self._download(mwman, req, resp) assert isinstance(ret, Response), "Non-response returned" @coroutine_test async def test_none_from_download_func(self): async def download_func(request: Request) -> None: return None async with self.get_mwman() as mwman: with pytest.raises(TypeError, match="Received None in process_response"): await mwman.download_async( download_func, # type: ignore[arg-type] Request("http://example.com/index.html"), ) @coroutine_test async def test_3xx_and_invalid_gzipped_body_must_redirect(self): """Regression test for a failure when redirecting a compressed request. This happens when httpcompression middleware is executed before redirect middleware and attempts to decompress a non-compressed body. In particular when some website returns a 30x response with header 'Content-Encoding: gzip' giving as result the error below: BadGzipFile: Not a gzipped file (...) """ req = Request("http://example.com") body = b"

    You are being redirected

    " resp = Response( req.url, status=302, body=body, headers={ "Content-Length": str(len(body)), "Content-Type": "text/html", "Content-Encoding": "gzip", "Location": "http://example.com/login", }, ) async with self.get_mwman() as mwman: ret = await self._download(mwman, req, resp) assert isinstance(ret, Request), f"Not redirected: {ret!r}" assert to_bytes(ret.url) == resp.headers["Location"], ( "Not redirected to location header" ) @coroutine_test async def test_200_and_invalid_gzipped_body_must_fail(self): req = Request("http://example.com") body = b"

    You are being redirected

    " resp = Response( req.url, status=200, body=body, headers={ "Content-Length": str(len(body)), "Content-Type": "text/html", "Content-Encoding": "gzip", "Location": "http://example.com/login", }, ) with pytest.raises(BadGzipFile): async with self.get_mwman() as mwman: await self._download(mwman, req, resp) class TestResponseFromProcessRequest(TestManagerBase): """Tests middleware returning a response from process_request.""" @coroutine_test async def test_download_func_not_called(self): req = Request("http://example.com/index.html") resp = Response("http://example.com/index.html") download_func = mock.MagicMock() class ResponseMiddleware: def process_request(self, request): return resp async with self.get_mwman() as mwman: mwman._add_middleware(ResponseMiddleware()) result = await mwman.download_async(download_func, req) assert result is resp assert not download_func.called class TestResponseFromProcessException(TestManagerBase): """Tests middleware returning a response from process_exception.""" @coroutine_test async def test_process_response_called(self): req = Request("http://example.com/index.html") resp = Response("http://example.com/index.html") calls = [] def download_func(request): raise ValueError("test") class ResponseMiddleware: def process_response(self, request, response): calls.append("process_response") return resp def process_exception(self, request, exception): calls.append("process_exception") return resp async with self.get_mwman() as mwman: mwman._add_middleware(ResponseMiddleware()) result = await mwman.download_async(download_func, req) assert result is resp assert calls == [ "process_exception", "process_response", ] class TestInvalidOutput(TestManagerBase): @coroutine_test async def test_invalid_process_request(self): """Invalid return value for process_request method should raise an exception""" req = Request("http://example.com/index.html") class InvalidProcessRequestMiddleware: def process_request(self, request): return 1 async with self.get_mwman() as mwman: mwman._add_middleware(InvalidProcessRequestMiddleware()) with pytest.raises(_InvalidOutput): await self._download(mwman, req) @coroutine_test async def test_invalid_process_response(self): """Invalid return value for process_response method should raise an exception""" req = Request("http://example.com/index.html") class InvalidProcessResponseMiddleware: def process_response(self, request, response): return 1 async with self.get_mwman() as mwman: mwman._add_middleware(InvalidProcessResponseMiddleware()) with pytest.raises(_InvalidOutput): await self._download(mwman, req) @coroutine_test async def test_invalid_process_exception(self): """Invalid return value for process_exception method should raise an exception""" req = Request("http://example.com/index.html") class InvalidProcessExceptionMiddleware: def process_request(self, request): raise RuntimeError def process_exception(self, request, exception): return 1 async with self.get_mwman() as mwman: mwman._add_middleware(InvalidProcessExceptionMiddleware()) with pytest.raises(_InvalidOutput): await self._download(mwman, req) class TestMiddlewareUsingDeferreds(TestManagerBase): """Middlewares using Deferreds (deprecated) should work""" @coroutine_test async def test_deferred(self): req = Request("http://example.com/index.html") resp = Response("http://example.com/index.html") download_func = mock.MagicMock() class DeferredMiddleware: def cb(self, result): return result def process_request(self, request): d: Deferred[Response] = Deferred() d.addCallback(self.cb) d.callback(resp) return d async with self.get_mwman() as mwman: mwman._add_middleware(DeferredMiddleware()) with pytest.warns( ScrapyDeprecationWarning, match="returned a Deferred, this is deprecated", ): result = await mwman.download_async(download_func, req) assert result is resp assert not download_func.called class TestMiddlewareUsingCoro(TestManagerBase): """Middlewares using asyncio coroutines should work""" @coroutine_test async def test_asyncdef(self): req = Request("http://example.com/index.html") resp = Response("http://example.com/index.html") download_func = mock.MagicMock() class CoroMiddleware: async def process_request(self, request): await succeed(42) return resp async with self.get_mwman() as mwman: mwman._add_middleware(CoroMiddleware()) result = await mwman.download_async(download_func, req) assert result is resp assert not download_func.called @pytest.mark.only_asyncio @coroutine_test async def test_asyncdef_asyncio(self): req = Request("http://example.com/index.html") resp = Response("http://example.com/index.html") download_func = mock.MagicMock() class CoroMiddleware: async def process_request(self, request): await asyncio.sleep(0.1) return await get_from_asyncio_queue(resp) async with self.get_mwman() as mwman: mwman._add_middleware(CoroMiddleware()) result = await mwman.download_async(download_func, req) assert result is resp assert not download_func.called class TestDownloadDeprecated(TestManagerBase): @coroutine_test async def test_mwman_download(self): req = Request("http://example.com/index.html") resp = Response(req.url, status=200) def download_func(request: Request, spider: Spider) -> Deferred[Response]: return succeed(resp) async with self.get_mwman() as mwman: assert mwman.crawler assert mwman.crawler.spider with pytest.warns( ScrapyDeprecationWarning, match=r"DownloaderMiddlewareManager.download\(\) is deprecated, use download_async\(\) instead", ): ret = await maybe_deferred_to_future( mwman.download(download_func, req, mwman.crawler.spider) ) assert isinstance(ret, Response) class TestDeprecatedSpiderArg(TestManagerBase): @coroutine_test async def test_deprecated_spider_arg(self): req = Request("http://example.com/index.html") resp = Response("http://example.com/index.html") download_func = mock.MagicMock() class DeprecatedSpiderArgMiddleware: def process_request(self, request, spider): 1 / 0 def process_response(self, request, response, spider): return response def process_exception(self, request, exception, spider): return resp async with self.get_mwman() as mwman: with ( pytest.warns( ScrapyDeprecationWarning, match=r"process_request\(\) requires a spider argument", ), pytest.warns( ScrapyDeprecationWarning, match=r"process_response\(\) requires a spider argument", ), pytest.warns( ScrapyDeprecationWarning, match=r"process_exception\(\) requires a spider argument", ), ): mwman._add_middleware(DeprecatedSpiderArgMiddleware()) result = await mwman.download_async(download_func, req) assert result is resp assert not download_func.called scrapy-scrapy-5569de3/tests/test_downloadermiddleware_cookies.py000066400000000000000000001003431525051012400253370ustar00rootroot00000000000000import logging from collections.abc import Iterable from typing import Any import pytest from scrapy.downloadermiddlewares.cookies import CookiesMiddleware from scrapy.downloadermiddlewares.defaultheaders import DefaultHeadersMiddleware from scrapy.downloadermiddlewares.redirect import RedirectMiddleware from scrapy.exceptions import NotConfigured from scrapy.http import Request, Response from scrapy.http.request import CookiesT, VerboseCookie from scrapy.utils.misc import build_from_crawler from scrapy.utils.python import to_bytes from scrapy.utils.request import _to_verbose_cookies from scrapy.utils.spider import DefaultSpider from scrapy.utils.test import get_crawler UNSET = object() def _cookie_to_set_cookie_value(cookie: VerboseCookie) -> str | None: """Given a cookie defined as a dictionary with name and value keys, and optional path and domain keys, return the equivalent string that can be associated to a ``Set-Cookie`` header.""" decoded = {} for key in ("name", "value", "path", "domain"): value = cookie.get(key) if value is None: if key in {"name", "value"}: return None continue if isinstance(value, (bool, float, int, str)): decoded[key] = str(value) else: assert isinstance(value, bytes) try: decoded[key] = value.decode("utf8") except UnicodeDecodeError: decoded[key] = value.decode("latin1", errors="replace") cookie_str = f"{decoded.pop('name')}={decoded.pop('value')}" for key, value in decoded.items(): # path, domain cookie_str += f"; {key.capitalize()}={value}" return cookie_str def _cookies_to_set_cookie_list(cookies: CookiesT) -> Iterable[str]: """Given a group of cookie defined either as a dictionary or as a list of dictionaries (i.e. in a format supported by the cookies parameter of Request), return the equivalent list of strings that can be associated to a ``Set-Cookie`` header.""" if not cookies: return [] return filter( None, ( _cookie_to_set_cookie_value(cookie) for cookie in _to_verbose_cookies(cookies) ), ) class TestCookiesMiddleware: @staticmethod def assertCookieValEqual(first: bytes | str | None, second: bytes | str) -> None: def split_cookies(cookies: bytes | str) -> list[bytes]: return sorted([s.strip() for s in to_bytes(cookies).split(b";")]) assert first is not None assert split_cookies(first) == split_cookies(second) def setup_method(self): crawler = get_crawler(DefaultSpider) crawler.spider = crawler._create_spider() self.mw = build_from_crawler(CookiesMiddleware, crawler) self.redirect_middleware = build_from_crawler(RedirectMiddleware, crawler) def teardown_method(self): del self.mw del self.redirect_middleware def test_basic(self): req = Request("http://scrapytest.org/") assert self.mw.process_request(req) is None assert "Cookie" not in req.headers headers = {"Set-Cookie": "C1=value1; path=/"} res = Response("http://scrapytest.org/", headers=headers) assert self.mw.process_response(req, res) is res req2 = Request("http://scrapytest.org/sub1/") assert self.mw.process_request(req2) is None assert req2.headers.get("Cookie") == b"C1=value1" def test_setting_false_cookies_enabled(self): with pytest.raises(NotConfigured): build_from_crawler( CookiesMiddleware, get_crawler(settings_dict={"COOKIES_ENABLED": False}) ) def test_setting_default_cookies_enabled(self): assert isinstance( build_from_crawler(CookiesMiddleware, get_crawler()), CookiesMiddleware ) def test_setting_true_cookies_enabled(self): assert isinstance( build_from_crawler( CookiesMiddleware, get_crawler(settings_dict={"COOKIES_ENABLED": True}) ), CookiesMiddleware, ) def test_setting_enabled_cookies_debug( self, caplog: pytest.LogCaptureFixture ) -> None: crawler = get_crawler(settings_dict={"COOKIES_DEBUG": True}) mw = build_from_crawler(CookiesMiddleware, crawler) caplog.clear() with caplog.at_level( logging.DEBUG, logger="scrapy.downloadermiddlewares.cookies" ): req = Request("http://scrapytest.org/") res = Response( "http://scrapytest.org/", headers={"Set-Cookie": "C1=value1; path=/"} ) mw.process_response(req, res) req2 = Request("http://scrapytest.org/sub1/") mw.process_request(req2) assert caplog.record_tuples == [ ( "scrapy.downloadermiddlewares.cookies", logging.DEBUG, "Received cookies from: <200 http://scrapytest.org/>\n" "Set-Cookie: C1=value1; path=/\n", ), ( "scrapy.downloadermiddlewares.cookies", logging.DEBUG, "Sending cookies to: \n" "Cookie: C1=value1\n", ), ] def test_debug_no_cookies(self, caplog: pytest.LogCaptureFixture) -> None: crawler = get_crawler(settings_dict={"COOKIES_DEBUG": True}) mw = build_from_crawler(CookiesMiddleware, crawler) caplog.clear() with caplog.at_level( logging.DEBUG, logger="scrapy.downloadermiddlewares.cookies" ): req = Request("http://scrapytest.org/") res = Response("http://scrapytest.org/") # no Set-Cookie header mw.process_response(req, res) mw.process_request(req) # no cookies to send either # no log output since cl is empty in both cases assert caplog.record_tuples == [] def test_setting_disabled_cookies_debug( self, caplog: pytest.LogCaptureFixture ) -> None: crawler = get_crawler(settings_dict={"COOKIES_DEBUG": False}) mw = build_from_crawler(CookiesMiddleware, crawler) caplog.clear() with caplog.at_level( logging.DEBUG, logger="scrapy.downloadermiddlewares.cookies" ): req = Request("http://scrapytest.org/") res = Response( "http://scrapytest.org/", headers={"Set-Cookie": "C1=value1; path=/"} ) mw.process_response(req, res) req2 = Request("http://scrapytest.org/sub1/") mw.process_request(req2) assert caplog.record_tuples == [] def test_do_not_break_on_non_utf8_header(self): req = Request("http://scrapytest.org/") assert self.mw.process_request(req) is None assert "Cookie" not in req.headers headers = {"Set-Cookie": b"C1=in\xa3valid; path=/", "Other": b"ignore\xa3me"} res = Response("http://scrapytest.org/", headers=headers) assert self.mw.process_response(req, res) is res req2 = Request("http://scrapytest.org/sub1/") assert self.mw.process_request(req2) is None assert "Cookie" in req2.headers def test_dont_merge_cookies(self): # merge some cookies into jar headers = {"Set-Cookie": "C1=value1; path=/"} req = Request("http://scrapytest.org/") res = Response("http://scrapytest.org/", headers=headers) assert self.mw.process_response(req, res) is res # test Cookie header is not seted to request req = Request("http://scrapytest.org/dontmerge", meta={"dont_merge_cookies": 1}) assert self.mw.process_request(req) is None assert "Cookie" not in req.headers # check that returned cookies are not merged back to jar res = Response( "http://scrapytest.org/dontmerge", headers={"Set-Cookie": "dont=mergeme; path=/"}, ) assert self.mw.process_response(req, res) is res # check that cookies are merged back req = Request("http://scrapytest.org/mergeme") assert self.mw.process_request(req) is None assert req.headers.get("Cookie") == b"C1=value1" # check that cookies are merged when dont_merge_cookies is passed as 0 req = Request("http://scrapytest.org/mergeme", meta={"dont_merge_cookies": 0}) assert self.mw.process_request(req) is None assert req.headers.get("Cookie") == b"C1=value1" def test_complex_cookies(self): # merge some cookies into jar cookies: list[VerboseCookie] = [ { "name": "C1", "value": "value1", "path": "/foo", "domain": "scrapytest.org", }, { "name": "C2", "value": "value2", "path": "/bar", "domain": "scrapytest.org", }, { "name": "C3", "value": "value3", "path": "/foo", "domain": "scrapytest.org", }, {"name": "C4", "value": "value4", "path": "/foo", "domain": "scrapy.org"}, ] req = Request("http://scrapytest.org/", cookies=cookies) self.mw.process_request(req) # embed C1 and C3 for scrapytest.org/foo req = Request("http://scrapytest.org/foo") self.mw.process_request(req) assert req.headers.get("Cookie") in ( b"C1=value1; C3=value3", b"C3=value3; C1=value1", ) # embed C2 for scrapytest.org/bar req = Request("http://scrapytest.org/bar") self.mw.process_request(req) assert req.headers.get("Cookie") == b"C2=value2" # embed nothing for scrapytest.org/baz req = Request("http://scrapytest.org/baz") self.mw.process_request(req) assert "Cookie" not in req.headers def test_merge_request_cookies(self): req = Request("http://scrapytest.org/", cookies={"galleta": "salada"}) assert self.mw.process_request(req) is None assert req.headers.get("Cookie") == b"galleta=salada" headers = {"Set-Cookie": "C1=value1; path=/"} res = Response("http://scrapytest.org/", headers=headers) assert self.mw.process_response(req, res) is res req2 = Request("http://scrapytest.org/sub1/") assert self.mw.process_request(req2) is None self.assertCookieValEqual( req2.headers.get("Cookie"), b"C1=value1; galleta=salada" ) def test_cookiejar_key(self): req = Request( "http://scrapytest.org/", cookies={"galleta": "salada"}, meta={"cookiejar": "store1"}, ) assert self.mw.process_request(req) is None assert req.headers.get("Cookie") == b"galleta=salada" headers = {"Set-Cookie": "C1=value1; path=/"} res = Response("http://scrapytest.org/", headers=headers, request=req) assert self.mw.process_response(req, res) is res req2 = Request("http://scrapytest.org/", meta=res.meta) assert self.mw.process_request(req2) is None self.assertCookieValEqual( req2.headers.get("Cookie"), b"C1=value1; galleta=salada" ) req3 = Request( "http://scrapytest.org/", cookies={"galleta": "dulce"}, meta={"cookiejar": "store2"}, ) assert self.mw.process_request(req3) is None assert req3.headers.get("Cookie") == b"galleta=dulce" headers = {"Set-Cookie": "C2=value2; path=/"} res2 = Response("http://scrapytest.org/", headers=headers, request=req3) assert self.mw.process_response(req3, res2) is res2 req4 = Request("http://scrapytest.org/", meta=res2.meta) assert self.mw.process_request(req4) is None self.assertCookieValEqual( req4.headers.get("Cookie"), b"C2=value2; galleta=dulce" ) # cookies from hosts with port req5_1 = Request("http://scrapytest.org:1104/") assert self.mw.process_request(req5_1) is None headers = {"Set-Cookie": "C1=value1; path=/"} res5_1 = Response( "http://scrapytest.org:1104/", headers=headers, request=req5_1 ) assert self.mw.process_response(req5_1, res5_1) is res5_1 req5_2 = Request("http://scrapytest.org:1104/some-redirected-path") assert self.mw.process_request(req5_2) is None assert req5_2.headers.get("Cookie") == b"C1=value1" req5_3 = Request("http://scrapytest.org/some-redirected-path") assert self.mw.process_request(req5_3) is None assert req5_3.headers.get("Cookie") == b"C1=value1" # skip cookie retrieval for not http request req6 = Request("file:///scrapy/sometempfile") assert self.mw.process_request(req6) is None assert req6.headers.get("Cookie") is None def test_local_domain(self): request = Request("http://example-host/", cookies={"currencyCookie": "USD"}) assert self.mw.process_request(request) is None assert "Cookie" in request.headers assert request.headers["Cookie"] == b"currencyCookie=USD" @pytest.mark.parametrize( ("url", "domain"), [ ("http://example-host/", "example-host.local"), ("http://127.0.0.1/", "127.0.0.1"), pytest.param( "http://example-host/", "example-host", marks=pytest.mark.xfail( reason=( "http.cookiejar accepts a dotless domain for a dotless " "host but never returns the resulting cookie" ) ), ), ], ) def test_explicit_local_domain(self, url: str, domain: str) -> None: request = Request( url, cookies=[{"name": "currencyCookie", "value": "USD", "domain": domain}] ) assert self.mw.process_request(request) is None assert request.headers.get("Cookie") == b"currencyCookie=USD" @pytest.mark.xfail(reason="Cookie header is not currently being processed") def test_keep_cookie_from_default_request_headers_middleware(self): 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"}) assert mw_default_headers.process_request(req1) is None assert self.mw.process_request(req1) is None self.assertCookieValEqual( req1.headers["Cookie"], b"default=something; asdf=qwerty" ) # keep both req2 = Request("http://example.com", cookies={"a": "b"}) assert mw_default_headers.process_request(req2) is None assert self.mw.process_request(req2) is None self.assertCookieValEqual( req2.headers["Cookie"], b"default=value; a=b; asdf=qwerty" ) @pytest.mark.xfail(reason="Cookie header is not currently being processed") def test_keep_cookie_header(self): # keep only cookies from 'Cookie' request header req1 = Request("http://scrapytest.org", headers={"Cookie": "a=b; c=d"}) assert self.mw.process_request(req1) is None self.assertCookieValEqual(req1.headers["Cookie"], "a=b; c=d") # keep cookies from both 'Cookie' request header and 'cookies' keyword req2 = Request( "http://scrapytest.org", headers={"Cookie": "a=b; c=d"}, cookies={"e": "f"} ) assert self.mw.process_request(req2) is None self.assertCookieValEqual(req2.headers["Cookie"], "a=b; c=d; e=f") # overwrite values from 'Cookie' request header with 'cookies' keyword req3 = Request( "http://scrapytest.org", headers={"Cookie": "a=b; c=d"}, cookies={"a": "new", "e": "f"}, ) assert self.mw.process_request(req3) is None self.assertCookieValEqual(req3.headers["Cookie"], "a=new; c=d; e=f") @pytest.mark.parametrize( "cookies", [ # UTF8-encoded bytes {"a": "á".encode()}, # non UTF8-encoded bytes {"a": "á".encode("latin1")}, # string {"a": "á"}, # key as bytes {b"a": "á"}, # key and value as bytes {b"a": "á".encode()}, ], ) def test_request_cookies_encoding(self, cookies: CookiesT) -> None: req = Request("http://example.org", cookies=cookies) assert self.mw.process_request(req) is None self.assertCookieValEqual(req.headers["Cookie"], b"a=\xc3\xa1") @pytest.mark.xfail(reason="Cookie header is not currently being processed") def test_request_headers_cookie_encoding(self): # 1) UTF8-encoded bytes req1 = Request("http://example.org", headers={"Cookie": "a=á".encode()}) assert self.mw.process_request(req1) is None self.assertCookieValEqual(req1.headers["Cookie"], b"a=\xc3\xa1") # 2) Non UTF8-encoded bytes req2 = Request("http://example.org", headers={"Cookie": "a=á".encode("latin1")}) assert self.mw.process_request(req2) is None self.assertCookieValEqual(req2.headers["Cookie"], b"a=\xc3\xa1") # 3) String req3 = Request("http://example.org", headers={"Cookie": "a=á"}) assert self.mw.process_request(req3) is None self.assertCookieValEqual(req3.headers["Cookie"], b"a=\xc3\xa1") def test_invalid_cookies(self, caplog: pytest.LogCaptureFixture) -> None: """ Invalid cookies are logged as warnings and discarded """ caplog.clear() with caplog.at_level(logging.INFO, logger="scrapy.utils.request"): cookies1 = [{"value": "bar"}, {"name": "key", "value": "value1"}] req1 = Request("http://example.org/1", cookies=cookies1) # type: ignore[arg-type] assert self.mw.process_request(req1) is None cookies2 = [{"name": "foo"}, {"name": "key", "value": "value2"}] req2 = Request("http://example.org/2", cookies=cookies2) # type: ignore[arg-type] assert self.mw.process_request(req2) is None cookies3 = [{"name": "foo", "value": None}, {"name": "key", "value": ""}] req3 = Request("http://example.org/3", cookies=cookies3) # type: ignore[arg-type] assert self.mw.process_request(req3) is None assert caplog.record_tuples == [ ( "scrapy.utils.request", logging.WARNING, "Invalid cookie found in request :" " {'value': 'bar', 'secure': False} ('name' is missing)", ), ( "scrapy.utils.request", logging.WARNING, "Invalid cookie found in request :" " {'name': 'foo', 'secure': False} ('value' is missing)", ), ( "scrapy.utils.request", logging.WARNING, "Invalid cookie found in request :" " {'name': 'foo', 'value': None, 'secure': False} ('value' is missing)", ), ] self.assertCookieValEqual(req1.headers["Cookie"], "key=value1") self.assertCookieValEqual(req2.headers["Cookie"], "key=value2") self.assertCookieValEqual(req3.headers["Cookie"], "key=") def test_primitive_type_cookies(self): # Boolean req1 = Request("http://example.org", cookies={"a": True}) assert self.mw.process_request(req1) is None self.assertCookieValEqual(req1.headers["Cookie"], b"a=True") # Float req2 = Request("http://example.org", cookies={"a": 9.5}) assert self.mw.process_request(req2) is None self.assertCookieValEqual(req2.headers["Cookie"], b"a=9.5") # Integer req3 = Request("http://example.org", cookies={"a": 10}) assert self.mw.process_request(req3) is None self.assertCookieValEqual(req3.headers["Cookie"], b"a=10") # String req4 = Request("http://example.org", cookies={"a": "b"}) assert self.mw.process_request(req4) is None self.assertCookieValEqual(req4.headers["Cookie"], b"a=b") def _test_cookie_redirect( self, source: str | dict[str, Any], target: str | dict[str, Any], *, cookies1: bool, cookies2: bool, ) -> None: input_cookies: CookiesT = {"a": "b"} if not isinstance(source, dict): source = {"url": source} if not isinstance(target, dict): target = {"url": target} target.setdefault("status", 301) request1 = Request(cookies=input_cookies, **source) self.mw.process_request(request1) cookies = request1.headers.get("Cookie") assert cookies == (b"a=b" if cookies1 else None) response = Response( headers={ "Location": target["url"], }, **target, ) assert self.mw.process_response(request1, response) == response request2 = self.redirect_middleware.process_response(request1, response) assert isinstance(request2, Request) self.mw.process_request(request2) cookies = request2.headers.get("Cookie") assert cookies == (b"a=b" if cookies2 else None) def test_cookie_redirect_same_domain(self): self._test_cookie_redirect( "https://toscrape.com", "https://toscrape.com", cookies1=True, cookies2=True, ) def test_cookie_redirect_same_domain_forcing_get(self): self._test_cookie_redirect( "https://toscrape.com", {"url": "https://toscrape.com", "status": 302}, cookies1=True, cookies2=True, ) def test_cookie_redirect_different_domain(self): self._test_cookie_redirect( "https://toscrape.com", "https://example.com", cookies1=True, cookies2=False, ) def test_cookie_redirect_different_domain_forcing_get(self): self._test_cookie_redirect( "https://toscrape.com", {"url": "https://example.com", "status": 302}, cookies1=True, cookies2=False, ) def _test_cookie_header_redirect( self, source: str | dict[str, Any], target: str | dict[str, Any], *, cookies2: bool, ) -> None: """Test the handling of a user-defined Cookie header when building a redirect follow-up request. We follow RFC 6265 for cookie handling. The Cookie header can only contain a list of key-value pairs (i.e. no additional cookie parameters like Domain or Path). Because of that, we follow the same rules that we would follow for the handling of the Set-Cookie response header when the Domain is not set: the cookies must be limited to the target URL domain (not even subdomains can receive those cookies). .. note:: This method tests the scenario where the cookie middleware is disabled. Because of known issue #1992, when the cookies middleware is enabled we do not need to be concerned about the Cookie header getting leaked to unintended domains, because the middleware empties the header from every request. """ if not isinstance(source, dict): source = {"url": source} if not isinstance(target, dict): target = {"url": target} target.setdefault("status", 301) request1 = Request(headers={"Cookie": b"a=b"}, **source) response = Response( headers={ "Location": target["url"], }, **target, ) request2 = self.redirect_middleware.process_response(request1, response) assert isinstance(request2, Request) cookies = request2.headers.get("Cookie") assert cookies == (b"a=b" if cookies2 else None) def test_cookie_header_redirect_same_domain(self): self._test_cookie_header_redirect( "https://toscrape.com", "https://toscrape.com", cookies2=True, ) def test_cookie_header_redirect_same_domain_forcing_get(self): self._test_cookie_header_redirect( "https://toscrape.com", {"url": "https://toscrape.com", "status": 302}, cookies2=True, ) def test_cookie_header_redirect_different_domain(self): self._test_cookie_header_redirect( "https://toscrape.com", "https://example.com", cookies2=False, ) def test_cookie_header_redirect_different_domain_forcing_get(self): self._test_cookie_header_redirect( "https://toscrape.com", {"url": "https://example.com", "status": 302}, cookies2=False, ) def _test_user_set_cookie_domain_followup( self, url1: str, url2: str, domain: str, *, cookies1: bool, cookies2: bool, ) -> None: input_cookies: list[VerboseCookie] = [ { "name": "a", "value": "b", "domain": domain, } ] request1 = Request(url1, cookies=input_cookies) self.mw.process_request(request1) cookies = request1.headers.get("Cookie") assert cookies == (b"a=b" if cookies1 else None) request2 = Request(url2) self.mw.process_request(request2) cookies = request2.headers.get("Cookie") assert cookies == (b"a=b" if cookies2 else None) def test_user_set_cookie_domain_suffix_private(self): self._test_user_set_cookie_domain_followup( "https://books.toscrape.com", "https://quotes.toscrape.com", "toscrape.com", cookies1=True, cookies2=True, ) def test_user_set_cookie_domain_suffix_public_period(self): self._test_user_set_cookie_domain_followup( "https://foo.co.uk", "https://bar.co.uk", "co.uk", cookies1=False, cookies2=False, ) def test_user_set_cookie_domain_suffix_public_private(self): self._test_user_set_cookie_domain_followup( "https://foo.blogspot.com", "https://bar.blogspot.com", "blogspot.com", cookies1=False, cookies2=False, ) def test_user_set_cookie_domain_public_period(self): self._test_user_set_cookie_domain_followup( "https://co.uk", "https://co.uk", "co.uk", cookies1=True, cookies2=True, ) def _test_server_set_cookie_domain_followup( self, url1: str, url2: str, domain: str, *, cookies: bool, ) -> None: request1 = Request(url1) self.mw.process_request(request1) input_cookies: list[VerboseCookie] = [ { "name": "a", "value": "b", "domain": domain, } ] headers = { "Set-Cookie": _cookies_to_set_cookie_list(input_cookies), } response = Response(url1, status=200, headers=headers) assert self.mw.process_response(request1, response) == response request2 = Request(url2) self.mw.process_request(request2) actual_cookies = request2.headers.get("Cookie") assert actual_cookies == (b"a=b" if cookies else None) def test_server_set_cookie_domain_suffix_private(self): self._test_server_set_cookie_domain_followup( "https://books.toscrape.com", "https://quotes.toscrape.com", "toscrape.com", cookies=True, ) def test_server_set_cookie_domain_suffix_public_period(self): self._test_server_set_cookie_domain_followup( "https://foo.co.uk", "https://bar.co.uk", "co.uk", cookies=False, ) def test_server_set_cookie_domain_suffix_public_private(self): self._test_server_set_cookie_domain_followup( "https://foo.blogspot.com", "https://bar.blogspot.com", "blogspot.com", cookies=False, ) def test_server_set_cookie_domain_public_period(self): self._test_server_set_cookie_domain_followup( "https://co.uk", "https://co.uk", "co.uk", cookies=True, ) def _test_cookie_redirect_scheme_change( self, secure: bool | object, from_scheme: str, to_scheme: str, cookies1: bool, cookies2: bool, cookies3: bool, ) -> None: """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: VerboseCookie = {"name": "a", "value": "b"} if secure is not UNSET: assert isinstance(secure, bool) cookie["secure"] = secure input_cookies = [cookie] request1 = Request(f"{from_scheme}://a.example", cookies=input_cookies) self.mw.process_request(request1) cookies = request1.headers.get("Cookie") assert cookies == (b"a=b" if cookies1 else None) response = Response( f"{from_scheme}://a.example", headers={"Location": f"{to_scheme}://a.example"}, status=301, ) assert self.mw.process_response(request1, response) == response request2 = self.redirect_middleware.process_response(request1, response) assert isinstance(request2, Request) cookies = request2.headers.get("Cookie") assert cookies == (b"a=b" if cookies2 else None) self.mw.process_request(request2) cookies = request2.headers.get("Cookie") assert 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, ) def test_hostless_url(self): request = Request("data:,", cookies={"a": "b"}) assert self.mw.process_request(request) is None assert "Cookie" not in request.headers scrapy-scrapy-5569de3/tests/test_downloadermiddleware_defaultheaders.py000066400000000000000000000023221525051012400266610ustar00rootroot00000000000000from __future__ import annotations from scrapy.downloadermiddlewares.defaultheaders import DefaultHeadersMiddleware from scrapy.http import Request from scrapy.spiders import Spider from scrapy.utils.misc import build_from_crawler from scrapy.utils.python import to_bytes from scrapy.utils.test import get_crawler def get_defaults_mw() -> tuple[dict[bytes, list[bytes]], DefaultHeadersMiddleware]: crawler = get_crawler(Spider) defaults = { to_bytes(k): [to_bytes(v)] for k, v in crawler.settings.get("DEFAULT_REQUEST_HEADERS").items() } return defaults, build_from_crawler(DefaultHeadersMiddleware, crawler) def test_process_request(): defaults, mw = get_defaults_mw() req = Request("http://www.scrapytest.org") mw.process_request(req) assert req.headers == defaults def test_update_headers(): defaults, mw = get_defaults_mw() headers = {"Accept-Language": ["es"], "Test-Header": ["test"]} bytes_headers = {b"Accept-Language": [b"es"], b"Test-Header": [b"test"]} req = Request("http://www.scrapytest.org", headers=headers) assert req.headers == bytes_headers mw.process_request(req) defaults.update(bytes_headers) assert req.headers == defaults scrapy-scrapy-5569de3/tests/test_downloadermiddleware_downloadtimeout.py000066400000000000000000000033431525051012400271230ustar00rootroot00000000000000from __future__ import annotations from typing import Any from scrapy.downloadermiddlewares.downloadtimeout import DownloadTimeoutMiddleware from scrapy.http import Request from scrapy.spiders import Spider from scrapy.utils.misc import build_from_crawler from scrapy.utils.test import get_crawler def get_request_spider_mw(settings: dict[str, Any] | None = None): crawler = get_crawler(Spider, settings) spider = crawler._create_spider("foo") request = Request("http://scrapytest.org/") return request, spider, build_from_crawler(DownloadTimeoutMiddleware, crawler) def test_default_download_timeout(): req, spider, mw = get_request_spider_mw() mw.spider_opened(spider) assert mw.process_request(req) is None assert req.meta.get("download_timeout") == 180 def test_string_download_timeout(): req, spider, mw = get_request_spider_mw({"DOWNLOAD_TIMEOUT": "20.1"}) mw.spider_opened(spider) assert mw.process_request(req) is None assert req.meta.get("download_timeout") == 20.1 def test_setting_has_download_timeout(): req, spider, mw = get_request_spider_mw({"DOWNLOAD_TIMEOUT": 2}) mw.spider_opened(spider) assert mw.process_request(req) is None assert req.meta.get("download_timeout") == 2 def test_request_has_download_timeout(): req, spider, mw = get_request_spider_mw({"DOWNLOAD_TIMEOUT": 2}) mw.spider_opened(spider) req.meta["download_timeout"] = 1 assert mw.process_request(req) is None assert req.meta.get("download_timeout") == 1 def test_zero_download_timeout(): req, spider, mw = get_request_spider_mw({"DOWNLOAD_TIMEOUT": 0}) mw.spider_opened(spider) assert mw.process_request(req) is None assert req.meta.get("download_timeout") is None scrapy-scrapy-5569de3/tests/test_downloadermiddleware_httpauth.py000066400000000000000000000134031525051012400255440ustar00rootroot00000000000000from typing import Any import pytest from w3lib.http import basic_auth_header from scrapy.downloadermiddlewares.httpauth import HttpAuthMiddleware from scrapy.exceptions import ScrapyDeprecationWarning from scrapy.http import Request from scrapy.spiders import Spider from scrapy.utils.misc import build_from_crawler from scrapy.utils.test import get_crawler _DOMAIN_NOT_SET = object() def make_mw( user: str = "", passwd: str = "", domain: str | object = _DOMAIN_NOT_SET ) -> HttpAuthMiddleware: settings: dict[str, Any] = { "HTTPAUTH_USER": user, "HTTPAUTH_PASS": passwd, } if domain is not _DOMAIN_NOT_SET: settings["HTTPAUTH_DOMAIN"] = domain return build_from_crawler(HttpAuthMiddleware, get_crawler(settings_dict=settings)) # --- Spider attribute tests (deprecated) --- class LegacySpider(Spider): http_user = "foo" http_pass = "bar" class DomainSpider(Spider): http_user = "foo" http_pass = "bar" http_auth_domain = "example.com" class AnyDomainSpider(Spider): http_user = "foo" http_pass = "bar" http_auth_domain = None class TestHttpAuthMiddlewareLegacySpiderAttr: def test_missing_domain_raises(self): mw = HttpAuthMiddleware() with pytest.warns(ScrapyDeprecationWarning), pytest.raises(AttributeError): mw.spider_opened(LegacySpider("foo")) def test_domain_spider(self): mw = HttpAuthMiddleware() with pytest.warns(ScrapyDeprecationWarning): mw.spider_opened(DomainSpider("foo")) req = Request("http://example.com/") mw.process_request(req) assert req.headers["Authorization"] == basic_auth_header("foo", "bar") def test_no_auth_wrong_domain(self): mw = HttpAuthMiddleware() with pytest.warns(ScrapyDeprecationWarning): mw.spider_opened(DomainSpider("foo")) req = Request("http://other.com/") mw.process_request(req) assert "Authorization" not in req.headers def test_any_domain_spider(self): mw = HttpAuthMiddleware() with pytest.warns(ScrapyDeprecationWarning): mw.spider_opened(AnyDomainSpider("foo")) req = Request("http://anywhere.com/") mw.process_request(req) assert req.headers["Authorization"] == basic_auth_header("foo", "bar") # --- Settings-based tests --- class TestHttpAuthMiddlewareSettings: def test_no_auth(self): mw = make_mw() req = Request("http://example.com/") mw.process_request(req) assert "Authorization" not in req.headers def test_auth_without_domain_raises(self): with pytest.raises(ValueError, match="HTTPAUTH_DOMAIN"): make_mw(user="foo", passwd="bar") def test_auth_all_domains(self): mw = make_mw(user="foo", passwd="bar", domain=None) req = Request("http://example.com/") mw.process_request(req) assert req.headers["Authorization"] == basic_auth_header("foo", "bar") def test_auth_domain_match(self): mw = make_mw(user="foo", passwd="bar", domain="example.com") req = Request("http://example.com/") mw.process_request(req) assert req.headers["Authorization"] == basic_auth_header("foo", "bar") def test_auth_subdomain(self): mw = make_mw(user="foo", passwd="bar", domain="example.com") req = Request("http://sub.example.com/") mw.process_request(req) assert req.headers["Authorization"] == basic_auth_header("foo", "bar") def test_no_auth_wrong_domain(self): mw = make_mw(user="foo", passwd="bar", domain="example.com") req = Request("http://other.com/") mw.process_request(req) assert "Authorization" not in req.headers def test_auth_already_set(self): mw = make_mw(user="foo", passwd="bar", domain="example.com") req = Request("http://example.com/", headers={"Authorization": "Digest 123"}) mw.process_request(req) assert req.headers["Authorization"] == b"Digest 123" # --- Per-request meta tests --- class TestHttpAuthMiddlewareMeta: def test_meta_auth_no_domain(self): mw = make_mw() req = Request("http://example.com/", meta={"http_user": "u", "http_pass": "p"}) mw.process_request(req) assert req.headers["Authorization"] == basic_auth_header("u", "p") def test_meta_auth_domain_match(self): mw = make_mw() req = Request( "http://example.com/", meta={ "http_user": "u", "http_pass": "p", "http_auth_domain": "example.com", }, ) mw.process_request(req) assert req.headers["Authorization"] == basic_auth_header("u", "p") def test_meta_auth_domain_no_match(self): mw = make_mw() req = Request( "http://other.com/", meta={ "http_user": "u", "http_pass": "p", "http_auth_domain": "example.com", }, ) mw.process_request(req) assert "Authorization" not in req.headers def test_meta_overrides_middleware(self): mw = make_mw(user="mw_user", passwd="mw_pass", domain="example.com") req = Request( "http://example.com/", meta={"http_user": "meta_user", "http_pass": "meta_pass"}, ) mw.process_request(req) assert req.headers["Authorization"] == basic_auth_header( "meta_user", "meta_pass" ) def test_meta_already_set(self): mw = make_mw() req = Request( "http://example.com/", headers={"Authorization": "Digest 123"}, meta={"http_user": "u", "http_pass": "p"}, ) mw.process_request(req) assert req.headers["Authorization"] == b"Digest 123" scrapy-scrapy-5569de3/tests/test_downloadermiddleware_httpcache.py000066400000000000000000001124711525051012400256530ustar00rootroot00000000000000from __future__ import annotations import email.utils import logging import pickle import shutil import tempfile import time from contextlib import contextmanager from pathlib import Path from typing import TYPE_CHECKING, Any from unittest import mock import pytest from scrapy.downloadermiddlewares.httpcache import HttpCacheMiddleware from scrapy.exceptions import IgnoreRequest from scrapy.extensions.httpcache import DummyPolicy, rfc1123_to_epoch from scrapy.http import HtmlResponse, Request, Response from scrapy.spiders import Spider from scrapy.utils.misc import build_from_crawler from scrapy.utils.test import get_crawler if TYPE_CHECKING: from collections.abc import Generator from scrapy.crawler import Crawler class CustomResponse(Response): attributes: tuple[str, ...] = (*Response.attributes, "custom") def __init__(self, *args: Any, custom: str | None = None, **kwargs: Any): self.custom = custom super().__init__(*args, **kwargs) class AlwaysStalePolicy(DummyPolicy): """:class:`~scrapy.extensions.httpcache.DummyPolicy` that always revalidates cached responses.""" def is_cached_response_fresh(self, cachedresponse, request): return False class TestBase: """Base class with common setup and helper methods.""" policy_class: str storage_class: str def setup_method(self): self.yesterday = email.utils.formatdate(time.time() - 86400) self.today = email.utils.formatdate() self.tomorrow = email.utils.formatdate(time.time() + 86400) self.tmpdir = tempfile.mkdtemp() self.request = Request("http://www.example.com", headers={"User-Agent": "test"}) self.response = Response( "http://www.example.com", headers={"Content-Type": "text/html"}, body=b"test body", status=202, ) def teardown_method(self): shutil.rmtree(self.tmpdir) def _get_settings(self, **new_settings: Any) -> dict[str, Any]: settings = { "HTTPCACHE_ENABLED": True, "HTTPCACHE_DIR": self.tmpdir, "HTTPCACHE_IGNORE_HTTP_CODES": [], "HTTPCACHE_POLICY": self.policy_class, "HTTPCACHE_STORAGE": self.storage_class, } settings.update(new_settings) return settings @contextmanager def _get_crawler(self, **new_settings: Any) -> Generator[Crawler]: settings = self._get_settings(**new_settings) crawler = get_crawler(Spider, settings) crawler.spider = crawler._create_spider("example.com") crawler.stats.open_spider() try: yield crawler finally: crawler.stats.close_spider() @contextmanager def _storage(self, **new_settings: Any): with self._middleware(**new_settings) as mw: yield mw.storage, mw.crawler @contextmanager def _middleware(self, **new_settings: Any) -> Generator[HttpCacheMiddleware]: with self._get_crawler(**new_settings) as crawler: assert crawler.spider mw = build_from_crawler(HttpCacheMiddleware, crawler) mw.spider_opened(crawler.spider) try: yield mw finally: mw.spider_closed(crawler.spider) def assertEqualResponse(self, response1: Response, response2: Response) -> None: assert response1.url == response2.url assert response1.status == response2.status assert response1.headers == response2.headers assert response1.body == response2.body class StorageTestMixin(TestBase): """Mixin containing storage-specific test methods.""" def _corrupt_cache_entry( self, storage: Any, spider: Spider, request: Request ) -> None: """Make the cache entry of *request* unreadable for *storage*.""" raise NotImplementedError def _downgrade_cache_entry( self, storage: Any, spider: Spider, request: Request ) -> None: """Rewrite the cache entry of *request* as Scrapy 2.14 would have.""" raise NotImplementedError def test_storage(self): with self._storage(HTTPCACHE_EXPIRATION_SECS=100) as (storage, crawler): request2 = self.request.copy() assert storage.retrieve_response(crawler.spider, request2) is None before = time.time() storage.store_response(crawler.spider, self.request, self.response) after = time.time() response2 = storage.retrieve_response(crawler.spider, request2) assert isinstance(response2, HtmlResponse) # content-type header self.assertEqualResponse(self.response, response2) assert before <= request2.meta["cache_timestamp"] <= after expired = time.time() + storage.expiration_secs + 1 with mock.patch("scrapy.extensions.httpcache.time", return_value=expired): assert storage.retrieve_response(crawler.spider, request2) is None def test_storage_never_expire(self): with self._storage(HTTPCACHE_EXPIRATION_SECS=0) as (storage, crawler): assert storage.retrieve_response(crawler.spider, self.request) is None storage.store_response(crawler.spider, self.request, self.response) future = time.time() + 10**6 with mock.patch("scrapy.extensions.httpcache.time", return_value=future): assert storage.retrieve_response(crawler.spider, self.request) def test_corrupted_cache_entry_is_a_miss(self, caplog): with self._middleware() as mw: spider = mw.crawler.spider assert spider mw.storage.store_response(spider, self.request, self.response) self._corrupt_cache_entry(mw.storage, spider, self.request) caplog.clear() with caplog.at_level(logging.WARNING): assert mw.process_request(self.request) is None assert "treating it as a cache miss" in caplog.text assert mw.crawler.stats.get_value("httpcache/retrieve_error") == 1 assert mw.crawler.stats.get_value("httpcache/miss") == 1 # Storing the response again replaces the corrupted cache entry. mw.storage.store_response(spider, self.request, self.response) self.assertEqualResponse( self.response, mw.storage.retrieve_response(spider, self.request) ) def test_corrupted_cache_entry_ignore_missing(self): with self._middleware(HTTPCACHE_IGNORE_MISSING=True) as mw: spider = mw.crawler.spider assert spider mw.storage.store_response(spider, self.request, self.response) self._corrupt_cache_entry(mw.storage, spider, self.request) with pytest.raises(IgnoreRequest): mw.process_request(self.request) assert mw.crawler.stats.get_value("httpcache/retrieve_error") == 1 assert mw.crawler.stats.get_value("httpcache/ignore") == 1 def test_storage_no_content_type_header(self): """Test that the response body is used to get the right response class even if there is no Content-Type header""" with self._storage() as (storage, crawler): assert storage.retrieve_response(crawler.spider, self.request) is None response = Response( "http://www.example.com", body=b"\n.", status=202, ) storage.store_response(crawler.spider, self.request, response) cached_response = storage.retrieve_response(crawler.spider, self.request) assert isinstance(cached_response, HtmlResponse) self.assertEqualResponse(response, cached_response) def test_storage_response_class(self): with self._storage() as (storage, crawler): response = CustomResponse( "http://www.example.com", body=b"test body", custom="value" ) storage.store_response(crawler.spider, self.request, response) cached_response = storage.retrieve_response(crawler.spider, self.request) assert isinstance(cached_response, CustomResponse) assert cached_response.custom == "value" def test_storage_encoding(self): """The encoding of the stored response is kept even when it cannot be inferred from the response data.""" with self._storage() as (storage, crawler): response = HtmlResponse( "http://www.example.com", body='€'.encode(), encoding="utf-8", ) storage.store_response(crawler.spider, self.request, response) cached_response = storage.retrieve_response(crawler.spider, self.request) assert cached_response.encoding == "utf-8" assert cached_response.text == response.text def test_storage_old_cache_entry(self): with self._storage() as (storage, crawler): storage.store_response(crawler.spider, self.request, self.response) self._downgrade_cache_entry(storage, crawler.spider, self.request) cached_response = storage.retrieve_response(crawler.spider, self.request) assert isinstance(cached_response, HtmlResponse) self.assertEqualResponse(self.response, cached_response) class PolicyTestMixin(TestBase): """Mixin containing policy-specific test methods.""" def test_cache_timestamp(self): with self._middleware() as mw: assert mw.process_request(self.request) is None assert "cache_timestamp" not in self.request.meta before = time.time() mw.process_response(self.request, self.response) after = time.time() if not mw.policy.should_cache_response(self.response, self.request): return response = mw.process_request(self.request) assert isinstance(response, Response) assert before <= self.request.meta["cache_timestamp"] <= after def test_dont_cache(self): with self._middleware() as mw: self.request.meta["dont_cache"] = True assert mw.process_request(self.request) is None mw.process_response(self.request, self.response) assert mw.storage.retrieve_response(mw.crawler.spider, self.request) is None with self._middleware() as mw: self.request.meta["dont_cache"] = False mw.process_response(self.request, self.response) if mw.policy.should_cache_response(self.response, self.request): assert isinstance( mw.storage.retrieve_response(mw.crawler.spider, self.request), self.response.__class__, ) class DummyPolicyTestMixin(PolicyTestMixin): """Mixin containing dummy policy specific test methods.""" def test_middleware(self): with self._middleware() as mw: assert mw.process_request(self.request) is None mw.process_response(self.request, self.response) response = mw.process_request(self.request) assert isinstance(response, HtmlResponse) self.assertEqualResponse(self.response, response) assert "cached" in response.flags def test_different_request_response_urls(self): with self._middleware() as mw: req = Request("http://host.com/path") res = Response("http://host2.net/test.html") assert mw.process_request(req) is None mw.process_response(req, res) cached = mw.process_request(req) assert isinstance(cached, Response) self.assertEqualResponse(res, cached) assert "cached" in cached.flags def test_middleware_ignore_missing(self): with self._middleware(HTTPCACHE_IGNORE_MISSING=True) as mw: with pytest.raises(IgnoreRequest): mw.process_request(self.request) mw.process_response(self.request, self.response) response = mw.process_request(self.request) assert isinstance(response, HtmlResponse) self.assertEqualResponse(self.response, response) assert "cached" in response.flags def test_middleware_ignore_schemes(self): # http responses are cached by default req, res = Request("http://test.com/"), Response("http://test.com/") with self._middleware() as mw: assert mw.process_request(req) is None mw.process_response(req, res) cached = mw.process_request(req) assert isinstance(cached, Response), type(cached) self.assertEqualResponse(res, cached) assert "cached" in cached.flags # file response is not cached by default req, res = Request("file:///tmp/t.txt"), Response("file:///tmp/t.txt") with self._middleware() as mw: assert mw.process_request(req) is None mw.process_response(req, res) assert mw.storage.retrieve_response(mw.crawler.spider, req) is None assert mw.process_request(req) is None # s3 scheme response is cached by default req, res = Request("s3://bucket/key"), Response("s3://bucket/key") with self._middleware() as mw: assert mw.process_request(req) is None mw.process_response(req, res) cached = mw.process_request(req) assert isinstance(cached, Response), type(cached) self.assertEqualResponse(res, cached) assert "cached" in cached.flags # ignore s3 scheme req, res = Request("s3://bucket/key2"), Response("s3://bucket/key2") with self._middleware(HTTPCACHE_IGNORE_SCHEMES=["s3"]) as mw: assert mw.process_request(req) is None mw.process_response(req, res) assert mw.storage.retrieve_response(mw.crawler.spider, req) is None assert mw.process_request(req) is None def test_middleware_ignore_http_codes(self): # test response is not cached with self._middleware(HTTPCACHE_IGNORE_HTTP_CODES=[202]) as mw: assert mw.process_request(self.request) is None mw.process_response(self.request, self.response) assert mw.storage.retrieve_response(mw.crawler.spider, self.request) is None assert mw.process_request(self.request) is None # test response is cached with self._middleware(HTTPCACHE_IGNORE_HTTP_CODES=[203]) as mw: mw.process_response(self.request, self.response) response = mw.process_request(self.request) assert isinstance(response, HtmlResponse) self.assertEqualResponse(self.response, response) assert "cached" in response.flags def test_revalidation_keeps_cached_response(self): # The dummy policy considers every cached response valid, so a policy # that subclasses it to force revalidation always gets the cached # response back, whatever the new response is. with self._middleware(HTTPCACHE_POLICY=AlwaysStalePolicy) as mw: assert mw.process_request(self.request) is None mw.process_response(self.request, self.response) assert mw.process_request(self.request) is None fresh_response = self.response.replace(body=b"new body") response = mw.process_response(self.request, fresh_response) assert isinstance(response, Response) self.assertEqualResponse(self.response, response) assert "cached" in response.flags assert mw.stats.get_value("httpcache/revalidate") == 1 class RFC2616PolicyTestMixin(PolicyTestMixin): """Mixin containing RFC2616 policy specific test methods.""" @staticmethod def _process_requestresponse( mw: HttpCacheMiddleware, request: Request, response: Response | None ) -> Response: result: Request | Response | None = None try: result = mw.process_request(request) if result: assert isinstance(result, Response) return result assert response is not None result = mw.process_response(request, response) assert isinstance(result, Response) return result except Exception: print("Request", request) print("Response", response) print("Result", result) raise def test_request_cacheability(self): res0 = Response( self.request.url, status=200, headers={"Expires": self.tomorrow} ) req0 = Request("http://example.com") req1 = req0.replace(headers={"Cache-Control": "no-store"}) req2 = req0.replace(headers={"Cache-Control": "no-cache"}) with self._middleware() as mw: # response for a request with no-store must not be cached res1 = self._process_requestresponse(mw, req1, res0) self.assertEqualResponse(res1, res0) assert mw.storage.retrieve_response(mw.crawler.spider, req1) is None # Re-do request without no-store and expect it to be cached res2 = self._process_requestresponse(mw, req0, res0) assert "cached" not in res2.flags res3 = mw.process_request(req0) assert isinstance(res3, Response) assert "cached" in res3.flags self.assertEqualResponse(res2, res3) # request with no-cache directive must not return cached response # but it allows new response to be stored res0b = res0.replace(body=b"foo") res4 = self._process_requestresponse(mw, req2, res0b) self.assertEqualResponse(res4, res0b) assert "cached" not in res4.flags res5 = self._process_requestresponse(mw, req0, None) self.assertEqualResponse(res5, res0b) assert "cached" in res5.flags def test_response_cacheability(self): responses = [ # 304 is not cacheable no matter what servers sends (False, 304, {}), (False, 304, {"Last-Modified": self.yesterday}), (False, 304, {"Expires": self.tomorrow}), (False, 304, {"Etag": "bar"}), (False, 304, {"Cache-Control": "max-age=3600"}), # Always obey no-store cache control (False, 200, {"Cache-Control": "no-store"}), (False, 200, {"Cache-Control": "no-store, max-age=300"}), # invalid ( False, 200, {"Cache-Control": "no-store", "Expires": self.tomorrow}, ), # invalid # Ignore responses missing expiration and/or validation headers (False, 200, {}), (False, 302, {}), (False, 307, {}), (False, 404, {}), # Cache responses with expiration and/or validation headers (True, 200, {"Last-Modified": self.yesterday}), (True, 203, {"Last-Modified": self.yesterday}), (True, 300, {"Last-Modified": self.yesterday}), (True, 301, {"Last-Modified": self.yesterday}), (True, 308, {"Last-Modified": self.yesterday}), (True, 401, {"Last-Modified": self.yesterday}), (True, 404, {"Cache-Control": "public, max-age=600"}), (True, 302, {"Expires": self.tomorrow}), (True, 200, {"Etag": "foo"}), ] with self._middleware() as mw: for idx, (shouldcache, status, headers) in enumerate(responses): req0 = Request(f"http://example-{idx}.com") res0 = Response(req0.url, status=status, headers=headers) res1 = self._process_requestresponse(mw, req0, res0) res304 = res0.replace(status=304) res2 = self._process_requestresponse( mw, req0, res304 if shouldcache else res0 ) self.assertEqualResponse(res1, res0) self.assertEqualResponse(res2, res0) resc = mw.storage.retrieve_response(mw.crawler.spider, req0) if shouldcache: self.assertEqualResponse(resc, res1) assert "cached" in res2.flags assert res2.status != 304 else: assert not resc assert "cached" not in res2.flags # cache unconditionally unless response contains no-store or is a 304 with self._middleware(HTTPCACHE_ALWAYS_STORE=True) as mw: for idx, (_, status, headers) in enumerate(responses): shouldcache = ( "no-store" not in headers.get("Cache-Control", "") and status != 304 ) req0 = Request(f"http://example2-{idx}.com") res0 = Response(req0.url, status=status, headers=headers) res1 = self._process_requestresponse(mw, req0, res0) res304 = res0.replace(status=304) res2 = self._process_requestresponse( mw, req0, res304 if shouldcache else res0 ) self.assertEqualResponse(res1, res0) self.assertEqualResponse(res2, res0) resc = mw.storage.retrieve_response(mw.crawler.spider, req0) if shouldcache: self.assertEqualResponse(resc, res1) assert "cached" in res2.flags assert res2.status != 304 else: assert not resc assert "cached" not in res2.flags def test_cached_and_fresh(self): sampledata = [ (200, {"Date": self.yesterday, "Expires": self.tomorrow}), (200, {"Date": self.yesterday, "Cache-Control": "max-age=86405"}), (200, {"Age": "299", "Cache-Control": "max-age=300"}), # Obey max-age if present over any others ( 200, { "Date": self.today, "Age": "86405", "Cache-Control": "max-age=" + str(86400 * 3), "Expires": self.yesterday, "Last-Modified": self.yesterday, }, ), # obey Expires if max-age is not present ( 200, { "Date": self.yesterday, "Age": "86400", "Cache-Control": "public", "Expires": self.tomorrow, "Last-Modified": self.yesterday, }, ), # Default missing Date header to right now (200, {"Expires": self.tomorrow}), # Firefox - Expires if age is greater than 10% of (Date - Last-Modified) ( 200, { "Date": self.today, "Last-Modified": self.yesterday, "Age": str(86400 / 10 - 1), }, ), # Firefox - Set one year maxage to permanent redirects missing expiration info (300, {}), (301, {}), (308, {}), ] with self._middleware() as mw: for idx, (status, headers) in enumerate(sampledata): req0 = Request(f"http://example-{idx}.com") res0 = Response(req0.url, status=status, headers=headers) # cache fresh response res1 = self._process_requestresponse(mw, req0, res0) self.assertEqualResponse(res1, res0) assert "cached" not in res1.flags # return fresh cached response without network interaction res2 = self._process_requestresponse(mw, req0, None) self.assertEqualResponse(res1, res2) assert "cached" in res2.flags # validate cached response if request max-age set as 0 req1 = req0.replace(headers={"Cache-Control": "max-age=0"}) res304 = res0.replace(status=304) assert mw.process_request(req1) is None res3 = self._process_requestresponse(mw, req1, res304) self.assertEqualResponse(res1, res3) assert "cached" in res3.flags def test_cached_and_stale(self): sampledata = [ (200, {"Date": self.today, "Expires": self.yesterday}), ( 200, { "Date": self.today, "Expires": self.yesterday, "Last-Modified": self.yesterday, }, ), (200, {"Expires": self.yesterday}), (200, {"Expires": self.yesterday, "ETag": "foo"}), (200, {"Expires": self.yesterday, "Last-Modified": self.yesterday}), (200, {"Expires": self.tomorrow, "Age": "86405"}), (200, {"Cache-Control": "max-age=86400", "Age": "86405"}), # no-cache forces expiration, also revalidation if validators exists (200, {"Cache-Control": "no-cache"}), (200, {"Cache-Control": "no-cache", "ETag": "foo"}), (200, {"Cache-Control": "no-cache", "Last-Modified": self.yesterday}), ( 200, { "Cache-Control": "no-cache,must-revalidate", "Last-Modified": self.yesterday, }, ), ( 200, { "Cache-Control": "must-revalidate", "Expires": self.yesterday, "Last-Modified": self.yesterday, }, ), (200, {"Cache-Control": "max-age=86400,must-revalidate", "Age": "86405"}), ] with self._middleware() as mw: for idx, (status, headers) in enumerate(sampledata): req0 = Request(f"http://example-{idx}.com") res0a = Response(req0.url, status=status, headers=headers) # cache expired response res1 = self._process_requestresponse(mw, req0, res0a) self.assertEqualResponse(res1, res0a) assert "cached" not in res1.flags # Same request but as cached response is stale a new response must # be returned res0b = res0a.replace(body=b"bar") res2 = self._process_requestresponse(mw, req0, res0b) self.assertEqualResponse(res2, res0b) assert "cached" not in res2.flags cc = headers.get("Cache-Control", "") # Previous response expired too, subsequent request to same # resource must revalidate and succeed on 304 if validators # are present if "ETag" in headers or "Last-Modified" in headers: res0c = res0b.replace(status=304) res3 = self._process_requestresponse(mw, req0, res0c) self.assertEqualResponse(res3, res0b) assert "cached" in res3.flags # get cached response on server errors unless must-revalidate # in cached response res0d = res0b.replace(status=500) res4 = self._process_requestresponse(mw, req0, res0d) if "must-revalidate" in cc: assert "cached" not in res4.flags self.assertEqualResponse(res4, res0d) else: assert "cached" in res4.flags self.assertEqualResponse(res4, res0b) # Requests with max-stale can fetch expired cached responses # unless cached response has must-revalidate req1 = req0.replace(headers={"Cache-Control": "max-stale"}) res5 = self._process_requestresponse(mw, req1, res0b) self.assertEqualResponse(res5, res0b) if "no-cache" in cc or "must-revalidate" in cc: assert "cached" not in res5.flags else: assert "cached" in res5.flags def test_middleware_ignore_schemes(self): # file responses are not cached by default req = Request("file:///tmp/t.txt") res = Response(req.url, headers={"Expires": self.tomorrow}) with self._middleware() as mw: assert mw.process_request(req) is None mw.process_response(req, res) assert mw.storage.retrieve_response(mw.crawler.spider, req) is None assert mw.process_request(req) is None def test_max_stale_with_value(self): # A response that expired one day ago. headers = {"Date": self.yesterday, "Expires": self.yesterday} with self._middleware() as mw: req0 = Request("http://example.com") res0 = Response(req0.url, headers=headers) self._process_requestresponse(mw, req0, res0) # max-stale greater than the staleness of the cached response req1 = req0.replace(headers={"Cache-Control": "max-stale=172800"}) res1 = mw.process_request(req1) assert isinstance(res1, Response) assert "cached" in res1.flags # max-stale lower than the staleness of the cached response req2 = req0.replace(headers={"Cache-Control": "max-stale=60"}) assert mw.process_request(req2) is None # a non-integer max-stale value is ignored req3 = req0.replace(headers={"Cache-Control": "max-stale=soon"}) assert mw.process_request(req3) is None def test_freshen_response_on_revalidation(self): # A response successfully revalidated (304) must have its Date # header, and thus its age, refreshed in the cache. headers = { "Date": self.yesterday, "Cache-Control": "max-age=60", "Last-Modified": self.yesterday, "Warning": ["110 - old", "214 - keep"], } with self._middleware() as mw: req0 = Request("http://example.com") res0 = Response(req0.url, headers=headers) self._process_requestresponse(mw, req0, res0) # The cached response is stale, so the request must be # revalidated with the server. assert mw.process_request(req0) is None res304 = Response(req0.url, status=304, headers={"Date": self.today}) res1 = mw.process_response(req0, res304) assert res1.headers["Date"] == self.today.encode() assert res1.headers["Warning"] == b"214 - keep" # The freshened response must now be considered fresh again, # without a further round trip to the server. res2 = mw.process_request(req0) assert isinstance(res2, Response) assert "cached" in res2.flags assert res2.headers["Date"] == self.today.encode() def test_response_dated_in_the_future(self): # A Date header ahead of the local clock must not make the cached # response look aged. headers = {"Date": self.tomorrow, "Cache-Control": "max-age=10"} with self._middleware() as mw: req0 = Request("http://example.com") res0 = Response(req0.url, headers=headers) res1 = self._process_requestresponse(mw, req0, res0) assert "cached" not in res1.flags res2 = self._process_requestresponse(mw, req0, None) self.assertEqualResponse(res1, res2) assert "cached" in res2.flags def test_cache_timestamp_not_leaked_on_invalidate(self): # A stale cached response invalidated by a fresh network response must # not leave its storage timestamp behind on the request meta. with self._middleware() as mw: req0 = Request("http://example.com") res0 = Response(req0.url, headers={"Expires": self.yesterday}) self._process_requestresponse(mw, req0, res0) assert mw.process_request(req0) is None assert "cache_timestamp" in req0.meta res1 = res0.replace(body=b"new body") response = mw.process_response(req0, res1) assert response is res1 assert "cache_timestamp" not in req0.meta def test_process_exception(self): with self._middleware() as mw: res0 = Response(self.request.url, headers={"Expires": self.yesterday}) req0 = Request(self.request.url) self._process_requestresponse(mw, req0, res0) for e in mw.DOWNLOAD_EXCEPTIONS: # Simulate encountering an error on download attempts assert mw.process_request(req0) is None res1 = mw.process_exception(req0, e("foo")) # Use cached response as recovery assert isinstance(res1, Response) assert "cached" in res1.flags self.assertEqualResponse(res0, res1) # Do not use cached response for unhandled exceptions mw.process_request(req0) assert mw.process_exception(req0, Exception("foo")) is None def test_ignore_response_cache_controls(self): sampledata = [ (200, {"Date": self.yesterday, "Expires": self.tomorrow}), (200, {"Date": self.yesterday, "Cache-Control": "no-store,max-age=86405"}), (200, {"Age": "299", "Cache-Control": "max-age=300,no-cache"}), (300, {"Cache-Control": "no-cache"}), (200, {"Expires": self.tomorrow, "Cache-Control": "no-store"}), ] with self._middleware( HTTPCACHE_IGNORE_RESPONSE_CACHE_CONTROLS=["no-cache", "no-store"] ) as mw: for idx, (status, headers) in enumerate(sampledata): req0 = Request(f"http://example-{idx}.com") res0 = Response(req0.url, status=status, headers=headers) # cache fresh response res1 = self._process_requestresponse(mw, req0, res0) self.assertEqualResponse(res1, res0) assert "cached" not in res1.flags # return fresh cached response without network interaction res2 = self._process_requestresponse(mw, req0, None) self.assertEqualResponse(res1, res2) assert "cached" in res2.flags # Concrete test classes that combine storage and policy mixins class FilesystemStorageTestMixin(StorageTestMixin): storage_class = "scrapy.extensions.httpcache.FilesystemCacheStorage" def _corrupt_cache_entry(self, storage, spider, request) -> None: rpath = Path(storage._get_request_path(spider, request)) (rpath / "response_body").unlink() def _downgrade_cache_entry(self, storage, spider, request) -> None: rpath = Path(storage._get_request_path(spider, request)) (rpath / "response_data").unlink() class DbmStorageTestMixin(StorageTestMixin): storage_class = "scrapy.extensions.httpcache.DbmCacheStorage" def _corrupt_cache_entry(self, storage, spider, request) -> None: key = storage._fingerprinter.fingerprint(request).hex() storage.db[f"{key}_data"] = b"not a pickle" def _downgrade_cache_entry(self, storage, spider, request) -> None: key = storage._fingerprinter.fingerprint(request).hex() data = pickle.loads(storage.db[f"{key}_data"]) data = { k: v for k, v in data.items() if k in ("status", "url", "headers", "body") } storage.db[f"{key}_data"] = pickle.dumps(data, protocol=4) class TestFilesystemStorageWithDummyPolicy( FilesystemStorageTestMixin, DummyPolicyTestMixin ): policy_class = "scrapy.extensions.httpcache.DummyPolicy" class TestFilesystemStorageWithRFC2616Policy( FilesystemStorageTestMixin, RFC2616PolicyTestMixin ): policy_class = "scrapy.extensions.httpcache.RFC2616Policy" class TestDbmStorageWithDummyPolicy(DbmStorageTestMixin, DummyPolicyTestMixin): policy_class = "scrapy.extensions.httpcache.DummyPolicy" class TestDbmStorageWithRFC2616Policy(DbmStorageTestMixin, RFC2616PolicyTestMixin): policy_class = "scrapy.extensions.httpcache.RFC2616Policy" class TestDbmStorageWithCustomDbmModule(TestDbmStorageWithDummyPolicy): dbm_module = "tests.mocks.dummydbm" def _get_settings(self, **new_settings) -> dict[str, Any]: new_settings.setdefault("HTTPCACHE_DBM_MODULE", self.dbm_module) return super()._get_settings(**new_settings) def test_custom_dbm_module_loaded(self): # make sure our dbm module has been loaded with self._storage() as (storage, _): assert storage.dbmodule.__name__ == self.dbm_module class TestFilesystemStorageGzipWithDummyPolicy(TestFilesystemStorageWithDummyPolicy): def _get_settings(self, **new_settings) -> dict[str, Any]: new_settings.setdefault("HTTPCACHE_GZIP", True) return super()._get_settings(**new_settings) def _corrupt_cache_entry(self, storage, spider, request) -> None: # A spider killed while writing a gzip file leaves it truncated. body_path = Path(storage._get_request_path(spider, request), "response_body") body_path.write_bytes(body_path.read_bytes()[:-5]) @pytest.mark.parametrize( ("string", "expected"), [ # RFC examples ("Sun, 06 Nov 1994 08:49:37 GMT", 784111777), ("Sunday, 06-Nov-94 08:49:37 GMT", 784111777), ("Sun Nov 6 08:49:37 1994", 784111777), (None, None), ("foo", None), ], ) def test_rfc1123_to_epoch(string: str | None, expected: int | None) -> None: assert rfc1123_to_epoch(string) == expected scrapy-scrapy-5569de3/tests/test_downloadermiddleware_httpcompression.py000066400000000000000000001016561525051012400271540ustar00rootroot00000000000000import zlib from gzip import GzipFile from io import BytesIO from logging import WARNING from pathlib import Path from threading import Thread from typing import Any import pytest from w3lib.encoding import resolve_encoding from scrapy.downloadermiddlewares.httpcompression import ( ACCEPTED_ENCODINGS, HttpCompressionMiddleware, ) from scrapy.exceptions import IgnoreRequest, NotConfigured, ScrapyDeprecationWarning from scrapy.http import HtmlResponse, Request, Response from scrapy.responsetypes import responsetypes from scrapy.spiders import Spider from scrapy.utils._compression import _CHUNK_SIZE, _DecompressionMaxSizeExceeded from scrapy.utils.gz import gunzip from scrapy.utils.misc import build_from_crawler from scrapy.utils.test import get_crawler from tests import tests_datadir SAMPLEDIR = Path(tests_datadir, "compressed") FORMAT = { "gzip": ("html-gzip.bin", "gzip"), "x-gzip": ("html-gzip.bin", "x-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"), # $ zstd raw.html --no-content-size -o html-zstd-static-no-content-size.bin "zstd-static-no-content-size": ("html-zstd-static-no-content-size.bin", "zstd"), # $ cat raw.html | zstd -o html-zstd-streaming-no-content-size.bin "zstd-streaming-no-content-size": ( "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 ) }, } class TestHttpCompression: def setup_method(self): self.crawler = get_crawler(Spider) self.mw = build_from_crawler(HttpCompressionMiddleware, self.crawler) self.crawler.stats.open_spider() def _getresponse(self, coding: str) -> Response: if coding not in FORMAT: raise ValueError samplefile, contentencoding = FORMAT[coding] body = (SAMPLEDIR / samplefile).read_bytes() headers = { "Server": "Yaws/1.49 Yet Another Web Server", "Date": "Sun, 08 Mar 2009 00:41:03 GMT", "Content-Length": len(body), "Content-Type": "text/html", "Content-Encoding": contentencoding, } response = Response("http://scrapytest.org/", body=body, headers=headers) response.request = Request( "http://scrapytest.org", headers={"Accept-Encoding": "gzip, deflate"} ) return response def assertStatsEqual(self, key: str, value: Any) -> None: assert self.crawler.stats.get_value(key) == value, str( self.crawler.stats.get_stats() ) def test_setting_false_compression_enabled(self): with pytest.raises(NotConfigured): build_from_crawler( HttpCompressionMiddleware, get_crawler(settings_dict={"COMPRESSION_ENABLED": False}), ) def test_setting_default_compression_enabled(self): assert isinstance( build_from_crawler(HttpCompressionMiddleware, get_crawler()), HttpCompressionMiddleware, ) def test_setting_true_compression_enabled(self): assert isinstance( build_from_crawler( HttpCompressionMiddleware, get_crawler(settings_dict={"COMPRESSION_ENABLED": True}), ), HttpCompressionMiddleware, ) def test_no_crawler_constructor(self): with pytest.warns(ScrapyDeprecationWarning, match="HttpCompressionMiddleware"): mw = HttpCompressionMiddleware() buf = BytesIO() with GzipFile(fileobj=buf, mode="wb") as f: f.write(b"hello") body = buf.getvalue() request = Request("http://scrapytest.org") response = Response( "http://scrapytest.org", body=body, headers={"Content-Encoding": "gzip"}, ) newresponse = mw.process_response(request, response) assert newresponse.body == b"hello" def test_process_request(self): request = Request("http://scrapytest.org") assert "Accept-Encoding" not in request.headers self.mw.process_request(request) assert request.headers.get("Accept-Encoding") == b", ".join(ACCEPTED_ENCODINGS) def test_process_response_gzip(self): response = self._getresponse("gzip") assert response.request request = response.request assert response.headers["Content-Encoding"] == b"gzip" newresponse = self.mw.process_response(request, response) assert newresponse is not response assert newresponse.body.startswith(b" None: response = self._getresponse("gzip-deflate") response.headers["Content-Encoding"] = [b"gzip, foo, deflate"] assert response.request request = response.request caplog.clear() with caplog.at_level( WARNING, logger="scrapy.downloadermiddlewares.httpcompression" ): newresponse = self.mw.process_response(request, response) assert caplog.record_tuples == [ ( "scrapy.downloadermiddlewares.httpcompression", WARNING, ( "HttpCompressionMiddleware cannot decode the response for" " http://scrapytest.org/ from unsupported encoding(s) 'gzip,foo'." ), ), ] assert newresponse is not response assert newresponse.headers.getlist("Content-Encoding") == [b"gzip", b"foo"] def test_multi_compression_multiple_header(self): response = self._getresponse("gzip-deflate") response.headers["Content-Encoding"] = ["gzip", "deflate"] assert response.request request = response.request newresponse = self.mw.process_response(request, response) assert newresponse is not response assert "Content-Encoding" not in newresponse.headers assert newresponse.body.startswith(b"Some page" b'' ) zf = GzipFile(fileobj=f, mode="wb") zf.write(plainbody) zf.close() response = Response( "http://www.example.com/", headers=headers, body=f.getvalue() ) request = Request("http://www.example.com/") newresponse = self.mw.process_response(request, response) assert isinstance(newresponse, HtmlResponse) assert newresponse.body == plainbody assert newresponse.encoding == resolve_encoding("gb2312") self.assertStatsEqual("httpcompression/response_count", 1) self.assertStatsEqual("httpcompression/response_bytes", len(plainbody)) def test_process_response_force_recalculate_encoding(self): headers = { "Content-Type": "text/html", "Content-Encoding": "gzip", } f = BytesIO() plainbody = ( b"Some page" b'' ) zf = GzipFile(fileobj=f, mode="wb") zf.write(plainbody) zf.close() response = HtmlResponse( "http://www.example.com/page.html", headers=headers, body=f.getvalue() ) request = Request("http://www.example.com/") newresponse = self.mw.process_response(request, response) assert isinstance(newresponse, HtmlResponse) assert newresponse.body == plainbody assert newresponse.encoding == resolve_encoding("gb2312") self.assertStatsEqual("httpcompression/response_count", 1) self.assertStatsEqual("httpcompression/response_bytes", len(plainbody)) def test_process_response_no_content_type_header(self): headers = {b"Content-Encoding": b"identity"} plainbody = ( b"Some page" b'' ) respcls = responsetypes.from_args( url="http://www.example.com/index", headers=headers, body=plainbody ) response = respcls( "http://www.example.com/index", headers=headers, body=plainbody ) request = Request("http://www.example.com/index") newresponse = self.mw.process_response(request, response) assert isinstance(newresponse, respcls) assert isinstance(newresponse, HtmlResponse) assert newresponse.body == plainbody assert newresponse.encoding == resolve_encoding("gb2312") self.assertStatsEqual("httpcompression/response_count", 1) self.assertStatsEqual("httpcompression/response_bytes", len(plainbody)) def test_process_response_gzipped_contenttype(self): response = self._getresponse("gzip") response.headers["Content-Type"] = "application/gzip" assert response.request request = response.request newresponse = self.mw.process_response(request, response) assert newresponse is not response assert newresponse.body.startswith(b" http://www.example.com/ 2009-08-16 daily 1 http://www.example.com/Special-Offers.html 2009-08-16 weekly 0.8 """ gz_file = GzipFile(fileobj=f, mode="wb") gz_file.write(plainbody) gz_file.close() # build a gzipped response body containing this gzipped file r = BytesIO() gz_resp = GzipFile(fileobj=r, mode="wb") gz_resp.write(f.getvalue()) gz_resp.close() response = Response( "http://www.example.com/", headers=headers, body=r.getvalue() ) request = Request("http://www.example.com/") newresponse = self.mw.process_response(request, response) assert gunzip(newresponse.body) == plainbody self.assertStatsEqual("httpcompression/response_count", 1) self.assertStatsEqual("httpcompression/response_bytes", len(f.getvalue())) def test_process_response_head_request_no_decode_required(self): response = self._getresponse("gzip") response.headers["Content-Type"] = "application/gzip" assert response.request request = response.request request.method = "HEAD" response = response.replace(body=None) newresponse = self.mw.process_response(request, response) assert newresponse is response assert response.body == b"" self.assertStatsEqual("httpcompression/response_count", None) self.assertStatsEqual("httpcompression/response_bytes", None) @pytest.mark.parametrize( "extra", [ pytest.param(b"garbage", id="trailing-garbage"), pytest.param(zlib.compress(b"b" * 100_000), id="second-zlib-stream"), ], ) def test_deflate_extra_data_after_stream(self, extra: bytes) -> None: """Bytes after the end of a deflate stream must not stall decompression. Such bytes stay in ``unconsumed_tail`` and produce no output, so feeding them back to the decompressor never makes progress. Decompressing in a thread lets the test fail instead of hanging if that ever regresses. """ # The body must decompress to more than _CHUNK_SIZE, or the whole stream # is consumed by the first decompress() call and no loop is entered. plain = b"a" * 100_000 response = Response( "http://example.com", body=zlib.compress(plain) + extra, headers={"Content-Encoding": "deflate"}, ) result: list[Request | Response] = [] thread = Thread( target=lambda: result.append( self.mw.process_response(Request("http://example.com"), response) ), daemon=True, ) thread.start() thread.join(60) assert not thread.is_alive(), "decompression did not terminate" assert result[0].body == plain def test_deflate_chunked_output_leaves_nothing_to_flush(self) -> None: """Guard the zlib behaviour that lets ``_inflate()`` skip ``flush()``. ``decompress(max_length=...)`` leaves the input it could not turn into output in ``unconsumed_tail``, so once that is empty every complete stream has been fully emitted. If a future zlib buffers output instead, this fails and ``_inflate()`` needs to flush the remainder again. """ for size in (_CHUNK_SIZE - 1, _CHUNK_SIZE, _CHUNK_SIZE + 1, 4 * _CHUNK_SIZE): decompressor = zlib.decompressobj() decompressor.decompress(zlib.compress(b"a" * size), max_length=_CHUNK_SIZE) while decompressor.unconsumed_tail and not decompressor.eof: decompressor.decompress( decompressor.unconsumed_tail, max_length=_CHUNK_SIZE ) assert decompressor.flush() == b"", f"pending output for size {size}" def _test_compression_bomb_setting(self, compression_id: str) -> None: settings = {"DOWNLOAD_MAXSIZE": 1_000_000} crawler = get_crawler(Spider, settings_dict=settings) spider = crawler._create_spider("scrapytest.org") mw = build_from_crawler(HttpCompressionMiddleware, crawler) mw.open_spider(spider) response = self._getresponse(f"bomb-{compression_id}") # 11_511_612 B assert response.request with pytest.raises(IgnoreRequest) as exc_info: mw.process_response(response.request, response) cause = exc_info.value.__cause__ assert isinstance(cause, _DecompressionMaxSizeExceeded) assert cause.decompressed_size < 1_100_000 assert str(cause) == ( f"The number of bytes decompressed so far ({cause.decompressed_size} B) " "exceeded the specified maximum (1000000 B)." ) def test_compression_bomb_setting_br(self): self._test_compression_bomb_setting("br") def test_compression_bomb_setting_deflate(self): self._test_compression_bomb_setting("deflate") def test_compression_bomb_setting_gzip(self): self._test_compression_bomb_setting("gzip") def test_compression_bomb_setting_zstd(self): self._test_compression_bomb_setting("zstd") def test_compression_bomb_setting_logs_warning(self, caplog): settings = {"DOWNLOAD_MAXSIZE": 1_000_000} crawler = get_crawler(Spider, settings_dict=settings) spider = crawler._create_spider("scrapytest.org") mw = build_from_crawler(HttpCompressionMiddleware, crawler) mw.open_spider(spider) response = self._getresponse("bomb-gzip") # 11_511_612 B assert response.request caplog.clear() with ( caplog.at_level( WARNING, logger="scrapy.downloadermiddlewares.httpcompression" ), pytest.raises(IgnoreRequest) as exc_info, ): mw.process_response(response.request, response) assert caplog.record_tuples == [ ( "scrapy.downloadermiddlewares.httpcompression", WARNING, str(exc_info.value), ) ] def _test_compression_bomb_spider_attr(self, compression_id: str) -> None: class DownloadMaxSizeSpider(Spider): download_maxsize = 1_000_000 crawler = get_crawler(DownloadMaxSizeSpider) spider = crawler._create_spider("scrapytest.org") mw = build_from_crawler(HttpCompressionMiddleware, crawler) mw.open_spider(spider) response = self._getresponse(f"bomb-{compression_id}") assert response.request with pytest.raises(IgnoreRequest) as exc_info: mw.process_response(response.request, response) cause = exc_info.value.__cause__ assert isinstance(cause, _DecompressionMaxSizeExceeded) assert cause.decompressed_size < 1_100_000 @pytest.mark.filterwarnings("ignore::scrapy.exceptions.ScrapyDeprecationWarning") def test_compression_bomb_spider_attr_br(self): self._test_compression_bomb_spider_attr("br") @pytest.mark.filterwarnings("ignore::scrapy.exceptions.ScrapyDeprecationWarning") def test_compression_bomb_spider_attr_deflate(self): self._test_compression_bomb_spider_attr("deflate") @pytest.mark.filterwarnings("ignore::scrapy.exceptions.ScrapyDeprecationWarning") def test_compression_bomb_spider_attr_gzip(self): self._test_compression_bomb_spider_attr("gzip") @pytest.mark.filterwarnings("ignore::scrapy.exceptions.ScrapyDeprecationWarning") def test_compression_bomb_spider_attr_zstd(self): self._test_compression_bomb_spider_attr("zstd") def _test_compression_bomb_request_meta(self, compression_id: str) -> None: crawler = get_crawler(Spider) spider = crawler._create_spider("scrapytest.org") mw = build_from_crawler(HttpCompressionMiddleware, crawler) mw.open_spider(spider) response = self._getresponse(f"bomb-{compression_id}") response.meta["download_maxsize"] = 1_000_000 assert response.request with pytest.raises(IgnoreRequest) as exc_info: mw.process_response(response.request, response) cause = exc_info.value.__cause__ assert isinstance(cause, _DecompressionMaxSizeExceeded) assert cause.decompressed_size < 1_100_000 def test_compression_bomb_request_meta_br(self): self._test_compression_bomb_request_meta("br") def test_compression_bomb_request_meta_deflate(self): self._test_compression_bomb_request_meta("deflate") def test_compression_bomb_request_meta_gzip(self): self._test_compression_bomb_request_meta("gzip") def test_compression_bomb_request_meta_zstd(self): self._test_compression_bomb_request_meta("zstd") def _test_download_warnsize_setting( self, caplog: pytest.LogCaptureFixture, compression_id: str ) -> None: settings = {"DOWNLOAD_WARNSIZE": 10_000_000} crawler = get_crawler(Spider, settings_dict=settings) spider = crawler._create_spider("scrapytest.org") mw = build_from_crawler(HttpCompressionMiddleware, crawler) mw.open_spider(spider) response = self._getresponse(f"bomb-{compression_id}") assert response.request caplog.clear() with caplog.at_level( WARNING, logger="scrapy.downloadermiddlewares.httpcompression" ): mw.process_response(response.request, response) assert caplog.record_tuples == [ ( "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_setting_br( self, caplog: pytest.LogCaptureFixture ) -> None: self._test_download_warnsize_setting(caplog, "br") def test_download_warnsize_setting_deflate( self, caplog: pytest.LogCaptureFixture ) -> None: self._test_download_warnsize_setting(caplog, "deflate") def test_download_warnsize_setting_gzip( self, caplog: pytest.LogCaptureFixture ) -> None: self._test_download_warnsize_setting(caplog, "gzip") def test_download_warnsize_setting_zstd( self, caplog: pytest.LogCaptureFixture ) -> None: self._test_download_warnsize_setting(caplog, "zstd") def _test_download_warnsize_spider_attr( self, caplog: pytest.LogCaptureFixture, compression_id: str ) -> None: class DownloadWarnSizeSpider(Spider): download_warnsize = 10_000_000 crawler = get_crawler(DownloadWarnSizeSpider) spider = crawler._create_spider("scrapytest.org") mw = build_from_crawler(HttpCompressionMiddleware, crawler) mw.open_spider(spider) response = self._getresponse(f"bomb-{compression_id}") assert response.request caplog.clear() with caplog.at_level( WARNING, logger="scrapy.downloadermiddlewares.httpcompression" ): mw.process_response(response.request, response) assert caplog.record_tuples == [ ( "scrapy.downloadermiddlewares.httpcompression", WARNING, ( "<200 http://scrapytest.org/> body size after " "decompression (11511612 B) is larger than the download " "warning size (10000000 B)." ), ), ] @pytest.mark.filterwarnings("ignore::scrapy.exceptions.ScrapyDeprecationWarning") def test_download_warnsize_spider_attr_br( self, caplog: pytest.LogCaptureFixture ) -> None: self._test_download_warnsize_spider_attr(caplog, "br") @pytest.mark.filterwarnings("ignore::scrapy.exceptions.ScrapyDeprecationWarning") def test_download_warnsize_spider_attr_deflate( self, caplog: pytest.LogCaptureFixture ) -> None: self._test_download_warnsize_spider_attr(caplog, "deflate") @pytest.mark.filterwarnings("ignore::scrapy.exceptions.ScrapyDeprecationWarning") def test_download_warnsize_spider_attr_gzip( self, caplog: pytest.LogCaptureFixture ) -> None: self._test_download_warnsize_spider_attr(caplog, "gzip") @pytest.mark.filterwarnings("ignore::scrapy.exceptions.ScrapyDeprecationWarning") def test_download_warnsize_spider_attr_zstd( self, caplog: pytest.LogCaptureFixture ) -> None: self._test_download_warnsize_spider_attr(caplog, "zstd") def _test_download_warnsize_request_meta( self, caplog: pytest.LogCaptureFixture, compression_id: str ) -> None: crawler = get_crawler(Spider) spider = crawler._create_spider("scrapytest.org") mw = build_from_crawler(HttpCompressionMiddleware, crawler) mw.open_spider(spider) response = self._getresponse(f"bomb-{compression_id}") response.meta["download_warnsize"] = 10_000_000 assert response.request caplog.clear() with caplog.at_level( WARNING, logger="scrapy.downloadermiddlewares.httpcompression" ): mw.process_response(response.request, response) assert caplog.record_tuples == [ ( "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, caplog: pytest.LogCaptureFixture ) -> None: self._test_download_warnsize_request_meta(caplog, "br") def test_download_warnsize_request_meta_deflate( self, caplog: pytest.LogCaptureFixture ) -> None: self._test_download_warnsize_request_meta(caplog, "deflate") def test_download_warnsize_request_meta_gzip( self, caplog: pytest.LogCaptureFixture ) -> None: self._test_download_warnsize_request_meta(caplog, "gzip") def test_download_warnsize_request_meta_zstd( self, caplog: pytest.LogCaptureFixture ) -> None: self._test_download_warnsize_request_meta(caplog, "zstd") def _get_truncated_response(self, compression_id: str) -> Response: crawler = get_crawler(Spider) spider = crawler._create_spider("scrapytest.org") mw = build_from_crawler(HttpCompressionMiddleware, crawler) mw.open_spider(spider) response = self._getresponse(compression_id) truncated_body = response.body[: len(response.body) // 2] response = response.replace(body=truncated_body) assert response.request new_response = mw.process_response(response.request, response) assert isinstance(new_response, Response) return new_response def test_process_truncated_response_br(self): resp = self._get_truncated_response("br") assert resp.body.startswith(b" bool: allowed_domains: list[str] = getattr(spider, "allowed_domains", []) return urlparse_cached(request).hostname in allowed_domains crawler = get_crawler(Spider) crawler.spider = crawler._create_spider(name="a", allowed_domains=["example.com"]) mw = build_from_crawler(RootOnlyOffsiteMiddleware, crawler) mw.spider_opened(crawler.spider) assert mw.process_request(Request("https://example.com/1")) is None with pytest.raises(IgnoreRequest): mw.process_request(Request("https://www.example.com/1")) def test_ignore_request_reason(): crawler = get_crawler(Spider) crawler.spider = crawler._create_spider(name="a", allowed_domains=["example.com"]) mw = build_from_crawler(OffsiteMiddleware, crawler) mw.spider_opened(crawler.spider) request = Request("http://other.org/1") with pytest.raises( IgnoreRequest, match=re.escape("Filtered offsite request to 'other.org'") ): mw.process_request(request) class DomainSpider(Spider): name = "a" allowed_domains: list[str] def test_dynamic_allowed_domains(): crawler = get_crawler(DomainSpider) spider = DomainSpider.from_crawler(crawler, allowed_domains=["a.example"]) crawler.spider = spider mw = build_from_crawler(OffsiteMiddleware, crawler) mw.spider_opened(spider) with pytest.raises(IgnoreRequest): mw.process_request(Request("https://b.example")) spider.allowed_domains.append("b.example") assert mw.process_request(Request("https://b.example")) is None spider.allowed_domains.remove("a.example") with pytest.raises(IgnoreRequest): mw.process_request(Request("https://a.example")) def test_dynamic_allowed_domains_caching(): calls = 0 class TrackingMiddleware(OffsiteMiddleware): def get_host_regex(self, spider: Spider) -> re.Pattern[str]: nonlocal calls calls += 1 return super().get_host_regex(spider) crawler = get_crawler(DomainSpider) spider = DomainSpider.from_crawler(crawler, allowed_domains=["a.example"]) crawler.spider = spider mw = build_from_crawler(TrackingMiddleware, crawler) mw.spider_opened(spider) for _ in range(3): mw.process_request(Request("https://a.example")) assert calls == 1 spider.allowed_domains.append("b.example") assert mw.process_request(Request("https://b.example")) is None assert calls == 2 scrapy-scrapy-5569de3/tests/test_downloadermiddleware_redirect.py000066400000000000000000000433541525051012400255140ustar00rootroot00000000000000import logging from unittest.mock import MagicMock import pytest from scrapy.downloadermiddlewares.redirect import RedirectMiddleware from scrapy.exceptions import NotConfigured from scrapy.http import Request, Response from scrapy.spidermiddlewares.referer import ( POLICY_NO_REFERRER, POLICY_ORIGIN, POLICY_UNSAFE_URL, RefererMiddleware, ) from scrapy.spiders import Spider from scrapy.utils.misc import build_from_crawler from scrapy.utils.spider import DefaultSpider from scrapy.utils.test import get_crawler from tests.utils.bases.redirect import TestRedirectBase from tests.utils.redirect import REDIRECT_SCHEME_CASES, SCHEME_PARAMS class TestRedirectMiddleware(TestRedirectBase): mwcls = RedirectMiddleware reason = 302 def setup_method(self): crawler = get_crawler(DefaultSpider) crawler.spider = crawler._create_spider() self.mw = build_from_crawler(self.mwcls, crawler) def get_response(self, request, location, status=302): headers = {"Location": location} return Response(request.url, status=status, headers=headers) def test_redirect_307_308_preserve_method(self): def _test(method, status: int): url = f"http://www.example.com/{status}" url2 = "http://www.example.com/redirected" req = Request(url, method=method) rsp = Response(url, headers={"Location": url2}, status=status) req2 = self.mw.process_response(req, rsp) assert isinstance(req2, Request) assert req2.url == url2 assert req2.method == method # response without Location header but with status code is 3XX should be ignored del rsp.headers["Location"] assert self.mw.process_response(req, rsp) is rsp _test("GET", status=307) _test("POST", status=307) _test("HEAD", status=307) _test("GET", status=308) _test("POST", status=308) _test("HEAD", status=308) @pytest.mark.parametrize("status", [301, 302, 303]) def test_method_becomes_get(self, status): source_url = f"http://www.example.com/{status}" target_url = "http://www.example.com/redirected2" request = Request( source_url, method="POST", body="test", headers={"Content-Type": "text/plain", "Content-length": "4"}, ) response = Response(source_url, headers={"Location": target_url}, status=status) redirect_request = self.mw.process_response(request, response) assert isinstance(redirect_request, Request) assert redirect_request.url == target_url assert redirect_request.method == "GET" assert "Content-Type" not in redirect_request.headers assert "Content-Length" not in redirect_request.headers assert not redirect_request.body @pytest.mark.parametrize("status", [301, 302]) @pytest.mark.parametrize("method", ["PUT", "DELETE"]) def test_method_not_converted_on_301_302(self, status, method): url = f"http://www.example.com/{status}" url2 = "http://www.example.com/redirected" body = b"test-body" req = Request( url, method=method, body=body, headers={"Content-Type": "text/plain", "Content-Length": str(len(body))}, ) rsp = Response(url, headers={"Location": url2}, status=status) req2 = self.mw.process_response(req, rsp) assert isinstance(req2, Request) assert req2.url == url2 assert req2.method == method assert req2.body == body assert req2.headers[b"Content-Type"] == b"text/plain" assert req2.headers[b"Content-Length"] == str(len(body)).encode() @pytest.mark.parametrize("method", ["PUT", "DELETE"]) def test_method_converted_on_303(self, method): status = 303 url = f"http://www.example.com/{status}" url2 = "http://www.example.com/redirected" req = Request(url, method=method) rsp = Response(url, headers={"Location": url2}, status=status) req2 = self.mw.process_response(req, rsp) assert isinstance(req2, Request) assert req2.url == url2 assert req2.method == "GET" def test_get_method_body_preserved_on_303(self): status = 303 url = f"http://www.example.com/{status}" url2 = "http://www.example.com/redirected" body = b"test-body" req = Request( url, method="GET", body=body, headers={"Content-Type": "text/plain", "Content-Length": str(len(body))}, ) rsp = Response(url, headers={"Location": url2}, status=status) req2 = self.mw.process_response(req, rsp) assert isinstance(req2, Request) assert req2.url == url2 assert req2.method == "GET" assert req2.body == body assert req2.headers[b"Content-Type"] == b"text/plain" assert req2.headers[b"Content-Length"] == str(len(body)).encode() def test_redirect_strips_content_headers(self): url = "http://www.example.com/303" url2 = "http://www.example.com/redirected" headers = { "Content-Type": "application/json", "Content-Length": "100", "Content-Encoding": "gzip", "Content-Language": "en", "Content-Location": "http://www.example.com/original", "X-Custom": "foo", } req = Request(url, method="POST", headers=headers, body=b"foo") rsp = Response(url, headers={"Location": url2}, status=303) req2 = self.mw.process_response(req, rsp) assert isinstance(req2, Request) assert req2.url == url2 assert req2.method == "GET" assert req2.body == b"" assert "Content-Type" not in req2.headers assert "Content-Length" not in req2.headers assert "Content-Encoding" not in req2.headers assert "Content-Language" not in req2.headers assert "Content-Location" not in req2.headers assert req2.headers["X-Custom"] == b"foo" @pytest.mark.parametrize( ( "referer", "source_url", "target_url", "policy_header", "expected_referer", ), [ ( None, "http://www.example.com/302", "http://www.example.com/redirected", None, b"http://www.example.com/302", ), ( "http://example.com/old", "http://www.example.com/302", "http://www.example.com/redirected", None, b"http://www.example.com/302", ), ( "https://example.com/old", "https://www.example.com/302", "http://www.example.com/redirected", None, None, ), ( "http://example.com/old", "http://www.example.com/foo/bar", "http://www.example.com/redirected", "origin", b"http://www.example.com/", ), ], ) def test_redirect_referer( self, referer, source_url, target_url, policy_header, expected_referer ): headers = {"Referer": referer} if referer else {} source_request = Request(source_url, headers=headers) resp_headers = {"Location": target_url} if policy_header: resp_headers["Referrer-Policy"] = policy_header response = Response(source_url, headers=resp_headers, status=302) crawler = get_crawler() referer_mw = build_from_crawler(RefererMiddleware, crawler) redirect_mw = build_from_crawler(self.mwcls, crawler) redirect_mw._referer_spider_middleware = referer_mw redirect_request = redirect_mw.process_response(source_request, response) if expected_referer: assert redirect_request.headers.get("Referer") == expected_referer else: assert "Referer" not in redirect_request.headers def test_redirect_strips_referer_no_middleware(self): source_url = "http://www.example.com/302" redirect_url = "http://www.example.com/redirected" source_request = Request( source_url, headers={"Referer": "http://example.com/old"} ) response = Response(source_url, headers={"Location": redirect_url}, status=302) redirect_mw = build_from_crawler(self.mwcls, get_crawler()) redirect_mw._referer_spider_middleware = None redirect_request = redirect_mw.process_response(source_request, response) assert "Referer" not in redirect_request.headers @pytest.mark.parametrize("status", [307, 308]) def test_cross_origin_maintain_body(self, status): source_url = "https://example.com" target_url = "https://attacker.example" body = b"secret" request = Request( source_url, method="POST", body=body, headers={ "Content-Type": "application/json", "Content-Length": str(len(body)), }, ) response1 = Response( source_url, headers={"Location": target_url}, status=status ) redirect_request = self.mw.process_response(request, response1) assert isinstance(redirect_request, Request) assert redirect_request.url == target_url assert redirect_request.method == "POST" assert redirect_request.body == body assert redirect_request.headers[b"Content-Type"] == b"application/json" assert redirect_request.headers[b"Content-Length"] == str(len(body)).encode() def test_redirect_keeps_fragment(self): url = "http://www.example.com/301#frag" url2 = "http://www.example.com/redirected" req = Request(url) rsp = Response(url, headers={"Location": url2}, status=302) req2 = self.mw.process_response(req, rsp) assert isinstance(req2, Request) assert req2.url == "http://www.example.com/redirected#frag" def test_redirect_target_has_fragment(self): url = "http://www.example.com/302#original" url2 = "http://www.example.com/redirected#target" req = Request(url) rsp = Response(url, headers={"Location": url2}, status=302) req2 = self.mw.process_response(req, rsp) assert isinstance(req2, Request) assert req2.url == "http://www.example.com/redirected#target" def test_redirect_302_head(self): url = "http://www.example.com/302" url2 = "http://www.example.com/redirected2" req = Request(url, method="HEAD") rsp = Response(url, headers={"Location": url2}, status=302) req2 = self.mw.process_response(req, rsp) assert isinstance(req2, Request) assert req2.url == url2 assert req2.method == "HEAD" def test_redirect_302_relative(self): url = "http://www.example.com/302" url2 = "///i8n.example2.com/302" url3 = "http://i8n.example2.com/302" req = Request(url, method="HEAD") rsp = Response(url, headers={"Location": url2}, status=302) req2 = self.mw.process_response(req, rsp) assert isinstance(req2, Request) assert req2.url == url3 assert req2.method == "HEAD" def test_spider_handling(self): self.mw.crawler.spider.handle_httpstatus_list = [404, 301, 302] url = "http://www.example.com/301" url2 = "http://www.example.com/redirected" req = Request(url) rsp = Response(url, headers={"Location": url2}, status=301) r = self.mw.process_response(req, rsp) assert r is rsp def test_request_meta_handling(self): url = "http://www.example.com/301" url2 = "http://www.example.com/redirected" def _test_passthrough(req: Request) -> None: rsp = Response(url, headers={"Location": url2}, status=301, request=req) r = self.mw.process_response(req, rsp) assert r is rsp _test_passthrough( Request(url, meta={"handle_httpstatus_list": [404, 301, 302]}) ) _test_passthrough(Request(url, meta={"handle_httpstatus_all": True})) def test_latin1_location(self): req = Request("http://scrapytest.org/first") latin1_location = "/ação".encode("latin1") # HTTP historically supports latin1 resp = Response( "http://scrapytest.org/first", headers={"Location": latin1_location}, status=302, ) req_result = self.mw.process_response(req, resp) perc_encoded_utf8_url = "http://scrapytest.org/a%E7%E3o" assert perc_encoded_utf8_url == req_result.url def test_utf8_location(self): req = Request("http://scrapytest.org/first") utf8_location = "/ação".encode() # header using UTF-8 encoding resp = Response( "http://scrapytest.org/first", headers={"Location": utf8_location}, status=302, ) req_result = self.mw.process_response(req, resp) perc_encoded_utf8_url = "http://scrapytest.org/a%C3%A7%C3%A3o" assert perc_encoded_utf8_url == req_result.url def test_no_location(self): request = Request("https://example.com") response = Response(request.url, status=302) assert self.mw.process_response(request, response) is response @pytest.mark.parametrize(SCHEME_PARAMS, REDIRECT_SCHEME_CASES) def test_redirect_schemes(url, location, target): crawler = get_crawler(Spider) mw = build_from_crawler(RedirectMiddleware, crawler) request = Request(url) response = Response(url, headers={"Location": location}, status=301) redirect = mw.process_response(request, response) if target is None: assert redirect == response else: assert isinstance(redirect, Request) assert redirect.url == target @pytest.mark.parametrize( ("policy", "source_url", "target_url", "expected_referrer"), [ # The policy header affects the outcome. # (without it, the https → http switch would drop the referer) ( POLICY_UNSAFE_URL, "https://a.example/1", "http://a.example/2", b"https://a.example/1", ), # The policy header can get the Referer header removed. ( POLICY_NO_REFERRER, "http://a.example/1", "http://a.example/2", None, ), # The policy header can get the Referer header edited (path stripped). ( POLICY_ORIGIN, "http://a.example/1", "http://a.example/2", b"http://a.example/", ), ], ) def test_response_referrer_policy(policy, source_url, target_url, expected_referrer): crawler = get_crawler() referrer_mw = build_from_crawler(RefererMiddleware, crawler) redirect_mw = build_from_crawler(RedirectMiddleware, crawler) redirect_mw._referer_spider_middleware = referrer_mw source_request = Request(source_url) extra_headers = {} if policy: extra_headers["Referrer-Policy"] = policy response_redirect = Response( source_request.url, status=301, headers={"Location": target_url, **extra_headers}, ) target_request = redirect_mw.process_response(source_request, response_redirect) assert isinstance(target_request, Request) assert target_request.headers.get("Referer") == expected_referrer def test_no_warning_when_referer_middleware_present(caplog): crawler = get_crawler() crawler.get_spider_middleware = MagicMock( # type: ignore[method-assign] return_value=MagicMock() ) mw = build_from_crawler(RedirectMiddleware, crawler) caplog.clear() with caplog.at_level(logging.WARNING): mw._engine_started() assert not [ record for record in caplog.records if record.name == "scrapy.downloadermiddlewares.redirect" ] def test_warning_redirect_middleware(caplog): crawler = get_crawler() crawler.get_spider_middleware = MagicMock( # type: ignore[method-assign] return_value=None ) mw = build_from_crawler(RedirectMiddleware, crawler) with caplog.at_level(logging.WARNING): mw._engine_started() assert ( "scrapy.downloadermiddlewares.redirect.RedirectMiddleware found no " "scrapy.spidermiddlewares.referer.RefererMiddleware" ) in caplog.text assert ( "enable scrapy.spidermiddlewares.referer.RefererMiddleware (or a subclass)" in caplog.text ) assert ( "replace scrapy.downloadermiddlewares.redirect.RedirectMiddleware " "with a subclass that overrides the handle_referer() method" ) in caplog.text def test_warning_subclass(caplog): class MyRedirectMiddleware(RedirectMiddleware): pass crawler = get_crawler() crawler.get_spider_middleware = MagicMock( # type: ignore[method-assign] return_value=None ) mw = build_from_crawler(MyRedirectMiddleware, crawler) with caplog.at_level(logging.WARNING): mw._engine_started() assert ( "test_warning_subclass..MyRedirectMiddleware found no " "scrapy.spidermiddlewares.referer.RefererMiddleware" ) in caplog.text assert ( "enable scrapy.spidermiddlewares.referer.RefererMiddleware (or a subclass)" in caplog.text ) assert "edit " in caplog.text assert "test_warning_subclass..MyRedirectMiddleware" in caplog.text assert ( "(if defined in your code base) to override the handle_referer() method" ) in caplog.text def test_not_configured(): crawler = get_crawler(DefaultSpider, {"REDIRECT_ENABLED": False}) with pytest.raises(NotConfigured): build_from_crawler(RedirectMiddleware, crawler) scrapy-scrapy-5569de3/tests/test_downloadermiddleware_redirect_metarefresh.py000066400000000000000000000137111525051012400300730ustar00rootroot00000000000000from __future__ import annotations import logging from itertools import chain from unittest.mock import MagicMock import pytest from scrapy.downloadermiddlewares.redirect import MetaRefreshMiddleware from scrapy.exceptions import NotConfigured from scrapy.http import HtmlResponse, Request, Response from scrapy.spiders import Spider from scrapy.utils.misc import build_from_crawler from scrapy.utils.test import get_crawler from tests.utils.bases.redirect import TestRedirectBase from tests.utils.redirect import ( HTTP_SCHEMES, NON_HTTP_SCHEMES, REDIRECT_SCHEME_CASES, SCHEME_PARAMS, ) def meta_refresh_body(url: str, interval: int = 5) -> bytes: html = f"""""" return html.encode("utf-8") class TestMetaRefreshMiddleware(TestRedirectBase): mwcls = MetaRefreshMiddleware reason = "meta refresh" def setup_method(self): crawler = get_crawler(Spider) self.mw = build_from_crawler(self.mwcls, crawler) def _body( self, interval: int = 5, url: str = "http://example.org/newpage" ) -> bytes: return meta_refresh_body(url, interval) def get_response( self, request: Request, location: str, status: int = 302 ) -> Response: return HtmlResponse(request.url, body=self._body(url=location)) def test_meta_refresh(self): req = Request(url="http://example.org") rsp = HtmlResponse(req.url, body=self._body()) req2 = self.mw.process_response(req, rsp) assert isinstance(req2, Request) assert req2.url == "http://example.org/newpage" def test_meta_refresh_with_high_interval(self): # meta-refresh with high intervals don't trigger redirects req = Request(url="http://example.org") rsp = HtmlResponse( url="http://example.org", body=self._body(interval=1000), encoding="utf-8" ) rsp2 = self.mw.process_response(req, rsp) assert rsp is rsp2 def test_meta_refresh_trough_posted_request(self): req = Request( url="http://example.org", method="POST", body="test", headers={"Content-Type": "text/plain", "Content-length": "4"}, ) rsp = HtmlResponse(req.url, body=self._body()) req2 = self.mw.process_response(req, rsp) assert isinstance(req2, Request) assert req2.url == "http://example.org/newpage" assert 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!r}" def test_ignore_tags_default(self): req = Request(url="http://example.org") body = ( """""" ) rsp = HtmlResponse(req.url, body=body.encode()) response = self.mw.process_response(req, rsp) assert isinstance(response, Response) def test_ignore_tags_1_x_list(self): """Test that Scrapy 1.x behavior remains possible""" settings = {"METAREFRESH_IGNORE_TAGS": ["script", "noscript"]} crawler = get_crawler(Spider, settings) mw = build_from_crawler(MetaRefreshMiddleware, crawler) req = Request(url="http://example.org") body = ( """""" ) rsp = HtmlResponse(req.url, body=body.encode()) response = mw.process_response(req, rsp) 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) mw = build_from_crawler(MetaRefreshMiddleware, crawler) request = Request(url) response = HtmlResponse(url, body=meta_refresh_body(location)) redirect = mw.process_response(request, response) if target is None: assert redirect == response else: assert isinstance(redirect, Request) def test_warning_meta_refresh_middleware(caplog): crawler = get_crawler() crawler.get_spider_middleware = MagicMock( # type: ignore[method-assign] return_value=None ) mw = build_from_crawler(MetaRefreshMiddleware, crawler) with caplog.at_level(logging.WARNING): mw._engine_started() assert ( "scrapy.downloadermiddlewares.redirect.MetaRefreshMiddleware found no " "scrapy.spidermiddlewares.referer.RefererMiddleware" ) in caplog.text assert ( "enable scrapy.spidermiddlewares.referer.RefererMiddleware (or a subclass)" in caplog.text ) assert ( "replace scrapy.downloadermiddlewares.redirect.MetaRefreshMiddleware " "with a subclass that overrides the handle_referer() method" ) in caplog.text def test_not_configured(): crawler = get_crawler(Spider, {"METAREFRESH_ENABLED": False}) with pytest.raises(NotConfigured): build_from_crawler(MetaRefreshMiddleware, crawler) scrapy-scrapy-5569de3/tests/test_downloadermiddleware_retry.py000066400000000000000000000640411525051012400250540ustar00rootroot00000000000000from __future__ import annotations import logging from typing import Any, cast import pytest from twisted.internet.error import ConnectError, ConnectionDone, ConnectionLost from scrapy.downloadermiddlewares.retry import RetryMiddleware, get_retry_request from scrapy.exceptions import ( CannotResolveHostError, DownloadConnectionRefusedError, DownloadTimeoutError, IgnoreRequest, ) from scrapy.http import Request, Response from scrapy.settings.default_settings import RETRY_EXCEPTIONS from scrapy.spiders import Spider from scrapy.utils.misc import build_from_crawler from scrapy.utils.spider import DefaultSpider from scrapy.utils.test import get_crawler class TestRetry: def setup_method(self): self.crawler = get_crawler(DefaultSpider) self.crawler.spider = self.crawler._create_spider() self.mw = build_from_crawler(RetryMiddleware, self.crawler) self.mw.max_retry_times = 2 def test_priority_adjust(self): req = Request("http://www.scrapytest.org/503") rsp = Response("http://www.scrapytest.org/503", body=b"", status=503) req2 = self.mw.process_response(req, rsp) assert isinstance(req2, Request) assert req2.priority < req.priority def test_404(self): req = Request("http://www.scrapytest.org/404") rsp = Response("http://www.scrapytest.org/404", body=b"", status=404) # dont retry 404s assert self.mw.process_response(req, rsp) is rsp def test_dont_retry(self): req = Request("http://www.scrapytest.org/503", meta={"dont_retry": True}) rsp = Response("http://www.scrapytest.org/503", body=b"", status=503) # no retry r = self.mw.process_response(req, rsp) assert r is rsp # Test retry when dont_retry set to False req = Request("http://www.scrapytest.org/503", meta={"dont_retry": False}) rsp = Response("http://www.scrapytest.org/503", body=b"", status=503) # first retry req2 = self.mw.process_response(req, rsp) assert isinstance(req2, Request) assert req2.meta["retry_times"] == 1 def test_dont_retry_exc(self): req = Request("http://www.scrapytest.org/503", meta={"dont_retry": True}) r = self.mw.process_exception(req, CannotResolveHostError()) assert r is None def test_503(self): req = Request("http://www.scrapytest.org/503") rsp = Response("http://www.scrapytest.org/503", body=b"", status=503) # first retry req2 = self.mw.process_response(req, rsp) assert isinstance(req2, Request) assert req2.meta["retry_times"] == 1 # second retry req3 = self.mw.process_response(req2, rsp) assert isinstance(req3, Request) assert req3.meta["retry_times"] == 2 # discard it assert self.mw.process_response(req3, rsp) is rsp assert self.crawler.stats.get_value("retry/max_reached") == 1 assert ( self.crawler.stats.get_value("retry/reason_count/503 Service Unavailable") == 2 ) assert self.crawler.stats.get_value("retry/count") == 2 def test_give_up_log_level_setting(self, caplog: pytest.LogCaptureFixture) -> None: crawler = get_crawler( DefaultSpider, settings_dict={"RETRY_GIVE_UP_LOG_LEVEL": "WARNING"} ) crawler.spider = crawler._create_spider() mw = build_from_crawler(RetryMiddleware, crawler) mw.max_retry_times = 0 req = Request("http://example.com/503") rsp = Response("http://example.com/503", body=b"", status=503) with caplog.at_level(logging.WARNING): assert mw.process_response(req, rsp) is rsp assert ( "scrapy.downloadermiddlewares.retry", logging.WARNING, f"Gave up retrying {req} (failed 1 times): 503 Service Unavailable", ) in caplog.record_tuples def test_give_up_log_level_meta(self, caplog: pytest.LogCaptureFixture) -> None: self.mw.max_retry_times = 0 req = Request("http://example.com/503", meta={"give_up_log_level": "WARNING"}) rsp = Response("http://example.com/503", body=b"", status=503) with caplog.at_level(logging.WARNING): assert self.mw.process_response(req, rsp) is rsp assert ( "scrapy.downloadermiddlewares.retry", logging.WARNING, f"Gave up retrying {req} (failed 1 times): 503 Service Unavailable", ) in caplog.record_tuples def test_twistederrors(self): exceptions = [ ConnectError, ConnectionDone, ConnectionLost, DownloadTimeoutError, DownloadConnectionRefusedError, CannotResolveHostError, ] for exc in exceptions: req = Request(f"http://www.scrapytest.org/{exc.__name__}") self._test_retry_exception(req, exc("foo")) stats = self.crawler.stats assert stats.get_value("retry/max_reached") == len(exceptions) assert stats.get_value("retry/count") == len(exceptions) * 2 assert ( stats.get_value("retry/reason_count/scrapy.exceptions.DownloadTimeoutError") == 2 ) def test_exception_to_retry_added(self): exc = ValueError settings_dict = { "RETRY_EXCEPTIONS": [*RETRY_EXCEPTIONS, exc], } crawler = get_crawler(DefaultSpider, settings_dict=settings_dict) crawler.spider = crawler._create_spider() mw = build_from_crawler(RetryMiddleware, crawler) req = Request(f"http://www.scrapytest.org/{exc.__name__}") self._test_retry_exception(req, exc("foo"), mw) def _test_retry_exception( self, req: Request, exception: Exception, mw: RetryMiddleware | None = None ) -> None: if mw is None: mw = self.mw # first retry req2 = mw.process_exception(req, exception) assert isinstance(req2, Request) assert req2.meta["retry_times"] == 1 # second retry req3 = mw.process_exception(req2, exception) assert isinstance(req3, Request) assert req3.meta["retry_times"] == 2 # discard it assert mw.process_exception(req3, exception) is None class TestMaxRetryTimes: invalid_url = "http://www.scrapytest.org/invalid_url" def get_middleware(self, settings: dict[str, Any] | None = None) -> RetryMiddleware: crawler = get_crawler(DefaultSpider, settings or {}) crawler.spider = crawler._create_spider() return build_from_crawler(RetryMiddleware, crawler) def test_with_settings_zero(self): max_retry_times = 0 settings = {"RETRY_TIMES": max_retry_times} middleware = self.get_middleware(settings) req = Request(self.invalid_url) self._test_retry( req, CannotResolveHostError("foo"), max_retry_times, middleware=middleware, ) def test_with_metakey_zero(self): max_retry_times = 0 middleware = self.get_middleware() meta = {"max_retry_times": max_retry_times} req = Request(self.invalid_url, meta=meta) self._test_retry( req, CannotResolveHostError("foo"), max_retry_times, middleware=middleware, ) def test_without_metakey(self): max_retry_times = 5 settings = {"RETRY_TIMES": max_retry_times} middleware = self.get_middleware(settings) req = Request(self.invalid_url) self._test_retry( req, CannotResolveHostError("foo"), max_retry_times, middleware=middleware, ) def test_with_metakey_greater(self): meta_max_retry_times = 3 middleware_max_retry_times = 2 req1 = Request(self.invalid_url, meta={"max_retry_times": meta_max_retry_times}) req2 = Request(self.invalid_url) settings = {"RETRY_TIMES": middleware_max_retry_times} middleware = self.get_middleware(settings) self._test_retry( req1, CannotResolveHostError("foo"), meta_max_retry_times, middleware=middleware, ) self._test_retry( req2, CannotResolveHostError("foo"), middleware_max_retry_times, middleware=middleware, ) def test_with_metakey_lesser(self): meta_max_retry_times = 4 middleware_max_retry_times = 5 req1 = Request(self.invalid_url, meta={"max_retry_times": meta_max_retry_times}) req2 = Request(self.invalid_url) settings = {"RETRY_TIMES": middleware_max_retry_times} middleware = self.get_middleware(settings) self._test_retry( req1, CannotResolveHostError("foo"), meta_max_retry_times, middleware=middleware, ) self._test_retry( req2, CannotResolveHostError("foo"), middleware_max_retry_times, middleware=middleware, ) def test_with_dont_retry(self): max_retry_times = 4 middleware = self.get_middleware() meta = { "max_retry_times": max_retry_times, "dont_retry": True, } req = Request(self.invalid_url, meta=meta) self._test_retry( req, CannotResolveHostError("foo"), 0, middleware=middleware, ) def _test_retry( self, req: Request, exception: Exception, max_retry_times: int, middleware: RetryMiddleware, ) -> None: for _ in range(max_retry_times): result = middleware.process_exception(req, exception) assert isinstance(result, Request) req = result # discard it assert middleware.process_exception(req, exception) is None class TestGetRetryRequest: @staticmethod def get_spider(settings: dict[str, Any] | None = None) -> Spider: crawler = get_crawler(Spider, settings or {}) return crawler._create_spider("foo") def test_basic_usage(self, caplog: pytest.LogCaptureFixture) -> None: request = Request("https://example.com") spider = self.get_spider() with caplog.at_level(logging.DEBUG): new_request = get_retry_request( request, spider=spider, ) assert isinstance(new_request, Request) assert new_request != request assert new_request.dont_filter expected_retry_times = 1 assert new_request.meta["retry_times"] == expected_retry_times assert new_request.priority == -1 expected_reason = "unspecified" for stat in ("retry/count", f"retry/reason_count/{expected_reason}"): assert spider.crawler.stats.get_value(stat) == 1 assert ( "scrapy.downloadermiddlewares.retry", logging.DEBUG, f"Retrying {request} (failed {expected_retry_times} times): " f"{expected_reason}", ) in caplog.record_tuples def test_max_retries_reached(self, caplog: pytest.LogCaptureFixture) -> None: request = Request("https://example.com") spider = self.get_spider() max_retry_times = 0 with caplog.at_level(logging.DEBUG): new_request = get_retry_request( request, spider=spider, max_retry_times=max_retry_times, ) assert new_request is None assert spider.crawler.stats.get_value("retry/max_reached") == 1 failure_count = max_retry_times + 1 expected_reason = "unspecified" assert ( "scrapy.downloadermiddlewares.retry", logging.ERROR, f"Gave up retrying {request} (failed {failure_count} times): " f"{expected_reason}", ) in caplog.record_tuples def test_one_retry(self, caplog: pytest.LogCaptureFixture) -> None: request = Request("https://example.com") spider = self.get_spider() with caplog.at_level(logging.DEBUG): new_request = get_retry_request( request, spider=spider, max_retry_times=1, ) assert isinstance(new_request, Request) assert new_request != request assert new_request.dont_filter expected_retry_times = 1 assert new_request.meta["retry_times"] == expected_retry_times assert new_request.priority == -1 expected_reason = "unspecified" for stat in ("retry/count", f"retry/reason_count/{expected_reason}"): assert spider.crawler.stats.get_value(stat) == 1 assert ( "scrapy.downloadermiddlewares.retry", logging.DEBUG, f"Retrying {request} (failed {expected_retry_times} times): " f"{expected_reason}", ) in caplog.record_tuples def test_two_retries(self, caplog: pytest.LogCaptureFixture) -> None: spider = self.get_spider() request = Request("https://example.com") new_request = request max_retry_times = 2 for index in range(max_retry_times): caplog.clear() with caplog.at_level(logging.DEBUG): new_request = cast( "Request", get_retry_request( new_request, spider=spider, max_retry_times=max_retry_times, ), ) assert isinstance(new_request, Request) assert new_request != request assert new_request.dont_filter expected_retry_times = index + 1 assert new_request.meta["retry_times"] == expected_retry_times assert new_request.priority == -expected_retry_times expected_reason = "unspecified" for stat in ("retry/count", f"retry/reason_count/{expected_reason}"): value = spider.crawler.stats.get_value(stat) assert value == expected_retry_times assert ( "scrapy.downloadermiddlewares.retry", logging.DEBUG, f"Retrying {request} (failed {expected_retry_times} times): " f"{expected_reason}", ) in caplog.record_tuples caplog.clear() with caplog.at_level(logging.DEBUG): new_request = cast( "Request", get_retry_request( new_request, spider=spider, max_retry_times=max_retry_times, ), ) assert new_request is None assert spider.crawler.stats.get_value("retry/max_reached") == 1 failure_count = max_retry_times + 1 expected_reason = "unspecified" assert ( "scrapy.downloadermiddlewares.retry", logging.ERROR, f"Gave up retrying {request} (failed {failure_count} times): " f"{expected_reason}", ) in caplog.record_tuples def test_no_spider(self): request = Request("https://example.com") with pytest.raises(TypeError): get_retry_request(request) # type: ignore[call-arg] # pylint: disable=missing-kwoa def test_max_retry_times_setting(self): max_retry_times = 0 spider = self.get_spider({"RETRY_TIMES": max_retry_times}) request = Request("https://example.com") new_request = get_retry_request( request, spider=spider, ) assert new_request is None def test_max_retry_times_meta(self): max_retry_times = 0 spider = self.get_spider({"RETRY_TIMES": max_retry_times + 1}) meta = {"max_retry_times": max_retry_times} request = Request("https://example.com", meta=meta) new_request = get_retry_request( request, spider=spider, ) assert new_request is None def test_max_retry_times_argument(self): max_retry_times = 0 spider = self.get_spider({"RETRY_TIMES": max_retry_times + 1}) meta = {"max_retry_times": max_retry_times + 1} request = Request("https://example.com", meta=meta) new_request = get_retry_request( request, spider=spider, max_retry_times=max_retry_times, ) assert new_request is None def test_priority_adjust_setting(self): priority_adjust = 1 spider = self.get_spider({"RETRY_PRIORITY_ADJUST": priority_adjust}) request = Request("https://example.com") new_request = get_retry_request( request, spider=spider, ) assert new_request assert new_request.priority == priority_adjust def test_priority_adjust_argument(self): priority_adjust = 1 spider = self.get_spider({"RETRY_PRIORITY_ADJUST": priority_adjust + 1}) request = Request("https://example.com") new_request = get_retry_request( request, spider=spider, priority_adjust=priority_adjust, ) assert new_request assert new_request.priority == priority_adjust def test_log_extra_retry_success(self, caplog: pytest.LogCaptureFixture) -> None: request = Request("https://example.com") spider = self.get_spider() with caplog.at_level(logging.DEBUG): get_retry_request( request, spider=spider, ) assert any(getattr(r, "spider", None) is spider for r in caplog.records) def test_log_extra_retries_exceeded(self, caplog: pytest.LogCaptureFixture) -> None: request = Request("https://example.com") spider = self.get_spider() with caplog.at_level(logging.DEBUG): get_retry_request( request, spider=spider, max_retry_times=0, ) assert any(getattr(r, "spider", None) is spider for r in caplog.records) def test_reason_string(self, caplog: pytest.LogCaptureFixture) -> None: request = Request("https://example.com") spider = self.get_spider() expected_reason = "because" with caplog.at_level(logging.DEBUG): get_retry_request( request, spider=spider, reason=expected_reason, ) expected_retry_times = 1 for stat in ("retry/count", f"retry/reason_count/{expected_reason}"): assert spider.crawler.stats.get_value(stat) == 1 assert ( "scrapy.downloadermiddlewares.retry", logging.DEBUG, f"Retrying {request} (failed {expected_retry_times} times): " f"{expected_reason}", ) in caplog.record_tuples def test_reason_builtin_exception(self, caplog: pytest.LogCaptureFixture) -> None: request = Request("https://example.com") spider = self.get_spider() expected_reason = NotImplementedError() expected_reason_string = "builtins.NotImplementedError" with caplog.at_level(logging.DEBUG): get_retry_request( request, spider=spider, reason=expected_reason, ) expected_retry_times = 1 stat = spider.crawler.stats.get_value( f"retry/reason_count/{expected_reason_string}" ) assert stat == 1 assert ( "scrapy.downloadermiddlewares.retry", logging.DEBUG, f"Retrying {request} (failed {expected_retry_times} times): " f"{expected_reason}", ) in caplog.record_tuples def test_reason_builtin_exception_class( self, caplog: pytest.LogCaptureFixture ) -> None: request = Request("https://example.com") spider = self.get_spider() expected_reason = NotImplementedError expected_reason_string = "builtins.NotImplementedError" with caplog.at_level(logging.DEBUG): get_retry_request( request, spider=spider, reason=expected_reason, ) expected_retry_times = 1 stat = spider.crawler.stats.get_value( f"retry/reason_count/{expected_reason_string}" ) assert stat == 1 assert ( "scrapy.downloadermiddlewares.retry", logging.DEBUG, f"Retrying {request} (failed {expected_retry_times} times): " f"{expected_reason}", ) in caplog.record_tuples def test_reason_custom_exception(self, caplog: pytest.LogCaptureFixture) -> None: request = Request("https://example.com") spider = self.get_spider() expected_reason = IgnoreRequest() expected_reason_string = "scrapy.exceptions.IgnoreRequest" with caplog.at_level(logging.DEBUG): get_retry_request( request, spider=spider, reason=expected_reason, ) expected_retry_times = 1 stat = spider.crawler.stats.get_value( f"retry/reason_count/{expected_reason_string}" ) assert stat == 1 assert ( "scrapy.downloadermiddlewares.retry", logging.DEBUG, f"Retrying {request} (failed {expected_retry_times} times): " f"{expected_reason}", ) in caplog.record_tuples def test_reason_custom_exception_class( self, caplog: pytest.LogCaptureFixture ) -> None: request = Request("https://example.com") spider = self.get_spider() expected_reason = IgnoreRequest expected_reason_string = "scrapy.exceptions.IgnoreRequest" with caplog.at_level(logging.DEBUG): get_retry_request( request, spider=spider, reason=expected_reason, ) expected_retry_times = 1 stat = spider.crawler.stats.get_value( f"retry/reason_count/{expected_reason_string}" ) assert stat == 1 assert ( "scrapy.downloadermiddlewares.retry", logging.DEBUG, f"Retrying {request} (failed {expected_retry_times} times): " f"{expected_reason}", ) in caplog.record_tuples def test_custom_logger(self, caplog: pytest.LogCaptureFixture) -> None: logger = logging.getLogger("custom-logger") request = Request("https://example.com") spider = self.get_spider() expected_reason = "because" with caplog.at_level(logging.DEBUG): get_retry_request( request, spider=spider, reason=expected_reason, logger=logger, ) assert ( "custom-logger", logging.DEBUG, f"Retrying {request} (failed 1 times): {expected_reason}", ) in caplog.record_tuples def test_give_up_log_level_default(self, caplog: pytest.LogCaptureFixture) -> None: request = Request("https://example.com") spider = self.get_spider() with caplog.at_level(logging.ERROR): get_retry_request( request, spider=spider, max_retry_times=0, ) assert ( "scrapy.downloadermiddlewares.retry", logging.ERROR, f"Gave up retrying {request} (failed 1 times): unspecified", ) in caplog.record_tuples def test_give_up_log_level_argument_name( self, caplog: pytest.LogCaptureFixture ) -> None: request = Request("https://example.com") spider = self.get_spider() with caplog.at_level(logging.WARNING): get_retry_request( request, spider=spider, max_retry_times=0, give_up_log_level="WARNING", ) assert ( "scrapy.downloadermiddlewares.retry", logging.WARNING, f"Gave up retrying {request} (failed 1 times): unspecified", ) in caplog.record_tuples def test_give_up_log_level_argument_number( self, caplog: pytest.LogCaptureFixture ) -> None: request = Request("https://example.com") spider = self.get_spider() with caplog.at_level(logging.WARNING): get_retry_request( request, spider=spider, max_retry_times=0, give_up_log_level=logging.WARNING, ) assert ( "scrapy.downloadermiddlewares.retry", logging.WARNING, f"Gave up retrying {request} (failed 1 times): unspecified", ) in caplog.record_tuples def test_give_up_log_level_setting(self, caplog: pytest.LogCaptureFixture) -> None: request = Request("https://example.com") spider = self.get_spider({"RETRY_GIVE_UP_LOG_LEVEL": "WARNING"}) with caplog.at_level(logging.WARNING): get_retry_request( request, spider=spider, max_retry_times=0, ) assert ( "scrapy.downloadermiddlewares.retry", logging.WARNING, f"Gave up retrying {request} (failed 1 times): unspecified", ) in caplog.record_tuples def test_give_up_log_level_invalid(self): request = Request("https://example.com") spider = self.get_spider() with pytest.raises(ValueError, match="Invalid give-up log level"): get_retry_request( request, spider=spider, max_retry_times=0, give_up_log_level="NOT_A_LEVEL", ) def test_custom_stats_key(self): request = Request("https://example.com") spider = self.get_spider() expected_reason = "because" stats_key = "custom_retry" get_retry_request( request, spider=spider, reason=expected_reason, stats_base_key=stats_key, ) for stat in ( f"{stats_key}/count", f"{stats_key}/reason_count/{expected_reason}", ): assert spider.crawler.stats.get_value(stat) == 1 scrapy-scrapy-5569de3/tests/test_downloadermiddleware_robotstxt.py000066400000000000000000000273641525051012400257660ustar00rootroot00000000000000from __future__ import annotations import asyncio from unittest import mock import pytest from twisted.internet.defer import Deferred, DeferredList from twisted.python import failure from scrapy import signals from scrapy.downloadermiddlewares.robotstxt import RobotsTxtMiddleware from scrapy.exceptions import CannotResolveHostError, IgnoreRequest, NotConfigured from scrapy.http import Request, Response, TextResponse from scrapy.http.request import NO_CALLBACK from scrapy.settings import Settings from scrapy.utils.asyncio import call_later from scrapy.utils.defer import deferred_from_coro, maybe_deferred_to_future from scrapy.utils.misc import build_from_crawler from tests.utils.decorators import coroutine_test from tests.utils.robotstxt import rerp_available class TestRobotsTxtMiddleware: def setup_method(self) -> None: self.crawler: mock.MagicMock = mock.MagicMock() self.crawler.settings = Settings() self.crawler.engine.download_async = mock.AsyncMock() self.crawler.signals.send_catch_log_async = mock.AsyncMock(return_value=[]) def teardown_method(self): del self.crawler def test_robotstxt_settings(self): self.crawler.settings = Settings() self.crawler.settings.set("USER_AGENT", "CustomAgent") with pytest.raises(NotConfigured): build_from_crawler(RobotsTxtMiddleware, self.crawler) def _get_successful_crawler(self) -> mock.MagicMock: crawler = self.crawler crawler.settings.set("ROBOTSTXT_OBEY", True) ROBOTS = """ User-Agent: * Disallow: /admin/ Disallow: /static/ # taken from https://en.wikipedia.org/robots.txt 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() response = TextResponse("http://site.local/robots.txt", body=ROBOTS) async def return_response(request: Request) -> Response: deferred: Deferred[Response] = Deferred() call_later(0, deferred.callback, response) return await maybe_deferred_to_future(deferred) crawler.engine.download_async.side_effect = return_response return crawler @coroutine_test async def test_robotstxt(self): middleware = build_from_crawler( RobotsTxtMiddleware, self._get_successful_crawler() ) await self.assertNotIgnored(Request("http://site.local/allowed"), middleware) self.assertRobotsTxtRequested("http://site.local") await self.assertIgnored(Request("http://site.local/admin/main"), middleware) await self.assertIgnored(Request("http://site.local/static/"), middleware) await self.assertIgnored( Request("http://site.local/wiki/K%C3%A4ytt%C3%A4j%C3%A4:"), middleware ) await self.assertIgnored( Request("http://site.local/wiki/Käyttäjä:"), middleware ) @coroutine_test async def test_robotstxt_emits_robots_parsed_signal(self): crawler = self._get_successful_crawler() middleware = RobotsTxtMiddleware(crawler) request = Request("http://site.local/allowed") await self.assertNotIgnored(request, middleware) calls = [ kwargs for _, kwargs in crawler.signals.send_catch_log_async.call_args_list if kwargs.get("signal") is signals.robots_parsed ] assert len(calls) == 1 assert calls[0]["request"] is request assert calls[0]["robotparser"] is not None @coroutine_test async def test_robotstxt_multiple_reqs(self) -> None: middleware = build_from_crawler( RobotsTxtMiddleware, self._get_successful_crawler() ) d1 = deferred_from_coro( middleware.process_request(Request("http://site.local/allowed1")) ) d2 = deferred_from_coro( middleware.process_request(Request("http://site.local/allowed2")) ) await maybe_deferred_to_future(DeferredList([d1, d2], fireOnOneErrback=True)) @pytest.mark.only_asyncio @coroutine_test async def test_robotstxt_multiple_reqs_asyncio(self) -> None: middleware = build_from_crawler( RobotsTxtMiddleware, self._get_successful_crawler() ) c1 = middleware.process_request(Request("http://site.local/allowed1")) c2 = middleware.process_request(Request("http://site.local/allowed2")) await asyncio.gather(c1, c2) @coroutine_test async def test_robotstxt_ready_parser(self): middleware = build_from_crawler( RobotsTxtMiddleware, self._get_successful_crawler() ) await self.assertNotIgnored(Request("http://site.local/allowed"), middleware) await self.assertNotIgnored(Request("http://site.local/allowed"), middleware) @coroutine_test async def test_robotstxt_meta(self): middleware = build_from_crawler( RobotsTxtMiddleware, self._get_successful_crawler() ) meta = {"dont_obey_robotstxt": True} await self.assertNotIgnored( Request("http://site.local/allowed", meta=meta), middleware ) await self.assertNotIgnored( Request("http://site.local/admin/main", meta=meta), middleware ) await self.assertNotIgnored( Request("http://site.local/static/", meta=meta), middleware ) def _get_garbage_crawler(self) -> mock.MagicMock: crawler = self.crawler crawler.settings.set("ROBOTSTXT_OBEY", True) response = Response( "http://site.local/robots.txt", body=b"GIF89a\xd3\x00\xfe\x00\xa2" ) async def return_response(request: Request) -> Response: deferred: Deferred[Response] = Deferred() call_later(0, deferred.callback, response) return await maybe_deferred_to_future(deferred) crawler.engine.download_async.side_effect = return_response return crawler @coroutine_test async def test_robotstxt_garbage(self): # garbage response should be discarded, equal 'allow all' middleware = build_from_crawler( RobotsTxtMiddleware, self._get_garbage_crawler() ) await self.assertNotIgnored(Request("http://site.local"), middleware) await self.assertNotIgnored(Request("http://site.local/allowed"), middleware) await self.assertNotIgnored(Request("http://site.local/admin/main"), middleware) await self.assertNotIgnored(Request("http://site.local/static/"), middleware) def _get_emptybody_crawler(self) -> mock.MagicMock: crawler = self.crawler crawler.settings.set("ROBOTSTXT_OBEY", True) response = Response("http://site.local/robots.txt") async def return_response(request: Request) -> Response: deferred: Deferred[Response] = Deferred() call_later(0, deferred.callback, response) return await maybe_deferred_to_future(deferred) crawler.engine.download_async.side_effect = return_response return crawler @coroutine_test async def test_robotstxt_empty_response(self): # empty response should equal 'allow all' middleware = build_from_crawler( RobotsTxtMiddleware, self._get_emptybody_crawler() ) await self.assertNotIgnored(Request("http://site.local/allowed"), middleware) await self.assertNotIgnored(Request("http://site.local/admin/main"), middleware) await self.assertNotIgnored(Request("http://site.local/static/"), middleware) @coroutine_test async def test_robotstxt_error(self, caplog: pytest.LogCaptureFixture) -> None: self.crawler.settings.set("ROBOTSTXT_OBEY", True) err = CannotResolveHostError("Robotstxt address not found") async def return_failure(request: Request) -> Response: deferred: Deferred[Response] = Deferred() call_later(0, deferred.errback, failure.Failure(err)) return await maybe_deferred_to_future(deferred) self.crawler.engine.download_async.side_effect = return_failure middleware = build_from_crawler(RobotsTxtMiddleware, self.crawler) await middleware.process_request(Request("http://site.local")) assert "Robotstxt address not found" in caplog.text @coroutine_test async def test_robotstxt_immediate_error(self): self.crawler.settings.set("ROBOTSTXT_OBEY", True) err = CannotResolveHostError("Robotstxt address not found") async def immediate_failure(request): raise err self.crawler.engine.download_async.side_effect = immediate_failure middleware = build_from_crawler(RobotsTxtMiddleware, self.crawler) await self.assertNotIgnored(Request("http://site.local"), middleware) @coroutine_test async def test_ignore_robotstxt_request(self): self.crawler.settings.set("ROBOTSTXT_OBEY", True) async def ignore_request(request: Request) -> Response: deferred: Deferred[Response] = Deferred() call_later(0, deferred.errback, failure.Failure(IgnoreRequest())) return await maybe_deferred_to_future(deferred) self.crawler.engine.download_async.side_effect = ignore_request middleware = build_from_crawler(RobotsTxtMiddleware, self.crawler) with mock.patch( "scrapy.downloadermiddlewares.robotstxt.logger" ) as mw_module_logger: await self.assertNotIgnored( Request("http://site.local/allowed"), middleware ) assert not mw_module_logger.error.called def test_robotstxt_user_agent_setting(self): crawler = self._get_successful_crawler() crawler.settings.set("ROBOTSTXT_USER_AGENT", "Examplebot") crawler.settings.set("USER_AGENT", "Mozilla/5.0 (X11; Linux x86_64)") middleware = build_from_crawler(RobotsTxtMiddleware, crawler) rp = mock.MagicMock(return_value=True) middleware.process_request_2(rp, Request("http://site.local/allowed")) rp.allowed.assert_called_once_with("http://site.local/allowed", "Examplebot") @coroutine_test async def test_robotstxt_local_file(self): middleware = build_from_crawler( RobotsTxtMiddleware, self._get_emptybody_crawler() ) middleware.process_request_2 = mock.MagicMock() # type: ignore[method-assign] await middleware.process_request(Request("data:text/plain,Hello World data")) assert not middleware.process_request_2.called await middleware.process_request( Request("file:///tests/sample_data/test_site/nothinghere.html") ) assert not middleware.process_request_2.called await middleware.process_request(Request("http://site.local/allowed")) assert middleware.process_request_2.called async def assertNotIgnored( self, request: Request, middleware: RobotsTxtMiddleware ) -> None: try: await middleware.process_request(request) except IgnoreRequest: pytest.fail("IgnoreRequest was raised unexpectedly") async def assertIgnored( self, request: Request, middleware: RobotsTxtMiddleware ) -> None: with pytest.raises(IgnoreRequest): await middleware.process_request(request) def assertRobotsTxtRequested(self, base_url: str) -> None: calls = self.crawler.engine.download_async.call_args_list request = calls[0][0][0] assert request.url == f"{base_url}/robots.txt" assert request.callback == NO_CALLBACK @pytest.mark.skipif(not rerp_available(), reason="Rerp parser is not installed") class TestRobotsTxtMiddlewareWithRerp(TestRobotsTxtMiddleware): def setup_method(self): super().setup_method() self.crawler.settings.set( "ROBOTSTXT_PARSER", "scrapy.robotstxt.RerpRobotParser" ) scrapy-scrapy-5569de3/tests/test_downloadermiddleware_stats.py000066400000000000000000000041041525051012400250370ustar00rootroot00000000000000from __future__ import annotations import pytest from scrapy.downloadermiddlewares.stats import DownloaderStats, get_header_size from scrapy.exceptions import NotConfigured from scrapy.http import Request, Response from scrapy.spiders import Spider from scrapy.utils.misc import build_from_crawler from scrapy.utils.test import get_crawler class MyException(Exception): pass class TestDownloaderStats: def setup_method(self) -> None: self.crawler = get_crawler(Spider) self.mw = build_from_crawler(DownloaderStats, self.crawler) self.crawler.stats.open_spider() self.req = Request("http://scrapytest.org") self.res = Response("http://scrapytest.org", status=400) def teardown_method(self) -> None: self.crawler.stats.close_spider() def assertStatsEqual(self, key: str, value: object) -> None: assert self.crawler.stats.get_value(key) == value, str( self.crawler.stats.get_stats() ) def test_process_request(self) -> None: self.mw.process_request(self.req) self.assertStatsEqual("downloader/request_count", 1) def test_process_response(self) -> None: self.mw.process_response(self.req, self.res) self.assertStatsEqual("downloader/response_count", 1) def test_process_exception(self) -> None: self.mw.process_exception(self.req, MyException()) self.assertStatsEqual("downloader/exception_count", 1) self.assertStatsEqual( "downloader/exception_type_count/tests.test_downloadermiddleware_stats.MyException", 1, ) def test_from_crawler_not_configured(self) -> None: crawler = get_crawler(Spider, {"DOWNLOADER_STATS": False}) with pytest.raises(NotConfigured): build_from_crawler(DownloaderStats, crawler) def test_get_header_size_non_list_value() -> None: # Deliberately passing a non-list/tuple header value to make sure # get_header_size() degrades gracefully instead of raising. assert get_header_size({"Content-Type": "text/html"}) == 0 # type: ignore[dict-item] scrapy-scrapy-5569de3/tests/test_downloadermiddleware_useragent.py000066400000000000000000000024221525051012400256770ustar00rootroot00000000000000from __future__ import annotations from scrapy.downloadermiddlewares.useragent import UserAgentMiddleware from scrapy.http import Request from scrapy.spiders import Spider from scrapy.utils.misc import build_from_crawler from scrapy.utils.test import get_crawler def get_spider_and_mw( default_useragent: str | None, ) -> tuple[Spider, UserAgentMiddleware]: crawler = get_crawler(Spider, {"USER_AGENT": default_useragent}) spider = crawler._create_spider("foo") return spider, build_from_crawler(UserAgentMiddleware, crawler) def test_default_agent(): _, mw = get_spider_and_mw("default_useragent") req = Request("http://scrapytest.org/") assert mw.process_request(req) is None assert req.headers["User-Agent"] == b"default_useragent" def test_header_agent(): spider, mw = get_spider_and_mw("default_useragent") mw.spider_opened(spider) req = Request("http://scrapytest.org/", headers={"User-Agent": "header_useragent"}) assert mw.process_request(req) is None assert req.headers["User-Agent"] == b"header_useragent" def test_no_agent(): spider, mw = get_spider_and_mw(None) mw.spider_opened(spider) req = Request("http://scrapytest.org/") assert mw.process_request(req) is None assert "User-Agent" not in req.headers scrapy-scrapy-5569de3/tests/test_downloaderslotssettings.py000066400000000000000000000137441525051012400244430ustar00rootroot00000000000000import time from typing import Any import pytest from scrapy import Request from scrapy.core.downloader import Downloader, Slot from scrapy.exceptions import ScrapyDeprecationWarning from scrapy.utils.spider import DefaultSpider from scrapy.utils.test import get_crawler from tests.mockserver.http import MockServer from tests.spiders import MetaSpider from tests.utils.decorators import coroutine_test, inline_callbacks_test class DownloaderSlotsSettingsTestSpider(MetaSpider): name = "downloader_slots" custom_settings = { "DOWNLOAD_DELAY": 1, "DOWNLOAD_DELAY_JITTER": 0, "DOWNLOAD_SLOTS": { "quotes.toscrape.com": { "concurrency": 1, "delay": 2, "jitter": 0, "throttle": False, }, "books.toscrape.com": {"delay": 3, "jitter": 0}, }, } def __init__(self, *args: Any, **kwargs: Any): super().__init__(*args, **kwargs) assert self.mockserver self.default_slot = self.mockserver.host self.times: dict[str, list[float]] = {} async def start(self): assert self.mockserver slots = [*self.custom_settings.get("DOWNLOAD_SLOTS", {}), None] for slot in slots: url = self.mockserver.url(f"/?downloader_slot={slot}") self.times[slot or self.default_slot] = [] yield Request(url, callback=self.parse, meta={"download_slot": slot}) def parse(self, response): assert self.mockserver slot = response.meta.get("download_slot", self.default_slot) self.times[slot].append(time.time()) url = self.mockserver.url(f"/?downloader_slot={slot}&req=2") yield Request(url, callback=self.not_parse, meta={"download_slot": slot}) def not_parse(self, response): slot = response.meta.get("download_slot", self.default_slot) self.times[slot].append(time.time()) class NoDelayDownloaderSlotsSpider(DownloaderSlotsSettingsTestSpider): custom_settings = { "DOWNLOAD_SLOTS": { slot: {} for slot in DownloaderSlotsSettingsTestSpider.custom_settings[ "DOWNLOAD_SLOTS" ] }, } @inline_callbacks_test def test_delay(mockserver: MockServer): crawler = get_crawler(DownloaderSlotsSettingsTestSpider) yield crawler.crawl(mockserver=mockserver) assert isinstance(crawler.spider, DownloaderSlotsSettingsTestSpider) slots = crawler.engine.downloader.slots times = crawler.spider.times # Downloading and processing a response add a roughly constant amount # of time on top of the configured delay, so the margin is absolute. # It stays well below the 1 second that separates the delays being # compared, so a slot using the delay of another one still fails. tolerance = 0.75 for slot, (first, second) in times.items(): assert abs((second - first) - slots[slot].delay) < tolerance @coroutine_test async def test_params(): params: dict[str, Any] = { "concurrency": 1, "delay": 2, "jitter": 0, } settings = { "DOWNLOAD_SLOTS": { "example.com": params, }, } crawler = get_crawler(DefaultSpider, settings_dict=settings) crawler.spider = crawler._create_spider() downloader = Downloader(crawler) request = Request("https://example.com") _, actual = downloader._get_slot(request) downloader.close() await downloader.handlers._close() 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}" ) @pytest.mark.parametrize(("value", "expected"), [(True, 0.5), (False, 0.0)]) @coroutine_test async def test_deprecated_randomize_delay_param(value: bool, expected: float): settings = {"DOWNLOAD_SLOTS": {"example.com": {"randomize_delay": value}}} crawler = get_crawler(DefaultSpider, settings_dict=settings) crawler.spider = crawler._create_spider() downloader = Downloader(crawler) with pytest.warns( ScrapyDeprecationWarning, match="The randomize_delay key of the DOWNLOAD_SLOTS setting is deprecated", ): _, slot = downloader._get_slot(Request("https://example.com")) downloader.close() await downloader.handlers._close() assert slot.jitter == expected @coroutine_test async def test_get_slot_deprecated_spider_arg(): crawler = get_crawler(DefaultSpider) crawler.spider = crawler._create_spider() downloader = Downloader(crawler) request = Request("https://example.com") with pytest.warns( ScrapyDeprecationWarning, match=r"Passing a 'spider' argument to Downloader\._get_slot\(\) is deprecated", ): key1, slot1 = downloader._get_slot(request, spider=crawler.spider) key2, slot2 = downloader._get_slot(request) downloader.close() await downloader.handlers._close() assert key1 == key2 assert slot1 == slot2 @pytest.mark.parametrize( "priority_queue_class", [ "scrapy.pqueues.ScrapyPriorityQueue", "scrapy.pqueues.DownloaderAwarePriorityQueue", ], ) @coroutine_test async def test_none_slot_with_priority_queue( mockserver: MockServer, priority_queue_class: str ) -> None: """Test specific cases for None slot handling with different priority queues.""" crawler = get_crawler( NoDelayDownloaderSlotsSpider, settings_dict={"SCHEDULER_PRIORITY_QUEUE": priority_queue_class}, ) await crawler.crawl_async(mockserver=mockserver) assert isinstance(crawler.spider, NoDelayDownloaderSlotsSpider) assert hasattr(crawler.spider, "times") assert None not in crawler.spider.times assert crawler.spider.default_slot in crawler.spider.times assert len(crawler.spider.times[crawler.spider.default_slot]) == 2 stats = crawler.stats assert stats.get_value("spider_exceptions", 0) == 0 assert stats.get_value("downloader/exception_count", 0) == 0 scrapy-scrapy-5569de3/tests/test_dupefilters.py000066400000000000000000000217231525051012400217610ustar00rootroot00000000000000from __future__ import annotations import hashlib import logging import shutil import tempfile from pathlib import Path from typing import TYPE_CHECKING, Any, cast import pytest from scrapy.core.scheduler import Scheduler from scrapy.dupefilters import BaseDupeFilter, RFPDupeFilter from scrapy.exceptions import ScrapyDeprecationWarning from scrapy.http import Request from scrapy.utils.misc import build_from_crawler from scrapy.utils.python import to_bytes from scrapy.utils.test import get_crawler from tests.spiders import SimpleSpider if TYPE_CHECKING: from scrapy.crawler import Crawler def _get_dupefilter( *, crawler: Crawler | None = None, settings: dict[str, Any] | None = None, open_: bool = True, ) -> BaseDupeFilter: if crawler is None: crawler = get_crawler(settings_dict=settings) scheduler = build_from_crawler(Scheduler, crawler) dupefilter = scheduler.df if open_: dupefilter.open() return dupefilter class FromCrawlerRFPDupeFilter(RFPDupeFilter): method: str @classmethod def from_crawler(cls, crawler): df = super().from_crawler(crawler) df.method = "from_crawler" return df class DirectDupeFilter: method = "n/a" class TestRFPDupeFilter: def test_df_from_crawler_scheduler(self): settings = { "DUPEFILTER_DEBUG": True, "DUPEFILTER_CLASS": FromCrawlerRFPDupeFilter, } crawler = get_crawler(settings_dict=settings) scheduler = build_from_crawler(Scheduler, crawler) dupefilter = cast("FromCrawlerRFPDupeFilter", scheduler.df) assert dupefilter.debug assert dupefilter.method == "from_crawler" def test_df_direct_scheduler(self): settings = { "DUPEFILTER_CLASS": DirectDupeFilter, } crawler = get_crawler(settings_dict=settings) scheduler = build_from_crawler(Scheduler, crawler) assert cast("DirectDupeFilter", scheduler.df).method == "n/a" def test_filter(self): dupefilter = _get_dupefilter() r1 = Request("http://scrapytest.org/1") r2 = Request("http://scrapytest.org/2") r3 = Request("http://scrapytest.org/2") assert not dupefilter.request_seen(r1) assert dupefilter.request_seen(r1) assert not dupefilter.request_seen(r2) assert dupefilter.request_seen(r3) dupefilter.close("finished") def test_dupefilter_path(self): r1 = Request("http://scrapytest.org/1") r2 = Request("http://scrapytest.org/2") path = tempfile.mkdtemp() try: df = _get_dupefilter(settings={"JOBDIR": path}, open_=False) try: df.open() assert not df.request_seen(r1) assert df.request_seen(r1) finally: df.close("finished") df2 = _get_dupefilter(settings={"JOBDIR": path}, open_=False) assert df is not df2 try: df2.open() assert df2.request_seen(r1) assert not df2.request_seen(r2) assert df2.request_seen(r2) finally: df2.close("finished") finally: shutil.rmtree(path) def test_request_fingerprint(self): """Test if customization of request_fingerprint method will change output of request_seen. """ dupefilter = _get_dupefilter() r1 = Request("http://scrapytest.org/index.html") r2 = Request("http://scrapytest.org/INDEX.html") assert not dupefilter.request_seen(r1) assert not dupefilter.request_seen(r2) dupefilter.close("finished") class RequestFingerprinter: def fingerprint(self, request): fp = hashlib.sha1() fp.update(to_bytes(request.url.lower())) return fp.digest() settings = {"REQUEST_FINGERPRINTER_CLASS": RequestFingerprinter} case_insensitive_dupefilter = _get_dupefilter(settings=settings) assert not case_insensitive_dupefilter.request_seen(r1) assert case_insensitive_dupefilter.request_seen(r2) case_insensitive_dupefilter.close("finished") def test_seenreq_truncated(self): r1 = Request("http://scrapytest.org/1") r2 = Request("http://scrapytest.org/2") path = tempfile.mkdtemp() try: df = _get_dupefilter(settings={"JOBDIR": path}, open_=False) try: df.open() df.request_seen(r1) df.request_seen(r2) finally: df.close("finished") seen_file = Path(path, "requests.seen") seen_file.write_bytes(seen_file.read_bytes()[:-1]) df2 = _get_dupefilter(settings={"JOBDIR": path}, open_=False) try: df2.open() assert df2.request_seen(r1) assert not df2.request_seen(r2) finally: df2.close("finished") finally: shutil.rmtree(path) def test_log(self, caplog: pytest.LogCaptureFixture) -> None: settings = { "DUPEFILTER_DEBUG": False, "DUPEFILTER_CLASS": FromCrawlerRFPDupeFilter, } crawler = get_crawler(SimpleSpider, settings_dict=settings) spider = SimpleSpider.from_crawler(crawler) dupefilter = _get_dupefilter(crawler=crawler) r1 = Request("http://scrapytest.org/index.html") r2 = Request("http://scrapytest.org/index.html") with caplog.at_level(logging.DEBUG): dupefilter.log(r1, spider) dupefilter.log(r2, spider) assert crawler.stats.get_value("dupefilter/filtered") == 2 assert ( "scrapy.dupefilters", logging.DEBUG, "Filtered duplicate request: - no more" " duplicates will be shown (see DUPEFILTER_DEBUG to show all duplicates)", ) in caplog.record_tuples dupefilter.close("finished") @pytest.mark.parametrize("df", [None, FromCrawlerRFPDupeFilter]) def test_log_debug( self, caplog: pytest.LogCaptureFixture, df: type[BaseDupeFilter] | None ) -> None: settings: dict[str, Any] = { "DUPEFILTER_DEBUG": True, } if df: settings["DUPEFILTER_CLASS"] = df crawler = get_crawler(SimpleSpider, settings_dict=settings) spider = SimpleSpider.from_crawler(crawler) dupefilter = _get_dupefilter(crawler=crawler) r1 = Request("http://scrapytest.org/index.html") r2 = Request( "http://scrapytest.org/index.html", headers={"Referer": "http://scrapytest.org/INDEX.html"}, ) with caplog.at_level(logging.DEBUG): dupefilter.log(r1, spider) dupefilter.log(r2, spider) assert crawler.stats.get_value("dupefilter/filtered") == 2 assert ( "scrapy.dupefilters", logging.DEBUG, "Filtered duplicate request: (referer: None)", ) in caplog.record_tuples assert ( "scrapy.dupefilters", logging.DEBUG, "Filtered duplicate request: " " (referer: http://scrapytest.org/INDEX.html)", ) in caplog.record_tuples dupefilter.close("finished") def test_fingerprints_deprecation(self): dupefilter = cast("RFPDupeFilter", _get_dupefilter()) request = Request("http://scrapytest.org/index.html") dupefilter.request_seen(request) with pytest.warns( ScrapyDeprecationWarning, match=r"RFPDupeFilter\.fingerprints is deprecated\.", ): fingerprints = dupefilter.fingerprints assert fingerprints == {dupefilter.request_fingerprint(request)} dupefilter.close("finished") def test_request_fingerprint_override_deprecation(self): class LegacyDupeFilter(RFPDupeFilter): def request_fingerprint(self, request): return hashlib.sha1(to_bytes(request.url.lower())).hexdigest() with pytest.warns( ScrapyDeprecationWarning, match=r"Overriding RFPDupeFilter\.request_fingerprint\(\) is deprecated", ): dupefilter = _get_dupefilter( settings={"DUPEFILTER_CLASS": LegacyDupeFilter} ) assert not dupefilter.request_seen(Request("http://scrapytest.org/index.html")) assert dupefilter.request_seen(Request("http://scrapytest.org/INDEX.html")) dupefilter.close("finished") class TestBaseDupeFilter: def test_log_deprecation(self): dupefilter = _get_dupefilter( settings={"DUPEFILTER_CLASS": BaseDupeFilter}, ) with pytest.warns( ScrapyDeprecationWarning, match=r"Calling BaseDupeFilter\.log\(\) is deprecated.", ): dupefilter.log(None, None) # type: ignore[arg-type] scrapy-scrapy-5569de3/tests/test_engine.py000066400000000000000000000341261525051012400207010ustar00rootroot00000000000000from __future__ import annotations import asyncio import logging import subprocess import sys from typing import TYPE_CHECKING, Any from unittest.mock import Mock import pytest from scrapy import signals from scrapy.core.downloader import Downloader from scrapy.core.engine import ExecutionEngine, _Slot from scrapy.core.scheduler import BaseScheduler from scrapy.exceptions import CloseSpider, DontCloseSpider, IgnoreRequest from scrapy.http import Request from scrapy.spiders import Spider from scrapy.utils.defer import _schedule_coro, deferred_from_coro from scrapy.utils.misc import build_from_crawler from scrapy.utils.spider import DefaultSpider from scrapy.utils.test import get_crawler from tests.utils.bases.engine import TestEngineBase from tests.utils.decorators import coroutine_test, inline_callbacks_test from tests.utils.engine import ( AttrsItemsSpider, CrawlerRun, DataClassItemsSpider, DictItemsSpider, MySpider, ) if TYPE_CHECKING: from collections.abc import AsyncIterator, Generator from twisted.internet.defer import Deferred from tests.mockserver.http import MockServer class DupeFilterSpider(MySpider): async def start(self): for url in self.start_urls: yield Request(url) # no dont_filter=True class ItemZeroDivisionErrorSpider(MySpider): custom_settings = { "ITEM_PIPELINES": { "tests.pipelines.ProcessWithZeroDivisionErrorPipeline": 300, } } class ChangeCloseReasonSpider(MySpider): @classmethod def from_crawler(cls, crawler, *args, **kwargs): spider = cls(*args, **kwargs) spider._set_crawler(crawler) crawler.signals.connect(spider.spider_idle, signals.spider_idle) return spider def spider_idle(self): raise CloseSpider(reason="custom_reason") class TestEngine(TestEngineBase): @coroutine_test async def test_crawler(self, mockserver: MockServer) -> None: for spider in ( MySpider, DictItemsSpider, AttrsItemsSpider, DataClassItemsSpider, ): run = CrawlerRun(spider) await run.run(mockserver) self._assert_visited_urls(run) self._assert_scheduled_requests(run, count=9) self._assert_downloaded_responses(run, count=9) self._assert_scraped_items(run) self._assert_signals_caught(run) self._assert_headers_received(run) self._assert_bytes_received(run) @coroutine_test async def test_crawler_dupefilter(self, mockserver: MockServer) -> None: run = CrawlerRun(DupeFilterSpider) await run.run(mockserver) self._assert_scheduled_requests(run, count=8) self._assert_dropped_requests(run) @coroutine_test async def test_crawler_itemerror(self, mockserver: MockServer) -> None: run = CrawlerRun(ItemZeroDivisionErrorSpider) await run.run(mockserver) self._assert_items_error(run) @coroutine_test async def test_crawler_change_close_reason_on_idle( self, mockserver: MockServer ) -> None: run = CrawlerRun(ChangeCloseReasonSpider) await run.run(mockserver) assert { "spider": run.crawler.spider, "reason": "custom_reason", } == run.signals_caught[signals.spider_closed] @coroutine_test async def test_close_downloader(self): e = ExecutionEngine(get_crawler(MySpider), lambda _: None) await e.close_async() def test_close_without_downloader(self): class CustomException(Exception): pass class BadDownloader: def __init__(self, crawler): raise CustomException with pytest.raises(CustomException): ExecutionEngine( get_crawler(MySpider, {"DOWNLOADER": BadDownloader}), lambda _: None ) @inline_callbacks_test def test_start_already_running_exception(self): crawler = get_crawler(DefaultSpider) crawler.spider = crawler._create_spider() e = ExecutionEngine(crawler, lambda _: None) crawler.engine = e yield deferred_from_coro(e.open_spider_async()) _schedule_coro(e.start_async()) with pytest.raises(RuntimeError, match="Engine already running"): yield deferred_from_coro(e.start_async()) yield deferred_from_coro(e.stop_async()) @pytest.mark.only_asyncio @coroutine_test async def test_start_already_running_exception_asyncio(self): crawler = get_crawler(DefaultSpider) crawler.spider = crawler._create_spider() e = ExecutionEngine(crawler, lambda _: None) crawler.engine = e await e.open_spider_async() with pytest.raises(RuntimeError, match="Engine already running"): await asyncio.gather(e.start_async(), e.start_async()) await e.stop_async() @coroutine_test async def test_start_request_processing_exception( self, caplog: pytest.LogCaptureFixture ) -> None: class BadRequestFingerprinter: def fingerprint(self, request): raise ValueError # to make Scheduler.enqueue_request() fail class SimpleSpider(Spider): name = "simple" async def start(self): yield Request("data:,") crawler = get_crawler( SimpleSpider, {"REQUEST_FINGERPRINTER_CLASS": BadRequestFingerprinter} ) with caplog.at_level(logging.DEBUG): await crawler.crawl_async() assert "Error while processing requests from start()" in caplog.text assert "Spider closed (shutdown)" in caplog.text def test_short_timeout(self): args = ( sys.executable, "-m", "scrapy.cmdline", "fetch", "-s", "CLOSESPIDER_TIMEOUT=0.001", "-s", "LOG_LEVEL=DEBUG", "http://toscrape.com", ) p = subprocess.Popen( args, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, ) try: _, stderr = p.communicate(timeout=15) except subprocess.TimeoutExpired: p.kill() p.communicate() pytest.fail("Command took too much time to complete") stderr_str = stderr.decode("utf-8") assert "AttributeError" not in stderr_str, stderr_str assert "AssertionError" not in stderr_str, stderr_str @coroutine_test async def test_request_scheduled_signal(): class TestScheduler(BaseScheduler): def __init__(self) -> None: self.enqueued: list[Request] = [] 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 crawler = get_crawler(MySpider) engine = ExecutionEngine(crawler, lambda _: None) scheduler = build_from_crawler(TestScheduler, crawler) async def start() -> AsyncIterator[Any]: return yield engine._start = start() engine._slot = _Slot(False, Mock(), scheduler) crawler.signals.connect(signal_handler, signals.request_scheduled) keep_request = Request("https://keep.example") engine._schedule_request(keep_request) drop_request = Request("https://drop.example") engine._schedule_request(drop_request) assert scheduler.enqueued == [keep_request], ( f"{scheduler.enqueued!r} != [{keep_request!r}]" ) crawler.signals.disconnect(signal_handler, signals.request_scheduled) class ClosingPipeline: def open_spider(self): raise CloseSpider("pipeline_reason") class TestCloseSpiderOnStartup: @coroutine_test async def test_pipeline(self, caplog: pytest.LogCaptureFixture) -> None: closed: list[str] = [] def spider_closed(reason: str) -> None: closed.append(reason) crawler = get_crawler(DefaultSpider, {"ITEM_PIPELINES": {ClosingPipeline: 1}}) crawler.signals.connect(spider_closed, signals.spider_closed) with caplog.at_level(logging.INFO): await crawler.crawl_async() assert crawler.stats.get_value("finish_reason") == "pipeline_reason" assert closed == ["pipeline_reason"] assert "Traceback" not in caplog.text @coroutine_test async def test_spider_opened(self) -> None: def spider_opened(spider: Spider) -> None: raise CloseSpider("signal_reason") crawler = get_crawler(DefaultSpider) crawler.signals.connect(spider_opened, signals.spider_opened) await crawler.crawl_async() assert crawler.stats.get_value("finish_reason") == "signal_reason" @coroutine_test async def test_startup_wins_over_spider_opened(self) -> None: def spider_opened(spider: Spider) -> None: raise CloseSpider("signal_reason") crawler = get_crawler(DefaultSpider, {"ITEM_PIPELINES": {ClosingPipeline: 1}}) crawler.signals.connect(spider_opened, signals.spider_opened) await crawler.crawl_async() assert crawler.stats.get_value("finish_reason") == "pipeline_reason" @inline_callbacks_test def test_deferred_crawl(self) -> Generator[Deferred[Any], Any, None]: crawler = get_crawler(DefaultSpider, {"ITEM_PIPELINES": {ClosingPipeline: 1}}) yield crawler.crawl() assert crawler.stats.get_value("finish_reason") == "pipeline_reason" class TestMisuse: """The engine raises on operations that its state does not allow.""" @coroutine_test async def test_stop_not_running(self) -> None: engine = ExecutionEngine(get_crawler(DefaultSpider), lambda _: None) try: with pytest.raises(RuntimeError, match="Engine not running"): await engine.stop_async() finally: await engine.close_async() def test_invalid_scheduler_class(self) -> None: class NotAScheduler: pass with pytest.raises( TypeError, match="does not fully implement the scheduler interface" ): ExecutionEngine( get_crawler(DefaultSpider, {"SCHEDULER": NotAScheduler}), lambda _: None, ) @coroutine_test async def test_spider_is_idle_without_slot(self) -> None: engine = ExecutionEngine(get_crawler(DefaultSpider), lambda _: None) try: with pytest.raises(RuntimeError, match="Engine slot not assigned"): engine.spider_is_idle() finally: await engine.close_async() @coroutine_test async def test_crawl_without_spider(self) -> None: engine = ExecutionEngine(get_crawler(DefaultSpider), lambda _: None) try: with pytest.raises(RuntimeError, match="No open spider to crawl"): engine.crawl(Request("data:,")) finally: await engine.close_async() @coroutine_test async def test_open_spider_twice(self) -> None: crawler = get_crawler(DefaultSpider) crawler.spider = crawler._create_spider() engine = crawler.engine = ExecutionEngine(crawler, lambda _: None) await engine.open_spider_async() try: with pytest.raises(RuntimeError, match="No free spider slot"): await engine.open_spider_async() finally: await engine.close_async() @coroutine_test async def test_pause_unpause() -> None: engine = ExecutionEngine(get_crawler(DefaultSpider), lambda _: None) try: engine.pause() assert engine.paused engine.unpause() assert not engine.paused finally: await engine.close_async() class TestSpiderIdle: @coroutine_test async def test_dont_close_spider(self) -> None: """A ``DontCloseSpider`` handler keeps the spider open for one more loop.""" idle_calls = 0 def spider_idle() -> None: nonlocal idle_calls idle_calls += 1 if idle_calls == 1: # Schedule a request so that the engine loops again soon # instead of waiting for the slot heartbeat. crawler.engine.crawl(Request("data:,")) raise DontCloseSpider crawler = get_crawler(DefaultSpider) crawler.signals.connect(spider_idle, signals.spider_idle) await crawler.crawl_async() assert idle_calls == 2 assert crawler.stats.get_value("finish_reason") == "finished" @coroutine_test async def test_not_idle_anymore(self) -> None: """A handler that schedules a request keeps the spider open.""" urls: list[str] = [] def spider_idle() -> None: if not urls: urls.append("data:,") crawler.engine.crawl(Request(urls[0])) crawler = get_crawler(DefaultSpider) crawler.signals.connect(spider_idle, signals.spider_idle) await crawler.crawl_async() assert crawler.stats.get_value("downloader/request_count") == 1 @coroutine_test async def test_download_wrong_type(caplog: pytest.LogCaptureFixture) -> None: class WrongTypeDownloader(Downloader): def fetch(self, request: Request) -> str: # type: ignore[override] return "not a response" class WrongTypeSpider(Spider): name = "wrong_type" async def start(self): yield Request("data:,") crawler = get_crawler(WrongTypeSpider, {"DOWNLOADER": WrongTypeDownloader}) await crawler.crawl_async() assert "expected Response or Request, got " in caplog.text @coroutine_test async def test_enqueue_scrape_error( caplog: pytest.LogCaptureFixture, monkeypatch: pytest.MonkeyPatch ) -> None: def enqueue_scrape(self, result, request): raise ValueError("enqueue error") monkeypatch.setattr("scrapy.core.engine.Scraper.enqueue_scrape", enqueue_scrape) class SimpleSpider(Spider): name = "simple" async def start(self): yield Request("data:,") crawler = get_crawler(SimpleSpider) await crawler.crawl_async() assert "Error while enqueuing scrape" in caplog.text scrapy-scrapy-5569de3/tests/test_engine_close_spider.py000066400000000000000000000110261525051012400234260ustar00rootroot00000000000000from __future__ import annotations from typing import TYPE_CHECKING, cast import pytest from twisted.internet import defer from scrapy import signals from scrapy.core.engine import ExecutionEngine from scrapy.statscollectors import MemoryStatsCollector from scrapy.utils.spider import DefaultSpider from scrapy.utils.test import get_crawler from tests.utils.decorators import coroutine_test if TYPE_CHECKING: from scrapy.core.scheduler import Scheduler from scrapy.crawler import Crawler @pytest.fixture def crawler() -> Crawler: crawler = get_crawler(DefaultSpider) crawler.spider = crawler._create_spider() return crawler @coroutine_test async def test_no_slot(crawler: Crawler) -> None: engine = ExecutionEngine(crawler, lambda _: None) crawler.engine = engine await engine.open_spider_async() slot = engine._slot engine._slot = None with pytest.raises(RuntimeError, match="Engine slot not assigned"): await engine.close_spider_async() # close it correctly engine._slot = slot await engine.close_spider_async() @coroutine_test async def test_no_spider(crawler: Crawler) -> None: engine = ExecutionEngine(crawler, lambda _: None) with pytest.raises(RuntimeError, match="Spider not opened"): await engine.close_spider_async() engine.downloader.close() # cleanup @coroutine_test async def test_exception_slot( crawler: Crawler, caplog: pytest.LogCaptureFixture ) -> None: engine = ExecutionEngine(crawler, lambda _: None) crawler.engine = engine await engine.open_spider_async() assert engine._slot del engine._slot.heartbeat await engine.close_spider_async() assert "Slot close failure" in caplog.text @coroutine_test async def test_exception_downloader( crawler: Crawler, caplog: pytest.LogCaptureFixture ) -> None: engine = ExecutionEngine(crawler, lambda _: None) crawler.engine = engine await engine.open_spider_async() del engine.downloader.slots await engine.close_spider_async() assert "Downloader close failure" in caplog.text @coroutine_test async def test_exception_scraper( crawler: Crawler, caplog: pytest.LogCaptureFixture ) -> None: engine = ExecutionEngine(crawler, lambda _: None) crawler.engine = engine await engine.open_spider_async() engine.scraper.slot = None await engine.close_spider_async() assert "Scraper close failure" in caplog.text @coroutine_test async def test_exception_scheduler( crawler: Crawler, caplog: pytest.LogCaptureFixture ) -> None: engine = ExecutionEngine(crawler, lambda _: None) crawler.engine = engine await engine.open_spider_async() assert engine._slot del cast("Scheduler", engine._slot.scheduler).dqs await engine.close_spider_async() assert "Scheduler close failure" in caplog.text @coroutine_test async def test_exception_signal( crawler: Crawler, caplog: pytest.LogCaptureFixture ) -> None: engine = ExecutionEngine(crawler, lambda _: None) crawler.engine = engine await engine.open_spider_async() signal_manager = engine.signals del engine.signals await engine.close_spider_async() assert "Error while sending spider_close signal" in caplog.text # send the spider_closed signal to close various components await signal_manager.send_catch_log_async( signal=signals.spider_closed, spider=engine.spider, reason="cancelled", ) @coroutine_test async def test_exception_stats( crawler: Crawler, caplog: pytest.LogCaptureFixture ) -> None: engine = ExecutionEngine(crawler, lambda _: None) crawler.engine = engine await engine.open_spider_async() assert isinstance(crawler.stats, MemoryStatsCollector) del crawler.stats.spider_stats await engine.close_spider_async() assert "Stats close failure" in caplog.text @coroutine_test async def test_exception_callback( crawler: Crawler, caplog: pytest.LogCaptureFixture ) -> None: engine = ExecutionEngine(crawler, lambda _: defer.fail(ValueError())) crawler.engine = engine await engine.open_spider_async() await engine.close_spider_async() assert "Error running spider_closed_callback" in caplog.text @coroutine_test async def test_exception_async_callback( crawler: Crawler, caplog: pytest.LogCaptureFixture ) -> None: async def cb(_): raise ValueError engine = ExecutionEngine(crawler, cb) crawler.engine = engine await engine.open_spider_async() await engine.close_spider_async() assert "Error running spider_closed_callback" in caplog.text scrapy-scrapy-5569de3/tests/test_engine_download.py000066400000000000000000000127131525051012400225660ustar00rootroot00000000000000from __future__ import annotations import sys from unittest.mock import Mock, call import pytest from twisted.internet import defer from scrapy.core.engine import ExecutionEngine from scrapy.http import Request, Response from scrapy.utils.defer import maybe_deferred_to_future from scrapy.utils.spider import DefaultSpider from scrapy.utils.test import get_crawler from tests.utils.decorators import coroutine_test @pytest.fixture def engine() -> ExecutionEngine: crawler = get_crawler(DefaultSpider) engine = ExecutionEngine(crawler, lambda _: None) engine.downloader.close() engine.downloader = Mock() engine._slot = Mock() engine._slot.inprogress = set() return engine class TestEngineDownloadAsync: """Test cases for ExecutionEngine.download_async().""" @staticmethod async def _download(engine: ExecutionEngine, request: Request) -> Response: return await engine.download_async(request) @coroutine_test async def test_download_async_success(self, engine): """Test basic successful async download of a request.""" request = Request("http://example.com") response = Response("http://example.com", body=b"test body") engine.spider = Mock() engine.downloader.fetch.return_value = defer.succeed(response) engine._slot.add_request = Mock() engine._slot.remove_request = Mock() result = await self._download(engine, request) assert result == response engine._slot.add_request.assert_called_once_with(request) engine._slot.remove_request.assert_called_once_with(request) engine.downloader.fetch.assert_called_once_with(request) @coroutine_test async def test_download_async_redirect(self, engine): """Test async download with a redirect request.""" original_request = Request("http://example.com") redirect_request = Request("http://example.com/redirect") final_response = Response("http://example.com/redirect", body=b"redirected") # First call returns redirect request, second call returns final response engine.downloader.fetch.side_effect = [ defer.succeed(redirect_request), defer.succeed(final_response), ] engine.spider = Mock() engine._slot.add_request = Mock() engine._slot.remove_request = Mock() result = await self._download(engine, original_request) assert result == final_response assert engine.downloader.fetch.call_count == 2 engine._slot.add_request.assert_has_calls( [call(original_request), call(redirect_request)] ) engine._slot.remove_request.assert_has_calls( [call(original_request), call(redirect_request)] ) @coroutine_test async def test_download_async_many_redirects(self, engine): """A long chain of requests being replaced by new ones is handled iteratively, without hitting the recursion limit.""" count = sys.getrecursionlimit() * 2 requests = [Request(f"http://example.com/{i}") for i in range(count)] final_response = Response("http://example.com/final", body=b"done") engine.downloader.fetch.side_effect = [ *(defer.succeed(request) for request in requests[1:]), defer.succeed(final_response), ] engine.spider = Mock() engine._slot.add_request = Mock() engine._slot.remove_request = Mock() result = await self._download(engine, requests[0]) assert result == final_response assert engine.downloader.fetch.call_count == count @coroutine_test async def test_download_async_no_spider(self, engine): """Test async download attempt when no spider is available.""" request = Request("http://example.com") engine.spider = None with pytest.raises(RuntimeError, match="No open spider to crawl:"): await self._download(engine, request) @coroutine_test async def test_download_async_failure(self, engine): """Test async download when the downloader raises an exception.""" request = Request("http://example.com") error = RuntimeError("Download failed") engine.spider = Mock() engine.downloader.fetch.return_value = defer.fail(error) engine._slot.add_request = Mock() engine._slot.remove_request = Mock() with pytest.raises(RuntimeError, match="Download failed"): await self._download(engine, request) engine._slot.add_request.assert_called_once_with(request) engine._slot.remove_request.assert_called_once_with(request) @coroutine_test async def test_download_async_fetch_needs_spider(self, engine): engine._downloader_fetch_needs_spider = True request = Request("http://example.com") response = Response("http://example.com", body=b"test body") engine.spider = Mock() engine.downloader.fetch.return_value = defer.succeed(response) result = await self._download(engine, request) assert result == response engine.downloader.fetch.assert_called_once_with(request, engine.spider) @pytest.mark.filterwarnings("ignore::scrapy.exceptions.ScrapyDeprecationWarning") class TestEngineDownload(TestEngineDownloadAsync): """Test cases for ExecutionEngine.download().""" @staticmethod async def _download(engine: ExecutionEngine, request: Request) -> Response: return await maybe_deferred_to_future(engine.download(request)) scrapy-scrapy-5569de3/tests/test_engine_loop.py000066400000000000000000000411031525051012400217230ustar00rootroot00000000000000from __future__ import annotations from collections import deque from logging import ERROR from typing import TYPE_CHECKING, Any import pytest from scrapy import Request, Spider, signals from scrapy.core.scheduler import BaseScheduler from scrapy.exceptions import CloseSpider from scrapy.utils.asyncio import call_later, sleep from scrapy.utils.test import get_crawler from tests.mockserver.http import MockServer from tests.utils.decorators import coroutine_test if TYPE_CHECKING: from collections.abc import AsyncIterator, Callable, Iterator from scrapy.http import Response class MemoryScheduler(BaseScheduler): paused = False def __init__(self, *args: Any, **kwargs: Any): super().__init__(*args, **kwargs) self.queue: deque[Request] = deque( Request(value) if isinstance(value, str) else value for value in getattr(self, "queue", []) ) def enqueue_request(self, request: Request) -> bool: self.queue.append(request) return True def has_pending_requests(self) -> bool: return self.paused or bool(self.queue) def next_request(self) -> Request | None: if self.paused: return None try: return self.queue.pop() except IndexError: return None def pause(self) -> None: self.paused = True def unpause(self) -> None: self.paused = False class NoneStartSpider(Spider): name = "test" def start(self) -> None: # type: ignore[override] return None class CoroutineStartSpider(Spider): name = "test" async def start(self) -> None: # type: ignore[override] return None class SyncStartSpider(Spider): name = "test" def start(self) -> Iterator[Request]: # type: ignore[override] yield Request("data:,a") class TestMain: @coroutine_test async def test_sleep(self) -> None: """Neither asynchronous sleeps on Spider.start() nor the equivalent on the scheduler (returning no requests while also returning True from the has_pending_requests() method) should cause the spider to miss the processing of any later requests.""" # Long enough to span several engine heartbeats, which are what # wake the engine up while the scheduler reports no requests. seconds = 0.5 class TestSpider(Spider): name = "test" async def start(self) -> AsyncIterator[Any]: assert self.crawler.engine._slot scheduler = self.crawler.engine._slot.scheduler assert isinstance(scheduler, MemoryScheduler) yield Request("data:,a") await sleep(seconds) scheduler.pause() scheduler.enqueue_request(Request("data:,b")) # During this time, the scheduler reports having requests but # returns None. await sleep(seconds) scheduler.unpause() # The scheduler request is processed. await sleep(seconds) yield Request("data:,c") await sleep(seconds) scheduler.pause() scheduler.enqueue_request(Request("data:,d")) # The last start request is processed during the time until the # delayed call below, proving that the start iteration can # finish before a scheduler “sleepâ€� without causing the # scheduler to finish. call_later(seconds, scheduler.unpause) def parse(self, response: Response) -> None: pass actual_urls = [] def track_url(request: Request, spider: Spider) -> None: actual_urls.append(request.url) settings = {"SCHEDULER": MemoryScheduler} crawler = get_crawler(TestSpider, settings_dict=settings) crawler.signals.connect(track_url, signals.request_reached_downloader) await crawler.crawl_async() assert crawler.stats.get_value("finish_reason") == "finished" expected_urls = ["data:,a", "data:,b", "data:,c", "data:,d"] assert actual_urls == expected_urls, f"{actual_urls=} != {expected_urls=}" @coroutine_test async def test_close_during_start_iteration( self, caplog: pytest.LogCaptureFixture ) -> None: class TestSpider(Spider): name = "test" async def start(self) -> AsyncIterator[Any]: await self.crawler.engine.close_async() yield Request("data:,a") def parse(self, response: Response) -> None: pass actual_urls = [] def track_url(request: Request, spider: Spider) -> None: actual_urls.append(request.url) settings = {"SCHEDULER": MemoryScheduler} crawler = get_crawler(TestSpider, settings_dict=settings) crawler.signals.connect(track_url, signals.request_reached_downloader) caplog.clear() with caplog.at_level(ERROR): await crawler.crawl_async() assert not caplog.records assert crawler.stats.get_value("finish_reason") == "shutdown" assert not actual_urls @pytest.mark.parametrize( ("spider_cls", "expected_type"), [ (NoneStartSpider, ""), (CoroutineStartSpider, ""), (SyncStartSpider, ""), ], ) @coroutine_test async def test_start_not_an_async_generator( self, spider_cls: type[Spider], expected_type: str, caplog: pytest.LogCaptureFixture, ) -> None: crawler = get_crawler(spider_cls) caplog.clear() with caplog.at_level(ERROR): await crawler.crawl_async() assert ( f"{spider_cls.__name__}.start() must be an asynchronous generator," f" i.e. an async def method with yield statements, got {expected_type}" ) in caplog.text assert crawler.stats assert crawler.stats.get_value("finish_reason") == "start_error" @coroutine_test async def test_start_error(self, caplog: pytest.LogCaptureFixture) -> None: class TestSpider(Spider): name = "test" async def start(self): yield Request("data:,a") raise ValueError def parse(self, response): pass actual_urls = [] errors = [] def track_url(request, spider): actual_urls.append(request.url) def track_error(failure, response, spider): errors.append((failure, response)) settings = {"SCHEDULER": MemoryScheduler} crawler = get_crawler(TestSpider, settings_dict=settings) crawler.signals.connect(track_url, signals.request_reached_downloader) crawler.signals.connect(track_error, signals.spider_error) caplog.clear() with caplog.at_level(ERROR): await crawler.crawl_async() # The requests yielded before the exception are still crawled. assert actual_urls == ["data:,a"] assert len(caplog.records) == 1 assert len(errors) == 1 failure, response = errors[0] assert isinstance(failure.value, ValueError) assert response is None assert crawler.stats.get_value("finish_reason") == "start_error" assert crawler.stats.get_value("spider_exceptions/count") == 1 assert crawler.stats.get_value("spider_exceptions/ValueError") == 1 @coroutine_test async def test_close_spider_from_start( self, caplog: pytest.LogCaptureFixture ) -> None: class TestSpider(Spider): name = "test" async def start(self): yield Request("data:,a") raise CloseSpider("my_reason") def parse(self, response): pass settings = {"SCHEDULER": MemoryScheduler} crawler = get_crawler(TestSpider, settings_dict=settings) caplog.clear() with caplog.at_level(ERROR): await crawler.crawl_async() assert not caplog.records assert crawler.stats.get_value("finish_reason") == "my_reason" assert crawler.stats.get_value("spider_exceptions/count") is None class TestRequestSendOrder: mockserver: MockServer seconds = 0.1 # increase if flaky @classmethod def setup_class(cls): cls.mockserver = MockServer() cls.mockserver.__enter__() @classmethod def teardown_class(cls): cls.mockserver.__exit__(None, None, None) def request( self, num: int, response_seconds: float, download_slots: int, priority: int = 0, ) -> Request: url = self.mockserver.url(f"/delay?n={response_seconds}&{num}") meta = {"download_slot": str(num % download_slots)} return Request(url, meta=meta, priority=priority) def get_num(self, request_or_response: Request | Response) -> int: return int(request_or_response.url.rsplit("&", maxsplit=1)[1]) async def _test_request_order( self, start_nums: list[int], cb_nums: list[int] | None = None, settings: dict[str, Any] | None = None, response_seconds: float | None = None, download_slots: int = 1, start_fn: Callable[[Spider], AsyncIterator[Any]] | None = None, parse_fn: Callable[..., Iterator[Any]] | None = None, ) -> None: cb_nums = cb_nums or [] settings = settings or {} seconds = response_seconds or self.seconds cb_requests = deque( [self.request(num, seconds, download_slots) for num in cb_nums] ) if start_fn is None: async def default_start(spider: Spider) -> AsyncIterator[Any]: for num in start_nums: yield self.request(num, seconds, download_slots) start_fn = default_start if parse_fn is None: def default_parse(spider: Spider, response: Response) -> Iterator[Any]: while cb_requests: yield cb_requests.popleft() parse_fn = default_parse spider_start = start_fn spider_parse = parse_fn class TestSpider(Spider): name = "test" start = spider_start parse = spider_parse actual_nums = [] def track_num(request: Request, spider: Spider) -> None: actual_nums.append(self.get_num(request)) crawler = get_crawler(TestSpider, settings_dict=settings) crawler.signals.connect(track_num, signals.request_reached_downloader) await crawler.crawl_async() assert crawler.stats.get_value("finish_reason") == "finished" expected_nums = sorted(start_nums + cb_nums) assert actual_nums == expected_nums, f"{actual_nums=} != {expected_nums=}" @coroutine_test async def test_default(self): """By default, callback requests take priority over start requests and are sent in order. Priority matters, but given the same priority, a callback request takes precedence.""" nums = [1, 2, 3, 4, 5, 6] response_seconds = 0 download_slots = 1 def _request(num: int, priority: int = 0) -> Request: return self.request( num, response_seconds, download_slots, priority=priority ) async def start(spider: Spider) -> AsyncIterator[Any]: assert spider.crawler.engine._slot # The first CONCURRENT_REQUESTS start requests are sent # immediately. yield _request(1) for request in ( _request(2, priority=1), _request(5), ): spider.crawler.engine._slot.scheduler.enqueue_request(request) yield _request(6) yield _request(3, priority=1) yield _request(4, priority=1) def parse(spider: Spider, response: Response) -> Iterator[Any]: return yield await self._test_request_order( start_nums=nums, settings={"CONCURRENT_REQUESTS": 1}, response_seconds=response_seconds, start_fn=start, parse_fn=parse, ) @coroutine_test async def test_lifo_start(self): """Changing the queues of start requests to LIFO, matching the queues of non-start requests, does not cause all requests to be stored in the same queue objects, it only affects the order of start requests.""" nums = [1, 2, 3, 4, 5, 6] response_seconds = 0 download_slots = 1 def _request(num: int, priority: int = 0) -> Request: return self.request( num, response_seconds, download_slots, priority=priority ) async def start(spider: Spider) -> AsyncIterator[Any]: assert spider.crawler.engine._slot # The first CONCURRENT_REQUESTS start requests are sent # immediately. yield _request(1) for request in ( _request(2, priority=1), _request(5), ): spider.crawler.engine._slot.scheduler.enqueue_request(request) yield _request(6) yield _request(4, priority=1) yield _request(3, priority=1) def parse(spider: Spider, response: Response) -> Iterator[Any]: return yield await self._test_request_order( start_nums=nums, settings={ "CONCURRENT_REQUESTS": 1, "SCHEDULER_START_MEMORY_QUEUE": "scrapy.squeues.LifoMemoryQueue", }, response_seconds=response_seconds, start_fn=start, parse_fn=parse, ) @coroutine_test async def test_shared_queues(self): """If SCHEDULER_START_*_QUEUE is falsy, start requests and other requests share the same queue, i.e. start requests are not prioritized over other requests if their priority matches.""" nums = list(range(1, 14)) response_seconds = 0 download_slots = 1 def _request(num: int, priority: int = 0) -> Request: return self.request( num, response_seconds, download_slots, priority=priority ) async def start(spider: Spider) -> AsyncIterator[Any]: assert spider.crawler.engine._slot # The first CONCURRENT_REQUESTS start requests are sent # immediately. yield _request(1) # Below, priority 1 requests are sent first, and requests are sent # in LIFO order. for request in ( _request(7, priority=1), _request(6, priority=1), _request(13), _request(12), ): spider.crawler.engine._slot.scheduler.enqueue_request(request) yield _request(11) yield _request(10) yield _request(5, priority=1) yield _request(4, priority=1) for request in ( _request(3, priority=1), _request(2, priority=1), _request(9), _request(8), ): spider.crawler.engine._slot.scheduler.enqueue_request(request) def parse(spider: Spider, response: Response) -> Iterator[Any]: return yield await self._test_request_order( start_nums=nums, settings={ "CONCURRENT_REQUESTS": 1, "SCHEDULER_START_MEMORY_QUEUE": None, }, response_seconds=response_seconds, start_fn=start, parse_fn=parse, ) # Examples from the “Start requestsâ€� section of the documentation about # spiders. @coroutine_test async def test_lazy(self): start_nums = [1, 2, 4] cb_nums = [3] response_seconds = self.seconds * 2**1 # increase if flaky download_slots = 1 async def start(spider: Spider) -> AsyncIterator[Any]: for num in start_nums: if spider.crawler.engine.needs_backout(): await spider.crawler.signals.wait_for(signals.scheduler_empty) request = self.request(num, response_seconds, download_slots) yield request await self._test_request_order( start_nums=start_nums, cb_nums=cb_nums, settings={ "CONCURRENT_REQUESTS": 1, }, response_seconds=response_seconds, start_fn=start, ) scrapy-scrapy-5569de3/tests/test_engine_stop_download_bytes.py000066400000000000000000000045121525051012400250370ustar00rootroot00000000000000from __future__ import annotations from typing import TYPE_CHECKING from scrapy.exceptions import StopDownload from tests.utils.bases.engine import TestEngineBase from tests.utils.decorators import coroutine_test from tests.utils.engine import ( AttrsItemsSpider, CrawlerRun, DataClassItemsSpider, DictItemsSpider, MySpider, ) if TYPE_CHECKING: import pytest from tests.mockserver.http import MockServer class BytesReceivedCrawlerRun(CrawlerRun): def bytes_received(self, data, request, spider): super().bytes_received(data, request, spider) raise StopDownload(fail=False) class TestBytesReceivedEngine(TestEngineBase): @coroutine_test async def test_crawler( self, mockserver: MockServer, caplog: pytest.LogCaptureFixture ) -> None: for spider in ( MySpider, DictItemsSpider, AttrsItemsSpider, DataClassItemsSpider, ): run = BytesReceivedCrawlerRun(spider) with caplog.at_level("DEBUG"): await run.run(mockserver) for url in ("/redirected", "/static/", "/numbers"): assert ( f"Download stopped for " "from signal handler BytesReceivedCrawlerRun.bytes_received" ) in caplog.text self._assert_visited_urls(run) self._assert_scheduled_requests(run, count=9) self._assert_downloaded_responses(run, count=9) self._assert_signals_caught(run) self._assert_headers_received(run) self._assert_bytes_received(run) @staticmethod def _assert_bytes_received(run: CrawlerRun) -> None: assert len(run.bytes) == 9 for request, data in run.bytes.items(): joined_data = b"".join(data) assert len(data) == 1 # signal was fired only once if run.getpath(request.url) == "/numbers": # Received bytes are not the complete response. The exact amount depends # on the buffer size, which can vary, so we only check that the amount # of received bytes is strictly less than the full response. numbers = [str(x).encode("utf8") for x in range(2**18)] assert len(joined_data) < len(b"".join(numbers)) scrapy-scrapy-5569de3/tests/test_engine_stop_download_headers.py000066400000000000000000000041601525051012400253230ustar00rootroot00000000000000from __future__ import annotations from typing import TYPE_CHECKING from scrapy.exceptions import StopDownload from tests.utils.bases.engine import TestEngineBase from tests.utils.decorators import coroutine_test from tests.utils.engine import ( AttrsItemsSpider, CrawlerRun, DataClassItemsSpider, DictItemsSpider, MySpider, ) if TYPE_CHECKING: import pytest from tests.mockserver.http import MockServer class HeadersReceivedCrawlerRun(CrawlerRun): def headers_received(self, headers, body_length, request, spider): super().headers_received(headers, body_length, request, spider) raise StopDownload(fail=False) class TestHeadersReceivedEngine(TestEngineBase): @coroutine_test async def test_crawler( self, mockserver: MockServer, caplog: pytest.LogCaptureFixture ) -> None: for spider in ( MySpider, DictItemsSpider, AttrsItemsSpider, DataClassItemsSpider, ): run = HeadersReceivedCrawlerRun(spider) with caplog.at_level("DEBUG"): await run.run(mockserver) for url in ("/redirected", "/static/", "/numbers"): assert ( f"Download stopped for " "from signal handler HeadersReceivedCrawlerRun.headers_received" ) in caplog.text self._assert_visited_urls(run) self._assert_downloaded_responses(run, count=6) self._assert_signals_caught(run) self._assert_bytes_received(run) self._assert_headers_received(run) @staticmethod def _assert_bytes_received(run: CrawlerRun) -> None: assert len(run.bytes) == 0 @staticmethod def _assert_visited_urls(run: CrawlerRun) -> None: must_be_visited = ["/static/", "/redirect", "/redirected"] urls_visited = {rp[0].url for rp in run.respplug} urls_expected = {run.geturl(p) for p in must_be_visited} assert urls_expected <= urls_visited, ( f"URLs not visited: {list(urls_expected - urls_visited)}" ) scrapy-scrapy-5569de3/tests/test_exporters.py000066400000000000000000000614571525051012400214760ustar00rootroot00000000000000import dataclasses import json import marshal import pickle import re from abc import ABC, abstractmethod from collections.abc import Mapping from datetime import datetime from io import BytesIO from typing import Any import lxml.etree import pytest from itemadapter import ItemAdapter from scrapy.exporters import ( BaseItemExporter, CsvItemExporter, JsonItemExporter, JsonLinesItemExporter, MarshalItemExporter, PickleItemExporter, PprintItemExporter, PythonItemExporter, XmlItemExporter, ) from scrapy.item import Field, Item from scrapy.utils.python import to_unicode def custom_serializer(value): return str(int(value) + 2) class MyItem(Item): name = Field() age = Field() class CustomFieldItem(Item): name = Field() age = Field(serializer=custom_serializer) @dataclasses.dataclass class MyDataClass: name: str age: int @dataclasses.dataclass class CustomFieldDataclass: name: str age: int = dataclasses.field(metadata={"serializer": custom_serializer}) class TestBaseItemExporter(ABC): item_class: type = MyItem custom_field_item_class: type = CustomFieldItem def setup_method(self): self.i = self.item_class(name="John\xa3", age="22") self.output = BytesIO() self.ie = self._get_exporter() @abstractmethod def _get_exporter(self, **kwargs: Any) -> BaseItemExporter: raise NotImplementedError def _check_output(self) -> None: # noqa: B027 pass def _assert_expected_item(self, exported_dict: dict[str, Any]) -> None: for k, v in exported_dict.items(): exported_dict[k] = to_unicode(v) assert self.i == self.item_class(**exported_dict) def _get_nonstring_types_item(self) -> dict[str, Any]: return { "boolean": False, "number": 22, "time": datetime(2015, 1, 1, 1, 1, 1), "float": 3.14, } def assertItemExportWorks(self, item: Any) -> None: self.ie.start_exporting() self.ie.export_item(item) self.ie.finish_exporting() # Delete the item exporter object, so that if it causes the output # file handle to be closed, which should not be the case, follow-up # interactions with the output file handle will surface the issue. del self.ie self._check_output() def test_export_item(self) -> None: self.assertItemExportWorks(self.i) def test_export_dict_item(self): self.assertItemExportWorks(ItemAdapter(self.i).asdict()) def test_serialize_field(self): a = ItemAdapter(self.i) res = self.ie.serialize_field(a.get_field_meta("name"), "name", a["name"]) assert res == "John\xa3" res = self.ie.serialize_field(a.get_field_meta("age"), "age", a["age"]) assert res == "22" def test_fields_to_export(self): ie = self._get_exporter(fields_to_export=["name"]) assert list(ie.get_serialized_fields(self.i)) == [("name", "John\xa3")] ie = self._get_exporter(fields_to_export=["name"], encoding="latin-1") _, name = next(iter(ie.get_serialized_fields(self.i))) assert isinstance(name, str) assert name == "John\xa3" ie = self._get_exporter(fields_to_export={"name": "å��稱"}) assert list(ie.get_serialized_fields(self.i)) == [("å��稱", "John\xa3")] def test_field_order(self): item = self.item_class(age="22", name="John\xa3") ie = self._get_exporter() assert [name for name, _ in ie.get_serialized_fields(item)] == ["name", "age"] def test_field_order_dict_item(self): ie = self._get_exporter() assert [name for name, _ in ie.get_serialized_fields({"age": "22"})] == ["age"] assert [ name for name, _ in ie.get_serialized_fields({"age": "22", "name": "John"}) ] == ["age", "name"] def test_field_custom_serializer(self): i = self.custom_field_item_class(name="John\xa3", age="22") a = ItemAdapter(i) ie = self._get_exporter() assert ( ie.serialize_field(a.get_field_meta("name"), "name", a["name"]) == "John\xa3" ) assert ie.serialize_field(a.get_field_meta("age"), "age", a["age"]) == "24" class TestPythonItemExporter(TestBaseItemExporter): def _get_exporter(self, **kwargs: Any) -> BaseItemExporter: return PythonItemExporter(**kwargs) def test_invalid_option(self): with pytest.raises(TypeError, match="Unexpected options: invalid_option"): PythonItemExporter(invalid_option="something") def test_nested_item(self): i1 = self.item_class(name="Joseph", age="22") i2 = {"name": "Maria", "age": i1} i3 = self.item_class(name="Jesus", age=i2) ie = self._get_exporter() exported = ie.export_item(i3) assert isinstance(exported, dict) assert exported == { "age": {"age": {"age": "22", "name": "Joseph"}, "name": "Maria"}, "name": "Jesus", } assert isinstance(exported["age"], dict) assert isinstance(exported["age"]["age"], dict) def test_export_list(self): i1 = self.item_class(name="Joseph", age="22") i2 = self.item_class(name="Maria", age=[i1]) i3 = self.item_class(name="Jesus", age=[i2]) ie = self._get_exporter() exported = ie.export_item(i3) assert exported == { "age": [{"age": [{"age": "22", "name": "Joseph"}], "name": "Maria"}], "name": "Jesus", } assert exported is not None assert isinstance(exported["age"][0], dict) assert isinstance(exported["age"][0]["age"][0], dict) def test_export_item_dict_list(self): i1 = self.item_class(name="Joseph", age="22") i2 = {"name": "Maria", "age": [i1]} i3 = self.item_class(name="Jesus", age=[i2]) ie = self._get_exporter() exported = ie.export_item(i3) assert exported == { "age": [{"age": [{"age": "22", "name": "Joseph"}], "name": "Maria"}], "name": "Jesus", } assert exported is not None assert isinstance(exported["age"][0], dict) assert isinstance(exported["age"][0]["age"][0], dict) def test_nonstring_types_item(self): item = self._get_nonstring_types_item() ie = self._get_exporter() exported = ie.export_item(item) assert exported == item class TestPythonItemExporterDataclass(TestPythonItemExporter): item_class = MyDataClass custom_field_item_class = CustomFieldDataclass class TestPprintItemExporter(TestBaseItemExporter): def _get_exporter(self, **kwargs: Any) -> BaseItemExporter: return PprintItemExporter(self.output, **kwargs) def _check_output(self) -> None: self._assert_expected_item(eval(self.output.getvalue())) class TestPprintItemExporterDataclass(TestPprintItemExporter): item_class = MyDataClass custom_field_item_class = CustomFieldDataclass class TestPickleItemExporter(TestBaseItemExporter): def _get_exporter(self, **kwargs: Any) -> BaseItemExporter: return PickleItemExporter(self.output, **kwargs) def _check_output(self) -> None: self._assert_expected_item(pickle.loads(self.output.getvalue())) def test_export_multiple_items(self): i1 = self.item_class(name="hello", age="world") i2 = self.item_class(name="bye", age="world") f = BytesIO() ie = PickleItemExporter(f) ie.start_exporting() ie.export_item(i1) ie.export_item(i2) ie.finish_exporting() del ie # See the first “del self.ieâ€� in this file for context. f.seek(0) assert self.item_class(**pickle.load(f)) == i1 assert self.item_class(**pickle.load(f)) == i2 def test_nonstring_types_item(self): item = self._get_nonstring_types_item() fp = BytesIO() ie = PickleItemExporter(fp) ie.start_exporting() ie.export_item(item) ie.finish_exporting() del ie # See the first “del self.ieâ€� in this file for context. assert pickle.loads(fp.getvalue()) == item class TestPickleItemExporterDataclass(TestPickleItemExporter): item_class = MyDataClass custom_field_item_class = CustomFieldDataclass class TestMarshalItemExporter(TestBaseItemExporter): def _get_exporter(self, **kwargs: Any) -> BaseItemExporter: return MarshalItemExporter(self.output, **kwargs) def _check_output(self) -> None: self.output.seek(0) self._assert_expected_item(marshal.load(self.output)) def test_nonstring_types_item(self): item = self._get_nonstring_types_item() item.pop("time") # datetime is not marshallable fp = BytesIO() ie = MarshalItemExporter(fp) ie.start_exporting() ie.export_item(item) ie.finish_exporting() del ie # See the first “del self.ieâ€� in this file for context. fp.seek(0) assert marshal.load(fp) == item fp.close() class TestMarshalItemExporterDataclass(TestMarshalItemExporter): item_class = MyDataClass custom_field_item_class = CustomFieldDataclass class TestCsvItemExporter(TestBaseItemExporter): def _get_exporter(self, **kwargs: Any) -> BaseItemExporter: # We need a fresh instance for each exporter, because # CsvItemExporter.stream.__del__() closes the underlying file # (CsvItemExporter.finish_exporting() calls detach() but not all tests # call it). self.output = BytesIO() return CsvItemExporter(self.output, **kwargs) def assertCsvEqual( self, first: bytes | str, second: bytes | str, msg: str | None = None ) -> None: def split_csv(csv: bytes | str) -> list[list[str]]: return [ sorted(re.split(r"(,|\s+)", line)) for line in to_unicode(csv).splitlines(True) ] assert split_csv(first) == split_csv(second), msg def _check_output(self) -> None: self.output.seek(0) self.assertCsvEqual( to_unicode(self.output.read()), "age,name\r\n22,John\xa3\r\n" ) def assertExportResult( self, item: Any, expected: bytes | str = b"", **kwargs: Any ) -> None: fp = BytesIO() ie = CsvItemExporter(fp, **kwargs) ie.start_exporting() ie.export_item(item) ie.finish_exporting() del ie # See the first “del self.ieâ€� in this file for context. self.assertCsvEqual(fp.getvalue(), expected) def test_header_export_all(self): self.assertExportResult( item=self.i, fields_to_export=ItemAdapter(self.i).field_names(), expected=b"age,name\r\n22,John\xc2\xa3\r\n", ) def test_header_export_all_dict(self): self.assertExportResult( item=ItemAdapter(self.i).asdict(), expected=b"age,name\r\n22,John\xc2\xa3\r\n", ) def test_header_export_single_field(self): for item in [self.i, ItemAdapter(self.i).asdict()]: self.assertExportResult( item=item, fields_to_export=["age"], expected=b"age\r\n22\r\n", ) def test_header_export_two_items(self): for item in [self.i, ItemAdapter(self.i).asdict()]: output = BytesIO() ie = CsvItemExporter(output) ie.start_exporting() ie.export_item(item) ie.export_item(item) ie.finish_exporting() del ie # See the first “del self.ieâ€� in this file for context. self.assertCsvEqual( output.getvalue(), b"age,name\r\n22,John\xc2\xa3\r\n22,John\xc2\xa3\r\n" ) def test_header_no_header_line(self): for item in [self.i, ItemAdapter(self.i).asdict()]: self.assertExportResult( item=item, include_headers_line=False, expected=b"22,John\xc2\xa3\r\n", ) def test_join_multivalue(self): class TestItem2(Item): name = Field() friends = Field() for cls in TestItem2, dict: self.assertExportResult( item=cls(name="John", friends=["Mary", "Paul"]), include_headers_line=False, expected='"Mary,Paul",John\r\n', ) def test_join_multivalue_not_strings(self): self.assertExportResult( item={"name": "John", "friends": [4, 8]}, include_headers_line=False, expected='"[4, 8]",John\r\n', ) def test_nonstring_types_item(self): self.assertExportResult( item=self._get_nonstring_types_item(), include_headers_line=False, expected="22,False,3.14,2015-01-01 01:01:01\r\n", ) def test_errors_default(self): with pytest.raises(UnicodeEncodeError): self.assertExportResult( item={"text": "W\u0275\u200brd"}, encoding="windows-1251", ) def test_errors_xmlcharrefreplace(self): self.assertExportResult( item={"text": "W\u0275\u200brd"}, include_headers_line=False, expected="Wɵ​rd\r\n", encoding="windows-1251", errors="xmlcharrefreplace", ) def test_csv_dropped_fields_warning(self, caplog): out = BytesIO() exporter = CsvItemExporter(out) exporter.start_exporting() exporter.export_item({"name": "Apple"}) with caplog.at_level("WARNING", logger="scrapy.exporters"): exporter.export_item({"name": "Banana", "price": 2.00}) assert len(caplog.records) == 1 assert "CSVExporter dropped fields" in caplog.text assert "price" in caplog.text class TestCsvItemExporterDataclass(TestCsvItemExporter): item_class = MyDataClass custom_field_item_class = CustomFieldDataclass class TestXmlItemExporter(TestBaseItemExporter): def _get_exporter(self, **kwargs: Any) -> BaseItemExporter: # We need a fresh instance for each exporter, because # XmlItemExporter.stream.__del__() closes the underlying file # (XmlItemExporter.finish_exporting() calls detach() but not all tests # call it). self.output = BytesIO() return XmlItemExporter(self.output, **kwargs) def assertXmlEquivalent( self, first: bytes, second: bytes, msg: str | None = None ) -> None: def xmltuple(elem: Any) -> list[Any]: children = list(elem.iterchildren()) if children: return [(child.tag, sorted(xmltuple(child))) for child in children] return [(elem.tag, [(elem.text, ())])] def xmlsplit(xmlcontent: bytes) -> list[Any]: doc = lxml.etree.fromstring(xmlcontent) return xmltuple(doc) assert xmlsplit(first) == xmlsplit(second), msg def assertExportResult(self, item: Any, expected_value: bytes) -> None: fp = BytesIO() ie = XmlItemExporter(fp) ie.start_exporting() ie.export_item(item) ie.finish_exporting() del ie # See the first “del self.ieâ€� in this file for context. self.assertXmlEquivalent(fp.getvalue(), expected_value) def _check_output(self) -> None: expected_value = ( b'\n' b"22John\xc2\xa3" ) self.assertXmlEquivalent(self.output.getvalue(), expected_value) def test_multivalued_fields(self): self.assertExportResult( self.item_class(name=["John\xa3", "Doe"], age=[1, 2, 3]), b"""\n John\xc2\xa3Doe 123 """, ) def test_nested_item(self): i1 = {"name": "foo\xa3hoo", "age": "22"} i2 = {"name": "bar", "age": i1} i3 = self.item_class(name="buz", age=i2) self.assertExportResult( i3, b"""\n 22 foo\xc2\xa3hoo bar buz """, ) def test_nested_list_item(self): i1 = {"name": "foo"} i2 = {"name": "bar", "v2": {"egg": ["spam"]}} i3 = self.item_class(name="buz", age=[i1, i2]) self.assertExportResult( i3, b"""\n foo barspam buz """, ) def test_nonstring_types_item(self): item = self._get_nonstring_types_item() self.assertExportResult( item, b"""\n 3.14 False 22 """, ) class TestXmlItemExporterDataclass(TestXmlItemExporter): item_class = MyDataClass custom_field_item_class = CustomFieldDataclass class TestJsonLinesItemExporter(TestBaseItemExporter): _expected_nested: Any = { "name": "Jesus", "age": {"name": "Maria", "age": {"name": "Joseph", "age": "22"}}, } def _get_exporter(self, **kwargs: Any) -> BaseItemExporter: return JsonLinesItemExporter(self.output, **kwargs) def _check_output(self) -> None: exported = json.loads(to_unicode(self.output.getvalue().strip())) assert exported == ItemAdapter(self.i).asdict() def test_nested_item(self): i1 = self.item_class(name="Joseph", age="22") i2 = {"name": "Maria", "age": i1} i3 = self.item_class(name="Jesus", age=i2) self.ie.start_exporting() self.ie.export_item(i3) self.ie.finish_exporting() del self.ie # See the first “del self.ieâ€� in this file for context. exported = json.loads(to_unicode(self.output.getvalue())) assert exported == self._expected_nested def test_extra_keywords(self): self.ie = self._get_exporter(sort_keys=True) self.test_export_item() self._check_output() with pytest.raises(TypeError): self._get_exporter(foo_unknown_keyword_bar=True) def test_nonstring_types_item(self): item = self._get_nonstring_types_item() self.ie.start_exporting() self.ie.export_item(item) self.ie.finish_exporting() del self.ie # See the first “del self.ieâ€� in this file for context. exported = json.loads(to_unicode(self.output.getvalue())) item["time"] = item["time"].isoformat() assert exported == item class TestJsonLinesItemExporterDataclass(TestJsonLinesItemExporter): item_class = MyDataClass custom_field_item_class = CustomFieldDataclass class TestJsonItemExporter(TestJsonLinesItemExporter): _expected_nested = [TestJsonLinesItemExporter._expected_nested] def _get_exporter(self, **kwargs: Any) -> BaseItemExporter: return JsonItemExporter(self.output, **kwargs) def _check_output(self) -> None: exported = json.loads(to_unicode(self.output.getvalue().strip())) assert exported == [ItemAdapter(self.i).asdict()] def assertTwoItemsExported(self, item: Any) -> None: self.ie.start_exporting() self.ie.export_item(item) self.ie.export_item(item) self.ie.finish_exporting() del self.ie # See the first “del self.ieâ€� in this file for context. exported = json.loads(to_unicode(self.output.getvalue())) assert exported == [ItemAdapter(item).asdict(), ItemAdapter(item).asdict()] def test_two_items(self): self.assertTwoItemsExported(self.i) def test_two_dict_items(self): self.assertTwoItemsExported(ItemAdapter(self.i).asdict()) def test_two_items_with_failure_between(self): i1 = MyItem(name="Joseph\xa3", age="22") i2 = MyItem( name="Maria", age=1j ) # Invalid datetimes didn't consistently fail between Python versions i3 = MyItem(name="Jesus", age="44") self.ie.start_exporting() self.ie.export_item(i1) with pytest.raises(TypeError): self.ie.export_item(i2) self.ie.export_item(i3) self.ie.finish_exporting() exported = json.loads(to_unicode(self.output.getvalue())) assert exported == [dict(i1), dict(i3)] def test_nested_item(self): i1 = self.item_class(name="Joseph\xa3", age="22") i2 = self.item_class(name="Maria", age=i1) i3 = self.item_class(name="Jesus", age=i2) self.ie.start_exporting() self.ie.export_item(i3) self.ie.finish_exporting() del self.ie # See the first “del self.ieâ€� in this file for context. exported = json.loads(to_unicode(self.output.getvalue())) expected = { "name": "Jesus", "age": {"name": "Maria", "age": ItemAdapter(i1).asdict()}, } assert exported == [expected] def test_nested_dict_item(self): i1 = {"name": "Joseph\xa3", "age": "22"} i2 = self.item_class(name="Maria", age=i1) i3 = {"name": "Jesus", "age": i2} self.ie.start_exporting() self.ie.export_item(i3) self.ie.finish_exporting() del self.ie # See the first “del self.ieâ€� in this file for context. exported = json.loads(to_unicode(self.output.getvalue())) expected = {"name": "Jesus", "age": {"name": "Maria", "age": i1}} assert exported == [expected] def test_nonstring_types_item(self): item = self._get_nonstring_types_item() self.ie.start_exporting() self.ie.export_item(item) self.ie.finish_exporting() del self.ie # See the first “del self.ieâ€� in this file for context. exported = json.loads(to_unicode(self.output.getvalue())) item["time"] = item["time"].isoformat() assert exported == [item] class TestJsonItemExporterToBytes(TestBaseItemExporter): def _get_exporter(self, **kwargs: Any) -> BaseItemExporter: kwargs["encoding"] = "latin" return JsonItemExporter(self.output, **kwargs) def test_two_items_with_failure_between(self): i1 = MyItem(name="Joseph", age="22") i2 = MyItem(name="\u263a", age="11") i3 = MyItem(name="Jesus", age="44") self.ie.start_exporting() self.ie.export_item(i1) with pytest.raises(UnicodeEncodeError): self.ie.export_item(i2) self.ie.export_item(i3) self.ie.finish_exporting() exported = json.loads(to_unicode(self.output.getvalue(), encoding="latin")) assert exported == [dict(i1), dict(i3)] class TestJsonItemExporterDataclass(TestJsonItemExporter): item_class = MyDataClass custom_field_item_class = CustomFieldDataclass class TestCustomExporterItem: item_class: type = MyItem def setup_method(self): if self.item_class is None: pytest.skip("item class is None") def test_exporter_custom_serializer(self): class CustomItemExporter(BaseItemExporter): def serialize_field( self, field: Mapping[str, Any] | Field, name: str, value: Any ) -> Any: if name == "age": return str(int(value) + 1) return super().serialize_field(field, name, value) def export_item(self, item: Any) -> None: pass i = self.item_class(name="John", age="22") a = ItemAdapter(i) ie = CustomItemExporter() assert ie.serialize_field(a.get_field_meta("name"), "name", a["name"]) == "John" assert ie.serialize_field(a.get_field_meta("age"), "age", a["age"]) == "23" i2 = {"name": "John", "age": "22"} assert ie.serialize_field({}, "name", i2["name"]) == "John" assert ie.serialize_field({}, "age", i2["age"]) == "23" class TestCustomExporterDataclass(TestCustomExporterItem): item_class = MyDataClass scrapy-scrapy-5569de3/tests/test_extension_debug.py000066400000000000000000000115021525051012400226070ustar00rootroot00000000000000from __future__ import annotations import logging import os import signal import sys import threading from typing import TYPE_CHECKING from unittest import mock import pytest from scrapy.extensions.debug import Debugger, StackTraceDump from scrapy.spiders import Spider from scrapy.utils.misc import build_from_crawler from scrapy.utils.spider import DefaultSpider from scrapy.utils.test import get_crawler from tests.utils.decorators import coroutine_test if TYPE_CHECKING: from collections.abc import Generator @pytest.fixture(autouse=True) def preserve_signal_handlers() -> Generator[None]: """Restore the signal handlers that the extensions replace.""" signums = [ getattr(signal, name) for name in ("SIGUSR2", "SIGQUIT") if hasattr(signal, name) ] handlers = {signum: signal.getsignal(signum) for signum in signums} yield for signum, handler in handlers.items(): signal.signal(signum, handler) class SignalSpider(Spider): name = "signal_spider" start_urls = ["data:,"] def parse(self, response): os.kill(os.getpid(), signal.SIGUSR2) return [] @pytest.mark.skipif( sys.platform == "win32", reason="SIGUSR2 and SIGQUIT are POSIX-only" ) def test_stacktracedump_installs_signal_handlers() -> None: crawler = get_crawler() ext = build_from_crawler(StackTraceDump, crawler) assert signal.getsignal(signal.SIGUSR2) == ext.dump_stacktrace # pylint: disable=comparison-with-callable assert signal.getsignal(signal.SIGQUIT) == ext.dump_stacktrace # pylint: disable=comparison-with-callable def test_stacktracedump_works_without_signal_support( monkeypatch: pytest.MonkeyPatch, ) -> None: # simulate win32 platforms, which don't support SIGUSR signals monkeypatch.delattr(signal, "SIGUSR2", raising=False) ext = build_from_crawler(StackTraceDump, get_crawler()) assert isinstance(ext, StackTraceDump) def test_stacktracedump_dump_stacktrace(caplog: pytest.LogCaptureFixture) -> None: crawler = get_crawler() crawler.engine = mock.Mock() ext = build_from_crawler(StackTraceDump, crawler) spider = DefaultSpider.from_crawler(crawler) with caplog.at_level(logging.INFO, logger="scrapy.extensions.debug"): ext.dump_stacktrace(0, None) for r in caplog.records: message = r.getMessage() if "Dumping stack trace and engine status" in message: assert "Execution engine status" in message assert "Live References" in message assert type(spider).__name__ in message assert "# Thread: MainThread" in message assert getattr(r, "crawler", None) is crawler return raise AssertionError("No stack trace dump log message found") def test_stacktracedump_thread_stacks() -> None: ext = build_from_crawler(StackTraceDump, get_crawler()) stop = threading.Event() thread = threading.Thread(target=stop.wait, name="dump-test-thread") thread.start() try: stacks = ext._thread_stacks() finally: stop.set() thread.join() assert "# Thread: MainThread" in stacks assert "# Thread: dump-test-thread" in stacks @pytest.mark.skipif(sys.platform == "win32", reason="SIGUSR2 is POSIX-only") @coroutine_test async def test_stacktracedump_dumps_on_signal(caplog: pytest.LogCaptureFixture) -> None: settings = { "EXTENSIONS": {"scrapy.extensions.debug.StackTraceDump": 0}, "LOG_LEVEL": "INFO", } crawler = get_crawler(spidercls=SignalSpider, settings_dict=settings) with caplog.at_level(logging.INFO, logger="scrapy.extensions.debug"): await crawler.crawl_async() for r in caplog.records: message = r.getMessage() if "Dumping stack trace and engine status" in message: assert "Dumping stack trace and engine status" in message assert "engine.spider.name" in message assert "signal_spider" in message return raise AssertionError("No stack trace dump log message found") @pytest.mark.skipif(sys.platform == "win32", reason="SIGUSR2 is POSIX-only") def test_debugger_installs_signal_handler() -> None: ext = Debugger() assert signal.getsignal(signal.SIGUSR2) == ext._enter_debugger # pylint: disable=comparison-with-callable def test_debugger_works_without_signal_support(monkeypatch: pytest.MonkeyPatch) -> None: # simulate win32 platforms, which don't support SIGUSR signals monkeypatch.delattr(signal, "SIGUSR2", raising=False) ext = Debugger() assert isinstance(ext, Debugger) def test_debugger_enter_debugger(monkeypatch: pytest.MonkeyPatch) -> None: pdb_cls = mock.Mock() monkeypatch.setattr("scrapy.extensions.debug.Pdb", pdb_cls) ext = Debugger() frame = sys._getframe() ext._enter_debugger(0, frame) pdb_cls.return_value.set_trace.assert_called_once_with(frame.f_back) scrapy-scrapy-5569de3/tests/test_extension_memdebug.py000066400000000000000000000031601525051012400233070ustar00rootroot00000000000000from __future__ import annotations import gc import pytest from scrapy.exceptions import NotConfigured from scrapy.extensions.memdebug import MemoryDebugger from scrapy.utils.misc import build_from_crawler from scrapy.utils.spider import DefaultSpider from scrapy.utils.test import get_crawler from scrapy.utils.trackref import object_ref from tests.utils.decorators import coroutine_test def test_disabled_by_default() -> None: with pytest.raises(NotConfigured): build_from_crawler(MemoryDebugger, get_crawler()) def test_spider_closed_sets_stats() -> None: crawler = get_crawler(settings_dict={"MEMDEBUG_ENABLED": True}) ext = build_from_crawler(MemoryDebugger, crawler) class TrackedObject(object_ref): pass class CollectedObject(object_ref): pass tracked = [TrackedObject(), TrackedObject()] CollectedObject() ext.spider_closed(DefaultSpider.from_crawler(crawler), "finished") assert crawler.stats.get_value("memdebug/gc_garbage_count") == len(gc.garbage) assert crawler.stats.get_value("memdebug/live_refs/TrackedObject") == len(tracked) assert crawler.stats.get_value("memdebug/live_refs/CollectedObject") is None @coroutine_test async def test_crawl_sets_stats() -> None: # unique class so that other tests don't pollute live_refs class MemDebugSpider(DefaultSpider): pass crawler = get_crawler(MemDebugSpider, settings_dict={"MEMDEBUG_ENABLED": True}) await crawler.crawl_async() assert crawler.stats.get_value("memdebug/gc_garbage_count") is not None assert crawler.stats.get_value("memdebug/live_refs/MemDebugSpider") == 1 scrapy-scrapy-5569de3/tests/test_extension_memusage.py000066400000000000000000000134571525051012400233370ustar00rootroot00000000000000from __future__ import annotations import logging import sys from typing import TYPE_CHECKING import pytest from scrapy import signals from scrapy.core import engine as engine_mod from scrapy.exceptions import NotConfigured from scrapy.extensions import memusage as memusage_mod from scrapy.extensions.memusage import MemoryUsage from scrapy.spiders import Spider from scrapy.utils.misc import build_from_crawler from scrapy.utils.test import get_crawler from tests.utils import OneShotLoop from tests.utils.cmdline import proc from tests.utils.decorators import coroutine_test if TYPE_CHECKING: from tests.mockserver.http import MockServer # MemoryUsage relies on the stdlib 'resource' module (not available on Windows) pytestmark = pytest.mark.skipif( sys.platform.startswith("win"), reason="MemoryUsage extension not available on Windows", ) MB = 1024 * 1024 class TwoShotLoop(OneShotLoop): """Like :class:`OneShotLoop`, but runs the check twice.""" def start(self, interval: float, now: bool = True) -> None: super().start(interval, now=now) self.func() class _LoopSpider(Spider): name = "loop-data-spider" def __init__(self, url: str, loops: int = 60, **kw): super().__init__(**kw) self.url = url self.loops = loops self.start_urls = [url] def parse(self, response): count = response.meta.get("count", 0) if count + 1 < self.loops: yield response.follow( self.url, callback=self.parse, meta={"count": count + 1} ) def test_memusage_disabled() -> None: settings = { "MEMUSAGE_ENABLED": False, } with pytest.raises(NotConfigured): build_from_crawler(MemoryUsage, get_crawler(settings_dict=settings)) def test_memusage_limit_stops_crawler_without_spider(mockserver: MockServer) -> None: # The Scrapy shell starts the engine without opening a spider, so the # whole crawler is stopped instead of a spider being closed. _, out, err = proc( "shell", mockserver.url("/text"), "-c", "response.status", "--set", "MEMUSAGE_LIMIT_MB=1", ) assert "Memory usage exceeded 1MiB" in err assert "200" in out @coroutine_test async def test_memusage_below_thresholds_logs_peak( caplog: pytest.LogCaptureFixture, monkeypatch: pytest.MonkeyPatch ) -> None: settings = { "MEMUSAGE_LIMIT_MB": 100, "MEMUSAGE_WARNING_MB": 50, "MEMUSAGE_CHECK_INTERVAL_SECONDS": 0.01, "TELNETCONSOLE_ENABLED": False, "LOG_LEVEL": "INFO", } monkeypatch.setattr(memusage_mod, "create_looping_call", OneShotLoop) monkeypatch.setattr(MemoryUsage, "get_virtual_size", lambda _: 25 * MB) crawler = get_crawler(spidercls=_LoopSpider, settings_dict=settings) with caplog.at_level(logging.INFO, logger="scrapy.extensions.memusage"): await crawler.crawl_async(url="data:,", loops=1) assert crawler.stats.get_value("memusage/limit_reached") is None assert crawler.stats.get_value("memusage/warning_reached") is None assert crawler.stats.get_value("memusage/max") == 25 * MB assert crawler.stats.get_value("finish_reason") == "finished" assert any("Peak memory usage is 25MiB" in r.getMessage() for r in caplog.records) @coroutine_test async def test_memusage_limit_closes_spider_with_reason_and_error_log( caplog: pytest.LogCaptureFixture, monkeypatch: pytest.MonkeyPatch ) -> None: settings = { "MEMUSAGE_LIMIT_MB": 10, "MEMUSAGE_CHECK_INTERVAL_SECONDS": 0.01, "TELNETCONSOLE_ENABLED": False, "LOG_LEVEL": "INFO", } # Avoid background LoopingCall that can log after the test finishes. monkeypatch.setattr(memusage_mod, "create_looping_call", OneShotLoop) # Avoid engine start/stop races (the extension stops the engine in engine_started). monkeypatch.setattr(engine_mod, "create_looping_call", OneShotLoop) monkeypatch.setattr(MemoryUsage, "get_virtual_size", lambda _: 250 * MB) crawler = get_crawler(spidercls=_LoopSpider, settings_dict=settings) with caplog.at_level(logging.ERROR, logger="scrapy.extensions.memusage"): await crawler.crawl_async(url="data:,", loops=100) assert crawler.stats.get_value("memusage/limit_reached") == 1 assert crawler.stats.get_value("finish_reason") == "memusage_exceeded" assert any( "memory usage exceeded" in r.getMessage().lower() for r in caplog.records ) @coroutine_test async def test_memusage_warning_logs_but_allows_normal_finish( caplog: pytest.LogCaptureFixture, monkeypatch: pytest.MonkeyPatch ) -> None: settings = { "MEMUSAGE_WARNING_MB": 50, "MEMUSAGE_LIMIT_MB": 0, # no hard limit "MEMUSAGE_CHECK_INTERVAL_SECONDS": 0.01, "TELNETCONSOLE_ENABLED": False, "LOG_LEVEL": "INFO", } # Avoid background LoopingCall that can log after the test finishes; check # twice, since the warning is only meant to be reported once. monkeypatch.setattr(memusage_mod, "create_looping_call", TwoShotLoop) monkeypatch.setattr(MemoryUsage, "get_virtual_size", lambda self: 75 * MB) crawler = get_crawler(spidercls=_LoopSpider, settings_dict=settings) warning_signals: list[int] = [] def on_warning_reached() -> None: warning_signals.append(1) crawler.signals.connect(on_warning_reached, signal=signals.memusage_warning_reached) with caplog.at_level(logging.WARNING, logger="scrapy.extensions.memusage"): await crawler.crawl_async(url="data:,", loops=60) assert warning_signals == [1] assert crawler.stats.get_value("memusage/warning_reached") == 1 assert crawler.stats.get_value("finish_reason") == "finished" warnings_logged = [ r for r in caplog.records if "memory usage reached" in r.getMessage().lower() ] assert len(warnings_logged) == 1 scrapy-scrapy-5569de3/tests/test_extension_periodic_log.py000066400000000000000000000205211525051012400241610ustar00rootroot00000000000000from __future__ import annotations import datetime import json import logging from typing import TYPE_CHECKING, Any import pytest from scrapy.exceptions import NotConfigured, ScrapyDeprecationWarning from scrapy.extensions.periodic_log import PeriodicLog from scrapy.utils.misc import build_from_crawler from scrapy.utils.test import get_crawler from .spiders import MetaSpider from .utils.decorators import coroutine_test if TYPE_CHECKING: from collections.abc import Callable stats_dump_1 = { "log_count/INFO": 10, "log_count/WARNING": 1, "start_time": datetime.datetime(2023, 6, 16, 8, 59, 18, 993170), "scheduler/enqueued/memory": 190, "scheduler/enqueued": 190, "scheduler/dequeued/memory": 166, "scheduler/dequeued": 166, "downloader/request_count": 166, "downloader/request_method_count/GET": 166, "downloader/request_bytes": 56803, "downloader/response_count": 150, "downloader/response_status_count/200": 150, "downloader/response_bytes": 595698, "httpcompression/response_bytes": 3186068, "httpcompression/response_count": 150, "response_received_count": 150, "request_depth_max": 9, "dupefilter/filtered": 180, "item_scraped_count": 140, } stats_dump_2 = { "log_count/INFO": 12, "log_count/WARNING": 1, "start_time": datetime.datetime(2023, 6, 16, 8, 59, 18, 993170), "scheduler/enqueued/memory": 337, "scheduler/enqueued": 337, "scheduler/dequeued/memory": 280, "scheduler/dequeued": 280, "downloader/request_count": 280, "downloader/request_method_count/GET": 280, "downloader/request_bytes": 95754, "downloader/response_count": 264, "downloader/response_status_count/200": 264, "downloader/response_bytes": 1046274, "httpcompression/response_bytes": 5614484, "httpcompression/response_count": 264, "response_received_count": 264, "request_depth_max": 16, "dupefilter/filtered": 320, "item_scraped_count": 248, } class CustomPeriodicLog(PeriodicLog): def set_a(self) -> None: self.stats._stats = stats_dump_1 def set_b(self) -> None: self.stats._stats = stats_dump_2 def extension(settings: dict[str, Any] | None = None) -> CustomPeriodicLog: crawler = get_crawler(MetaSpider, settings) return build_from_crawler(CustomPeriodicLog, crawler) def test_extension_enabled() -> None: # Expected that settings for this extension loaded successfully # "PERIODIC_LOG_STATS": True -> set to {"enabled": True} # due to TypeError exception from settings.getdict assert extension({"PERIODIC_LOG_STATS": True, "LOGSTATS_INTERVAL": 60}) # "PERIODIC_LOG_STATS": "True" -> set to {"enabled": True} # due to JSONDecodeError(ValueError) exception from settings.getdict assert extension({"PERIODIC_LOG_STATS": "True", "LOGSTATS_INTERVAL": 60}) # The ame for PERIODIC_LOG_DELTA: assert extension({"PERIODIC_LOG_DELTA": True, "LOGSTATS_INTERVAL": 60}) assert extension({"PERIODIC_LOG_DELTA": "True", "LOGSTATS_INTERVAL": 60}) def test_no_interval() -> None: with pytest.raises(NotConfigured): extension({"PERIODIC_LOG_STATS": True, "LOGSTATS_INTERVAL": 0}) def test_nothing_enabled() -> None: with pytest.raises(NotConfigured): extension({"LOGSTATS_INTERVAL": 60}) @coroutine_test async def test_log_delta() -> None: def emulate( settings: dict[str, Any] | None = None, ) -> tuple[PeriodicLog, dict[str, Any], dict[str, Any]]: spider = MetaSpider() ext = extension(settings) ext.spider_opened(spider) ext.set_a() a = ext.log_delta() ext.set_b() b = ext.log_delta() ext.spider_closed(spider, reason="finished") return ext, a, b def check(settings: dict[str, Any], condition: Callable[[str, Any], bool]) -> None: ext, a, b = emulate(settings) assert list(a["delta"].keys()) == [ k for k, v in ext.stats._stats.items() if condition(k, v) ] assert list(b["delta"].keys()) == [ k for k, v in ext.stats._stats.items() if condition(k, v) ] # Including all check({"PERIODIC_LOG_DELTA": True}, lambda k, v: isinstance(v, (int, float))) # include: check( {"PERIODIC_LOG_DELTA": {"include": ["downloader/"]}}, lambda k, v: isinstance(v, (int, float)) and "downloader/" in k, ) # include multiple check( {"PERIODIC_LOG_DELTA": {"include": ["downloader/", "scheduler/"]}}, lambda k, v: ( isinstance(v, (int, float)) and ("downloader/" in k or "scheduler/" in k) ), ) # exclude check( {"PERIODIC_LOG_DELTA": {"exclude": ["downloader/"]}}, lambda k, v: isinstance(v, (int, float)) and "downloader/" not in k, ) # exclude multiple check( {"PERIODIC_LOG_DELTA": {"exclude": ["downloader/", "scheduler/"]}}, lambda k, v: ( isinstance(v, (int, float)) and ("downloader/" not in k and "scheduler/" not in k) ), ) # include exclude combined check( {"PERIODIC_LOG_DELTA": {"include": ["downloader/"], "exclude": ["bytes"]}}, lambda k, v: ( isinstance(v, (int, float)) and ("downloader/" in k and "bytes" not in k) ), ) @coroutine_test async def test_log_stats() -> None: def emulate( settings: dict[str, Any] | None = None, ) -> tuple[PeriodicLog, dict[str, Any], dict[str, Any]]: spider = MetaSpider() ext = extension(settings) ext.spider_opened(spider) ext.set_a() a = ext.log_crawler_stats() ext.set_b() b = ext.log_crawler_stats() ext.spider_closed(spider, reason="finished") return ext, a, b def check(settings: dict[str, Any], condition: Callable[[str, Any], bool]) -> None: ext, a, b = emulate(settings) assert list(a["stats"].keys()) == [ k for k, v in ext.stats._stats.items() if condition(k, v) ] assert list(b["stats"].keys()) == [ k for k, v in ext.stats._stats.items() if condition(k, v) ] # Including all check({"PERIODIC_LOG_STATS": True}, lambda k, v: True) # include: check( {"PERIODIC_LOG_STATS": {"include": ["downloader/"]}}, lambda k, v: "downloader/" in k, ) # include multiple check( {"PERIODIC_LOG_STATS": {"include": ["downloader/", "scheduler/"]}}, lambda k, v: "downloader/" in k or "scheduler/" in k, ) # exclude check( {"PERIODIC_LOG_STATS": {"exclude": ["downloader/"]}}, lambda k, v: "downloader/" not in k, ) # exclude multiple check( {"PERIODIC_LOG_STATS": {"exclude": ["downloader/", "scheduler/"]}}, lambda k, v: "downloader/" not in k and "scheduler/" not in k, ) # include exclude combined check( {"PERIODIC_LOG_STATS": {"include": ["downloader/"], "exclude": ["bytes"]}}, lambda k, v: "downloader/" in k and "bytes" not in k, ) @coroutine_test async def test_log_timing(caplog: pytest.LogCaptureFixture) -> None: settings = { "EXTENSIONS": {"scrapy.extensions.periodic_log.PeriodicLog": 0}, "PERIODIC_LOG_TIMING_ENABLED": True, "LOGSTATS_INTERVAL": 30, } crawler = get_crawler(MetaSpider, settings) with caplog.at_level(logging.INFO, logger="scrapy.extensions.periodic_log"): await crawler.crawl_async() records = [r for r in caplog.records if r.name == "scrapy.extensions.periodic_log"] assert records, "PeriodicLog logged nothing" # Only the timing section is enabled, and it is logged on spider close. data = json.loads(records[-1].getMessage()) assert list(data) == ["time"] assert data["time"]["log_interval"] == 30 assert data["time"]["log_interval_real"] >= 0 assert data["time"]["elapsed"] >= 0 assert data["time"]["start_time"] <= data["time"]["utcnow"] def test_multiplier_deprecated() -> None: crawler = get_crawler( MetaSpider, {"PERIODIC_LOG_TIMING_ENABLED": True, "LOGSTATS_INTERVAL": 30}, ) crawler._apply_settings() ext = build_from_crawler(PeriodicLog, crawler) with pytest.warns(ScrapyDeprecationWarning): assert ext.multiplier == 2.0 with pytest.warns(ScrapyDeprecationWarning): ext.multiplier = 3.0 with pytest.warns(ScrapyDeprecationWarning): assert ext.multiplier == 3.0 scrapy-scrapy-5569de3/tests/test_extension_remote_control.py000066400000000000000000000513151525051012400245620ustar00rootroot00000000000000from __future__ import annotations import asyncio import contextlib import json import logging import os from contextlib import asynccontextmanager from datetime import datetime, timezone from pathlib import Path from typing import TYPE_CHECKING, Any import aiohttp import pytest from aiohttp import web import scrapy from scrapy.exceptions import NotConfigured from scrapy.extensions import remote_control from scrapy.extensions.remote_control import ( RemoteControl, _cap, _compile, _effective_timeout, ) from scrapy.settings import default_settings from scrapy.utils.misc import build_from_crawler from scrapy.utils.test import get_crawler from tests.utils.decorators import coroutine_test if TYPE_CHECKING: from collections.abc import AsyncGenerator from types import CodeType pytestmark = pytest.mark.only_asyncio def _get_extension(settings: dict[str, Any] | None = None) -> RemoteControl: crawler = get_crawler( settings_dict={"REMOTE_CONTROL_ENABLED": True, **(settings or {})} ) crawler.spider = crawler._create_spider() return build_from_crawler(RemoteControl, crawler) def compile_or_fail(source: str) -> CodeType: compiled = _compile(source) assert not isinstance(compiled, str) return compiled @asynccontextmanager async def _started_extension( jobs_dir: Path, settings: dict[str, Any] | None = None ) -> AsyncGenerator[RemoteControl]: extension = _get_extension( {"REMOTE_CONTROL_JOBS_DIR": str(jobs_dir), **(settings or {})} ) await extension.start() assert extension._runner is not None, "the server did not start" try: yield extension finally: await extension.stop() async def _request( extension: RemoteControl, method: str, path: str, **kwargs: Any ) -> tuple[int, Any]: assert extension._runner host, port = extension._runner.addresses[0] url = f"http://{host}:{port}{path}" async with ( aiohttp.ClientSession() as session, session.request(method, url, **kwargs) as response, ): try: return response.status, await response.json(content_type=None) except json.JSONDecodeError: return response.status, await response.text() async def _request_execute(extension: RemoteControl, **kwargs: Any) -> tuple[int, Any]: return await _request(extension, "POST", "/execute", **kwargs) async def _request_status(extension: RemoteControl, **kwargs: Any) -> tuple[int, Any]: return await _request(extension, "GET", "/status", **kwargs) def _auth(extension: RemoteControl) -> dict[str, str]: return {"Authorization": f"Bearer {extension._auth_token}"} BAD_AUTH_HEADERS = [ {}, {"Authorization": "Bearer nope"}, {"Authorization": "Bearer ünicode"}, {"Authorization": "Basic nope"}, ] @coroutine_test async def test_ok_output() -> None: extension = _get_extension() result = await extension._run_code(compile_or_fail("print('hello')"), 5) assert result["status"] == "ok" assert result["output"] == "hello\n" assert result["traceback"] is None assert "output_truncated" not in result assert "traceback_truncated" not in result @coroutine_test async def test_top_level_await() -> None: extension = _get_extension() result = await extension._run_code( compile_or_fail("import asyncio\nawait asyncio.sleep(0)\nprint('done')"), 5 ) assert result["status"] == "ok" assert result["output"] == "done\n" @coroutine_test async def test_sync_code_runs() -> None: extension = _get_extension() result = await extension._run_code( compile_or_fail("x = sum(range(10))\nprint(x)"), 5 ) assert result["status"] == "ok" assert result["output"] == "45\n" @coroutine_test async def test_crawler_is_in_the_namespace() -> None: extension = _get_extension() result = await extension._run_code( compile_or_fail("print(crawler.spidercls.name)"), 5 ) assert result["output"] == f"{extension._crawler.spidercls.name}\n" @coroutine_test async def test_runtime_error_keeps_partial_output() -> None: extension = _get_extension() result = await extension._run_code( compile_or_fail("print('before')\nraise ValueError('boom')"), 5 ) assert result["status"] == "error" assert result["traceback"] is not None assert "ValueError: boom" in result["traceback"] assert "before" in result["output"] @coroutine_test async def test_timeout() -> None: extension = _get_extension() result = await extension._run_code( compile_or_fail("import asyncio\nawait asyncio.sleep(10)"), 0.05 ) assert result["status"] == "timeout" assert result["traceback"] is None @coroutine_test async def test_stash_persists_across_calls() -> None: extension = _get_extension() await extension._run_code(compile_or_fail("stash['x'] = 42"), 5) result = await extension._run_code(compile_or_fail("print(stash['x'])"), 5) assert result["output"] == "42\n" assert extension._stash == {"x": 42} @coroutine_test async def test_namespace_is_fresh_each_call() -> None: extension = _get_extension() await extension._run_code(compile_or_fail("y = 99"), 5) result = await extension._run_code(compile_or_fail("print('y' in dir())"), 5) assert result["status"] == "ok" assert result["output"] == "False\n" @coroutine_test async def test_concurrent_calls_are_not_serialized() -> None: # A slow, awaiting call must not block a quick one: the quick call finishes # first even though it was started second, and both share `stash`. extension = _get_extension() extension._stash["order"] = [] slow = extension._run_code( compile_or_fail( "import asyncio\nawait asyncio.sleep(0.3)\nstash['order'].append('slow')" ), 5, ) quick = extension._run_code(compile_or_fail("stash['order'].append('quick')"), 5) await asyncio.gather(slow, quick) assert extension._stash["order"] == ["quick", "slow"] @coroutine_test async def test_output_truncation() -> None: extension = _get_extension({"REMOTE_CONTROL_OUTPUT_MAX_BYTES": 10}) result = await extension._run_code(compile_or_fail("print('x' * 100)"), 5) assert result["output_truncated"] is True assert "truncated" in result["output"] @coroutine_test async def test_traceback_truncation() -> None: extension = _get_extension({"REMOTE_CONTROL_TRACEBACK_MAX_BYTES": 10}) result = await extension._run_code( compile_or_fail("raise ValueError('boom' * 100)"), 5 ) assert result["traceback_truncated"] is True assert result["traceback"] is not None assert "truncated" in result["traceback"] @pytest.mark.parametrize("source", ["def (:", "print('a')\x00"]) def test_compile_error(source: str) -> None: rendered = _compile(source) assert isinstance(rendered, str) # The docs only say "This function raises SyntaxError or ValueError if the # compiled source is invalid." and it depends on the Python version. assert "SyntaxError" in rendered or "ValueError" in rendered @pytest.mark.parametrize( ("requested", "expected"), [ (None, 30.0), (50, 50.0), (1000, 600.0), (0, 30.0), (-1, 30.0), (float("nan"), 30.0), (float("inf"), 600.0), ], ) def test_effective_timeout(requested: float | None, expected: float) -> None: assert _effective_timeout(requested, 30.0, 600.0) == expected def test_effective_timeout_caps_the_default_too() -> None: assert _effective_timeout(None, 1000.0, 600.0) == 600.0 def test_cap() -> None: assert _cap("hello", 10) == ("hello", False) capped, truncated = _cap("x" * 2048, 10) assert truncated is True assert capped.startswith("x" * 10) assert capped.endswith("…[truncated, +2KB]") # 2038 bytes dropped def test_cap_does_not_split_a_character() -> None: # "ä" takes two bytes, so a 5 byte cap must drop the third one entirely. capped, truncated = _cap("ä" * 10, 5) assert truncated is True assert capped.startswith("ää") assert "truncated" in capped def test_disabled_by_setting() -> None: with pytest.raises(NotConfigured): _get_extension({"REMOTE_CONTROL_ENABLED": False}) @pytest.mark.parametrize( "settings", [ {"REMOTE_CONTROL_TIMEOUT_DEFAULT": 0}, {"REMOTE_CONTROL_TIMEOUT_DEFAULT": -1}, {"REMOTE_CONTROL_TIMEOUT_MAX": 0}, {"REMOTE_CONTROL_TIMEOUT_MAX": -1}, ], ) def test_non_positive_timeouts_rejected(settings: dict[str, Any]) -> None: with pytest.raises(NotConfigured): _get_extension(settings) def test_default_timeouts() -> None: extension = _get_extension() assert extension._default_timeout == default_settings.REMOTE_CONTROL_TIMEOUT_DEFAULT assert extension._max_timeout == default_settings.REMOTE_CONTROL_TIMEOUT_MAX def test_settings_are_applied() -> None: extension = _get_extension( { "REMOTE_CONTROL_TIMEOUT_DEFAULT": 5.0, "REMOTE_CONTROL_TIMEOUT_MAX": 999.0, "REMOTE_CONTROL_OUTPUT_MAX_BYTES": 11, "REMOTE_CONTROL_TRACEBACK_MAX_BYTES": 22, } ) assert extension._default_timeout == 5.0 assert extension._max_timeout == 999.0 assert extension._output_max_bytes == 11 assert extension._traceback_max_bytes == 22 @coroutine_test async def test_status_ok(tmp_path: Path) -> None: async with _started_extension(tmp_path) as extension: status, result = await _request_execute( extension, json={"code": "print(6 * 7)"}, headers=_auth(extension) ) assert status == 200 assert result["status"] == "ok" assert result["output"] == "42\n" @coroutine_test async def test_status_compile_error(tmp_path: Path) -> None: async with _started_extension(tmp_path) as extension: status, result = await _request_execute( extension, json={"code": "def (:"}, headers=_auth(extension) ) assert status == 200 assert result["status"] == "compile_error" assert "SyntaxError" in result["traceback"] @coroutine_test async def test_status_error(tmp_path: Path) -> None: async with _started_extension(tmp_path) as extension: status, result = await _request_execute( extension, json={"code": "raise ValueError('boom')"}, headers=_auth(extension), ) assert status == 200 assert result["status"] == "error" assert "boom" in result["traceback"] @coroutine_test async def test_crawler_var(tmp_path: Path) -> None: async with _started_extension(tmp_path) as extension: status, result = await _request_execute( extension, json={"code": "print(type(crawler).__name__, crawler.crawling)"}, headers=_auth(extension), ) assert status == 200 assert result["status"] == "ok" assert result["output"] == "Crawler False\n" @pytest.mark.parametrize("headers", BAD_AUTH_HEADERS) @coroutine_test async def test_execute_unauthorized(tmp_path: Path, headers: dict[str, str]) -> None: async with _started_extension(tmp_path) as extension: status, body = await _request_execute( extension, json={"code": "print(1)"}, headers=headers ) assert status == 401 assert body == {"error": "unauthorized"} @coroutine_test async def test_execute_rejects_other_methods(tmp_path: Path) -> None: async with _started_extension(tmp_path) as extension: results = [ await _request(extension, method, "/execute", headers=_auth(extension)) for method in ("HEAD", "GET", "PUT", "DELETE") ] assert [status for status, _ in results] == [405] * 4 @pytest.mark.parametrize( ("kwargs", "error"), [ ({"data": "not json"}, "invalid JSON body"), ({"json": {"nope": 1}}, "Missing or invalid 'code' value"), ({"json": {"code": 1}}, "Missing or invalid 'code' value"), ({"json": [1, 2]}, "Missing or invalid 'code' value"), ], ) @coroutine_test async def test_execute_bad_requests( tmp_path: Path, kwargs: dict[str, Any], error: str ) -> None: async with _started_extension(tmp_path) as extension: status, body = await _request_execute( extension, headers=_auth(extension), **kwargs ) assert status == 400 assert body == {"error": error} @pytest.mark.parametrize("timeout_sec", ["abc", [1]]) @coroutine_test async def test_execute_bad_timeout_type(tmp_path: Path, timeout_sec: Any) -> None: async with _started_extension(tmp_path) as extension: status, body = await _request_execute( extension, json={"code": "print(1)", "timeout_sec": timeout_sec}, headers=_auth(extension), ) assert status == 400 assert body == {"error": "Invalid 'timeout_sec' value"} @pytest.mark.parametrize("timeout_sec", [None, 0, -1]) @coroutine_test async def test_execute_unset_timeout_is_accepted( tmp_path: Path, timeout_sec: Any ) -> None: async with _started_extension(tmp_path) as extension: status, envelope = await _request_execute( extension, json={"code": "print(1)", "timeout_sec": timeout_sec}, headers=_auth(extension), ) assert status == 200 assert envelope["status"] == "ok" def test_get_status_fields() -> None: extension = _get_extension() assert extension._get_status() == { "pid": os.getpid(), "spider": extension._crawler.spidercls.name, "project": "scrapybot", "scrapy_version": scrapy.__version__, "start_time": None, } def test_get_status_project() -> None: extension = _get_extension({"BOT_NAME": "my_project"}) assert extension._get_status()["project"] == "my_project" def test_get_status_project_unset() -> None: extension = _get_extension({"BOT_NAME": None}) assert extension._get_status()["project"] is None def test_get_status_start_time() -> None: extension = _get_extension() start_time = datetime(2026, 1, 2, 3, 4, 5, tzinfo=timezone.utc) extension._crawler.stats.set_value("start_time", start_time) assert extension._get_status()["start_time"] == start_time.timestamp() @coroutine_test async def test_status(tmp_path: Path) -> None: async with _started_extension(tmp_path) as extension: extension._crawler.stats.set_value( "start_time", datetime(2026, 1, 2, 3, 4, 5, tzinfo=timezone.utc) ) status, body = await _request_status(extension, headers=_auth(extension)) assert status == 200 assert body == { "pid": os.getpid(), "spider": extension._crawler.spidercls.name, "project": "scrapybot", "scrapy_version": scrapy.__version__, "start_time": datetime(2026, 1, 2, 3, 4, 5, tzinfo=timezone.utc).timestamp(), } @coroutine_test async def test_compare_status_with_job_file(tmp_path: Path) -> None: async with _started_extension(tmp_path) as extension: status, body = await _request_status(extension, headers=_auth(extension)) (job_file,) = tmp_path.glob("*.json") record = json.loads(job_file.read_text(encoding="utf-8")) assert status == 200 for key in ("pid", "spider", "project", "scrapy_version"): assert body[key] == record[key] @pytest.mark.parametrize("headers", BAD_AUTH_HEADERS) @coroutine_test async def test_status_unauthorized(tmp_path: Path, headers: dict[str, str]) -> None: async with _started_extension(tmp_path) as extension: status, body = await _request_status(extension, headers=headers) assert status == 401 assert body == {"error": "unauthorized"} @coroutine_test async def test_status_rejects_other_methods(tmp_path: Path) -> None: async with _started_extension(tmp_path) as extension: results = [ await _request(extension, method, "/status", headers=_auth(extension)) for method in ("HEAD", "POST", "PUT", "DELETE") ] assert [status for status, _ in results] == [405] * 4 @coroutine_test async def test_status_answers_while_code_is_running(tmp_path: Path) -> None: async with _started_extension(tmp_path) as extension: request = asyncio.ensure_future( _request_execute( extension, json={"code": "import asyncio\nawait asyncio.sleep(30)"}, headers=_auth(extension), ) ) await asyncio.sleep(0.1) # let the request reach the handler status, body = await asyncio.wait_for( _request_status(extension, headers=_auth(extension)), 10 ) request.cancel() with contextlib.suppress(asyncio.CancelledError, aiohttp.ClientError): await request assert status == 200 assert body["pid"] == os.getpid() @coroutine_test async def test_job_file_written_and_removed(tmp_path: Path) -> None: async with _started_extension(tmp_path) as extension: files = list(tmp_path.glob("*.json")) assert len(files) == 1 record = json.loads(files[0].read_text(encoding="utf-8")) assert record["token"] == extension._auth_token assert record["spider"] == extension._crawler.spidercls.name assert list(tmp_path.glob("*.json")) == [] @coroutine_test async def test_token_is_never_logged( tmp_path: Path, caplog: pytest.LogCaptureFixture ) -> None: with caplog.at_level(logging.DEBUG): async with _started_extension(tmp_path) as extension: token = extension._auth_token assert token assert token not in caplog.text @coroutine_test async def test_stop_without_start() -> None: extension = _get_extension() await extension.stop() assert extension._runner is None @coroutine_test async def test_stop_is_idempotent(tmp_path: Path) -> None: async with _started_extension(tmp_path) as extension: pass await extension.stop() assert extension._runner is None assert list(tmp_path.glob("*.json")) == [] @coroutine_test async def test_start_failure_disables_the_extension( tmp_path: Path, caplog: pytest.LogCaptureFixture ) -> None: # A file where the job file directory is expected makes writing the job # file fail, after the HTTP server has already started. jobs_dir = tmp_path / "jobs" jobs_dir.write_text("", encoding="utf-8") extension = _get_extension({"REMOTE_CONTROL_JOBS_DIR": str(jobs_dir)}) await extension.start() assert "Remote control HTTP server failed to start" in caplog.text assert "FileExistsError" in caplog.text assert extension._runner is None assert extension._auth_token is None assert extension._job_file_path is None @coroutine_test async def test_stop_logs_a_cleanup_failure( tmp_path: Path, caplog: pytest.LogCaptureFixture, monkeypatch: pytest.MonkeyPatch ) -> None: extension = _get_extension({"REMOTE_CONTROL_JOBS_DIR": str(tmp_path)}) await extension.start() runner = extension._runner assert runner is not None extension._stash["x"] = 42 real_cleanup = web.AppRunner.cleanup async def raise_runtime_error(self: web.AppRunner) -> None: raise RuntimeError("boom") monkeypatch.setattr(web.AppRunner, "cleanup", raise_runtime_error) await extension.stop() assert "Error stopping the remote control HTTP server" in caplog.text assert "RuntimeError: boom" in caplog.text assert extension._runner is None assert extension._auth_token is None assert extension._stash == {} assert list(tmp_path.glob("*.json")) == [] await real_cleanup(runner) @coroutine_test async def test_stop_ignores_a_failure_to_remove_the_job_file( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: def raise_os_error(*args: object, **kwargs: object) -> None: raise OSError("cannot remove") extension = _get_extension({"REMOTE_CONTROL_JOBS_DIR": str(tmp_path)}) await extension.start() monkeypatch.setattr(Path, "unlink", raise_os_error) await extension.stop() assert extension._runner is None assert extension._job_file_path is None assert len(list(tmp_path.glob("*.json"))) == 1 # could not be removed @coroutine_test async def test_stop_does_not_wait_for_a_running_snippet( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.setattr(remote_control, "STOP_TIMEOUT", 0.1) extension = _get_extension({"REMOTE_CONTROL_JOBS_DIR": str(tmp_path)}) extension._crawler.spider = extension._crawler.spidercls() await extension.start() request = asyncio.ensure_future( _request_execute( extension, json={"code": "import asyncio\nawait asyncio.sleep(30)"}, headers=_auth(extension), ) ) await asyncio.sleep(0.1) # let the request reach the handler await asyncio.wait_for(extension.stop(), 10) assert extension._runner is None request.cancel() with contextlib.suppress(asyncio.CancelledError, aiohttp.ClientError): await request scrapy-scrapy-5569de3/tests/test_extension_statsmailer.py000066400000000000000000000060171525051012400240560ustar00rootroot00000000000000import warnings from typing import Any from unittest.mock import MagicMock import pytest from scrapy import Spider, signals from scrapy.exceptions import NotConfigured, ScrapyDeprecationWarning from scrapy.signalmanager import SignalManager from scrapy.statscollectors import StatsCollector from scrapy.utils.misc import build_from_crawler from scrapy.utils.spider import DefaultSpider with warnings.catch_warnings(): warnings.filterwarnings( "ignore", r"The scrapy\.extensions\.statsmailer module is deprecated", ScrapyDeprecationWarning, ) warnings.filterwarnings( "ignore", r"The scrapy\.mail module is deprecated", ScrapyDeprecationWarning, ) from scrapy.extensions import statsmailer from scrapy.mail import MailSender @pytest.fixture def dummy_stats(): class DummyStats(StatsCollector): def __init__(self) -> None: # pylint: disable=super-init-not-called self._stats = {"global_item_scraped_count": 42} def get_stats(self, spider: Spider | None = None) -> dict[str, Any]: return {"item_scraped_count": 10, **self._stats} return DummyStats() def test_from_crawler_without_recipients_raises_notconfigured(): crawler = MagicMock() crawler.settings.getlist.return_value = [] crawler.stats = MagicMock() with pytest.raises(NotConfigured): build_from_crawler(statsmailer.StatsMailer, crawler) def test_from_crawler_with_recipients_initializes_extension(dummy_stats, monkeypatch): crawler = MagicMock() crawler.settings.getlist.return_value = ["test@example.com"] crawler.stats = dummy_stats crawler.signals = SignalManager(crawler) mailer = MagicMock(spec=MailSender) monkeypatch.setattr(MailSender, "from_crawler", lambda _: mailer) ext = build_from_crawler(statsmailer.StatsMailer, crawler) assert isinstance(ext, statsmailer.StatsMailer) assert ext.recipients == ["test@example.com"] assert ext.mail is mailer def test_from_crawler_connects_spider_closed_signal(dummy_stats, monkeypatch): crawler = MagicMock() crawler.settings.getlist.return_value = ["test@example.com"] crawler.stats = dummy_stats crawler.signals = SignalManager(crawler) mailer = MagicMock(spec=MailSender) monkeypatch.setattr(MailSender, "from_crawler", lambda _: mailer) build_from_crawler(statsmailer.StatsMailer, crawler) connected = crawler.signals.send_catch_log( signals.spider_closed, spider=DefaultSpider(name="dummy") ) assert connected is not None def test_spider_closed_sends_email(dummy_stats): recipients = ["test@example.com"] mail = MagicMock(spec=MailSender) ext = statsmailer.StatsMailer(dummy_stats, recipients, mail) spider = DefaultSpider(name="dummy") ext.spider_closed(spider) args, _ = mail.send.call_args to, subject, body = args assert to == recipients assert "Scrapy stats for: dummy" in subject assert "global_item_scraped_count" in body assert "item_scraped_count" in body scrapy-scrapy-5569de3/tests/test_extension_telnet.py000066400000000000000000000116631525051012400230240ustar00rootroot00000000000000from __future__ import annotations import socket from contextlib import contextmanager from typing import TYPE_CHECKING, Any import pytest from twisted.conch.telnet import ITelnetProtocol from twisted.cred import credentials from scrapy import Spider from scrapy.extensions.telnet import TelnetConsole, update_telnet_vars from scrapy.utils.defer import maybe_deferred_to_future from scrapy.utils.misc import build_from_crawler from scrapy.utils.test import get_crawler from tests.utils.decorators import coroutine_test if TYPE_CHECKING: from collections.abc import Generator from scrapy.crawler import Crawler from scrapy.http import Response pytestmark = pytest.mark.requires_reactor # TelnetConsole requires a reactor def _get_crawler( spidercls: type[Spider] | None = None, settings_dict: dict[str, Any] | None = None, ) -> Crawler: settings = { "TELNETCONSOLE_ENABLED": True, **(settings_dict or {}), } return get_crawler(spidercls, settings_dict=settings) @contextmanager def _get_console_and_portal( settings: dict[str, Any] | None = None, ) -> Generator[tuple[TelnetConsole, Any]]: crawler = _get_crawler(settings_dict=settings) console = build_from_crawler(TelnetConsole, crawler) # This function has some side effects we don't need for this test console._get_telnet_vars = dict # type: ignore[method-assign] console.start_listening() protocol = console.protocol() portal = protocol.protocolArgs[0] try: yield console, portal finally: console.stop_listening() @coroutine_test async def test_bad_credentials() -> None: with _get_console_and_portal() as (_, portal): creds = credentials.UsernamePassword(b"username", b"password") d = portal.login(creds, None, ITelnetProtocol) with pytest.raises(ValueError, match="Invalid credentials"): await maybe_deferred_to_future(d) @coroutine_test async def test_good_credentials() -> None: with _get_console_and_portal() as (console, portal): creds = credentials.UsernamePassword( console.username.encode("utf8"), console.password.encode("utf8") ) d = portal.login(creds, None, ITelnetProtocol) await maybe_deferred_to_future(d) @coroutine_test async def test_custom_credentials() -> None: settings = { "TELNETCONSOLE_USERNAME": "user", "TELNETCONSOLE_PASSWORD": "pass", } with _get_console_and_portal(settings=settings) as (_, portal): creds = credentials.UsernamePassword(b"user", b"pass") d = portal.login(creds, None, ITelnetProtocol) await maybe_deferred_to_future(d) def test_invalid_reversed_portrange() -> None: settings = {"TELNETCONSOLE_PORT": [2, 1]} console = build_from_crawler(TelnetConsole, _get_crawler(settings_dict=settings)) with pytest.raises(ValueError, match=r"invalid portrange: \[2, 1\]"): console.start_listening() @coroutine_test async def test_unavailable_port(caplog: pytest.LogCaptureFixture) -> None: """Run a crawl where the console cannot bind any port.""" with socket.create_server(("127.0.0.1", 0)) as sock: port = sock.getsockname()[1] crawler = _get_crawler(settings_dict={"TELNETCONSOLE_PORT": [port]}) await crawler.crawl_async() assert "CannotListenError" in caplog.text assert "AttributeError" not in caplog.text @coroutine_test async def test_telnet_vars() -> None: """Log into the console of a running crawl, which is when the telnet variables are built.""" received: list[dict[str, Any]] = [] def on_update_telnet_vars(telnet_vars: dict[str, Any]) -> None: received.append(telnet_vars) class TelnetSpider(Spider): name = "telnet" start_urls = ["data:,"] async def parse(self, response: Response) -> None: console = next( ext for ext in self.crawler.extensions.middlewares if isinstance(ext, TelnetConsole) ) creds = credentials.UsernamePassword( console.username.encode("utf8"), console.password.encode("utf8") ) portal = console.protocol().protocolArgs[0] await maybe_deferred_to_future(portal.login(creds, None, ITelnetProtocol)) crawler = _get_crawler(TelnetSpider) crawler.signals.connect(on_update_telnet_vars, signal=update_telnet_vars) await crawler.crawl_async() assert len(received) == 1 telnet_vars = received[0] assert telnet_vars["crawler"] is crawler assert telnet_vars["engine"] is crawler.engine assert telnet_vars["spider"] is crawler.spider assert telnet_vars["extensions"] is crawler.extensions assert telnet_vars["stats"] is crawler.stats assert telnet_vars["settings"] is crawler.settings assert callable(telnet_vars["est"]) assert callable(telnet_vars["p"]) assert callable(telnet_vars["prefs"]) assert "telnetconsole.html" in telnet_vars["help"] scrapy-scrapy-5569de3/tests/test_extension_throttle.py000066400000000000000000000232601525051012400233720ustar00rootroot00000000000000from logging import INFO from typing import Any from unittest.mock import Mock import pytest from scrapy import Request, Spider from scrapy.crawler import Crawler 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.spider import DefaultSpider from scrapy.utils.test import get_crawler as _get_crawler UNSET = object() def get_crawler( settings: dict[str, Any] | None = None, spidercls: type[Spider] | None = None ) -> Crawler: settings = settings or {} settings["AUTOTHROTTLE_ENABLED"] = True return _get_crawler(settings_dict=settings, spidercls=spidercls) def _mock_downloader(crawler: Crawler) -> Mock: """Give *crawler* a mock engine, whose downloader AutoThrottle reads.""" crawler.engine = Mock() downloader: Mock = crawler.engine.downloader downloader.slots = {} return downloader @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( ("setting", "expected"), [ (UNSET, DOWNLOAD_DELAY), (1.0, 1.0), ], ) def test_mindelay_definition(setting, expected): settings = {} if setting is not UNSET: settings["DOWNLOAD_DELAY"] = setting crawler = get_crawler(settings) at = build_from_crawler(AutoThrottle, crawler) _mock_downloader(crawler) at._spider_opened(DefaultSpider.from_crawler(crawler)) 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) _mock_downloader(crawler) at._spider_opened(DefaultSpider.from_crawler(crawler)) assert at.maxdelay == expected @pytest.mark.parametrize( ("min_setting", "start_setting", "expected"), [ (UNSET, UNSET, AUTOTHROTTLE_START_DELAY), (AUTOTHROTTLE_START_DELAY - 1.0, UNSET, AUTOTHROTTLE_START_DELAY), (AUTOTHROTTLE_START_DELAY + 1.0, UNSET, AUTOTHROTTLE_START_DELAY + 1.0), (UNSET, AUTOTHROTTLE_START_DELAY - 1.0, AUTOTHROTTLE_START_DELAY - 1.0), (UNSET, AUTOTHROTTLE_START_DELAY + 1.0, AUTOTHROTTLE_START_DELAY + 1.0), ( AUTOTHROTTLE_START_DELAY + 2.0, AUTOTHROTTLE_START_DELAY + 1.0, AUTOTHROTTLE_START_DELAY + 2.0, ), ( AUTOTHROTTLE_START_DELAY + 1.0, AUTOTHROTTLE_START_DELAY + 2.0, AUTOTHROTTLE_START_DELAY + 2.0, ), ], ) def test_startdelay_definition(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 crawler = get_crawler(settings) at = build_from_crawler(AutoThrottle, crawler) downloader = _mock_downloader(crawler) at._spider_opened(DefaultSpider.from_crawler(crawler)) assert downloader._delay == expected @pytest.mark.parametrize( ("meta", "slot"), [ ({}, None), ({"download_latency": 1.0}, None), ({"download_slot": "foo"}, None), ({"download_slot": "foo"}, "foo"), ({"download_latency": 1.0, "download_slot": "foo"}, None), ( { "download_latency": 1.0, "download_slot": "foo", "autothrottle_dont_adjust_delay": True, }, "foo", ), ], ) def test_skipped(meta, slot, monkeypatch): crawler = get_crawler() at = build_from_crawler(AutoThrottle, crawler) downloader = _mock_downloader(crawler) spider = DefaultSpider.from_crawler(crawler) at._spider_opened(spider) request = Request("https://example.com", meta=meta) if slot is not None: downloader.slots[slot] = object() # Fail instead of adjusting the delay. monkeypatch.setattr(at, "_adjust_delay", Mock(side_effect=AssertionError)) at._response_downloaded(Response("https://example.com"), 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) downloader = _mock_downloader(crawler) spider = DefaultSpider.from_crawler(crawler) at._spider_opened(spider) meta = {"download_latency": download_latency, "download_slot": "foo"} request = Request("https://example.com", meta=meta) response = Response(request.url) slot = Mock() slot.delay = slot_delay 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) downloader = _mock_downloader(crawler) spider = DefaultSpider.from_crawler(crawler) at._spider_opened(spider) meta = {"download_latency": download_latency, "download_slot": "foo"} request = Request("https://example.com", meta=meta) response = Response(request.url) slot = Mock() slot.delay = slot_delay 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) downloader = _mock_downloader(crawler) spider = DefaultSpider.from_crawler(crawler) 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) slot = Mock() slot.delay = slot_delay 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) downloader = _mock_downloader(crawler) spider = DefaultSpider.from_crawler(crawler) 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") slot = Mock() slot.delay = 2.0 slot.transferring = (None, None) 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) downloader = _mock_downloader(crawler) spider = DefaultSpider.from_crawler(crawler) 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") slot = Mock() slot.delay = 2.0 slot.transferring = (None, None) downloader.slots["foo"] = slot caplog.clear() with caplog.at_level(INFO): at._response_downloaded(response, request, spider) assert caplog.record_tuples == [] scrapy-scrapy-5569de3/tests/test_feedexport.py000066400000000000000000001344351525051012400216050ustar00rootroot00000000000000from __future__ import annotations import csv import json import logging import marshal import pickle import tempfile from logging import getLogger from pathlib import Path from typing import IO, TYPE_CHECKING, Any from unittest import mock import lxml.etree import pytest from w3lib.url import file_uri_to_path import scrapy from scrapy import Spider, signals from scrapy.exceptions import NotConfigured from scrapy.exporters import CsvItemExporter, JsonItemExporter from scrapy.extensions.feedexport import ( BlockingFeedStorage, FeedExporter, FeedSlot, FileFeedStorage, ItemFilter, apply_uri_params, ) from scrapy.utils.misc import build_from_crawler from scrapy.utils.python import to_unicode from scrapy.utils.test import get_crawler from tests.spiders import ItemSpider from tests.utils.bases.feedexport import TestFeedExportBase from tests.utils.decorators import coroutine_test, inline_callbacks_test from tests.utils.feedexport import MyItem, MyItem2, path_to_url, printf_escape if TYPE_CHECKING: from collections.abc import Awaitable, Callable, Iterable class FromCrawlerMixin: init_with_crawler = False @classmethod def from_crawler(cls, crawler, *args, feed_options=None, **kwargs): cls.init_with_crawler = True return cls(*args, **kwargs) class FromCrawlerCsvItemExporter(CsvItemExporter, FromCrawlerMixin): pass class FromCrawlerFileFeedStorage(FileFeedStorage, FromCrawlerMixin): @classmethod def from_crawler(cls, crawler, *args, feed_options=None, **kwargs): cls.init_with_crawler = True return cls(*args, feed_options=feed_options, **kwargs) class DummyBlockingFeedStorage(BlockingFeedStorage): def __init__(self, uri, *args, feed_options=None): self.path = Path(file_uri_to_path(uri)) def _store_in_thread(self, file): dirname = self.path.parent if dirname and not dirname.exists(): dirname.mkdir(parents=True) with self.path.open("ab") as output_file: output_file.write(file.read()) file.close() class FailingBlockingFeedStorage(DummyBlockingFeedStorage): def _store_in_thread(self, file): file.close() raise OSError("Cannot store") class LogOnStoreFileStorage: """ This storage logs inside `store` method. It can be used to make sure `store` method is invoked. """ def __init__(self, uri, feed_options=None): self.path = file_uri_to_path(uri) self.logger = getLogger() def open(self, spider): return tempfile.NamedTemporaryFile(prefix="feed-") def store(self, file): self.logger.info("Storage.store is called") file.close() class InstrumentedFeedSlot(FeedSlot): """Instrumented FeedSlot subclass for keeping track of calls to start_exporting and finish_exporting.""" update_listener: Callable[[str], None] def start_exporting(self): self.update_listener("start") super().start_exporting() def finish_exporting(self): self.update_listener("finish") super().finish_exporting() @classmethod def subscribe__listener(cls, listener: IsExportingListener) -> None: cls.update_listener = listener.update class IsExportingListener: """When subscribed to InstrumentedFeedSlot, keeps track of when a call to start_exporting has been made without a closing call to finish_exporting and when a call to finish_exporting has been made before a call to start_exporting.""" def __init__(self) -> None: self.start_without_finish = False self.finish_without_start = False def update(self, method): if method == "start": self.start_without_finish = True elif method == "finish": if self.start_without_finish: self.start_without_finish = False else: self.finish_without_start = True class ExceptionJsonItemExporter(JsonItemExporter): """JsonItemExporter that throws an exception every time export_item is called.""" def export_item(self, _): raise RuntimeError("foo") class TestFeedExport(TestFeedExportBase): async def run_and_export( self, spider_cls: type[Spider], settings: dict[str, Any] ) -> dict[str, Any]: """Run spider with specified settings; return exported data.""" FEEDS = settings.get("FEEDS") or {} settings["FEEDS"] = { printf_escape(path_to_url(file_path)): feed_options for file_path, feed_options in FEEDS.items() } content: dict[str, Any] = {} try: spider_cls.start_urls = [self.mockserver.url("/")] crawler = get_crawler(spider_cls, settings) await crawler.crawl_async() for file_path, feed_options in FEEDS.items(): content[feed_options["format"]] = ( Path(file_path).read_bytes() if Path(file_path).exists() else None ) finally: for file_path in FEEDS: if not Path(file_path).exists(): continue Path(file_path).unlink() return content async def assertExportedCsv( self, items: Iterable[Any], header: Iterable[str], rows: Iterable[dict[str, Any]], settings: dict[str, Any] | None = None, ) -> None: settings = settings or {} settings.update( { "FEEDS": { self._random_temp_filename(): {"format": "csv"}, }, } ) data = await self.exported_data(items, settings) reader = csv.DictReader(to_unicode(data["csv"]).splitlines()) assert reader.fieldnames == list(header) assert rows == list(reader) async def assertExportedJsonLines( self, items: Iterable[Any], rows: Iterable[dict[str, Any]], settings: dict[str, Any] | None = None, ) -> None: settings = settings or {} settings.update( { "FEEDS": { self._random_temp_filename(): {"format": "jl"}, }, } ) data = await self.exported_data(items, settings) parsed = [json.loads(to_unicode(line)) for line in data["jl"].splitlines()] rows = [{k: v for k, v in row.items() if v} for row in rows] assert rows == parsed async def assertExportedXml( self, items: Iterable[Any], rows: Iterable[dict[str, Any]], settings: dict[str, Any] | None = None, ) -> None: settings = settings or {} settings.update( { "FEEDS": { self._random_temp_filename(): {"format": "xml"}, }, } ) data = await self.exported_data(items, settings) rows = [{k: v for k, v in row.items() if v} for row in rows] root = lxml.etree.fromstring(data["xml"]) got_rows = [{e.tag: e.text for e in it} for it in root.findall("item")] assert rows == got_rows async def assertExportedMultiple( self, items: Iterable[Any], rows: Iterable[dict[str, Any]], settings: dict[str, Any] | None = None, ) -> None: settings = settings or {} settings.update( { "FEEDS": { self._random_temp_filename(): {"format": "xml"}, self._random_temp_filename(): {"format": "json"}, }, } ) data = await self.exported_data(items, settings) rows = [{k: v for k, v in row.items() if v} for row in rows] # XML root = lxml.etree.fromstring(data["xml"]) xml_rows = [{e.tag: e.text for e in it} for it in root.findall("item")] assert rows == xml_rows # JSON json_rows = json.loads(to_unicode(data["json"])) assert rows == json_rows async def assertExportedPickle( self, items: Iterable[Any], rows: Iterable[dict[str, Any]], settings: dict[str, Any] | None = None, ) -> None: settings = settings or {} settings.update( { "FEEDS": { self._random_temp_filename(): {"format": "pickle"}, }, } ) data = await self.exported_data(items, settings) expected = [{k: v for k, v in row.items() if v} for row in rows] result = self._load_until_eof(data["pickle"], load_func=pickle.load) assert result == expected async def assertExportedMarshal( self, items: Iterable[Any], rows: Iterable[dict[str, Any]], settings: dict[str, Any] | None = None, ) -> None: settings = settings or {} settings.update( { "FEEDS": { self._random_temp_filename(): {"format": "marshal"}, }, } ) data = await self.exported_data(items, settings) expected = [{k: v for k, v in row.items() if v} for row in rows] result = self._load_until_eof(data["marshal"], load_func=marshal.load) assert result == expected @inline_callbacks_test def test_stats_file_success(self): settings = { "FEEDS": { printf_escape(path_to_url(str(self._random_temp_filename()))): { "format": "json", } }, } crawler = get_crawler(ItemSpider, settings) yield crawler.crawl(mockserver=self.mockserver) assert "feedexport/success_count/FileFeedStorage" in crawler.stats.get_stats() assert crawler.stats.get_value("feedexport/success_count/FileFeedStorage") == 1 @inline_callbacks_test def test_stats_file_failed(self): settings = { "FEEDS": { printf_escape(path_to_url(str(self._random_temp_filename()))): { "format": "json", } }, } crawler = get_crawler(ItemSpider, settings) def store(file: IO[bytes]) -> None: file.close() raise KeyError("foo") with mock.patch( "scrapy.extensions.feedexport.FileFeedStorage.store", side_effect=store, ): yield crawler.crawl(mockserver=self.mockserver) assert "feedexport/failed_count/FileFeedStorage" in crawler.stats.get_stats() assert crawler.stats.get_value("feedexport/failed_count/FileFeedStorage") == 1 @inline_callbacks_test def test_stats_multiple_file(self): settings = { "FEEDS": { printf_escape(path_to_url(str(self._random_temp_filename()))): { "format": "json", }, "stdout:": { "format": "xml", }, }, } crawler = get_crawler(ItemSpider, settings) yield crawler.crawl(mockserver=self.mockserver) assert "feedexport/success_count/FileFeedStorage" in crawler.stats.get_stats() assert "feedexport/success_count/StdoutFeedStorage" in crawler.stats.get_stats() assert crawler.stats.get_value("feedexport/success_count/FileFeedStorage") == 1 assert ( crawler.stats.get_value("feedexport/success_count/StdoutFeedStorage") == 1 ) @coroutine_test async def test_export_items(self): # feed exporters use field names from Item items = [ MyItem({"foo": "bar1", "egg": "spam1"}), MyItem({"foo": "bar2", "egg": "spam2", "baz": "quux2"}), ] rows = [ {"egg": "spam1", "foo": "bar1", "baz": ""}, {"egg": "spam2", "foo": "bar2", "baz": "quux2"}, ] header = MyItem.fields.keys() await self.assertExported(items, header, rows) @coroutine_test async def test_pathlib_uri_with_placeholders(self): feed_dir = Path(self.temp_dir, "pathlib_placeholders") feed_dir.mkdir() items = [MyItem({"foo": "bar1", "egg": "spam1"})] class TestSpider(scrapy.Spider): name = "testspider" def parse(self, response): yield from items TestSpider.start_urls = [self.mockserver.url("/")] settings = { "FEEDS": { feed_dir / "%(time)s.json": {"format": "json"}, }, } crawler = get_crawler(TestSpider, settings) await crawler.crawl_async() files = list(feed_dir.iterdir()) assert len(files) == 1 assert "%(time)s" not in files[0].name assert files[0].suffix == ".json" @coroutine_test async def test_pathlib_uri_with_spaces_and_unicode(self): # A pathlib.Path key with spaces and non-ASCII characters must be kept # verbatim (not percent-encoded), while %()s placeholders are still # substituted. %(name)s resolves to the spider name deterministically, # so the resulting file name can be asserted exactly. feed_dir = Path(self.temp_dir, "pathlib_spaces_unicode") feed_dir.mkdir() items = [MyItem({"foo": "bar1", "egg": "spam1"})] class TestSpider(scrapy.Spider): name = "testspider" def parse(self, response): yield from items TestSpider.start_urls = [self.mockserver.url("/")] settings = { "FEEDS": { feed_dir / "out %(name)s ünïcode.json": {"format": "json"}, }, } crawler = get_crawler(TestSpider, settings) await crawler.crawl_async() files = list(feed_dir.iterdir()) assert len(files) == 1 assert files[0].name == "out testspider ünïcode.json" @coroutine_test async def test_str_uri_with_percent_encoding_and_placeholder(self): # A percent-encoded string URI (e.g. %20 for a space) must reach # storage verbatim rather than being misinterpreted as a printf # directive, while %()s placeholders are still substituted. See #6425 # and #5794. feed_dir = Path(self.temp_dir, "dir with spaces") feed_dir.mkdir() items = [MyItem({"foo": "bar1", "egg": "spam1"})] class TestSpider(scrapy.Spider): name = "testspider" def parse(self, response): yield from items TestSpider.start_urls = [self.mockserver.url("/")] settings = { "FEEDS": { f"{feed_dir.as_uri()}/%(time)s.json": {"format": "json"}, }, } crawler = get_crawler(TestSpider, settings) await crawler.crawl_async() files = list(feed_dir.iterdir()) assert len(files) == 1 assert "%(time)s" not in files[0].name assert files[0].suffix == ".json" @coroutine_test async def test_export_no_items_not_store_empty(self): for fmt in ("json", "jsonlines", "xml", "csv"): settings = { "FEEDS": { self._random_temp_filename(): {"format": fmt}, }, "FEED_STORE_EMPTY": False, } data = await self.exported_no_data(settings) assert data[fmt] is None @coroutine_test async def test_start_finish_exporting_items(self): items = [ MyItem({"foo": "bar1", "egg": "spam1"}), ] settings = { "FEEDS": { self._random_temp_filename(): {"format": "json"}, }, "FEED_EXPORT_INDENT": None, } listener = IsExportingListener() InstrumentedFeedSlot.subscribe__listener(listener) with mock.patch("scrapy.extensions.feedexport.FeedSlot", InstrumentedFeedSlot): await self.exported_data(items, settings) assert not listener.start_without_finish assert not listener.finish_without_start @coroutine_test async def test_start_finish_exporting_no_items(self): items: list[Any] = [] settings = { "FEEDS": { self._random_temp_filename(): {"format": "json"}, }, "FEED_EXPORT_INDENT": None, } listener = IsExportingListener() InstrumentedFeedSlot.subscribe__listener(listener) with mock.patch("scrapy.extensions.feedexport.FeedSlot", InstrumentedFeedSlot): await self.exported_data(items, settings) assert not listener.start_without_finish assert not listener.finish_without_start @coroutine_test async def test_start_finish_exporting_items_exception(self): items = [ MyItem({"foo": "bar1", "egg": "spam1"}), ] settings = { "FEEDS": { self._random_temp_filename(): {"format": "json"}, }, "FEED_EXPORTERS": {"json": ExceptionJsonItemExporter}, "FEED_EXPORT_INDENT": None, } listener = IsExportingListener() InstrumentedFeedSlot.subscribe__listener(listener) with mock.patch("scrapy.extensions.feedexport.FeedSlot", InstrumentedFeedSlot): await self.exported_data(items, settings) assert not listener.start_without_finish assert not listener.finish_without_start @coroutine_test async def test_start_finish_exporting_no_items_exception(self): items: list[Any] = [] settings = { "FEEDS": { self._random_temp_filename(): {"format": "json"}, }, "FEED_EXPORTERS": {"json": ExceptionJsonItemExporter}, "FEED_EXPORT_INDENT": None, } listener = IsExportingListener() InstrumentedFeedSlot.subscribe__listener(listener) with mock.patch("scrapy.extensions.feedexport.FeedSlot", InstrumentedFeedSlot): await self.exported_data(items, settings) assert not listener.start_without_finish assert not listener.finish_without_start @coroutine_test async def test_export_no_items_store_empty(self): formats = ( ("json", b"[]"), ("jsonlines", b""), ("xml", b'\n'), ("csv", b""), ) for fmt, expctd in formats: settings = { "FEEDS": { self._random_temp_filename(): {"format": fmt}, }, "FEED_STORE_EMPTY": True, "FEED_EXPORT_INDENT": None, } data = await self.exported_no_data(settings) assert expctd == data[fmt] @coroutine_test async def test_export_no_items_multiple_feeds( self, caplog: pytest.LogCaptureFixture ) -> None: """Make sure that `storage.store` is not called.""" settings = { "FEEDS": { self._random_temp_filename(): {"format": "json"}, self._random_temp_filename(): {"format": "xml"}, self._random_temp_filename(): {"format": "csv"}, }, "FEED_STORAGES": {"file": LogOnStoreFileStorage}, "FEED_STORE_EMPTY": False, } with caplog.at_level(logging.INFO): await self.exported_no_data(settings) assert caplog.text.count("Storage.store is called") == 0 @coroutine_test async def test_export_multiple_item_classes(self): items = [ MyItem({"foo": "bar1", "egg": "spam1"}), MyItem2({"hello": "world2", "foo": "bar2"}), MyItem({"foo": "bar3", "egg": "spam3", "baz": "quux3"}), {"hello": "world4", "egg": "spam4"}, ] # by default, Scrapy uses fields of the first Item for CSV and # all fields for JSON Lines header = MyItem.fields.keys() rows_csv = [ {"egg": "spam1", "foo": "bar1", "baz": ""}, {"egg": "", "foo": "bar2", "baz": ""}, {"egg": "spam3", "foo": "bar3", "baz": "quux3"}, {"egg": "spam4", "foo": "", "baz": ""}, ] rows_jl = [dict(row) for row in items] await self.assertExportedCsv(items, header, rows_csv) await self.assertExportedJsonLines(items, rows_jl) @coroutine_test async def test_export_items_empty_field_list(self): # FEED_EXPORT_FIELDS==[] means the same as default None items = [{"foo": "bar"}] header = ["foo"] rows = [{"foo": "bar"}] settings: dict[str, Any] = {"FEED_EXPORT_FIELDS": []} await self.assertExportedCsv(items, header, rows) await self.assertExportedJsonLines(items, rows, settings) @coroutine_test async def test_export_items_field_list(self): items = [{"foo": "bar"}] header = ["foo", "baz"] rows = [{"foo": "bar", "baz": ""}] settings = {"FEED_EXPORT_FIELDS": header} await self.assertExported(items, header, rows, settings=settings) @coroutine_test async def test_export_items_comma_separated_field_list(self): items = [{"foo": "bar"}] header = ["foo", "baz"] rows = [{"foo": "bar", "baz": ""}] settings = {"FEED_EXPORT_FIELDS": ",".join(header)} await self.assertExported(items, header, rows, settings=settings) @coroutine_test async def test_export_items_json_field_list(self): items = [{"foo": "bar"}] header = ["foo", "baz"] rows = [{"foo": "bar", "baz": ""}] settings = {"FEED_EXPORT_FIELDS": json.dumps(header)} await self.assertExported(items, header, rows, settings=settings) @coroutine_test async def test_export_items_field_names(self): items = [{"foo": "bar"}] header = {"foo": "Foo"} rows = [{"Foo": "bar"}] settings = {"FEED_EXPORT_FIELDS": header} await self.assertExported(items, list(header.values()), rows, settings=settings) @coroutine_test async def test_export_items_dict_field_names(self): items = [{"foo": "bar"}] header = { "baz": "Baz", "foo": "Foo", } rows = [{"Baz": "", "Foo": "bar"}] settings = {"FEED_EXPORT_FIELDS": header} await self.assertExported(items, ["Baz", "Foo"], rows, settings=settings) @coroutine_test async def test_export_items_json_field_names(self): items = [{"foo": "bar"}] header = {"foo": "Foo"} rows = [{"Foo": "bar"}] settings = {"FEED_EXPORT_FIELDS": json.dumps(header)} await self.assertExported(items, list(header.values()), rows, settings=settings) @coroutine_test async def test_export_based_on_item_classes(self): items = [ MyItem({"foo": "bar1", "egg": "spam1"}), MyItem2({"hello": "world2", "foo": "bar2"}), {"hello": "world3", "egg": "spam3"}, ] formats = { "csv": b"foo,egg,baz\r\nbar1,spam1,\r\n", "json": b'[\n{"foo": "bar2", "hello": "world2"}\n]', "jsonlines": ( b'{"foo": "bar1", "egg": "spam1"}\n{"foo": "bar2", "hello": "world2"}\n' ), "xml": ( b'\n\n' b"bar1spam1\n" b"bar2world2\nworld3" b"spam3\n" ), } settings = { "FEEDS": { self._random_temp_filename(): { "format": "csv", "item_classes": [MyItem], }, self._random_temp_filename(): { "format": "json", "item_classes": [MyItem2], }, self._random_temp_filename(): { "format": "jsonlines", "item_classes": [MyItem, MyItem2], }, self._random_temp_filename(): { "format": "xml", }, }, } data = await self.exported_data(items, settings) for fmt, expected in formats.items(): assert data[fmt] == expected @coroutine_test async def test_export_based_on_custom_filters(self): items = [ MyItem({"foo": "bar1", "egg": "spam1"}), MyItem2({"hello": "world2", "foo": "bar2"}), {"hello": "world3", "egg": "spam3"}, ] class CustomFilter1: def __init__(self, feed_options): pass def accepts(self, item): return isinstance(item, MyItem) class CustomFilter2(ItemFilter): def accepts(self, item): return "foo" in item.fields class CustomFilter3(ItemFilter): def accepts(self, item): return ( isinstance(item, tuple(self.item_classes)) and item["foo"] == "bar1" # type: ignore[index] ) formats = { "json": b'[\n{"foo": "bar1", "egg": "spam1"}\n]', "xml": ( b'\n\n' b"bar1spam1\n" b"bar2world2\n" ), "jsonlines": b'{"foo": "bar1", "egg": "spam1"}\n', } settings = { "FEEDS": { self._random_temp_filename(): { "format": "json", "item_filter": CustomFilter1, }, self._random_temp_filename(): { "format": "xml", "item_filter": CustomFilter2, }, self._random_temp_filename(): { "format": "jsonlines", "item_classes": [MyItem, MyItem2], "item_filter": CustomFilter3, }, }, } data = await self.exported_data(items, settings) for fmt, expected in formats.items(): assert data[fmt] == expected @coroutine_test async def test_export_dicts(self): # When dicts are used, only keys from the first row are used as # a header for CSV, and all fields are used for JSON Lines. items = [ {"foo": "bar", "egg": "spam"}, {"foo": "bar", "egg": "spam", "baz": "quux"}, ] rows_csv = [{"egg": "spam", "foo": "bar"}, {"egg": "spam", "foo": "bar"}] rows_jl = items await self.assertExportedCsv(items, ["foo", "egg"], rows_csv) await self.assertExportedJsonLines(items, rows_jl) @coroutine_test async def test_export_tuple(self): items = [ {"foo": "bar1", "egg": "spam1"}, {"foo": "bar2", "egg": "spam2", "baz": "quux"}, ] settings = {"FEED_EXPORT_FIELDS": ("foo", "baz")} rows = [{"foo": "bar1", "baz": ""}, {"foo": "bar2", "baz": "quux"}] await self.assertExported(items, ["foo", "baz"], rows, settings=settings) @coroutine_test async def test_export_feed_export_fields(self): # FEED_EXPORT_FIELDS option allows to order export fields # and to select a subset of fields to export, both for Items and dicts. for item_cls in [MyItem, dict]: items = [ item_cls({"foo": "bar1", "egg": "spam1"}), item_cls({"foo": "bar2", "egg": "spam2", "baz": "quux2"}), ] # export all columns settings = {"FEED_EXPORT_FIELDS": "foo,baz,egg"} rows = [ {"egg": "spam1", "foo": "bar1", "baz": ""}, {"egg": "spam2", "foo": "bar2", "baz": "quux2"}, ] await self.assertExported( items, ["foo", "baz", "egg"], rows, settings=settings ) # export a subset of columns settings = {"FEED_EXPORT_FIELDS": "egg,baz"} rows = [{"egg": "spam1", "baz": ""}, {"egg": "spam2", "baz": "quux2"}] await self.assertExported(items, ["egg", "baz"], rows, settings=settings) @coroutine_test async def test_export_encoding(self): items = [{"foo": "Test\xd6"}] formats = { "json": b'[{"foo": "Test\\u00d6"}]', "jsonlines": b'{"foo": "Test\\u00d6"}\n', "xml": ( '\n' "Test\xd6" ).encode(), "csv": "foo\r\nTest\xd6\r\n".encode(), } for fmt, expected in formats.items(): settings: dict[str, Any] = { "FEEDS": { self._random_temp_filename(): {"format": fmt}, }, "FEED_EXPORT_INDENT": None, } data = await self.exported_data(items, settings) assert data[fmt] == expected formats = { "json": b'[{"foo": "Test\xd6"}]', "jsonlines": b'{"foo": "Test\xd6"}\n', "xml": ( b'\n' b"Test\xd6" ), "csv": b"foo\r\nTest\xd6\r\n", } for fmt, expected in formats.items(): settings = { "FEEDS": { self._random_temp_filename(): {"format": fmt}, }, "FEED_EXPORT_INDENT": None, "FEED_EXPORT_ENCODING": "latin-1", } data = await self.exported_data(items, settings) assert data[fmt] == expected @coroutine_test async def test_export_multiple_configs(self): items = [{"foo": "FOO", "bar": "BAR"}] formats = { "json": b'[\n{"bar": "BAR"}\n]', "xml": ( b'\n' b"\n \n FOO\n \n" ), "csv": b"bar,foo\r\nBAR,FOO\r\n", } settings = { "FEEDS": { self._random_temp_filename(): { "format": "json", "indent": 0, "fields": ["bar"], "encoding": "utf-8", }, self._random_temp_filename(): { "format": "xml", "indent": 2, "fields": ["foo"], "encoding": "latin-1", }, self._random_temp_filename(): { "format": "csv", "indent": None, "fields": ["bar", "foo"], "encoding": "utf-8", }, }, } data = await self.exported_data(items, settings) for fmt, expected in formats.items(): assert data[fmt] == expected @coroutine_test async def test_export_indentation(self): items = [ {"foo": ["bar"]}, {"key": "value"}, ] test_cases: list[dict[str, Any]] = [ # JSON { "format": "json", "indent": None, "expected": b'[{"foo": ["bar"]},{"key": "value"}]', }, { "format": "json", "indent": -1, "expected": b"""[ {"foo": ["bar"]}, {"key": "value"} ]""", }, { "format": "json", "indent": 0, "expected": b"""[ {"foo": ["bar"]}, {"key": "value"} ]""", }, { "format": "json", "indent": 2, "expected": b"""[ { "foo": [ "bar" ] }, { "key": "value" } ]""", }, { "format": "json", "indent": 4, "expected": b"""[ { "foo": [ "bar" ] }, { "key": "value" } ]""", }, { "format": "json", "indent": 5, "expected": b"""[ { "foo": [ "bar" ] }, { "key": "value" } ]""", }, # XML { "format": "xml", "indent": None, "expected": b""" barvalue""", }, { "format": "xml", "indent": -1, "expected": b""" bar value """, }, { "format": "xml", "indent": 0, "expected": b""" bar value """, }, { "format": "xml", "indent": 2, "expected": b""" bar value """, }, { "format": "xml", "indent": 4, "expected": b""" bar value """, }, { "format": "xml", "indent": 5, "expected": b""" bar value """, }, ] for row in test_cases: settings = { "FEEDS": { self._random_temp_filename(): { "format": row["format"], "indent": row["indent"], }, }, } data = await self.exported_data(items, settings) assert data[row["format"]] == row["expected"] @coroutine_test async def test_init_exporters_storages_with_crawler(self): settings = { "FEED_EXPORTERS": {"csv": FromCrawlerCsvItemExporter}, "FEED_STORAGES": {"file": FromCrawlerFileFeedStorage}, "FEEDS": { self._random_temp_filename(): {"format": "csv"}, }, } await self.exported_data(items=[], settings=settings) assert FromCrawlerCsvItemExporter.init_with_crawler assert FromCrawlerFileFeedStorage.init_with_crawler @coroutine_test async def test_str_uri(self): settings = { "FEED_STORE_EMPTY": True, "FEEDS": {str(self._random_temp_filename()): {"format": "csv"}}, } data = await self.exported_no_data(settings) assert data["csv"] == b"" @coroutine_test async def test_multiple_feeds_success_logs_blocking_feed_storage( self, caplog: pytest.LogCaptureFixture ): settings = { "FEEDS": { self._random_temp_filename(): {"format": "json"}, self._random_temp_filename(): {"format": "xml"}, self._random_temp_filename(): {"format": "csv"}, }, "FEED_STORAGES": {"file": DummyBlockingFeedStorage}, } items = [ {"foo": "bar1", "baz": ""}, {"foo": "bar2", "baz": "quux"}, ] with caplog.at_level(logging.DEBUG): await self.exported_data(items, settings) for fmt in ["json", "xml", "csv"]: assert f"Stored {fmt} feed (2 items)" in caplog.text @coroutine_test async def test_multiple_feeds_failing_logs_blocking_feed_storage( self, caplog: pytest.LogCaptureFixture ): settings = { "FEEDS": { self._random_temp_filename(): {"format": "json"}, self._random_temp_filename(): {"format": "xml"}, self._random_temp_filename(): {"format": "csv"}, }, "FEED_STORAGES": {"file": FailingBlockingFeedStorage}, } items = [ {"foo": "bar1", "baz": ""}, {"foo": "bar2", "baz": "quux"}, ] with caplog.at_level(logging.DEBUG): await self.exported_data(items, settings) for fmt in ["json", "xml", "csv"]: assert f"Error storing {fmt} feed (2 items)" in caplog.text @coroutine_test async def test_extend_kwargs(self): items = [{"foo": "FOO", "bar": "BAR"}] expected_with_title_csv = b"foo,bar\r\nFOO,BAR\r\n" expected_without_title_csv = b"FOO,BAR\r\n" test_cases: list[dict[str, Any]] = [ # with title { "options": { "format": "csv", "item_export_kwargs": {"include_headers_line": True}, }, "expected": expected_with_title_csv, }, # without title { "options": { "format": "csv", "item_export_kwargs": {"include_headers_line": False}, }, "expected": expected_without_title_csv, }, ] for row in test_cases: feed_options = row["options"] settings = { "FEEDS": { self._random_temp_filename(): feed_options, }, "FEED_EXPORT_INDENT": None, } data = await self.exported_data(items, settings) assert data[feed_options["format"]] == row["expected"] @coroutine_test async def test_storage_file_no_postprocessing(self): class Storage: open_file: IO[bytes] store_file: IO[bytes] def __init__(self, uri, *, feed_options=None): pass def open(self, spider): Storage.open_file = tempfile.NamedTemporaryFile(prefix="feed-") return Storage.open_file def store(self, file): Storage.store_file = file file.close() settings = { "FEEDS": {self._random_temp_filename(): {"format": "jsonlines"}}, "FEED_STORAGES": {"file": Storage}, } await self.exported_no_data(settings) assert Storage.open_file is Storage.store_file @coroutine_test async def test_storage_file_postprocessing(self): class Storage: open_file: IO[bytes] store_file: IO[bytes] file_was_closed: bool def __init__(self, uri, *, feed_options=None): pass def open(self, spider): Storage.open_file = tempfile.NamedTemporaryFile(prefix="feed-") return Storage.open_file def store(self, file): Storage.store_file = file Storage.file_was_closed = file.closed file.close() settings = { "FEEDS": { self._random_temp_filename(): { "format": "jsonlines", "postprocessing": [ "scrapy.extensions.postprocessing.GzipPlugin", ], }, }, "FEED_STORAGES": {"file": Storage}, } await self.exported_no_data(settings) assert Storage.open_file is Storage.store_file assert not Storage.file_was_closed # Test that the FeedExporer sends the feed_exporter_closed and feed_slot_closed signals class TestFeedExporterSignals: items = [ {"foo": "bar1", "egg": "spam1"}, {"foo": "bar2", "egg": "spam2", "baz": "quux2"}, {"foo": "bar3", "baz": "quux3"}, ] with tempfile.NamedTemporaryFile(suffix="json") as tmp: settings = { "FEEDS": { printf_escape(path_to_url(tmp.name)): { "format": "json", }, }, } def feed_exporter_closed_signal_handler(self): self.feed_exporter_closed_received = True def feed_slot_closed_signal_handler(self, slot): self.feed_slot_closed_received = True async def feed_exporter_closed_signal_handler_async(self): self.feed_exporter_closed_received = True async def feed_slot_closed_signal_handler_async(self, slot): self.feed_slot_closed_received = True async def run_signaled_feed_exporter( self, feed_exporter_signal_handler: Callable[[], Awaitable[None] | None], feed_slot_signal_handler: Callable[[Any], Awaitable[None] | None], ) -> None: crawler = get_crawler(settings_dict=self.settings) feed_exporter = build_from_crawler(FeedExporter, crawler) spider = scrapy.Spider.from_crawler(crawler, "default") crawler.signals.connect( feed_exporter_signal_handler, signal=signals.feed_exporter_closed, ) crawler.signals.connect( feed_slot_signal_handler, signal=signals.feed_slot_closed ) feed_exporter.open_spider(spider) for item in self.items: feed_exporter.item_scraped(item, spider) await feed_exporter.close_spider(spider) @coroutine_test async def test_feed_exporter_signals_sent(self) -> None: self.feed_exporter_closed_received = False self.feed_slot_closed_received = False await self.run_signaled_feed_exporter( self.feed_exporter_closed_signal_handler, self.feed_slot_closed_signal_handler, ) assert self.feed_slot_closed_received assert self.feed_exporter_closed_received @coroutine_test async def test_feed_exporter_signals_sent_async(self) -> None: self.feed_exporter_closed_received = False self.feed_slot_closed_received = False await self.run_signaled_feed_exporter( self.feed_exporter_closed_signal_handler_async, self.feed_slot_closed_signal_handler_async, ) assert self.feed_slot_closed_received assert self.feed_exporter_closed_received class TestItemFilter: def test_no_feed_options(self): item_filter = ItemFilter(None) assert item_filter.item_classes == () assert item_filter.accepts(MyItem({"foo": "bar"})) class TestFeedExportInit: def test_unsupported_storage(self): settings: dict[str, Any] = { "FEEDS": { "unsupported://uri": {}, }, } crawler = get_crawler(settings_dict=settings) with pytest.raises(NotConfigured): build_from_crawler(FeedExporter, crawler) def test_disabled_storage(self, caplog: pytest.LogCaptureFixture): class DisabledFeedStorage: def __init__(self, uri, *, feed_options=None): raise NotConfigured("not today") settings = { "FEED_STORAGES": {"disabled": DisabledFeedStorage}, "FEEDS": { "disabled://uri": {}, }, } crawler = get_crawler(settings_dict=settings) with caplog.at_level(logging.ERROR), pytest.raises(NotConfigured): build_from_crawler(FeedExporter, crawler) assert ( "Disabled feed storage scheme: disabled. Reason: not today" in caplog.text ) def test_unsupported_format(self): settings = { "FEEDS": { "file://path": { "format": "unsupported_format", }, }, } crawler = get_crawler(settings_dict=settings) with pytest.raises(NotConfigured): build_from_crawler(FeedExporter, crawler) def test_format_inferred_from_uri(self): settings: dict[str, Any] = { "FEEDS": { "output.json": {}, }, } crawler = get_crawler(settings_dict=settings) exporter = build_from_crawler(FeedExporter, crawler) assert exporter.feeds["output.json"]["format"] == "json" def test_format_missing_and_not_inferable(self, caplog: pytest.LogCaptureFixture): settings: dict[str, Any] = { "FEEDS": { "stdout:": {}, }, } crawler = get_crawler(settings_dict=settings) with caplog.at_level(logging.ERROR), pytest.raises(NotConfigured): build_from_crawler(FeedExporter, crawler) assert "Feed format not set" in caplog.text def test_absolute_pathlib_as_uri(self): with tempfile.NamedTemporaryFile(suffix="json") as tmp: settings = { "FEEDS": { Path(tmp.name).resolve(): { "format": "json", }, }, } crawler = get_crawler(settings_dict=settings) exporter = build_from_crawler(FeedExporter, crawler) assert isinstance(exporter, FeedExporter) def test_relative_pathlib_as_uri(self): settings = { "FEEDS": { Path("./items.json"): { "format": "json", }, }, } crawler = get_crawler(settings_dict=settings) exporter = build_from_crawler(FeedExporter, crawler) assert isinstance(exporter, FeedExporter) class TestApplyUriParams: params = { "name": "myspider", "time": "2020-01-01T00-00-00", "batch_id": 2, "batch_time": "2020-01-01T00-00-00", } @pytest.mark.parametrize( ("uri_template", "expected"), [ # Placeholders are substituted, including width/flags. ("/data/%(name)s/%(time)s.json", "/data/myspider/2020-01-01T00-00-00.json"), ("/data/%(batch_id)05d.json", "/data/00002.json"), # Percent-encoding is kept verbatim (#6425, #5794). ( "file:///path%20with%20spaces/%(name)s.json", "file:///path%20with%20spaces/myspider.json", ), ( "ftp://user:2%23um25%21M%23JZ@ftp.example.com/%(name)s.csv", "ftp://user:2%23um25%21M%23JZ@ftp.example.com/myspider.csv", ), # A lone percent character next to a placeholder stays literal. ("/100%/%(name)s.json", "/100%/myspider.json"), ], ) def test_apply_uri_params(self, uri_template, expected): assert apply_uri_params(uri_template, self.params) == expected scrapy-scrapy-5569de3/tests/test_feedexport_batch.py000066400000000000000000000436401525051012400227430ustar00rootroot00000000000000from __future__ import annotations import csv import json import marshal import pickle from collections import defaultdict from pathlib import Path from typing import TYPE_CHECKING, Any from urllib.parse import urljoin import lxml.etree import pytest from packaging.version import Version import scrapy from scrapy import Spider from scrapy.exceptions import NotConfigured from scrapy.extensions.feedexport import FeedExporter, S3FeedStorage from scrapy.settings import Settings from scrapy.utils.misc import build_from_crawler from scrapy.utils.python import to_unicode from scrapy.utils.test import get_crawler from tests.spiders import ItemSpider from tests.utils.bases.feedexport import TestFeedExportBase from tests.utils.decorators import coroutine_test, inline_callbacks_test from tests.utils.feedexport import MyItem if TYPE_CHECKING: from os import PathLike def build_url(path: str | PathLike[str]) -> str: path_str = str(path) if path_str[0] != "/": path_str = "/" + path_str return urljoin("file:", path_str) class TestBatchDeliveries(TestFeedExportBase): _file_mark = "_%(batch_time)s_#%(batch_id)02d_" async def run_and_export( self, spider_cls: type[Spider], settings: dict[str, Any] ) -> dict[str, list[bytes]]: """Run spider with specified settings; return exported data.""" FEEDS = settings.get("FEEDS") or {} settings["FEEDS"] = { build_url(file_path): feed for file_path, feed in FEEDS.items() } content: defaultdict[str, list[bytes]] = defaultdict(list) spider_cls.start_urls = [self.mockserver.url("/")] crawler = get_crawler(spider_cls, settings) await crawler.crawl_async() for path, feed in FEEDS.items(): dir_name = Path(path).parent if not dir_name.exists(): content[feed["format"]] = [] continue for file in sorted(dir_name.iterdir()): content[feed["format"]].append(file.read_bytes()) return content async def assertExportedJsonLines(self, items, rows, settings=None): settings = settings or {} settings.update( { "FEEDS": { self._random_temp_filename() / "jl" / self._file_mark: { "format": "jl" }, }, } ) batch_size = Settings(settings).getint("FEED_EXPORT_BATCH_ITEM_COUNT") rows = [{k: v for k, v in row.items() if v} for row in rows] data = await self.exported_data(items, settings) for batch in data["jl"]: got_batch = [ json.loads(to_unicode(batch_item)) for batch_item in batch.splitlines() ] expected_batch, rows = rows[:batch_size], rows[batch_size:] assert got_batch == expected_batch async def assertExportedCsv(self, items, header, rows, settings=None): settings = settings or {} settings.update( { "FEEDS": { self._random_temp_filename() / "csv" / self._file_mark: { "format": "csv" }, }, } ) batch_size = Settings(settings).getint("FEED_EXPORT_BATCH_ITEM_COUNT") data = await self.exported_data(items, settings) for batch in data["csv"]: got_batch = csv.DictReader(to_unicode(batch).splitlines()) assert list(header) == got_batch.fieldnames expected_batch, rows = rows[:batch_size], rows[batch_size:] assert list(got_batch) == expected_batch async def assertExportedXml(self, items, rows, settings=None): settings = settings or {} settings.update( { "FEEDS": { self._random_temp_filename() / "xml" / self._file_mark: { "format": "xml" }, }, } ) batch_size = Settings(settings).getint("FEED_EXPORT_BATCH_ITEM_COUNT") rows = [{k: v for k, v in row.items() if v} for row in rows] data = await self.exported_data(items, settings) for batch in data["xml"]: root = lxml.etree.fromstring(batch) got_batch = [{e.tag: e.text for e in it} for it in root.findall("item")] expected_batch, rows = rows[:batch_size], rows[batch_size:] assert got_batch == expected_batch async def assertExportedMultiple(self, items, rows, settings=None): settings = settings or {} settings.update( { "FEEDS": { self._random_temp_filename() / "xml" / self._file_mark: { "format": "xml" }, self._random_temp_filename() / "json" / self._file_mark: { "format": "json" }, }, } ) batch_size = Settings(settings).getint("FEED_EXPORT_BATCH_ITEM_COUNT") rows = [{k: v for k, v in row.items() if v} for row in rows] data = await self.exported_data(items, settings) # XML xml_rows = rows.copy() for batch in data["xml"]: root = lxml.etree.fromstring(batch) got_batch = [{e.tag: e.text for e in it} for it in root.findall("item")] expected_batch, xml_rows = xml_rows[:batch_size], xml_rows[batch_size:] assert got_batch == expected_batch # JSON json_rows = rows.copy() for batch in data["json"]: got_batch = json.loads(batch.decode("utf-8")) expected_batch, json_rows = json_rows[:batch_size], json_rows[batch_size:] assert got_batch == expected_batch async def assertExportedPickle(self, items, rows, settings=None): settings = settings or {} settings.update( { "FEEDS": { self._random_temp_filename() / "pickle" / self._file_mark: { "format": "pickle" }, }, } ) batch_size = Settings(settings).getint("FEED_EXPORT_BATCH_ITEM_COUNT") rows = [{k: v for k, v in row.items() if v} for row in rows] data = await self.exported_data(items, settings) for batch in data["pickle"]: got_batch = self._load_until_eof(batch, load_func=pickle.load) expected_batch, rows = rows[:batch_size], rows[batch_size:] assert got_batch == expected_batch async def assertExportedMarshal(self, items, rows, settings=None): settings = settings or {} settings.update( { "FEEDS": { self._random_temp_filename() / "marshal" / self._file_mark: { "format": "marshal" }, }, } ) batch_size = Settings(settings).getint("FEED_EXPORT_BATCH_ITEM_COUNT") rows = [{k: v for k, v in row.items() if v} for row in rows] data = await self.exported_data(items, settings) for batch in data["marshal"]: got_batch = self._load_until_eof(batch, load_func=marshal.load) expected_batch, rows = rows[:batch_size], rows[batch_size:] assert got_batch == expected_batch @coroutine_test async def test_export_items(self): """Test partial deliveries in all supported formats""" items = [ MyItem({"foo": "bar1", "egg": "spam1"}), MyItem({"foo": "bar2", "egg": "spam2", "baz": "quux2"}), MyItem({"foo": "bar3", "baz": "quux3"}), ] rows = [ {"egg": "spam1", "foo": "bar1", "baz": ""}, {"egg": "spam2", "foo": "bar2", "baz": "quux2"}, {"foo": "bar3", "baz": "quux3", "egg": ""}, ] settings = {"FEED_EXPORT_BATCH_ITEM_COUNT": 2} header = MyItem.fields.keys() await self.assertExported(items, header, rows, settings=settings) @coroutine_test async def test_batch_delivered_when_full(self): """Full batches must be finalized and delivered as soon as they are full, instead of when the spider closes.""" dir_path = self._random_temp_filename() batch1_path = Path(dir_path, "1.json") mockserver_url = self.mockserver.url("/") batch1_contents: list[bytes | None] = [] class TestSpider(scrapy.Spider): name = "testspider" start_urls = [mockserver_url] def parse(self, response): yield {"foo": "bar1"} yield {"foo": "bar2"} yield scrapy.Request( mockserver_url, callback=self.parse2, dont_filter=True ) def parse2(self, response): # the first batch was full after the second item, so it must # have been delivered by now batch1_contents.append( batch1_path.read_bytes() if batch1_path.exists() else None ) yield {"foo": "bar3"} settings = { "FEEDS": { build_url(dir_path / "%(batch_id)d.json"): {"format": "json"}, }, "FEED_EXPORT_BATCH_ITEM_COUNT": 2, } crawler = get_crawler(TestSpider, settings) await crawler.crawl_async() assert batch1_contents, "the second request was not processed" assert batch1_contents[0] is not None, "batch 1 was not stored during the crawl" assert json.loads(batch1_contents[0]) == [{"foo": "bar1"}, {"foo": "bar2"}] def test_wrong_path(self): """If path is without %(batch_time)s and %(batch_id) an exception must be raised""" settings = { "FEEDS": { self._random_temp_filename(): {"format": "xml"}, }, "FEED_EXPORT_BATCH_ITEM_COUNT": 1, } crawler = get_crawler(settings_dict=settings) with pytest.raises(NotConfigured): build_from_crawler(FeedExporter, crawler) @coroutine_test async def test_export_no_items_not_store_empty(self): for fmt in ("json", "jsonlines", "xml", "csv"): settings = { "FEEDS": { self._random_temp_filename() / fmt / self._file_mark: { "format": fmt }, }, "FEED_EXPORT_BATCH_ITEM_COUNT": 1, "FEED_STORE_EMPTY": False, } data = await self.exported_no_data(settings) data = dict(data) assert len(data[fmt]) == 0 @coroutine_test async def test_export_no_items_store_empty(self): formats = ( ("json", b"[]"), ("jsonlines", b""), ("xml", b'\n'), ("csv", b""), ) for fmt, expctd in formats: settings = { "FEEDS": { self._random_temp_filename() / fmt / self._file_mark: { "format": fmt }, }, "FEED_STORE_EMPTY": True, "FEED_EXPORT_INDENT": None, "FEED_EXPORT_BATCH_ITEM_COUNT": 1, } data = await self.exported_no_data(settings) data = dict(data) assert data[fmt][0] == expctd @coroutine_test async def test_export_multiple_configs(self): items = [ {"foo": "FOO", "bar": "BAR"}, {"foo": "FOO1", "bar": "BAR1"}, ] formats = { "json": [ b'[\n{"bar": "BAR"}\n]', b'[\n{"bar": "BAR1"}\n]', ], "xml": [ ( b'\n' b"\n \n FOO\n \n" ), ( b'\n' b"\n \n FOO1\n \n" ), ], "csv": [ b"foo,bar\r\nFOO,BAR\r\n", b"foo,bar\r\nFOO1,BAR1\r\n", ], } settings = { "FEEDS": { self._random_temp_filename() / "json" / self._file_mark: { "format": "json", "indent": 0, "fields": ["bar"], "encoding": "utf-8", }, self._random_temp_filename() / "xml" / self._file_mark: { "format": "xml", "indent": 2, "fields": ["foo"], "encoding": "latin-1", }, self._random_temp_filename() / "csv" / self._file_mark: { "format": "csv", "indent": None, "fields": ["foo", "bar"], "encoding": "utf-8", }, }, "FEED_EXPORT_BATCH_ITEM_COUNT": 1, } data = await self.exported_data(items, settings) for fmt, expected in formats.items(): for expected_batch, got_batch in zip(expected, data[fmt], strict=True): assert got_batch == expected_batch @coroutine_test async def test_batch_item_count_feeds_setting(self): items = [{"foo": "FOO"}, {"foo": "FOO1"}] formats = { "json": [ b'[{"foo": "FOO"}]', b'[{"foo": "FOO1"}]', ], } settings = { "FEEDS": { self._random_temp_filename() / "json" / self._file_mark: { "format": "json", "indent": None, "encoding": "utf-8", "batch_item_count": 1, }, }, } data = await self.exported_data(items, settings) for fmt, expected in formats.items(): for expected_batch, got_batch in zip(expected, data[fmt], strict=True): assert got_batch == expected_batch @coroutine_test async def test_batch_path_differ(self): """ Test that the name of all batch files differ from each other. So %(batch_id)d replaced with the current id. """ items = [ MyItem({"foo": "bar1", "egg": "spam1"}), MyItem({"foo": "bar2", "egg": "spam2", "baz": "quux2"}), MyItem({"foo": "bar3", "baz": "quux3"}), ] settings = { "FEEDS": { self._random_temp_filename() / "%(batch_id)d": { "format": "json", }, }, "FEED_EXPORT_BATCH_ITEM_COUNT": 1, } data = await self.exported_data(items, settings) assert len(items) == len(data["json"]) @inline_callbacks_test def test_stats_batch_file_success(self): settings = { "FEEDS": { build_url( str(self._random_temp_filename() / "json" / self._file_mark) ): { "format": "json", } }, "FEED_EXPORT_BATCH_ITEM_COUNT": 1, } crawler = get_crawler(ItemSpider, settings) yield crawler.crawl(total=2, mockserver=self.mockserver) assert "feedexport/success_count/FileFeedStorage" in crawler.stats.get_stats() assert crawler.stats.get_value("feedexport/success_count/FileFeedStorage") == 12 @pytest.mark.requires_boto3 @inline_callbacks_test def test_s3_export(self): bucket = "mybucket" items = [ MyItem({"foo": "bar1", "egg": "spam1"}), MyItem({"foo": "bar2", "egg": "spam2", "baz": "quux2"}), MyItem({"foo": "bar3", "baz": "quux3"}), ] class CustomS3FeedStorage(S3FeedStorage): from botocore.stub import Stubber # noqa: PLC0415 stubs: list[Stubber] = [] def open(self, *args, **kwargs): from botocore import __version__ as botocore_version # noqa: PLC0415 from botocore.stub import ANY, Stubber # noqa: PLC0415 expected_params = { "Body": ANY, "Bucket": bucket, "Key": ANY, } if Version(botocore_version) >= Version("1.36.0"): expected_params["ChecksumAlgorithm"] = ANY stub = Stubber(self.s3_client) stub.activate() CustomS3FeedStorage.stubs.append(stub) stub.add_response( "put_object", expected_params=expected_params, service_response={}, ) return super().open(*args, **kwargs) key = "export.csv" uri = f"s3://{bucket}/{key}/%(batch_id)d.json" batch_item_count = 1 settings = { "AWS_ACCESS_KEY_ID": "access_key", "AWS_SECRET_ACCESS_KEY": "secret_key", "FEED_EXPORT_BATCH_ITEM_COUNT": batch_item_count, "FEED_STORAGES": { "s3": CustomS3FeedStorage, }, "FEEDS": { uri: { "format": "json", }, }, } class TestSpider(scrapy.Spider): name = "testspider" def parse(self, response): yield from items TestSpider.start_urls = [self.mockserver.url("/")] crawler = get_crawler(TestSpider, settings) yield crawler.crawl() assert len(CustomS3FeedStorage.stubs) == len(items) for stub in CustomS3FeedStorage.stubs: stub.assert_no_pending_responses() assert ( "feedexport/success_count/CustomS3FeedStorage" in crawler.stats.get_stats() ) assert ( crawler.stats.get_value("feedexport/success_count/CustomS3FeedStorage") == 3 ) scrapy-scrapy-5569de3/tests/test_feedexport_postprocess.py000066400000000000000000000454021525051012400242440ustar00rootroot00000000000000from __future__ import annotations import bz2 import gzip import lzma import marshal import pickle import sys from io import BytesIO from pathlib import Path from typing import TYPE_CHECKING, Any import pytest from scrapy.extensions.postprocessing import PostProcessingManager from scrapy.utils.test import get_crawler from tests.utils.bases.feedexport import TestFeedExportBase from tests.utils.decorators import coroutine_test from tests.utils.feedexport import path_to_url, printf_escape if TYPE_CHECKING: from scrapy import Spider class TestFeedPostProcessedExports(TestFeedExportBase): items = [{"foo": "bar"}] expected = b"foo\r\nbar\r\n" class MyPlugin1: def __init__(self, file, feed_options): self.file = file self.feed_options = feed_options self.char = self.feed_options.get("plugin1_char", b"") def write(self, data): written_count = self.file.write(data) written_count += self.file.write(self.char) return written_count def close(self): self.file.close() def _named_tempfile(self, name) -> str: return str(Path(self.temp_dir, name)) async def run_and_export( self, spider_cls: type[Spider], settings: dict[str, Any] ) -> dict[str, bytes | None]: """Run spider with specified settings; return exported data with filename.""" FEEDS = settings.get("FEEDS") or {} settings["FEEDS"] = { printf_escape(path_to_url(file_path)): feed_options for file_path, feed_options in FEEDS.items() } content: dict[str, bytes | None] = {} try: spider_cls.start_urls = [self.mockserver.url("/")] crawler = get_crawler(spider_cls, settings) await crawler.crawl_async() for file_path in FEEDS: content[str(file_path)] = ( Path(file_path).read_bytes() if Path(file_path).exists() else None ) finally: for file_path in FEEDS: if not Path(file_path).exists(): continue Path(file_path).unlink() return content def get_gzip_compressed( self, data: bytes, compresslevel: int = 9, mtime: int = 0, filename: str = "", ) -> bytes: data_stream = BytesIO() gzipf = gzip.GzipFile( fileobj=data_stream, filename=filename, mtime=mtime, compresslevel=compresslevel, mode="wb", ) gzipf.write(data) gzipf.close() data_stream.seek(0) return data_stream.read() def test_tell_reports_target_file_position(self): """Exporters that wrap the file they get, e.g. through :class:`io.TextIOWrapper`, need it to report a position.""" file = BytesIO() manager = PostProcessingManager([self.MyPlugin1], file, {}) assert manager.tell() == 0 manager.write(b"foo") assert manager.tell() == file.tell() == 3 @coroutine_test async def test_gzip_plugin(self): filename = self._named_tempfile("gzip_file") settings = { "FEEDS": { filename: { "format": "csv", "postprocessing": ["scrapy.extensions.postprocessing.GzipPlugin"], }, }, } data = await self.exported_data(self.items, settings) try: gzip.decompress(data[filename]) except OSError: pytest.fail("Received invalid gzip data.") @coroutine_test async def test_gzip_plugin_compresslevel(self): filename_to_compressed = { self._named_tempfile("compresslevel_0"): self.get_gzip_compressed( self.expected, compresslevel=0 ), self._named_tempfile("compresslevel_9"): self.get_gzip_compressed( self.expected, compresslevel=9 ), } settings = { "FEEDS": { self._named_tempfile("compresslevel_0"): { "format": "csv", "postprocessing": ["scrapy.extensions.postprocessing.GzipPlugin"], "gzip_compresslevel": 0, "gzip_mtime": 0, "gzip_filename": "", }, self._named_tempfile("compresslevel_9"): { "format": "csv", "postprocessing": ["scrapy.extensions.postprocessing.GzipPlugin"], "gzip_compresslevel": 9, "gzip_mtime": 0, "gzip_filename": "", }, }, } data = await self.exported_data(self.items, settings) for filename, compressed in filename_to_compressed.items(): result = gzip.decompress(data[filename]) assert compressed == data[filename] assert result == self.expected @coroutine_test async def test_gzip_plugin_mtime(self): filename_to_compressed = { self._named_tempfile("mtime_123"): self.get_gzip_compressed( self.expected, mtime=123 ), self._named_tempfile("mtime_123456789"): self.get_gzip_compressed( self.expected, mtime=123456789 ), } settings = { "FEEDS": { self._named_tempfile("mtime_123"): { "format": "csv", "postprocessing": ["scrapy.extensions.postprocessing.GzipPlugin"], "gzip_mtime": 123, "gzip_filename": "", }, self._named_tempfile("mtime_123456789"): { "format": "csv", "postprocessing": ["scrapy.extensions.postprocessing.GzipPlugin"], "gzip_mtime": 123456789, "gzip_filename": "", }, }, } data = await self.exported_data(self.items, settings) for filename, compressed in filename_to_compressed.items(): result = gzip.decompress(data[filename]) assert compressed == data[filename] assert result == self.expected @coroutine_test async def test_gzip_plugin_filename(self): filename_to_compressed = { self._named_tempfile("filename_FILE1"): self.get_gzip_compressed( self.expected, filename="FILE1" ), self._named_tempfile("filename_FILE2"): self.get_gzip_compressed( self.expected, filename="FILE2" ), } settings = { "FEEDS": { self._named_tempfile("filename_FILE1"): { "format": "csv", "postprocessing": ["scrapy.extensions.postprocessing.GzipPlugin"], "gzip_mtime": 0, "gzip_filename": "FILE1", }, self._named_tempfile("filename_FILE2"): { "format": "csv", "postprocessing": ["scrapy.extensions.postprocessing.GzipPlugin"], "gzip_mtime": 0, "gzip_filename": "FILE2", }, }, } data = await self.exported_data(self.items, settings) for filename, compressed in filename_to_compressed.items(): result = gzip.decompress(data[filename]) assert compressed == data[filename] assert result == self.expected @coroutine_test async def test_lzma_plugin(self): filename = self._named_tempfile("lzma_file") settings = { "FEEDS": { filename: { "format": "csv", "postprocessing": ["scrapy.extensions.postprocessing.LZMAPlugin"], }, }, } data = await self.exported_data(self.items, settings) try: lzma.decompress(data[filename]) except lzma.LZMAError: pytest.fail("Received invalid lzma data.") @coroutine_test async def test_lzma_plugin_format(self): filename_to_compressed = { self._named_tempfile("format_FORMAT_XZ"): lzma.compress( self.expected, format=lzma.FORMAT_XZ ), self._named_tempfile("format_FORMAT_ALONE"): lzma.compress( self.expected, format=lzma.FORMAT_ALONE ), } settings = { "FEEDS": { self._named_tempfile("format_FORMAT_XZ"): { "format": "csv", "postprocessing": ["scrapy.extensions.postprocessing.LZMAPlugin"], "lzma_format": lzma.FORMAT_XZ, }, self._named_tempfile("format_FORMAT_ALONE"): { "format": "csv", "postprocessing": ["scrapy.extensions.postprocessing.LZMAPlugin"], "lzma_format": lzma.FORMAT_ALONE, }, }, } data = await self.exported_data(self.items, settings) for filename, compressed in filename_to_compressed.items(): result = lzma.decompress(data[filename]) assert compressed == data[filename] assert result == self.expected @coroutine_test async def test_lzma_plugin_check(self): filename_to_compressed = { self._named_tempfile("check_CHECK_NONE"): lzma.compress( self.expected, check=lzma.CHECK_NONE ), self._named_tempfile("CHECK_SHA256"): lzma.compress( self.expected, check=lzma.CHECK_SHA256 ), } settings = { "FEEDS": { self._named_tempfile("check_CHECK_NONE"): { "format": "csv", "postprocessing": ["scrapy.extensions.postprocessing.LZMAPlugin"], "lzma_check": lzma.CHECK_NONE, }, self._named_tempfile("CHECK_SHA256"): { "format": "csv", "postprocessing": ["scrapy.extensions.postprocessing.LZMAPlugin"], "lzma_check": lzma.CHECK_SHA256, }, }, } data = await self.exported_data(self.items, settings) for filename, compressed in filename_to_compressed.items(): result = lzma.decompress(data[filename]) assert compressed == data[filename] assert result == self.expected @coroutine_test async def test_lzma_plugin_preset(self): filename_to_compressed = { self._named_tempfile("preset_PRESET_0"): lzma.compress( self.expected, preset=0 ), self._named_tempfile("preset_PRESET_9"): lzma.compress( self.expected, preset=9 ), } settings = { "FEEDS": { self._named_tempfile("preset_PRESET_0"): { "format": "csv", "postprocessing": ["scrapy.extensions.postprocessing.LZMAPlugin"], "lzma_preset": 0, }, self._named_tempfile("preset_PRESET_9"): { "format": "csv", "postprocessing": ["scrapy.extensions.postprocessing.LZMAPlugin"], "lzma_preset": 9, }, }, } data = await self.exported_data(self.items, settings) for filename, compressed in filename_to_compressed.items(): result = lzma.decompress(data[filename]) assert compressed == data[filename] assert result == self.expected @coroutine_test async def test_lzma_plugin_filters(self): if "PyPy" in sys.version: # https://foss.heptapod.net/pypy/pypy/-/issues/3527 pytest.skip("lzma filters doesn't work in PyPy") filters = [{"id": lzma.FILTER_LZMA2}] compressed = lzma.compress(self.expected, filters=filters) filename = self._named_tempfile("filters") settings = { "FEEDS": { filename: { "format": "csv", "postprocessing": ["scrapy.extensions.postprocessing.LZMAPlugin"], "lzma_filters": filters, }, }, } data = await self.exported_data(self.items, settings) assert compressed == data[filename] result = lzma.decompress(data[filename]) assert result == self.expected @coroutine_test async def test_bz2_plugin(self): filename = self._named_tempfile("bz2_file") settings = { "FEEDS": { filename: { "format": "csv", "postprocessing": ["scrapy.extensions.postprocessing.Bz2Plugin"], }, }, } data = await self.exported_data(self.items, settings) try: bz2.decompress(data[filename]) except OSError: pytest.fail("Received invalid bz2 data.") @coroutine_test async def test_bz2_plugin_compresslevel(self): filename_to_compressed = { self._named_tempfile("compresslevel_1"): bz2.compress( self.expected, compresslevel=1 ), self._named_tempfile("compresslevel_9"): bz2.compress( self.expected, compresslevel=9 ), } settings = { "FEEDS": { self._named_tempfile("compresslevel_1"): { "format": "csv", "postprocessing": ["scrapy.extensions.postprocessing.Bz2Plugin"], "bz2_compresslevel": 1, }, self._named_tempfile("compresslevel_9"): { "format": "csv", "postprocessing": ["scrapy.extensions.postprocessing.Bz2Plugin"], "bz2_compresslevel": 9, }, }, } data = await self.exported_data(self.items, settings) for filename, compressed in filename_to_compressed.items(): result = bz2.decompress(data[filename]) assert compressed == data[filename] assert result == self.expected @coroutine_test async def test_custom_plugin(self): filename = self._named_tempfile("csv_file") settings = { "FEEDS": { filename: { "format": "csv", "postprocessing": [self.MyPlugin1], }, }, } data = await self.exported_data(self.items, settings) assert data[filename] == self.expected @coroutine_test async def test_custom_plugin_with_parameter(self): expected = b"foo\r\n\nbar\r\n\n" filename = self._named_tempfile("newline") settings = { "FEEDS": { filename: { "format": "csv", "postprocessing": [self.MyPlugin1], "plugin1_char": b"\n", }, }, } data = await self.exported_data(self.items, settings) assert data[filename] == expected @coroutine_test async def test_custom_plugin_with_compression(self): expected = b"foo\r\n\nbar\r\n\n" filename_to_decompressor = { self._named_tempfile("bz2"): bz2.decompress, self._named_tempfile("lzma"): lzma.decompress, self._named_tempfile("gzip"): gzip.decompress, } settings = { "FEEDS": { self._named_tempfile("bz2"): { "format": "csv", "postprocessing": [ self.MyPlugin1, "scrapy.extensions.postprocessing.Bz2Plugin", ], "plugin1_char": b"\n", }, self._named_tempfile("lzma"): { "format": "csv", "postprocessing": [ self.MyPlugin1, "scrapy.extensions.postprocessing.LZMAPlugin", ], "plugin1_char": b"\n", }, self._named_tempfile("gzip"): { "format": "csv", "postprocessing": [ self.MyPlugin1, "scrapy.extensions.postprocessing.GzipPlugin", ], "plugin1_char": b"\n", }, }, } data = await self.exported_data(self.items, settings) for filename, decompressor in filename_to_decompressor.items(): result = decompressor(data[filename]) assert result == expected @coroutine_test async def test_exports_compatibility_with_postproc(self): filename_to_expected = { self._named_tempfile("csv"): b"foo\r\nbar\r\n", self._named_tempfile("json"): b'[\n{"foo": "bar"}\n]', self._named_tempfile("jsonlines"): b'{"foo": "bar"}\n', self._named_tempfile("xml"): b'\n' b"\nbar\n", } settings = { "FEEDS": { self._named_tempfile("csv"): { "format": "csv", "postprocessing": [self.MyPlugin1], # empty plugin to activate postprocessing.PostProcessingManager }, self._named_tempfile("json"): { "format": "json", "postprocessing": [self.MyPlugin1], }, self._named_tempfile("jsonlines"): { "format": "jsonlines", "postprocessing": [self.MyPlugin1], }, self._named_tempfile("xml"): { "format": "xml", "postprocessing": [self.MyPlugin1], }, self._named_tempfile("marshal"): { "format": "marshal", "postprocessing": [self.MyPlugin1], }, self._named_tempfile("pickle"): { "format": "pickle", "postprocessing": [self.MyPlugin1], }, }, } data = await self.exported_data(self.items, settings) for filename, data_bytes in data.items(): expected: Any result: Any if "pickle" in filename: expected, result = self.items[0], pickle.loads(data_bytes) elif "marshal" in filename: expected, result = self.items[0], marshal.loads(data_bytes) else: expected, result = filename_to_expected[filename], data_bytes assert result == expected scrapy-scrapy-5569de3/tests/test_feedexport_storages.py000066400000000000000000000556361525051012400235210ustar00rootroot00000000000000from __future__ import annotations import logging import os import string import sys import tempfile from io import BytesIO from pathlib import Path from ssl import SSLCertVerificationError from typing import IO, Any from unittest import mock from urllib.parse import quote import pytest from w3lib.url import path_to_file_uri import scrapy from scrapy.exceptions import NotConfigured from scrapy.extensions.feedexport import ( BlockingFeedStorage, FileFeedStorage, FTPFeedStorage, GCSFeedStorage, S3FeedStorage, StdoutFeedStorage, ) from scrapy.utils.defer import maybe_deferred_to_future from scrapy.utils.misc import build_from_crawler from scrapy.utils.test import get_crawler from tests.mockserver.ftp import MockFTPServer from tests.utils.cloud import mock_google_cloud_storage from tests.utils.decorators import coroutine_test class TestFileFeedStorage: def test_store_file_uri(self, tmp_path): path = tmp_path / "file.txt" uri = path_to_file_uri(str(path)) self._assert_stores(FileFeedStorage(uri), path) def test_store_file_uri_makedirs(self, tmp_path): path = tmp_path / "more" / "paths" / "file.txt" uri = path_to_file_uri(str(path)) self._assert_stores(FileFeedStorage(uri), path) def test_store_direct_path(self, tmp_path): path = tmp_path / "file.txt" self._assert_stores(FileFeedStorage(str(path)), path) def test_store_direct_path_relative(self, tmp_path): old_cwd = Path.cwd() try: os.chdir(tmp_path) path = Path("foo", "bar") self._assert_stores(FileFeedStorage(str(path)), path) finally: os.chdir(old_cwd) @staticmethod def _store(path: Path, feed_options: dict[str, Any] | None = None) -> None: storage = FileFeedStorage(str(path), feed_options=feed_options) spider = scrapy.Spider("default") file = storage.open(spider) file.write(b"content") storage.store(file) def test_append(self, tmp_path): path = tmp_path / "file.txt" self._store(path) self._assert_stores(FileFeedStorage(str(path)), path, b"contentcontent") def test_overwrite(self, tmp_path): path = tmp_path / "file.txt" self._store(path, {"overwrite": True}) self._assert_stores( FileFeedStorage(str(path), feed_options={"overwrite": True}), path ) @staticmethod def _assert_stores( storage: FileFeedStorage, path: Path, expected_content: bytes = b"content" ) -> None: spider = scrapy.Spider("default") file = storage.open(spider) file.write(b"content") storage.store(file) assert path.exists() try: assert path.read_bytes() == expected_content finally: path.unlink() def test_preserves_windows_path_without_file_scheme(self): path = r"C:\Users\user\Desktop\test.txt" storage = FileFeedStorage(path) assert storage.path == path def get_test_spider(settings: dict[str, Any] | None = None) -> scrapy.Spider: class TestSpider(scrapy.Spider): name = "test_spider" crawler = get_crawler(settings_dict=settings) return TestSpider.from_crawler(crawler) class TestFTPFeedStorage: async def _store( self, uri: str, content: bytes, feed_options: dict[str, Any] | None = None, settings: dict[str, Any] | None = None, ) -> None: crawler = get_crawler(settings_dict=settings or {}) storage = build_from_crawler( FTPFeedStorage, crawler, uri, feed_options=feed_options, ) spider = get_test_spider() file = storage.open(spider) file.write(content) await maybe_deferred_to_future(storage.store(file)) def _assert_stored(self, path: Path, content: bytes) -> None: assert path.exists() try: assert path.read_bytes() == content finally: path.unlink() @coroutine_test async def test_append(self): with MockFTPServer() as ftp_server: filename = "file" url = ftp_server.url(filename) feed_options = {"overwrite": False} await self._store(url, b"foo", feed_options=feed_options) await self._store(url, b"bar", feed_options=feed_options) self._assert_stored(ftp_server.path / filename, b"foobar") @coroutine_test async def test_overwrite(self): with MockFTPServer() as ftp_server: filename = "file" url = ftp_server.url(filename) await self._store(url, b"foo") await self._store(url, b"bar") self._assert_stored(ftp_server.path / filename, b"bar") @coroutine_test async def test_append_active_mode(self): with MockFTPServer() as ftp_server: settings = {"FEED_STORAGE_FTP_ACTIVE": True} filename = "file" url = ftp_server.url(filename) feed_options = {"overwrite": False} await self._store(url, b"foo", feed_options=feed_options, settings=settings) await self._store(url, b"bar", feed_options=feed_options, settings=settings) self._assert_stored(ftp_server.path / filename, b"foobar") @coroutine_test async def test_overwrite_active_mode(self): with MockFTPServer() as ftp_server: settings = {"FEED_STORAGE_FTP_ACTIVE": True} filename = "file" url = ftp_server.url(filename) await self._store(url, b"foo", settings=settings) await self._store(url, b"bar", settings=settings) self._assert_stored(ftp_server.path / filename, b"bar") @coroutine_test async def test_missing_parent_directories(self): with MockFTPServer() as ftp_server: path = "missing/parent/dirs/file" await self._store(ftp_server.url(path), b"foo") self._assert_stored(ftp_server.path / path, b"foo") @coroutine_test async def test_tls(self, monkeypatch): monkeypatch.setenv( "SSL_CERT_FILE", str(Path(__file__).parent / "keys" / "localhost.crt") ) with MockFTPServer(tls=True) as ftp_server: filename = "file" await self._store(ftp_server.url(filename), b"foo") self._assert_stored(ftp_server.path / filename, b"foo") @coroutine_test async def test_tls_untrusted_certificate(self): with ( MockFTPServer(tls=True) as ftp_server, pytest.raises(SSLCertVerificationError), ): await self._store(ftp_server.url("file"), b"foo") def test_uri_auth_quote(self): # RFC3986: 3.2.1. User Information pw_quoted = quote(string.punctuation, safe="") st = FTPFeedStorage(f"ftp://foo:{pw_quoted}@example.com/some_path") assert st.password == string.punctuation def test_uri_without_hostname(self): with pytest.raises( ValueError, match="Got a storage URI without a hostname: ftp:///some_path" ): FTPFeedStorage("ftp:///some_path") class MyBlockingFeedStorage(BlockingFeedStorage): def _store_in_thread(self, file: IO[bytes]) -> None: return class TestBlockingFeedStorage: def test_default_temp_dir(self): b = MyBlockingFeedStorage() with b.open(get_test_spider()) as storage_file: storage_dir = Path(storage_file.name).parent assert str(storage_dir) == tempfile.gettempdir() def test_temp_file(self, tmp_path): b = MyBlockingFeedStorage() spider = get_test_spider({"FEED_TEMPDIR": str(tmp_path)}) with b.open(spider) as storage_file: storage_dir = Path(storage_file.name).parent assert storage_dir == tmp_path def test_invalid_folder(self, tmp_path): b = MyBlockingFeedStorage() invalid_path = tmp_path / "invalid_path" spider = get_test_spider({"FEED_TEMPDIR": str(invalid_path)}) with pytest.raises(OSError, match="Not a Directory:"): b.open(spider=spider) def test_s3_without_boto3(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setitem(sys.modules, "boto3", None) monkeypatch.setitem(sys.modules, "boto3.session", None) with pytest.raises(NotConfigured, match="missing boto3 library"): S3FeedStorage("s3://mybucket/export.csv", "access_key", "secret_key") @pytest.mark.requires_boto3 class TestS3FeedStorage: def test_parse_credentials(self): aws_credentials = { "AWS_ACCESS_KEY_ID": "settings_key", "AWS_SECRET_ACCESS_KEY": "settings_secret", "AWS_SESSION_TOKEN": "settings_token", } crawler = get_crawler(settings_dict=aws_credentials) # Instantiate with crawler storage = build_from_crawler( S3FeedStorage, crawler, "s3://mybucket/export.csv", ) assert storage.access_key == "settings_key" assert storage.secret_key == "settings_secret" assert storage.session_token == "settings_token" # Instantiate directly storage = S3FeedStorage( "s3://mybucket/export.csv", aws_credentials["AWS_ACCESS_KEY_ID"], aws_credentials["AWS_SECRET_ACCESS_KEY"], session_token=aws_credentials["AWS_SESSION_TOKEN"], ) assert storage.access_key == "settings_key" assert storage.secret_key == "settings_secret" assert storage.session_token == "settings_token" # URI priority > settings priority storage = S3FeedStorage( "s3://uri_key:uri_secret@mybucket/export.csv", aws_credentials["AWS_ACCESS_KEY_ID"], aws_credentials["AWS_SECRET_ACCESS_KEY"], ) assert storage.access_key == "uri_key" assert storage.secret_key == "uri_secret" @coroutine_test async def test_store(self): settings = { "AWS_ACCESS_KEY_ID": "access_key", "AWS_SECRET_ACCESS_KEY": "secret_key", } crawler = get_crawler(settings_dict=settings) bucket = "mybucket" key = "export.csv" storage = build_from_crawler(S3FeedStorage, crawler, f"s3://{bucket}/{key}") file = mock.MagicMock() storage.s3_client = mock.MagicMock() await maybe_deferred_to_future(storage.store(file)) assert storage.s3_client.upload_fileobj.call_args == mock.call( Bucket=bucket, Key=key, Fileobj=file ) def test_init_without_acl(self): storage = S3FeedStorage("s3://mybucket/export.csv", "access_key", "secret_key") assert storage.access_key == "access_key" assert storage.secret_key == "secret_key" assert storage.acl is None def test_init_with_acl(self): storage = S3FeedStorage( "s3://mybucket/export.csv", "access_key", "secret_key", "custom-acl" ) assert storage.access_key == "access_key" assert storage.secret_key == "secret_key" assert storage.acl == "custom-acl" def test_init_with_endpoint_url(self): storage = S3FeedStorage( "s3://mybucket/export.csv", "access_key", "secret_key", endpoint_url="https://example.com", ) assert storage.access_key == "access_key" assert storage.secret_key == "secret_key" assert storage.endpoint_url == "https://example.com" def test_init_with_region_name(self): region_name = "ap-east-1" storage = S3FeedStorage( "s3://mybucket/export.csv", "access_key", "secret_key", region_name=region_name, ) assert storage.access_key == "access_key" assert storage.secret_key == "secret_key" assert storage.region_name == region_name assert storage.s3_client._client_config.region_name == region_name # type: ignore[attr-defined] def test_from_crawler_without_acl(self): settings = { "AWS_ACCESS_KEY_ID": "access_key", "AWS_SECRET_ACCESS_KEY": "secret_key", } crawler = get_crawler(settings_dict=settings) storage = build_from_crawler( S3FeedStorage, crawler, "s3://mybucket/export.csv", ) assert storage.access_key == "access_key" assert storage.secret_key == "secret_key" assert storage.acl is None def test_without_endpoint_url(self): settings = { "AWS_ACCESS_KEY_ID": "access_key", "AWS_SECRET_ACCESS_KEY": "secret_key", } crawler = get_crawler(settings_dict=settings) storage = build_from_crawler( S3FeedStorage, crawler, "s3://mybucket/export.csv", ) assert storage.access_key == "access_key" assert storage.secret_key == "secret_key" assert storage.endpoint_url is None def test_without_region_name(self): settings = { "AWS_ACCESS_KEY_ID": "access_key", "AWS_SECRET_ACCESS_KEY": "secret_key", } crawler = get_crawler(settings_dict=settings) storage = build_from_crawler( S3FeedStorage, crawler, "s3://mybucket/export.csv", ) assert storage.access_key == "access_key" assert storage.secret_key == "secret_key" assert storage.s3_client._client_config.region_name == "us-east-1" # type: ignore[attr-defined] def test_from_crawler_with_acl(self): settings = { "AWS_ACCESS_KEY_ID": "access_key", "AWS_SECRET_ACCESS_KEY": "secret_key", "FEED_STORAGE_S3_ACL": "custom-acl", } crawler = get_crawler(settings_dict=settings) storage = build_from_crawler( S3FeedStorage, crawler, "s3://mybucket/export.csv", ) assert storage.access_key == "access_key" assert storage.secret_key == "secret_key" assert storage.acl == "custom-acl" def test_from_crawler_with_endpoint_url(self): settings = { "AWS_ACCESS_KEY_ID": "access_key", "AWS_SECRET_ACCESS_KEY": "secret_key", "AWS_ENDPOINT_URL": "https://example.com", } crawler = get_crawler(settings_dict=settings) storage = build_from_crawler(S3FeedStorage, crawler, "s3://mybucket/export.csv") assert storage.access_key == "access_key" assert storage.secret_key == "secret_key" assert storage.endpoint_url == "https://example.com" def test_from_crawler_with_region_name(self): region_name = "ap-east-1" settings = { "AWS_ACCESS_KEY_ID": "access_key", "AWS_SECRET_ACCESS_KEY": "secret_key", "AWS_REGION_NAME": region_name, } crawler = get_crawler(settings_dict=settings) storage = build_from_crawler(S3FeedStorage, crawler, "s3://mybucket/export.csv") assert storage.access_key == "access_key" assert storage.secret_key == "secret_key" assert storage.region_name == region_name assert storage.s3_client._client_config.region_name == region_name # type: ignore[attr-defined] def test_init_without_max_pool_connections(self) -> None: storage = S3FeedStorage("s3://mybucket/export.csv", "access_key", "secret_key") assert storage.max_pool_connections is None config: Any = storage.s3_client.meta.config assert config.max_pool_connections == 10 def test_init_with_max_pool_connections(self) -> None: storage = S3FeedStorage( "s3://mybucket/export.csv", "access_key", "secret_key", max_pool_connections=30, ) assert storage.max_pool_connections == 30 config: Any = storage.s3_client.meta.config assert config.max_pool_connections == 30 @pytest.mark.parametrize( ("settings", "expected"), [ ({}, 10), ({"REACTOR_THREADPOOL_MAXSIZE": 20}, 20), ({"AWS_MAX_POOL_CONNECTIONS": 30}, 30), ({"AWS_MAX_POOL_CONNECTIONS": 30, "REACTOR_THREADPOOL_MAXSIZE": 20}, 30), ], ) def test_from_crawler_max_pool_connections( self, settings: dict[str, Any], expected: int ) -> None: crawler = get_crawler(settings_dict=settings) storage = build_from_crawler(S3FeedStorage, crawler, "s3://mybucket/export.csv") assert storage.max_pool_connections == expected config: Any = storage.s3_client.meta.config assert config.max_pool_connections == expected @coroutine_test async def test_store_without_acl(self): storage = S3FeedStorage( "s3://mybucket/export.csv", "access_key", "secret_key", ) assert storage.access_key == "access_key" assert storage.secret_key == "secret_key" assert storage.acl is None storage.s3_client = mock.MagicMock() await maybe_deferred_to_future(storage.store(BytesIO(b"test file"))) acl = ( storage.s3_client.upload_fileobj.call_args[1] .get("ExtraArgs", {}) .get("ACL") ) assert acl is None @coroutine_test async def test_store_with_acl(self): storage = S3FeedStorage( "s3://mybucket/export.csv", "access_key", "secret_key", "custom-acl" ) assert storage.access_key == "access_key" assert storage.secret_key == "secret_key" assert storage.acl == "custom-acl" storage.s3_client = mock.MagicMock() await maybe_deferred_to_future(storage.store(BytesIO(b"test file"))) acl = storage.s3_client.upload_fileobj.call_args[1]["ExtraArgs"]["ACL"] assert acl == "custom-acl" def test_overwrite_default(self, caplog: pytest.LogCaptureFixture) -> None: S3FeedStorage( "s3://mybucket/export.csv", "access_key", "secret_key", "custom-acl" ) assert "S3 does not support appending to files" not in caplog.text def test_overwrite_false(self, caplog: pytest.LogCaptureFixture) -> None: S3FeedStorage( "s3://mybucket/export.csv", "access_key", "secret_key", "custom-acl", feed_options={"overwrite": False}, ) assert "S3 does not support appending to files" in caplog.text class TestGCSFeedStorage: def test_parse_settings(self): pytest.importorskip("google.cloud.storage") settings = {"GCS_PROJECT_ID": "123", "FEED_STORAGE_GCS_ACL": "publicRead"} crawler = get_crawler(settings_dict=settings) storage = build_from_crawler( GCSFeedStorage, crawler, "gs://mybucket/export.csv" ) assert storage.project_id == "123" assert storage.acl == "publicRead" assert storage.bucket_name == "mybucket" assert storage.blob_name == "export.csv" def test_parse_empty_acl(self): pytest.importorskip("google.cloud.storage") settings: dict[str, Any] = {"GCS_PROJECT_ID": "123", "FEED_STORAGE_GCS_ACL": ""} crawler = get_crawler(settings_dict=settings) storage = build_from_crawler( GCSFeedStorage, crawler, "gs://mybucket/export.csv" ) assert storage.acl is None settings = {"GCS_PROJECT_ID": "123", "FEED_STORAGE_GCS_ACL": None} crawler = get_crawler(settings_dict=settings) storage = build_from_crawler( GCSFeedStorage, crawler, "gs://mybucket/export.csv" ) assert storage.acl is None @coroutine_test async def test_store(self): pytest.importorskip("google.cloud.storage") uri = "gs://mybucket/export.csv" project_id = "myproject-123" acl = "publicRead" (client_mock, bucket_mock, blob_mock) = mock_google_cloud_storage() with mock.patch("google.cloud.storage.Client") as m: m.return_value = client_mock f = mock.Mock() storage = GCSFeedStorage(uri, project_id, acl) await maybe_deferred_to_future(storage.store(f)) f.seek.assert_called_once_with(0) m.assert_called_once_with(project=project_id) client_mock.bucket.assert_called_once_with("mybucket") bucket_mock.blob.assert_called_once_with("export.csv") blob_mock.upload_from_file.assert_called_once_with(f, predefined_acl=acl) f.close.assert_called_once_with() @coroutine_test async def test_store_closes_file_on_upload_error(self): pytest.importorskip("google.cloud.storage") uri = "gs://mybucket/export.csv" project_id = "myproject-123" acl = "publicRead" (client_mock, bucket_mock, blob_mock) = mock_google_cloud_storage() blob_mock.upload_from_file.side_effect = OSError("Upload failed") with mock.patch("google.cloud.storage.Client") as m: m.return_value = client_mock f = mock.Mock() storage = GCSFeedStorage(uri, project_id, acl) with pytest.raises(OSError, match="Upload failed"): await maybe_deferred_to_future(storage.store(f)) f.seek.assert_called_once_with(0) m.assert_called_once_with(project=project_id) client_mock.bucket.assert_called_once_with("mybucket") bucket_mock.blob.assert_called_once_with("export.csv") blob_mock.upload_from_file.assert_called_once_with(f, predefined_acl=acl) f.close.assert_called_once_with() def test_overwrite_default(self, caplog: pytest.LogCaptureFixture): with caplog.at_level(logging.DEBUG): GCSFeedStorage("gs://mybucket/export.csv", "myproject-123", "custom-acl") assert "GCS does not support appending to files" not in caplog.text def test_overwrite_false(self, caplog: pytest.LogCaptureFixture): with caplog.at_level(logging.DEBUG): GCSFeedStorage( "gs://mybucket/export.csv", "myproject-123", "custom-acl", feed_options={"overwrite": False}, ) assert "GCS does not support appending to files" in caplog.text class TestStdoutFeedStorage: def test_store(self): out = BytesIO() storage = StdoutFeedStorage("stdout:", _stdout=out) file = storage.open(scrapy.Spider("default")) file.write(b"content") storage.store(file) assert out.getvalue() == b"content" def test_overwrite_default(self, caplog: pytest.LogCaptureFixture): with caplog.at_level(logging.DEBUG): StdoutFeedStorage("stdout:") assert ( "Standard output (stdout) storage does not support overwriting" not in caplog.text ) def test_overwrite_true(self, caplog: pytest.LogCaptureFixture): with caplog.at_level(logging.DEBUG): StdoutFeedStorage("stdout:", feed_options={"overwrite": True}) assert ( "Standard output (stdout) storage does not support overwriting" in caplog.text ) scrapy-scrapy-5569de3/tests/test_feedexport_uri_params.py000066400000000000000000000117031525051012400240170ustar00rootroot00000000000000from __future__ import annotations import warnings from abc import ABC, abstractmethod from typing import TYPE_CHECKING, Any import pytest import scrapy from scrapy.exceptions import ScrapyDeprecationWarning from scrapy.extensions.feedexport import FeedExporter from scrapy.utils.test import get_crawler if TYPE_CHECKING: from collections.abc import Callable from scrapy.crawler import Crawler class TestURIParams(ABC): spider_name = "uri_params_spider" deprecated_options = False @abstractmethod def build_settings( self, uri: str = "file:///tmp/foobar", uri_params: Callable[..., dict[str, Any] | None] | None = None, ) -> dict[str, Any]: raise NotImplementedError def _crawler_feed_exporter( self, settings: dict[str, Any] ) -> tuple[Crawler, FeedExporter]: if self.deprecated_options: with pytest.warns( ScrapyDeprecationWarning, match="The `FEED_URI` and `FEED_FORMAT` settings have been deprecated", ): crawler = get_crawler(settings_dict=settings) else: crawler = get_crawler(settings_dict=settings) feed_exporter = crawler.get_extension(FeedExporter) assert feed_exporter is not None return crawler, feed_exporter def test_default(self): settings = self.build_settings( uri="file:///tmp/%(name)s", ) crawler, feed_exporter = self._crawler_feed_exporter(settings) spider = scrapy.Spider.from_crawler(crawler, self.spider_name) with warnings.catch_warnings(): warnings.simplefilter("error", ScrapyDeprecationWarning) feed_exporter.open_spider(spider) assert feed_exporter.slots[0].uri == f"file:///tmp/{self.spider_name}" def test_none(self): def uri_params(params, spider): pass settings = self.build_settings( uri="file:///tmp/%(name)s", uri_params=uri_params, ) crawler, feed_exporter = self._crawler_feed_exporter(settings) spider = scrapy.Spider.from_crawler(crawler, self.spider_name) feed_exporter.open_spider(spider) assert feed_exporter.slots[0].uri == f"file:///tmp/{self.spider_name}" def test_empty_dict(self): def uri_params(params, spider): return {} settings = self.build_settings( uri="file:///tmp/%(name)s", uri_params=uri_params, ) crawler, feed_exporter = self._crawler_feed_exporter(settings) spider = scrapy.Spider.from_crawler(crawler, self.spider_name) with warnings.catch_warnings(): warnings.simplefilter("error", ScrapyDeprecationWarning) with pytest.raises(KeyError): feed_exporter.open_spider(spider) def test_params_as_is(self): def uri_params(params, spider): return params settings = self.build_settings( uri="file:///tmp/%(name)s", uri_params=uri_params, ) crawler, feed_exporter = self._crawler_feed_exporter(settings) spider = scrapy.Spider.from_crawler(crawler, self.spider_name) with warnings.catch_warnings(): warnings.simplefilter("error", ScrapyDeprecationWarning) feed_exporter.open_spider(spider) assert feed_exporter.slots[0].uri == f"file:///tmp/{self.spider_name}" def test_custom_param(self): def uri_params(params, spider): return {**params, "foo": self.spider_name} settings = self.build_settings( uri="file:///tmp/%(foo)s", uri_params=uri_params, ) crawler, feed_exporter = self._crawler_feed_exporter(settings) spider = scrapy.Spider.from_crawler(crawler, self.spider_name) with warnings.catch_warnings(): warnings.simplefilter("error", ScrapyDeprecationWarning) feed_exporter.open_spider(spider) assert feed_exporter.slots[0].uri == f"file:///tmp/{self.spider_name}" class TestURIParamsSetting(TestURIParams): deprecated_options = True def build_settings( self, uri: str = "file:///tmp/foobar", uri_params: Callable[..., dict[str, Any] | None] | None = None, ) -> dict[str, Any]: extra_settings: dict[str, Any] = {} if uri_params: extra_settings["FEED_URI_PARAMS"] = uri_params return { "FEED_URI": uri, **extra_settings, } class TestURIParamsFeedOption(TestURIParams): deprecated_options = False def build_settings( self, uri: str = "file:///tmp/foobar", uri_params: Callable[..., dict[str, Any] | None] | None = None, ) -> dict[str, Any]: options: dict[str, Any] = { "format": "jl", } if uri_params: options["uri_params"] = uri_params return { "FEEDS": { uri: options, }, } scrapy-scrapy-5569de3/tests/test_http2_client_protocol.py000066400000000000000000000673241525051012400237620ustar00rootroot00000000000000from __future__ import annotations import json import random import re import string from ipaddress import IPv4Address from pathlib import Path from typing import TYPE_CHECKING, Any, cast from unittest import mock from urllib.parse import urlencode import pytest from pytest_twisted import async_yield_fixture from twisted.internet.defer import Deferred, DeferredList, inlineCallbacks from twisted.internet.endpoints import SSL4ClientEndpoint, SSL4ServerEndpoint from twisted.internet.ssl import Certificate, PrivateCertificate, optionsForClientTLS from twisted.internet.task import deferLater from twisted.web.client import URI, ResponseFailed from twisted.web.http import H2_ENABLED from twisted.web.http import Request as TxRequest from twisted.web.server import NOT_DONE_YET, Site from twisted.web.static import File from scrapy.exceptions import DownloadCancelledError, DownloadTimeoutError from scrapy.http import JsonRequest, Request, Response from scrapy.spiders import Spider from scrapy.utils.defer import ( deferred_f_from_coro_f, deferred_from_coro, maybe_deferred_to_future, ) from scrapy.utils.test import get_crawler from tests.mockserver.http_resources import LeafResource, Status, put_child from tests.mockserver.utils import ssl_context_factory if TYPE_CHECKING: from collections.abc import AsyncGenerator, Callable, Coroutine, Generator from scrapy.core._http2.protocol import H2ClientProtocol from scrapy.crawler import Crawler pytestmark = [ pytest.mark.requires_reactor, # H2ClientProtocol requires a reactor pytest.mark.skipif( not H2_ENABLED, reason="HTTP/2 support in Twisted is not enabled" ), ] def generate_random_string(size: int) -> str: return "".join(random.choices(string.ascii_uppercase + string.digits, k=size)) def make_html_body(val: str) -> bytes: response = f"""

    Hello from HTTP2

    {val}

    """ return bytes(response, "utf-8") class DummySpider(Spider): name = "dummy" start_urls = [] def parse(self, response): print(response) class Data: SMALL_SIZE = 1024 # 1 KB LARGE_SIZE = 1024**2 # 1 MB STR_SMALL = generate_random_string(SMALL_SIZE) STR_LARGE = generate_random_string(LARGE_SIZE) EXTRA_SMALL = generate_random_string(1024 * 15) EXTRA_LARGE = generate_random_string(LARGE_SIZE) HTML_SMALL = make_html_body(STR_SMALL) HTML_LARGE = make_html_body(STR_LARGE) JSON_SMALL = {"data": STR_SMALL} JSON_LARGE = {"data": STR_LARGE} DATALOSS = b"Dataloss Content" NO_CONTENT_LENGTH = b"This response do not have any content-length header" class GetDataHtmlSmall(LeafResource): def render_GET(self, request: TxRequest): request.setHeader("Content-Type", "text/html; charset=UTF-8") return Data.HTML_SMALL class GetDataHtmlLarge(LeafResource): def render_GET(self, request: TxRequest): request.setHeader("Content-Type", "text/html; charset=UTF-8") return Data.HTML_LARGE class PostDataJsonMixin: @staticmethod def make_response(request: TxRequest, extra_data: str) -> bytes: assert request.content is not None response = { "request-headers": {}, "request-body": json.loads(request.content.read()), "extra-data": extra_data, } for k, v in request.requestHeaders.getAllRawHeaders(): response["request-headers"][str(k, "utf-8")] = str(v[0], "utf-8") response_bytes = bytes(json.dumps(response), "utf-8") request.setHeader("Content-Type", "application/json; charset=UTF-8") request.setHeader("Content-Encoding", "UTF-8") return response_bytes class PostDataJsonSmall(LeafResource, PostDataJsonMixin): def render_POST(self, request: TxRequest): return self.make_response(request, Data.EXTRA_SMALL) class PostDataJsonLarge(LeafResource, PostDataJsonMixin): def render_POST(self, request: TxRequest): return self.make_response(request, Data.EXTRA_LARGE) class Dataloss(LeafResource): def render_GET(self, request: TxRequest): request.setHeader(b"Content-Length", b"1024") self.deferRequest(request, 0, self._delayed_render, request) return NOT_DONE_YET @staticmethod def _delayed_render(request: TxRequest): request.write(Data.DATALOSS) request.finish() class NoContentLengthHeader(LeafResource): def render_GET(self, request: TxRequest): request.responseHeaders.removeHeader("Content-Length") self.deferRequest(request, 0, self._delayed_render, request) return NOT_DONE_YET @staticmethod def _delayed_render(request: TxRequest): request.write(Data.NO_CONTENT_LENGTH) request.finish() class TimeoutResponse(LeafResource): def render_GET(self, request: TxRequest): return NOT_DONE_YET class QueryParams(LeafResource): def render_GET(self, request: TxRequest): request.setHeader("Content-Type", "application/json; charset=UTF-8") request.setHeader("Content-Encoding", "UTF-8") 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") return bytes(json.dumps(query_params), "utf-8") class RequestHeaders(LeafResource): """Sends all the headers received as a response""" def render_GET(self, request: TxRequest): request.setHeader("Content-Type", "application/json; charset=UTF-8") request.setHeader("Content-Encoding", "UTF-8") headers = {} for k, v in request.requestHeaders.getAllRawHeaders(): headers[str(k, "utf-8")] = str(v[0], "utf-8") return bytes(json.dumps(headers), "utf-8") def make_request_dfd(client: H2ClientProtocol, request: Request) -> Deferred[Response]: return client.request(request, DummySpider()) async def make_request(client: H2ClientProtocol, request: Request) -> Response: return await maybe_deferred_to_future(make_request_dfd(client, request)) class TestHttps2ClientProtocol: scheme = "https" host = "localhost" key_file = Path(__file__).parent / "keys" / "localhost.key" certificate_file = Path(__file__).parent / "keys" / "localhost.crt" @pytest.fixture def site(self, tmp_path): r = File(str(tmp_path)) put_child(r, b"get-data-html-small", GetDataHtmlSmall()) put_child(r, b"get-data-html-large", GetDataHtmlLarge()) put_child(r, b"post-data-json-small", PostDataJsonSmall()) put_child(r, b"post-data-json-large", PostDataJsonLarge()) put_child(r, b"dataloss", Dataloss()) put_child(r, b"no-content-length-header", NoContentLengthHeader()) put_child(r, b"status", Status()) put_child(r, b"query-params", QueryParams()) put_child(r, b"timeout", TimeoutResponse()) put_child(r, b"request-headers", RequestHeaders()) return Site(r, timeout=None) @async_yield_fixture # type: ignore[untyped-decorator] async def server_port(self, site: Site) -> AsyncGenerator[int]: from twisted.internet import reactor context_factory = ssl_context_factory( str(self.key_file), str(self.certificate_file) ) server_endpoint = SSL4ServerEndpoint( reactor, 0, context_factory, interface=self.host ) server = await server_endpoint.listen(site) yield server.getHost().port await server.stopListening() @pytest.fixture def client_certificate(self) -> PrivateCertificate: pem = self.key_file.read_text( encoding="utf-8" ) + self.certificate_file.read_text(encoding="utf-8") return PrivateCertificate.loadPEM(pem) # type: ignore[no-any-return] @pytest.fixture def crawler(self, request: pytest.FixtureRequest) -> Crawler: return get_crawler(settings_dict=getattr(request, "param", None)) @async_yield_fixture # type: ignore[untyped-decorator] async def client( self, server_port: int, client_certificate: PrivateCertificate, crawler: Crawler, ) -> AsyncGenerator[H2ClientProtocol]: from twisted.internet import reactor from scrapy.core._http2.protocol import H2ClientFactory # noqa: PLC0415 client_options = optionsForClientTLS( hostname=self.host, trustRoot=client_certificate, acceptableProtocols=[b"h2"], ) uri = URI.fromBytes(bytes(self.get_url(server_port, "/"), "utf-8")) h2_client_factory = H2ClientFactory(uri, crawler, Deferred()) client_endpoint = SSL4ClientEndpoint( reactor, self.host, server_port, client_options ) client = await client_endpoint.connect(h2_client_factory) yield client if not client.transport.disconnecting: client.transport.loseConnection() client.transport.abortConnection() def get_url(self, portno: int, path: str) -> str: """ :param path: Should have / at the starting compulsorily if not empty :return: Complete url """ assert len(path) > 0 assert path[0] == "/" or path[0] == "&" return f"{self.scheme}://{self.host}:{portno}{path}" @staticmethod async def _check_repeat( get_coro: Callable[[], Coroutine[Any, Any, None]], count: int ) -> None: d_list = [] for _ in range(count): d = deferred_from_coro(get_coro()) d_list.append(d) await maybe_deferred_to_future(DeferredList(d_list, fireOnOneErrback=True)) async def _check_GET( self, client: H2ClientProtocol, request: Request, expected_body: bytes, expected_status: int, ) -> None: response = await make_request(client, request) assert response.status == expected_status assert response.body == expected_body content_length_header = response.headers.get("Content-Length") assert content_length_header is not None content_length = int(content_length_header) assert len(response.body) == content_length @deferred_f_from_coro_f async def test_GET_small_body( self, server_port: int, client: H2ClientProtocol ) -> None: request = Request(self.get_url(server_port, "/get-data-html-small")) await self._check_GET(client, request, Data.HTML_SMALL, 200) @deferred_f_from_coro_f async def test_GET_large_body( self, server_port: int, client: H2ClientProtocol ) -> None: request = Request(self.get_url(server_port, "/get-data-html-large")) await self._check_GET(client, request, Data.HTML_LARGE, 200) @pytest.mark.parametrize( "crawler", [{"HTTP2_MAX_FRAME_SIZE": 1024**2}], indirect=True ) @deferred_f_from_coro_f async def test_GET_large_frames( self, server_port: int, client: H2ClientProtocol ) -> None: request = Request(self.get_url(server_port, "/get-data-html-large")) await self._check_GET(client, request, Data.HTML_LARGE, 200) assert client.conn.local_settings.max_frame_size == 1024**2 async def _check_GET_x10( self, client: H2ClientProtocol, request: Request, expected_body: bytes, expected_status: int, ) -> None: async def get_coro() -> None: await self._check_GET(client, request, expected_body, expected_status) await self._check_repeat(get_coro, 10) @deferred_f_from_coro_f async def test_GET_small_body_x10( self, server_port: int, client: H2ClientProtocol ) -> None: await self._check_GET_x10( client, Request(self.get_url(server_port, "/get-data-html-small")), Data.HTML_SMALL, 200, ) @deferred_f_from_coro_f async def test_GET_large_body_x10( self, server_port: int, client: H2ClientProtocol ) -> None: await self._check_GET_x10( client, Request(self.get_url(server_port, "/get-data-html-large")), Data.HTML_LARGE, 200, ) @staticmethod async def _check_POST_json( client: H2ClientProtocol, request: Request, expected_request_body: dict[str, str], expected_extra_data: str, expected_status: int, ) -> None: response = await make_request(client, request) assert response.status == expected_status content_length_header = response.headers.get("Content-Length") assert content_length_header is not None content_length = int(content_length_header) assert len(response.body) == content_length # Parse the body content_encoding_header = response.headers[b"Content-Encoding"] assert content_encoding_header is not None content_encoding = str(content_encoding_header, "utf-8") body = json.loads(str(response.body, content_encoding)) assert "request-body" in body assert "extra-data" in body assert "request-headers" in body request_body = body["request-body"] assert request_body == expected_request_body extra_data = body["extra-data"] assert extra_data == expected_extra_data # Check if headers were sent successfully request_headers = body["request-headers"] for k, v in request.headers.items(): k_str = str(k, "utf-8") assert k_str in request_headers assert request_headers[k_str] == str(v[0], "utf-8") @deferred_f_from_coro_f async def test_POST_small_json( self, server_port: int, client: H2ClientProtocol ) -> None: request = JsonRequest( url=self.get_url(server_port, "/post-data-json-small"), method="POST", data=Data.JSON_SMALL, ) await self._check_POST_json( client, request, Data.JSON_SMALL, Data.EXTRA_SMALL, 200 ) @deferred_f_from_coro_f async def test_POST_large_json( self, server_port: int, client: H2ClientProtocol ) -> None: request = JsonRequest( url=self.get_url(server_port, "/post-data-json-large"), method="POST", data=Data.JSON_LARGE, ) await self._check_POST_json( client, request, Data.JSON_LARGE, Data.EXTRA_LARGE, 200 ) async def _check_POST_json_x10( self, client: H2ClientProtocol, request: Request, expected_request_body: dict[str, str], expected_extra_data: str, expected_status: int, ) -> None: async def get_coro() -> None: await self._check_POST_json( client, request, expected_request_body, expected_extra_data, expected_status, ) await self._check_repeat(get_coro, 10) @deferred_f_from_coro_f async def test_POST_small_json_x10( self, server_port: int, client: H2ClientProtocol ) -> None: request = JsonRequest( url=self.get_url(server_port, "/post-data-json-small"), method="POST", data=Data.JSON_SMALL, ) await self._check_POST_json_x10( client, request, Data.JSON_SMALL, Data.EXTRA_SMALL, 200 ) @deferred_f_from_coro_f async def test_POST_large_json_x10( self, server_port: int, client: H2ClientProtocol ) -> None: request = JsonRequest( url=self.get_url(server_port, "/post-data-json-large"), method="POST", data=Data.JSON_LARGE, ) await self._check_POST_json_x10( client, request, Data.JSON_LARGE, Data.EXTRA_LARGE, 200 ) @inlineCallbacks def test_invalid_negotiated_protocol( self, server_port: int, client: H2ClientProtocol ) -> Generator[Deferred[Any], Any, None]: with mock.patch("scrapy.core._http2.protocol.PROTOCOL_NAME", new=b"not-h2"): request = Request(url=self.get_url(server_port, "/status?n=200")) with pytest.raises(ResponseFailed): yield make_request_dfd(client, request) @inlineCallbacks def test_cancel_request( self, server_port: int, client: H2ClientProtocol ) -> Generator[Deferred[Any], Any, None]: request = Request(url=self.get_url(server_port, "/get-data-html-large")) d = make_request_dfd(client, request) d.cancel() response = cast("Response", (yield d)) assert response.status == 499 @deferred_f_from_coro_f async def test_download_maxsize_exceeded( self, caplog: pytest.LogCaptureFixture, server_port: int, client: H2ClientProtocol, ) -> None: request = Request( url=self.get_url(server_port, "/get-data-html-large"), meta={"download_maxsize": 1000}, ) with pytest.raises( DownloadCancelledError, match=r"Expected to receive \d+ bytes which is larger than download max size \(1000\)", ): await make_request(client, request) @inlineCallbacks def test_received_dataloss_response( self, server_port: int, client: H2ClientProtocol ) -> Generator[Deferred[Any], Any, None]: """In case when value of Header Content-Length != len(Received Data) ProtocolError is raised""" from h2.exceptions import InvalidBodyLengthError # noqa: PLC0415 request = Request(url=self.get_url(server_port, "/dataloss")) with pytest.raises(ResponseFailed) as exc_info: yield make_request_dfd(client, request) assert len(exc_info.value.reasons) > 0 assert any( isinstance(error, InvalidBodyLengthError) for error in exc_info.value.reasons ) @deferred_f_from_coro_f async def test_missing_content_length_header( self, server_port: int, client: H2ClientProtocol ) -> None: request = Request(url=self.get_url(server_port, "/no-content-length-header")) response = await make_request(client, request) assert response.status == 200 assert response.body == Data.NO_CONTENT_LENGTH assert "Content-Length" not in response.headers async def _check_log_warnsize( self, client: H2ClientProtocol, request: Request, warn_pattern: re.Pattern[str], expected_body: bytes, caplog: pytest.LogCaptureFixture, ) -> None: with caplog.at_level("WARNING", "scrapy.core._http2.stream"): response = await make_request(client, request) assert response.status == 200 assert response.body == expected_body # Check the warning is raised only once for this request assert len(re.findall(warn_pattern, caplog.text)) == 1 @deferred_f_from_coro_f async def test_log_expected_warnsize( self, server_port: int, client: H2ClientProtocol, caplog: pytest.LogCaptureFixture, ) -> None: request = Request( url=self.get_url(server_port, "/get-data-html-large"), meta={"download_warnsize": 1000}, ) warn_pattern = re.compile( rf"Expected to receive \d+ bytes which is larger than " rf"download warn size \(1000\) in request {request}" ) await self._check_log_warnsize( client, request, warn_pattern, Data.HTML_LARGE, caplog ) @deferred_f_from_coro_f async def test_log_received_warnsize( self, server_port: int, client: H2ClientProtocol, caplog: pytest.LogCaptureFixture, ) -> None: request = Request( url=self.get_url(server_port, "/no-content-length-header"), meta={"download_warnsize": 10}, ) warn_pattern = re.compile( rf"Received \d+ bytes which is larger than " rf"download warn size \(10\) in request {request}" ) await self._check_log_warnsize( client, request, warn_pattern, Data.NO_CONTENT_LENGTH, caplog ) @deferred_f_from_coro_f async def test_max_concurrent_streams( self, server_port: int, client: H2ClientProtocol ) -> None: """Send 500 requests at one to check if we can handle very large number of request. """ async def get_coro() -> None: await self._check_GET( client, Request(self.get_url(server_port, "/get-data-html-small")), Data.HTML_SMALL, 200, ) await self._check_repeat(get_coro, 500) @inlineCallbacks def test_inactive_stream( self, server_port: int, client: H2ClientProtocol ) -> Generator[Deferred[Any], Any, None]: """Here we send 110 requests considering the MAX_CONCURRENT_STREAMS by default is 100. After sending the first 100 requests we close the connection.""" d_list = [] def assert_inactive_stream(failure): assert failure.check(ResponseFailed) is not None from scrapy.core._http2.stream import InactiveStreamClosed # noqa: PLC0415 assert any( isinstance(e, InactiveStreamClosed) for e in failure.value.reasons ) # Send 100 request (we do not check the result) for _ in range(100): d = make_request_dfd( client, Request(self.get_url(server_port, "/get-data-html-small")) ) d.addBoth(lambda _: None) d_list.append(d) # Now send 10 extra request and save the response deferred in a list for _ in range(10): d = make_request_dfd( client, Request(self.get_url(server_port, "/get-data-html-small")) ) d.addCallback(lambda _: pytest.fail("This request should have failed")) d.addErrback(assert_inactive_stream) d_list.append(d) # Close the connection now to fire all the extra 10 requests errback # with InactiveStreamClosed assert client.transport client.transport.loseConnection() yield DeferredList(d_list, consumeErrors=True, fireOnOneErrback=True) @deferred_f_from_coro_f async def test_invalid_request_type(self, client: H2ClientProtocol): with pytest.raises(TypeError): await make_request(client, "https://InvalidDataTypePassed.com") # type: ignore[arg-type] @deferred_f_from_coro_f async def test_query_parameters( self, server_port: int, client: H2ClientProtocol ) -> None: params = { "a": generate_random_string(20), "b": generate_random_string(20), "c": generate_random_string(20), "d": generate_random_string(20), } request = Request( self.get_url(server_port, f"/query-params?{urlencode(params)}") ) response = await make_request(client, request) content_encoding_header = response.headers[b"Content-Encoding"] assert content_encoding_header is not None content_encoding = str(content_encoding_header, "utf-8") data = json.loads(str(response.body, content_encoding)) assert data == params @deferred_f_from_coro_f async def test_status_codes( self, server_port: int, client: H2ClientProtocol ) -> None: for status in [200, 404]: request = Request(self.get_url(server_port, f"/status?n={status}")) response = await make_request(client, request) assert response.status == status @pytest.mark.filterwarnings( r"ignore:.*You should use cryptography's X\.509 APIs:DeprecationWarning" ) @deferred_f_from_coro_f async def test_response_has_correct_certificate_ip_address( self, server_port: int, client: H2ClientProtocol, client_certificate: PrivateCertificate, ) -> None: request = Request(self.get_url(server_port, "/status?n=200")) response = await make_request(client, request) assert isinstance(response.certificate, Certificate) assert response.certificate.original is not None assert response.certificate.getIssuer() == client_certificate.getIssuer() assert response.certificate.getPublicKey().matches( client_certificate.getPublicKey() ) assert isinstance(response.ip_address, IPv4Address) assert str(response.ip_address) == "127.0.0.1" @staticmethod async def _check_invalid_netloc(client: H2ClientProtocol, url: str) -> None: from scrapy.core._http2.stream import InvalidHostname # noqa: PLC0415 request = Request(url) with pytest.raises(InvalidHostname) as exc_info: await make_request(client, request) error_msg = str(exc_info.value) assert "localhost" in error_msg assert "127.0.0.1" in error_msg assert str(request) in error_msg @deferred_f_from_coro_f async def test_invalid_hostname(self, client: H2ClientProtocol) -> None: await self._check_invalid_netloc( client, "https://notlocalhost.notlocalhostdomain" ) @deferred_f_from_coro_f async def test_invalid_host_port( self, server_port: int, client: H2ClientProtocol ) -> None: port = server_port + 1 await self._check_invalid_netloc(client, f"https://127.0.0.1:{port}") @deferred_f_from_coro_f async def test_connection_stays_with_invalid_requests( self, server_port: int, client: H2ClientProtocol ): await maybe_deferred_to_future(self.test_invalid_hostname(client)) await maybe_deferred_to_future(self.test_invalid_host_port(server_port, client)) await maybe_deferred_to_future(self.test_GET_small_body(server_port, client)) await maybe_deferred_to_future(self.test_POST_small_json(server_port, client)) @inlineCallbacks def test_connection_timeout( self, server_port: int, client: H2ClientProtocol ) -> Generator[Deferred[Any], Any, None]: request = Request(self.get_url(server_port, "/timeout")) # Update the timer to 1s to test connection timeout client.setTimeout(1) # type: ignore[no-untyped-call] with pytest.raises(ResponseFailed) as exc_info: yield make_request_dfd(client, request) for err in exc_info.value.reasons: from scrapy.core._http2.protocol import H2ClientProtocol # noqa: PLC0415 if isinstance(err, DownloadTimeoutError): assert ( f"Connection was IDLE for more than {H2ClientProtocol.IDLE_TIMEOUT}s" in str(err) ) break else: pytest.fail("No TimeoutError raised.") @deferred_f_from_coro_f async def test_connection_timeout_no_streams( self, client: H2ClientProtocol ) -> None: """A connection with no streams is closed when it times out.""" from twisted.internet import reactor client.setTimeout(0.1) # type: ignore[no-untyped-call] await maybe_deferred_to_future(deferLater(reactor, 0.5)) assert client.transport.disconnecting @deferred_f_from_coro_f async def test_request_headers_received( self, server_port: int, client: H2ClientProtocol ) -> None: request = Request( self.get_url(server_port, "/request-headers"), headers={"header-1": "header value 1", "header-2": "header value 2"}, ) response = await make_request(client, request) assert response.status == 200 response_headers = json.loads(str(response.body, "utf-8")) assert isinstance(response_headers, dict) for k, v in request.headers.items(): k_decoded, v_decoded = str(k, "utf-8"), str(v[0], "utf-8") assert k_decoded in response_headers assert v_decoded == response_headers[k_decoded] scrapy-scrapy-5569de3/tests/test_http_cookies.py000066400000000000000000000102021525051012400221140ustar00rootroot00000000000000from __future__ import annotations from http.cookiejar import DefaultCookiePolicy from scrapy.http import Request, Response from scrapy.http.cookies import CookieJar, WrappedRequest, WrappedResponse from scrapy.utils.httpobj import urlparse_cached class TestCookieJar: def setup_method(self): self.jar = CookieJar() self.request = Request("http://example.com/") self.response = Response( "http://example.com/", headers={"Set-Cookie": "name=value; Domain=example.com; Path=/"}, ) def test_extract_cookies(self): assert len(self.jar) == 0 self.jar.extract_cookies(self.response, self.request) assert len(self.jar) == 1 cookie = next(iter(self.jar)) assert cookie.name == "name" assert cookie.value == "value" assert ".example.com" in self.jar._cookies def test_make_cookies_and_set_cookie(self): cookies = self.jar.make_cookies(self.response, self.request) assert len(cookies) == 1 jar = CookieJar() for cookie in cookies: jar.set_cookie(cookie) assert len(jar) == 1 def test_clear(self): self.jar.extract_cookies(self.response, self.request) assert len(self.jar) == 1 self.jar.clear() assert len(self.jar) == 0 def test_clear_session_cookies(self): self.jar.extract_cookies(self.response, self.request) assert len(self.jar) == 1 self.jar.clear_session_cookies() assert len(self.jar) == 0 def test_set_policy(self): policy = DefaultCookiePolicy() self.jar.set_policy(policy) assert self.jar.jar._policy is policy # type: ignore[attr-defined] def test_check_expired_frequency(self): jar = CookieJar(check_expired_frequency=1) jar.add_cookie_header(self.request) assert jar.processed == 1 class TestWrappedRequest: def setup_method(self): self.request = Request( "http://www.example.com/page.html", headers={"Content-Type": "text/html"} ) self.wrapped = WrappedRequest(self.request) def test_get_full_url(self): assert self.wrapped.get_full_url() == self.request.url assert self.wrapped.full_url == self.request.url def test_get_host(self): assert self.wrapped.get_host() == urlparse_cached(self.request).netloc assert self.wrapped.host == urlparse_cached(self.request).netloc def test_get_type(self): assert self.wrapped.get_type() == urlparse_cached(self.request).scheme assert self.wrapped.type == urlparse_cached(self.request).scheme def test_is_unverifiable(self): assert not self.wrapped.is_unverifiable() assert not self.wrapped.unverifiable def test_get_origin_req_host(self): assert self.wrapped.origin_req_host == "www.example.com" def test_has_header(self): assert self.wrapped.has_header("content-type") assert not self.wrapped.has_header("xxxxx") def test_get_header(self): assert self.wrapped.get_header("content-type") == "text/html" assert self.wrapped.get_header("xxxxx", "def") == "def" assert self.wrapped.get_header("xxxxx") is None wrapped = WrappedRequest( Request( "http://www.example.com/page.html", headers={"empty-binary-header": b""} ) ) assert wrapped.get_header("empty-binary-header") == "" def test_header_items(self): assert self.wrapped.header_items() == [("Content-Type", ["text/html"])] def test_add_unredirected_header(self): self.wrapped.add_unredirected_header("hello", "world") assert self.request.headers["hello"] == b"world" class TestWrappedResponse: def setup_method(self): self.response = Response( "http://www.example.com/page.html", headers={"Content-TYpe": "text/html"} ) self.wrapped = WrappedResponse(self.response) def test_info(self): assert self.wrapped.info() is self.wrapped def test_get_all(self): # get_all result must be native string assert self.wrapped.get_all("content-type") == ["text/html"] scrapy-scrapy-5569de3/tests/test_http_headers.py000066400000000000000000000127721525051012400221110ustar00rootroot00000000000000import copy import pytest from scrapy.http import Headers def test_basics() -> None: h = Headers({"Content-Type": "text/html", "Content-Length": 1234}) assert h["Content-Type"] assert h["Content-Length"] with pytest.raises(KeyError): h["Accept"] assert h.get("Accept") is None assert h.getlist("Accept") == [] assert h.get("Accept", "*/*") == b"*/*" assert h.getlist("Accept", "*/*") == [b"*/*"] assert h.getlist("Accept", ["text/html", "images/jpeg"]) == [ b"text/html", b"images/jpeg", ] def test_single_value() -> None: h = Headers() h["Content-Type"] = "text/html" assert h["Content-Type"] == b"text/html" assert h.get("Content-Type") == b"text/html" assert h.getlist("Content-Type") == [b"text/html"] def test_multivalue() -> None: h = Headers() h["X-Forwarded-For"] = hlist = ["ip1", "ip2"] assert h["X-Forwarded-For"] == b"ip2" assert h.get("X-Forwarded-For") == b"ip2" assert h.getlist("X-Forwarded-For") == [b"ip1", b"ip2"] assert h.getlist("X-Forwarded-For") is not hlist # type: ignore[comparison-overlap] def test_multivalue_for_one_header() -> None: h = Headers((("a", "b"), ("a", "c"))) assert h["a"] == b"c" assert h.get("a") == b"c" assert h.getlist("a") == [b"b", b"c"] def test_encode_utf8() -> None: h = Headers({"key": "\xa3"}, encoding="utf-8") key, val = dict(h.items()).popitem() assert isinstance(key, bytes), key assert isinstance(val[0], bytes), val[0] assert val[0] == b"\xc2\xa3" def test_encode_latin1() -> None: h = Headers({"key": "\xa3"}, encoding="latin1") _, val = dict(h.items()).popitem() assert val[0] == b"\xa3" def test_encode_multiple() -> None: h = Headers({"key": ["\xa3"]}, encoding="utf-8") _, val = dict(h.items()).popitem() assert val[0] == b"\xc2\xa3" def test_delete_and_contains() -> None: h = Headers() h["Content-Type"] = "text/html" assert "Content-Type" in h del h["Content-Type"] assert "Content-Type" not in h def test_setdefault() -> None: h = Headers() hlist = ["ip1", "ip2"] olist = h.setdefault("X-Forwarded-For", hlist) assert h.getlist("X-Forwarded-For") is not hlist # type: ignore[comparison-overlap] assert h.getlist("X-Forwarded-For") is olist h = Headers() olist = h.setdefault("X-Forwarded-For", "ip1") assert h.getlist("X-Forwarded-For") == [b"ip1"] assert h.getlist("X-Forwarded-For") is olist def test_fromkeys() -> None: h = Headers.fromkeys(("Content-Type", "Content-Length"), "value") assert h.getlist("Content-Type") == [b"value"] assert h.getlist("Content-Length") == [b"value"] def test_iterables() -> None: idict = {"Content-Type": "text/html", "X-Forwarded-For": ["ip1", "ip2"]} h = Headers(idict) assert dict(h.items()) == { b"Content-Type": [b"text/html"], b"X-Forwarded-For": [b"ip1", b"ip2"], } assert sorted(h.keys()) == [b"Content-Type", b"X-Forwarded-For"] assert sorted(h.items()) == [ (b"Content-Type", [b"text/html"]), (b"X-Forwarded-For", [b"ip1", b"ip2"]), ] assert set(h.values()) == {b"ip2", b"text/html"} def test_update() -> None: h = Headers() h.update({"Content-Type": "text/html", "X-Forwarded-For": ["ip1", "ip2"]}) assert h.getlist("Content-Type") == [b"text/html"] assert h.getlist("X-Forwarded-For") == [b"ip1", b"ip2"] def test_copy() -> None: h1 = Headers({"header1": ["value1", "value2"]}) h2 = copy.copy(h1) assert h1 == h2 assert h1.getlist("header1") == h2.getlist("header1") assert h1.getlist("header1") is not h2.getlist("header1") assert isinstance(h2, Headers) def test_appendlist() -> None: h1 = Headers({"header1": "value1"}) h1.appendlist("header1", "value3") assert h1.getlist("header1") == [b"value1", b"value3"] h1 = Headers() h1.appendlist("header1", "value1") h1.appendlist("header1", "value3") assert h1.getlist("header1") == [b"value1", b"value3"] def test_setlist() -> None: h1 = Headers({"header1": "value1"}) assert h1.getlist("header1") == [b"value1"] h1.setlist("header1", [b"value2", b"value3"]) assert h1.getlist("header1") == [b"value2", b"value3"] def test_setlistdefault() -> None: h1 = Headers({"header1": "value1"}) h1.setlistdefault("header1", ["value2", "value3"]) h1.setlistdefault("header2", ["value2", "value3"]) assert h1.getlist("header1") == [b"value1"] assert h1.getlist("header2") == [b"value2", b"value3"] def test_none_value() -> None: h1 = Headers() h1["foo"] = "bar" h1["foo"] = None h1.setdefault("foo", "bar") assert h1["foo"] is None assert h1.get("foo") is None assert h1.getlist("foo") == [] def test_int_value() -> None: h1 = Headers({"hey": 5}) h1["foo"] = 1 h1.setdefault("bar", 2) h1.setlist("buz", [1, "dos", 3]) assert h1.getlist("foo") == [b"1"] assert h1.getlist("bar") == [b"2"] assert h1.getlist("buz") == [b"1", b"dos", b"3"] assert h1.getlist("hey") == [b"5"] def test_invalid_value() -> None: with pytest.raises(TypeError, match="Unsupported value type"): Headers({"foo": object()}) with pytest.raises(TypeError, match="Unsupported value type"): Headers()["foo"] = object() with pytest.raises(TypeError, match="Unsupported value type"): Headers().setdefault("foo", object()) with pytest.raises(TypeError, match="Unsupported value type"): Headers().setlist("foo", [object()]) # type: ignore[list-item] scrapy-scrapy-5569de3/tests/test_http_request.py000066400000000000000000000027231525051012400221610ustar00rootroot00000000000000import xmlrpc.client from typing import Any import pytest from scrapy import Request from scrapy.http import XmlRpcRequest from scrapy.utils.python import to_bytes from tests.utils.bases.http_request import TestRequestBase class TestRequest(TestRequestBase): request_class = Request class TestXmlRpcRequest(TestRequestBase): request_class = XmlRpcRequest default_method = "POST" default_headers = {b"Content-Type": [b"text/xml"]} def _test_request(self, **kwargs: Any) -> None: r = self.request_class("http://scrapytest.org/rpc2", **kwargs) assert r.headers[b"Content-Type"] == b"text/xml" assert r.body == to_bytes( xmlrpc.client.dumps(**kwargs), encoding=kwargs.get("encoding", "utf-8") ) assert r.method == "POST" assert r.encoding == kwargs.get("encoding", "utf-8") assert r.dont_filter def test_xmlrpc_dumps(self): self._test_request(params=("value",)) self._test_request(params=("username", "password"), methodname="login") self._test_request(params=("response",), methodresponse="login") self._test_request(params=("pas£",), encoding="utf-8") self._test_request(params=(None,), allow_none=1) with pytest.raises(TypeError): self._test_request() with pytest.raises(TypeError): self._test_request(params=(None,)) def test_latin1(self): self._test_request(params=("pas£",), encoding="latin1") scrapy-scrapy-5569de3/tests/test_http_request_form.py000066400000000000000000001243531525051012400232100ustar00rootroot00000000000000from __future__ import annotations import re import warnings from typing import TYPE_CHECKING, Any from urllib.parse import parse_qs, unquote_to_bytes import pytest from scrapy.exceptions import ScrapyDeprecationWarning from scrapy.http import FormRequest, HtmlResponse from scrapy.utils.httpobj import urlparse_cached from scrapy.utils.python import to_unicode from tests.utils.bases.http_request import TestRequestBase if TYPE_CHECKING: from scrapy import Request def _buildresponse(body: bytes | str, **kwargs: Any) -> HtmlResponse: kwargs.setdefault("body", body) kwargs.setdefault("url", "http://example.com") kwargs.setdefault("encoding", "utf-8") return HtmlResponse(**kwargs) def _query_string(req: Request) -> bytes: return req.body if req.method == "POST" else req.url.partition("?")[2].encode() def _qs(req: Request) -> dict[bytes, list[bytes]]: return parse_qs(unquote_to_bytes(_query_string(req)), True) def _qs_unicode(req: Request, encoding: str = "utf-8") -> dict[str, list[str]]: qs = unquote_to_bytes(_query_string(req)).decode(encoding) return parse_qs(qs, True) def _assert_query_equal(first: bytes, second: bytes) -> None: assert sorted(to_unicode(first).split("&")) == sorted(to_unicode(second).split("&")) # FormRequest.from_response() is deprecated in favor of form2request, so the # many tests below that exercise it ignore the resulting deprecation warning. @pytest.mark.filterwarnings("ignore::scrapy.exceptions.ScrapyDeprecationWarning") class TestFormRequest(TestRequestBase): request_class = FormRequest def test_init_not_deprecated(self): # Building a request directly from form data is not deprecated. with warnings.catch_warnings(): warnings.simplefilter("error", ScrapyDeprecationWarning) self.request_class( "http://www.example.com", formdata={"a": "1"}, method="POST" ) self.request_class( "http://www.example.com", method="GET", formdata={"a": "1"} ) def test_from_response_deprecated(self): response = _buildresponse( """
    """ ) with pytest.warns( ScrapyDeprecationWarning, match=r"FormRequest\.from_response\(\)" ): self.request_class.from_response(response) def test_empty_formdata(self): r1 = self.request_class("http://www.example.com", formdata={}) assert r1.body == b"" def test_formdata_overrides_querystring(self): data = (("a", "one"), ("a", "two"), ("b", "2")) url = self.request_class( "http://www.example.com/?a=0&b=1&c=3#fragment", method="GET", formdata=data ).url.split("#", maxsplit=1)[0] fs = _qs(self.request_class(url, method="GET", formdata=data)) assert set(fs[b"a"]) == {b"one", b"two"} assert fs[b"b"] == [b"2"] assert fs.get(b"c") is None mapping = {"a": "1", "b": "2"} fs = _qs( self.request_class( "http://www.example.com/", method="GET", formdata=mapping ) ) assert fs[b"a"] == [b"1"] assert fs[b"b"] == [b"2"] def test_default_encoding_bytes(self): # using default encoding (utf-8) data: dict[Any, Any] = {b"one": b"two", b"price": b"\xc2\xa3 100"} r2 = self.request_class("http://www.example.com", formdata=data) assert r2.method == "POST" assert r2.encoding == "utf-8" _assert_query_equal(r2.body, b"price=%C2%A3+100&one=two") assert r2.headers[b"Content-Type"] == b"application/x-www-form-urlencoded" def test_default_encoding_textual_data(self): # using default encoding (utf-8) data = {"µ one": "two", "price": "£ 100"} r2 = self.request_class("http://www.example.com", formdata=data) assert r2.method == "POST" assert r2.encoding == "utf-8" _assert_query_equal(r2.body, b"price=%C2%A3+100&%C2%B5+one=two") assert r2.headers[b"Content-Type"] == b"application/x-www-form-urlencoded" def test_default_encoding_mixed_data(self): # using default encoding (utf-8) data: dict[Any, Any] = {"\u00b5one": b"two", b"price\xc2\xa3": "\u00a3 100"} r2 = self.request_class("http://www.example.com", formdata=data) assert r2.method == "POST" assert r2.encoding == "utf-8" _assert_query_equal(r2.body, b"%C2%B5one=two&price%C2%A3=%C2%A3+100") assert r2.headers[b"Content-Type"] == b"application/x-www-form-urlencoded" def test_custom_encoding_bytes(self): data: dict[Any, Any] = {b"\xb5 one": b"two", b"price": b"\xa3 100"} r2 = self.request_class( "http://www.example.com", formdata=data, encoding="latin1" ) assert r2.method == "POST" assert r2.encoding == "latin1" _assert_query_equal(r2.body, b"price=%A3+100&%B5+one=two") assert r2.headers[b"Content-Type"] == b"application/x-www-form-urlencoded" def test_custom_encoding_textual_data(self): data = {"price": "£ 100"} r3 = self.request_class( "http://www.example.com", formdata=data, encoding="latin1" ) assert r3.encoding == "latin1" assert r3.body == b"price=%A3+100" def test_multi_key_values(self): # using multiples values for a single key data = {"price": "\xa3 100", "colours": ["red", "blue", "green"]} r3 = self.request_class("http://www.example.com", formdata=data) _assert_query_equal( r3.body, b"colours=red&colours=blue&colours=green&price=%C2%A3+100" ) def test_from_response_post(self): response = _buildresponse( b"""
    """, url="http://www.example.com/this/list.html", ) req = self.request_class.from_response( response, formdata={"one": ["two", "three"], "six": "seven"} ) assert req.method == "POST" assert req.headers[b"Content-type"] == b"application/x-www-form-urlencoded" assert req.url == "http://www.example.com/this/post.php" fs = _qs(req) assert set(fs[b"test"]) == {b"val1", b"val2"} assert set(fs[b"one"]) == {b"two", b"three"} assert fs[b"test2"] == [b"xxx"] assert fs[b"six"] == [b"seven"] def test_from_response_post_nonascii_bytes_utf8(self): response = _buildresponse( b"""
    """, url="http://www.example.com/this/list.html", ) req = self.request_class.from_response( response, formdata={"one": ["two", "three"], "six": "seven"} ) assert req.method == "POST" assert req.headers[b"Content-type"] == b"application/x-www-form-urlencoded" assert req.url == "http://www.example.com/this/post.php" fs = _qs_unicode(req) assert set(fs["test £"]) == {"val1", "val2"} assert set(fs["one"]) == {"two", "three"} assert fs["test2"] == ["xxx µ"] assert fs["six"] == ["seven"] def test_from_response_post_nonascii_bytes_latin1(self): response = _buildresponse( b"""
    """, url="http://www.example.com/this/list.html", encoding="latin1", ) req = self.request_class.from_response( response, formdata={"one": ["two", "three"], "six": "seven"} ) assert req.method == "POST" assert req.headers[b"Content-type"] == b"application/x-www-form-urlencoded" assert req.url == "http://www.example.com/this/post.php" fs = _qs_unicode(req, encoding="latin1") assert set(fs["test £"]) == {"val1", "val2"} assert set(fs["one"]) == {"two", "three"} assert fs["test2"] == ["xxx µ"] assert fs["six"] == ["seven"] def test_from_response_post_nonascii_unicode(self): response = _buildresponse( """
    """, url="http://www.example.com/this/list.html", ) req = self.request_class.from_response( response, formdata={"one": ["two", "three"], "six": "seven"} ) assert req.method == "POST" assert req.headers[b"Content-type"] == b"application/x-www-form-urlencoded" assert req.url == "http://www.example.com/this/post.php" fs = _qs_unicode(req) assert set(fs["test £"]) == {"val1", "val2"} assert set(fs["one"]) == {"two", "three"} assert fs["test2"] == ["xxx µ"] assert fs["six"] == ["seven"] def test_from_response_duplicate_form_key(self): response = _buildresponse("
    ", url="http://www.example.com") req = self.request_class.from_response( response=response, method="GET", formdata=(("foo", "bar"), ("foo", "baz")), ) assert urlparse_cached(req).hostname == "www.example.com" assert urlparse_cached(req).query == "foo=bar&foo=baz" def test_from_response_override_duplicate_form_key(self): response = _buildresponse( """
    """ ) req = self.request_class.from_response( response, formdata=(("two", "2"), ("two", "4")) ) fs = _qs(req) assert fs[b"one"] == [b"1"] assert fs[b"two"] == [b"2", b"4"] def test_from_response_extra_headers(self): response = _buildresponse( """
    """ ) req = self.request_class.from_response( response=response, formdata={"one": ["two", "three"], "six": "seven"}, headers={"Accept-Encoding": "gzip,deflate"}, ) assert req.method == "POST" assert req.headers["Content-type"] == b"application/x-www-form-urlencoded" assert req.headers["Accept-Encoding"] == b"gzip,deflate" def test_from_response_get(self): response = _buildresponse( """
    """, url="http://www.example.com/this/list.html", ) r1 = self.request_class.from_response( response, formdata={"one": ["two", "three"], "six": "seven"} ) assert r1.method == "GET" assert urlparse_cached(r1).hostname == "www.example.com" assert urlparse_cached(r1).path == "/this/get.php" fs = _qs(r1) assert set(fs[b"test"]) == {b"val1", b"val2"} assert set(fs[b"one"]) == {b"two", b"three"} assert fs[b"test2"] == [b"xxx"] assert fs[b"six"] == [b"seven"] def test_from_response_override_params(self): response = _buildresponse( """
    """ ) req = self.request_class.from_response(response, formdata={"two": "2"}) fs = _qs(req) assert fs[b"one"] == [b"1"] assert fs[b"two"] == [b"2"] def test_from_response_drop_params(self): response = _buildresponse( """
    """ ) req = self.request_class.from_response( response, formdata={"two": None}, # type: ignore[arg-type] ) fs = _qs(req) assert fs[b"one"] == [b"1"] assert b"two" not in fs def test_from_response_override_method(self): response = _buildresponse( """
    """ ) request = FormRequest.from_response(response) assert request.method == "GET" request = FormRequest.from_response(response, method="POST") assert request.method == "POST" # an explicit method=None skips form-method normalization request = FormRequest.from_response(response, method=None) assert request.method == "NONE" def test_from_response_override_url(self): response = _buildresponse( """
    """ ) request = FormRequest.from_response(response) assert request.url == "http://example.com/app" request = FormRequest.from_response(response, url="http://foo.bar/absolute") assert request.url == "http://foo.bar/absolute" request = FormRequest.from_response(response, url="/relative") assert request.url == "http://example.com/relative" def test_from_response_case_insensitive(self): response = _buildresponse( """
    """ ) req = self.request_class.from_response(response) fs = _qs(req) assert fs[b"clickable1"] == [b"clicked1"] assert b"i1" not in fs, fs # xpath in _get_inputs() assert b"clickable2" not in fs, fs # xpath in _get_clickable() def test_from_response_submit_first_clickable(self): response = _buildresponse( """
    """ ) req = self.request_class.from_response(response, formdata={"two": "2"}) fs = _qs(req) assert fs[b"clickable1"] == [b"clicked1"] assert b"clickable2" not in fs, fs assert fs[b"one"] == [b"1"] assert fs[b"two"] == [b"2"] def test_from_response_submit_not_first_clickable(self): response = _buildresponse( """
    """ ) req = self.request_class.from_response( response, formdata={"two": "2"}, clickdata={"name": "clickable2"} ) fs = _qs(req) assert fs[b"clickable2"] == [b"clicked2"] assert b"clickable1" not in fs, fs assert fs[b"one"] == [b"1"] assert fs[b"two"] == [b"2"] def test_from_response_dont_submit_image_as_input(self): response = _buildresponse( """
    """ ) req = self.request_class.from_response(response, dont_click=True) fs = _qs(req) assert fs == {b"i1": [b"i1v"]} def test_from_response_dont_submit_reset_as_input(self): response = _buildresponse( """
    """ ) req = self.request_class.from_response(response, dont_click=True) fs = _qs(req) assert fs == {b"i1": [b"i1v"], b"i2": [b"i2v"]} def test_from_response_clickdata_does_not_ignore_image(self): response = _buildresponse( """
    """ ) req = self.request_class.from_response(response) fs = _qs(req) assert fs == {b"i1": [b"i1v"], b"i2": [b"i2v"]} def test_from_response_multiple_clickdata(self): response = _buildresponse( """
    """ ) req = self.request_class.from_response( response, clickdata={"name": "clickable", "value": "clicked2"} ) fs = _qs(req) assert fs[b"clickable"] == [b"clicked2"] assert fs[b"one"] == [b"clicked1"] assert fs[b"two"] == [b"clicked2"] def test_from_response_unicode_clickdata(self): response = _buildresponse( """
    """ ) req = self.request_class.from_response( response, clickdata={"name": "price in \u00a3"} ) fs = _qs_unicode(req) assert fs["price in \u00a3"] def test_from_response_unicode_clickdata_latin1(self): response = _buildresponse( """
    """, encoding="latin1", ) req = self.request_class.from_response( response, clickdata={"name": "price in \u00a5"} ) fs = _qs_unicode(req, encoding="latin1") assert fs["price in \u00a5"] def test_from_response_multiple_forms_clickdata(self): response = _buildresponse( """
    """ ) req = self.request_class.from_response( response, formname="form2", clickdata={"name": "clickable"} ) fs = _qs(req) assert fs[b"clickable"] == [b"clicked2"] assert fs[b"field2"] == [b"value2"] assert b"field1" not in fs, fs def test_from_response_override_clickable(self): response = _buildresponse( """
    """ ) req = self.request_class.from_response( response, formdata={"clickme": "two"}, clickdata={"name": "clickme"} ) fs = _qs(req) assert fs[b"clickme"] == [b"two"] def test_from_response_dont_click(self): response = _buildresponse( """
    """ ) r1 = self.request_class.from_response(response, dont_click=True) fs = _qs(r1) assert b"clickable1" not in fs, fs assert b"clickable2" not in fs, fs def test_from_response_ambiguous_clickdata(self): response = _buildresponse( """
    """ ) with pytest.raises( ValueError, match=r"Multiple elements found .* matching the criteria in clickdata", ): self.request_class.from_response(response, clickdata={"type": "submit"}) def test_from_response_non_matching_clickdata(self): response = _buildresponse( """
    """ ) with pytest.raises( ValueError, match="No clickable element matching clickdata:" ): self.request_class.from_response( response, clickdata={"nonexistent": "notme"} ) def test_from_response_nr_index_clickdata(self): response = _buildresponse( """
    """ ) req = self.request_class.from_response(response, clickdata={"nr": 1}) fs = _qs(req) assert b"clickable2" in fs assert b"clickable1" not in fs def test_from_response_invalid_nr_index_clickdata(self): response = _buildresponse( """
    """ ) with pytest.raises( ValueError, match="No clickable element matching clickdata:" ): self.request_class.from_response(response, clickdata={"nr": 1}) def test_from_response_errors_noform(self): response = _buildresponse("""""") with pytest.raises(ValueError, match="No
    element found in"): self.request_class.from_response(response) def test_from_response_invalid_html5(self): response = _buildresponse( """""" """""" """
    """ ) req = self.request_class.from_response(response, formdata={"bar": "buz"}) fs = _qs(req) assert fs == {b"foo": [b"xxx"], b"bar": [b"buz"]} def test_from_response_errors_formnumber(self): response = _buildresponse( """
    """ ) with pytest.raises(IndexError): self.request_class.from_response(response, formnumber=1) def test_from_response_noformname(self): response = _buildresponse( """
    """ ) r1 = self.request_class.from_response(response, formdata={"two": "3"}) assert r1.method == "POST" assert r1.headers["Content-type"] == b"application/x-www-form-urlencoded" fs = _qs(r1) assert fs == {b"one": [b"1"], b"two": [b"3"]} def test_from_response_formname_exists(self): response = _buildresponse( """
    """ ) r1 = self.request_class.from_response(response, formname="form2") assert r1.method == "POST" fs = _qs(r1) assert fs == {b"four": [b"4"], b"three": [b"3"]} def test_from_response_formname_nonexistent(self): response = _buildresponse( """
    """ ) r1 = self.request_class.from_response(response, formname="form3") assert r1.method == "POST" fs = _qs(r1) assert fs == {b"one": [b"1"]} def test_from_response_formname_errors_formnumber(self): response = _buildresponse( """
    """ ) with pytest.raises(IndexError): self.request_class.from_response(response, formname="form3", formnumber=2) def test_from_response_formid_exists(self): response = _buildresponse( """
    """ ) r1 = self.request_class.from_response(response, formid="form2") assert r1.method == "POST" fs = _qs(r1) assert fs == {b"four": [b"4"], b"three": [b"3"]} def test_from_response_formname_nonexistent_fallback_formid(self): response = _buildresponse( """
    """ ) r1 = self.request_class.from_response( response, formname="form3", formid="form2" ) assert r1.method == "POST" fs = _qs(r1) assert fs == {b"four": [b"4"], b"three": [b"3"]} def test_from_response_formid_nonexistent(self): response = _buildresponse( """
    """ ) r1 = self.request_class.from_response(response, formid="form3") assert r1.method == "POST" fs = _qs(r1) assert fs == {b"one": [b"1"]} def test_from_response_formid_errors_formnumber(self): response = _buildresponse( """
    """ ) with pytest.raises(IndexError): self.request_class.from_response(response, formid="form3", formnumber=2) def test_from_response_select(self): res = _buildresponse( """
    """ ) req = self.request_class.from_response(res) fs = _qs(req) assert fs == {b"i1": [b"iv2"], b"i2": [b"on"]} def test_from_response_checkbox(self): res = _buildresponse( """
    """ ) req = self.request_class.from_response(res) fs = _qs(req) assert fs == {b"i1": [b"iv2"], b"i2": [b"on"]} def test_from_response_input_text(self): res = _buildresponse( """
    """ ) req = self.request_class.from_response(res) fs = _qs(req) assert fs == {b"i1": [b"i1v1"], b"i2": [b""], b"i4": [b"i4v1"]} def test_from_response_input_hidden(self): res = _buildresponse( """
    """ ) req = self.request_class.from_response(res) fs = _qs(req) assert fs == {b"i1": [b"i1v1"], b"i2": [b""]} def test_from_response_input_textarea(self): res = _buildresponse( """
    """ ) req = self.request_class.from_response(res) fs = _qs(req) assert fs == {b"i1": [b"i1v"], b"i2": [b""], b"i3": [b""]} def test_from_response_descendants(self): res = _buildresponse( """
    """ ) req = self.request_class.from_response(res) fs = _qs(req) assert set(fs) == {b"h2", b"i2", b"i1", b"i3", b"h1", b"i5", b"i4"} def test_from_response_xpath(self): response = _buildresponse( """
    """ ) r1 = self.request_class.from_response( response, formxpath="//form[@action='post.php']" ) fs = _qs(r1) assert fs[b"one"] == [b"1"] r1 = self.request_class.from_response( response, formxpath="//form/input[@name='four']" ) fs = _qs(r1) assert fs[b"three"] == [b"3"] with pytest.raises(ValueError, match="No
    element found with"): self.request_class.from_response( response, formxpath="//form/input[@name='abc']" ) def test_from_response_unicode_xpath(self): response = _buildresponse(b'
    ') r = self.request_class.from_response( response, formxpath="//form[@name='\u044a']" ) fs = _qs(r) assert not fs xpath = "//form[@name='\u03b1']" with pytest.raises(ValueError, match=re.escape(xpath)): self.request_class.from_response(response, formxpath=xpath) def test_from_response_button_submit(self): response = _buildresponse( """
    """, url="http://www.example.com/this/list.html", ) req = self.request_class.from_response(response) assert req.method == "POST" assert req.headers["Content-type"] == b"application/x-www-form-urlencoded" assert req.url == "http://www.example.com/this/post.php" fs = _qs(req) assert fs[b"test1"] == [b"val1"] assert fs[b"test2"] == [b"val2"] assert fs[b"button1"] == [b"submit1"] def test_from_response_button_notype(self): response = _buildresponse( """
    """, url="http://www.example.com/this/list.html", ) req = self.request_class.from_response(response) assert req.method == "POST" assert req.headers["Content-type"] == b"application/x-www-form-urlencoded" assert req.url == "http://www.example.com/this/post.php" fs = _qs(req) assert fs[b"test1"] == [b"val1"] assert fs[b"test2"] == [b"val2"] assert fs[b"button1"] == [b"submit1"] def test_from_response_submit_novalue(self): response = _buildresponse( """
    Submit
    """, url="http://www.example.com/this/list.html", ) req = self.request_class.from_response(response) assert req.method == "POST" assert req.headers["Content-type"] == b"application/x-www-form-urlencoded" assert req.url == "http://www.example.com/this/post.php" fs = _qs(req) assert fs[b"test1"] == [b"val1"] assert fs[b"test2"] == [b"val2"] assert fs[b"button1"] == [b""] def test_from_response_button_novalue(self): response = _buildresponse( """
    """, url="http://www.example.com/this/list.html", ) req = self.request_class.from_response(response) assert req.method == "POST" assert req.headers["Content-type"] == b"application/x-www-form-urlencoded" assert req.url == "http://www.example.com/this/post.php" fs = _qs(req) assert fs[b"test1"] == [b"val1"] assert fs[b"test2"] == [b"val2"] assert fs[b"button1"] == [b""] def test_html_base_form_action(self): response = _buildresponse( """
    """, url="http://a.com/", ) req = self.request_class.from_response(response) assert req.url == "http://b.com/test_form" def test_spaces_in_action(self): resp = _buildresponse('
    ') req = self.request_class.from_response(resp) assert req.url == "http://example.com/path" def test_from_response_css(self): response = _buildresponse( """
    """ ) r1 = self.request_class.from_response( response, formcss="form[action='post.php']" ) fs = _qs(r1) assert fs[b"one"] == [b"1"] r1 = self.request_class.from_response(response, formcss="input[name='four']") fs = _qs(r1) assert fs[b"three"] == [b"3"] with pytest.raises(ValueError, match="No
    element found with"): self.request_class.from_response(response, formcss="input[name='abc']") def test_from_response_valid_form_methods(self): form_methods = [ [method, method] for method in self.request_class.valid_form_methods ] form_methods.append(["UNKNOWN", "GET"]) for method, expected in form_methods: response = _buildresponse( f'' '' "
    " ) r = self.request_class.from_response(response) assert 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 pytest.raises( ValueError, match="formdata should be a dict or iterable of tuples" ): FormRequest.from_response(response, formdata=123) # type: ignore[arg-type] 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 pytest.raises( ValueError, match="formdata should be a dict or iterable of tuples" ): FormRequest.from_response(response, formdata=("a",)) # type: ignore[arg-type] 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 pytest.raises(ValueError, match="No
    element found with"): FormRequest.from_response(response, formxpath='//div[@id="outside-form"]/p') scrapy-scrapy-5569de3/tests/test_http_request_json.py000066400000000000000000000143631525051012400232150ustar00rootroot00000000000000from __future__ import annotations import json import warnings from unittest import mock import pytest from scrapy.http import JsonRequest from scrapy.utils.python import to_bytes from tests.utils.bases.http_request import TestRequestBase class TestJsonRequest(TestRequestBase): request_class = JsonRequest default_method = "GET" default_headers = { b"Content-Type": [b"application/json"], b"Accept": [b"application/json, text/javascript, */*; q=0.01"], } def test_data(self): r1 = self.request_class(url="http://www.example.com/") assert r1.body == b"" body = b"body" r2 = self.request_class(url="http://www.example.com/", body=body) assert r2.body == body data = { "name": "value", } r3 = self.request_class(url="http://www.example.com/", data=data) assert r3.body == to_bytes(json.dumps(data)) # empty data r4 = self.request_class(url="http://www.example.com/", data=[]) assert r4.body == to_bytes(json.dumps([])) def test_data_method(self): # data is not passed r1 = self.request_class(url="http://www.example.com/") assert r1.method == "GET" body = b"body" r2 = self.request_class(url="http://www.example.com/", body=body) assert r2.method == "GET" data = { "name": "value", } r3 = self.request_class(url="http://www.example.com/", data=data) assert r3.method == "POST" # method passed explicitly r4 = self.request_class(url="http://www.example.com/", data=data, method="GET") assert r4.method == "GET" r5 = self.request_class(url="http://www.example.com/", data=[]) assert r5.method == "POST" def test_body_data(self): """passing both body and data should result a warning""" body = b"body" data = { "name": "value", } with pytest.warns(UserWarning, match="data will be ignored"): r5 = self.request_class(url="http://www.example.com/", body=body, data=data) assert r5.body == body assert r5.method == "GET" def test_empty_body_data(self): """passing any body value and data should result a warning""" data = { "name": "value", } with pytest.warns(UserWarning, match="data will be ignored"): r6 = self.request_class(url="http://www.example.com/", body=b"", data=data) assert r6.body == b"" assert r6.method == "GET" def test_body_none_data(self): data = { "name": "value", } with warnings.catch_warnings(): warnings.filterwarnings( "error", category=UserWarning, message="Both body and data passed" ) r7 = self.request_class(url="http://www.example.com/", body=None, data=data) assert r7.body == to_bytes(json.dumps(data)) assert r7.method == "POST" def test_body_data_none(self): with warnings.catch_warnings(): warnings.filterwarnings( "error", category=UserWarning, message="Both body and data passed" ) r8 = self.request_class(url="http://www.example.com/", body=None, data=None) assert r8.method == "GET" def test_dumps_sort_keys(self): """Test that sort_keys=True is passed to json.dumps by default""" data = { "name": "value", } with mock.patch("json.dumps", return_value=b"") as mock_dumps: self.request_class(url="http://www.example.com/", data=data) kwargs = mock_dumps.call_args[1] assert kwargs["sort_keys"] is True def test_dumps_kwargs(self): """Test that dumps_kwargs are passed to json.dumps""" data = { "name": "value", } dumps_kwargs = { "ensure_ascii": True, "allow_nan": True, } with mock.patch("json.dumps", return_value=b"") as mock_dumps: self.request_class( url="http://www.example.com/", data=data, dumps_kwargs=dumps_kwargs ) kwargs = mock_dumps.call_args[1] assert kwargs["ensure_ascii"] is True assert kwargs["allow_nan"] is True def test_replace_data(self): data1 = { "name1": "value1", } data2 = { "name2": "value2", } r1 = self.request_class(url="http://www.example.com/", data=data1) r2 = r1.replace(data=data2) assert r2.body == to_bytes(json.dumps(data2)) def test_replace_sort_keys(self): """Test that replace provides sort_keys=True to json.dumps""" data1 = { "name1": "value1", } data2 = { "name2": "value2", } r1 = self.request_class(url="http://www.example.com/", data=data1) with mock.patch("json.dumps", return_value=b"") as mock_dumps: r1.replace(data=data2) kwargs = mock_dumps.call_args[1] assert kwargs["sort_keys"] is True def test_replace_dumps_kwargs(self): """Test that dumps_kwargs are provided to json.dumps when replace is called""" data1 = { "name1": "value1", } data2 = { "name2": "value2", } dumps_kwargs = { "ensure_ascii": True, "allow_nan": True, } r1 = self.request_class( url="http://www.example.com/", data=data1, dumps_kwargs=dumps_kwargs ) with mock.patch("json.dumps", return_value=b"") as mock_dumps: r1.replace(data=data2) kwargs = mock_dumps.call_args[1] assert kwargs["ensure_ascii"] is True assert kwargs["allow_nan"] is 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 pytest.warns(UserWarning, match="data will be ignored"): r1.replace(data=data2, body=body2) scrapy-scrapy-5569de3/tests/test_http_response.py000066400000000000000000000003101525051012400223150ustar00rootroot00000000000000from __future__ import annotations from scrapy.http import Response from tests.utils.bases.http_response import TestResponseBase class TestResponse(TestResponseBase): response_class = Response scrapy-scrapy-5569de3/tests/test_http_response_text.py000066400000000000000000000662131525051012400233770ustar00rootroot00000000000000from __future__ import annotations import codecs from typing import cast from unittest import mock import pytest from scrapy.http import HtmlResponse, TextResponse, XmlResponse from scrapy.selector import Selector from scrapy.utils.python import to_unicode from tests.utils.bases.http_response import TestResponseBase class TestTextResponse(TestResponseBase): response_class = TextResponse def _links_response(self) -> TextResponse: return cast("TextResponse", super()._links_response()) def _links_response_no_href(self) -> TextResponse: return cast("TextResponse", super()._links_response_no_href()) def test_follow_None_encoding(self): # unlike the base Response, TextResponse.follow() falls back to the # response encoding when encoding is None instead of raising r = self.response_class("http://example.com", body=b"hello", encoding="cp1252") req = r.follow("foo", encoding=None) assert req.encoding == "cp1252" def test_replace(self) -> None: super().test_replace() r1 = self.response_class( "http://www.example.com", body="hello", encoding="cp852" ) r2 = r1.replace(url="http://www.example.com/other") r3 = r1.replace(url="http://www.example.com/other", encoding="latin1") assert isinstance(r2, self.response_class) assert r2.url == "http://www.example.com/other" self._assert_response_encoding(r2, "cp852") assert r3.url == "http://www.example.com/other" assert r3._declared_encoding() == "latin1" def test_unicode_url(self): # instantiate with unicode url without encoding (should set default encoding) resp = self.response_class("http://www.example.com/") self._assert_response_encoding(resp, self.response_class._DEFAULT_ENCODING) # make sure urls are converted to str resp = self.response_class(url="http://www.example.com/", encoding="utf-8") assert isinstance(resp.url, str) resp = self.response_class( url="http://www.example.com/price/\xa3", encoding="utf-8" ) assert resp.url == to_unicode(b"http://www.example.com/price/\xc2\xa3") resp = self.response_class( url="http://www.example.com/price/\xa3", encoding="latin-1" ) assert resp.url == "http://www.example.com/price/\xa3" resp = self.response_class( "http://www.example.com/price/\xa3", headers={"Content-type": ["text/html; charset=utf-8"]}, ) assert resp.url == to_unicode(b"http://www.example.com/price/\xc2\xa3") resp = self.response_class( "http://www.example.com/price/\xa3", headers={"Content-type": ["text/html; charset=iso-8859-1"]}, ) assert resp.url == "http://www.example.com/price/\xa3" def test_unicode_body(self): unicode_string = ( "\u043a\u0438\u0440\u0438\u043b\u043b\u0438\u0447\u0435\u0441\u043a\u0438\u0439 " "\u0442\u0435\u043a\u0441\u0442" ) with pytest.raises(TypeError): self.response_class("http://www.example.com", body="unicode body") original_string = unicode_string.encode("cp1251") r1 = self.response_class( "http://www.example.com", body=original_string, encoding="cp1251" ) # check response.text assert isinstance(r1.text, str) assert r1.text == unicode_string def test_encoding(self): r1 = self.response_class( "http://www.example.com", body=b"\xc2\xa3", headers={"Content-type": ["text/html; charset=utf-8"]}, ) r2 = self.response_class( "http://www.example.com", encoding="utf-8", body="\xa3" ) r3 = self.response_class( "http://www.example.com", body=b"\xa3", headers={"Content-type": ["text/html; charset=iso-8859-1"]}, ) r4 = self.response_class("http://www.example.com", body=b"\xa2\xa3") r5 = self.response_class( "http://www.example.com", body=b"\xc2\xa3", headers={"Content-type": ["text/html; charset=None"]}, ) r6 = self.response_class( "http://www.example.com", body=b"\xa8D", headers={"Content-type": ["text/html; charset=gb2312"]}, ) r7 = self.response_class( "http://www.example.com", body=b"\xa8D", headers={"Content-type": ["text/html; charset=gbk"]}, ) r8 = self.response_class( "http://www.example.com", body=codecs.BOM_UTF8 + b"\xc2\xa3", headers={"Content-type": ["text/html; charset=cp1251"]}, ) r9 = self.response_class( "http://www.example.com", body=b"\x80", headers={ "Content-type": [b"application/x-download; filename=\x80dummy.txt"] }, ) assert r1._headers_encoding() == "utf-8" assert r2._headers_encoding() is None assert r2._declared_encoding() == "utf-8" self._assert_response_encoding(r2, "utf-8") assert r3._headers_encoding() == "cp1252" assert r3._declared_encoding() == "cp1252" assert r4._headers_encoding() is None assert r5._headers_encoding() is None assert r8._headers_encoding() == "cp1251" assert r9._headers_encoding() is None assert r8._declared_encoding() == "utf-8" assert r9._declared_encoding() is None self._assert_response_encoding(r5, "utf-8") self._assert_response_encoding(r8, "utf-8") assert r4._body_inferred_encoding() is not None assert r4._body_inferred_encoding() != "ascii" assert r9._body_inferred_encoding() is not None assert r9._body_inferred_encoding() != "ascii" self._assert_response_values(r1, "utf-8", "\xa3") self._assert_response_values(r2, "utf-8", "\xa3") self._assert_response_values(r3, "iso-8859-1", "\xa3") self._assert_response_values(r6, "gb18030", "\u2015") self._assert_response_values(r7, "gb18030", "\u2015") # TextResponse (and subclasses) must be passed a encoding when instantiating with unicode bodies with pytest.raises(TypeError): self.response_class("http://www.example.com", body="\xa3") def test_declared_encoding_invalid(self): """Check that unknown declared encodings are ignored""" r = self.response_class( "http://www.example.com", headers={"Content-type": ["text/html; charset=UNKNOWN"]}, body=b"\xc2\xa3", ) assert r._declared_encoding() is None self._assert_response_values(r, "utf-8", "\xa3") def test_body_inferred_encoding_of_undeclared_legacy_page(self): body = "ICD10 国際疾病分類第10版2013年版 病�マスター".encode("shift-jis") r = self.response_class("http://www.example.com", body=body) assert r._declared_encoding() is None assert r.encoding in {"shift_jis", "cp932"} assert r.text == body.decode(r.encoding) def test_utf16(self): """Test utf-16 because UnicodeDammit is known to have problems with""" body = b"\xff\xfeh\x00i\x00" r = self.response_class( "http://www.example.com", body=body, encoding="utf-16", ) self._assert_response_values(r, "utf-16", body) def test_invalid_utf8_encoded_body_with_valid_utf8_BOM(self): r6 = self.response_class( "http://www.example.com", headers={"Content-type": ["text/html; charset=utf-8"]}, body=b"\xef\xbb\xbfWORD\xe3\xab", ) assert r6.encoding == "utf-8" assert r6.text in { "WORD\ufffd\ufffd", # w3lib < 1.19.0 "WORD\ufffd", # w3lib >= 1.19.0 } def test_bom_is_removed_from_body(self): # Inferring encoding from body also cache decoded body as sideeffect, # this test tries to ensure that calling response.encoding and # response.text in indistinct order doesn't affect final # values for encoding and decoded body. url = "http://example.com" body = b"\xef\xbb\xbfWORD" headers = {"Content-type": ["text/html; charset=utf-8"]} # Test response without content-type and BOM encoding response = self.response_class(url, body=body) assert response.encoding == "utf-8" assert response.text == "WORD" response = self.response_class(url, body=body) assert response.text == "WORD" assert response.encoding == "utf-8" # Body caching sideeffect isn't triggered when encoding is declared in # content-type header but BOM still need to be removed from decoded # body response = self.response_class(url, headers=headers, body=body) assert response.encoding == "utf-8" assert response.text == "WORD" response = self.response_class(url, headers=headers, body=body) assert response.text == "WORD" assert response.encoding == "utf-8" def test_replace_wrong_encoding(self): """Test invalid chars are replaced properly""" r = self.response_class( "http://www.example.com", encoding="utf-8", body=b"PREFIX\xe3\xabSUFFIX", ) # XXX: Policy for replacing invalid chars may suffer minor variations # but it should always contain the unicode replacement char ('\ufffd') assert "\ufffd" in r.text, repr(r.text) assert "PREFIX" in r.text, repr(r.text) assert "SUFFIX" in r.text, repr(r.text) # Do not destroy html tags due to encoding bugs r = self.response_class( "http://example.com", encoding="utf-8", body=b"\xf0value", ) assert "value" in r.text, repr(r.text) # FIXME: This test should pass once we stop using BeautifulSoup's UnicodeDammit in TextResponse # r = self.response_class("http://www.example.com", body=b'PREFIX\xe3\xabSUFFIX') # assert '\ufffd' in r.text, repr(r.text) def test_selector(self): body = b"Some page" response = self.response_class("http://www.example.com", body=body) assert isinstance(response.selector, Selector) assert response.selector.type == "html" assert response.selector is response.selector # property is cached assert response.selector.response is response assert response.selector.xpath("//title/text()").getall() == ["Some page"] assert response.selector.css("title::text").getall() == ["Some page"] assert response.selector.re("Some (.*)") == ["page"] def test_selector_shortcuts(self): body = b"Some page" response = self.response_class("http://www.example.com", body=body) assert ( response.xpath("//title/text()").getall() == response.selector.xpath("//title/text()").getall() ) assert ( response.css("title::text").getall() == response.selector.css("title::text").getall() ) def test_selector_shortcuts_kwargs(self): body = b'Some page

    A nice paragraph.

    ' response = self.response_class("http://www.example.com", body=body) assert ( response.xpath( "normalize-space(//p[@class=$pclass])", pclass="content" ).getall() == response.xpath('normalize-space(//p[@class="content"])').getall() ) assert ( response.xpath( "//title[count(following::p[@class=$pclass])=$pcount]/text()", pclass="content", pcount=1, ).getall() == response.xpath( '//title[count(following::p[@class="content"])=1]/text()' ).getall() ) def test_urljoin_with_base_url(self): """Test urljoin shortcut which also evaluates base-url through get_base_url().""" body = b'' joined = self.response_class("http://www.example.com", body=body).urljoin( "/test" ) absolute = "https://example.net/test" assert joined == absolute body = b'' joined = self.response_class("http://www.example.com", body=body).urljoin( "test" ) absolute = "http://www.example.com/test" assert joined == absolute body = b'' joined = self.response_class("http://www.example.com", body=body).urljoin( "test" ) absolute = "http://www.example.com/elsewhere/test" assert joined == absolute def test_follow_selector(self): resp = self._links_response() urls = [ "http://example.com/sample2.html", "http://example.com/sample3.html", "http://example.com/sample3.html", "http://example.com/sample3.html", "http://example.com/sample3.html#foo", "http://www.google.com/something", "http://example.com/innertag.html", "http://example.com/page%204.html", ] # select elements for sellist in [resp.css("a"), resp.xpath("//a")]: for sel, url in zip(sellist, urls, strict=True): self._assert_followed_url(sel, url, response=resp) # select elements self._assert_followed_url( Selector(text='').css("link")[0], "http://example.com/foo", response=resp, ) # href attributes should work for sellist in [resp.css("a::attr(href)"), resp.xpath("//a/@href")]: for sel, url in zip(sellist, urls, strict=True): self._assert_followed_url(sel, url, response=resp) # non-a elements are not supported with pytest.raises( ValueError, match="Only and elements are supported" ): resp.follow(resp.css("div")[0]) def test_follow_selector_list(self): resp = self._links_response() with pytest.raises(ValueError, match="SelectorList"): resp.follow(resp.css("a")) # type: ignore[arg-type] def test_follow_selector_invalid(self): resp = self._links_response() with pytest.raises(ValueError, match="Unsupported"): resp.follow(resp.xpath("count(//div)")[0]) def test_follow_selector_attribute(self): resp = self._links_response() for src in resp.css("img::attr(src)"): self._assert_followed_url(src, "http://example.com/sample2.jpg") def test_follow_selector_no_href(self): resp = self.response_class( url="http://example.com", body=b"click me", ) with pytest.raises(ValueError, match="no href"): resp.follow(resp.css("a")[0]) def test_follow_whitespace_selector(self): resp = self.response_class( "http://example.com", body=b"""click me""", ) self._assert_followed_url( resp.css("a")[0], "http://example.com/foo", response=resp ) self._assert_followed_url( resp.css("a::attr(href)")[0], "http://example.com/foo", response=resp, ) def test_follow_encoding(self): resp1 = self.response_class( "http://example.com", encoding="utf8", body='click me'.encode(), ) self._assert_followed_url( resp1.css("a")[0], "http://example.com/foo?%D0%BF%D1%80%D0%B8%D0%B2%D0%B5%D1%82", response=resp1, encoding="utf8", ) resp2 = self.response_class( "http://example.com", encoding="cp1251", body='click me'.encode( "cp1251" ), ) self._assert_followed_url( resp2.css("a")[0], "http://example.com/foo?%EF%F0%E8%E2%E5%F2", response=resp2, encoding="cp1251", ) def test_follow_all_css(self): expected = [ "http://example.com/sample3.html", "http://example.com/innertag.html", ] response = self._links_response() extracted = [r.url for r in response.follow_all(css='a[href*="example.com"]')] assert expected == extracted def test_follow_all_css_skip_invalid(self): expected = [ "http://example.com/page/1/", "http://example.com/page/3/", "http://example.com/page/4/", ] response = self._links_response_no_href() extracted1 = [r.url for r in response.follow_all(css=".pagination a")] assert expected == extracted1 extracted2 = [r.url for r in response.follow_all(response.css(".pagination a"))] assert expected == extracted2 def test_follow_all_xpath(self): expected = [ "http://example.com/sample3.html", "http://example.com/innertag.html", ] response = self._links_response() extracted = response.follow_all(xpath='//a[contains(@href, "example.com")]') assert expected == [r.url for r in extracted] def test_follow_all_xpath_skip_invalid(self): expected = [ "http://example.com/page/1/", "http://example.com/page/3/", "http://example.com/page/4/", ] response = self._links_response_no_href() extracted1 = [ r.url for r in response.follow_all(xpath='//div[@id="pagination"]/a') ] assert expected == extracted1 extracted2 = [ r.url for r in response.follow_all(response.xpath('//div[@id="pagination"]/a')) ] assert expected == extracted2 def test_follow_all_too_many_arguments(self): response = self._links_response() with pytest.raises( ValueError, match="Please supply exactly one of the following arguments" ): response.follow_all( css='a[href*="example.com"]', xpath='//a[contains(@href, "example.com")]', ) def test_json_response(self): json_body = b"""{"ip": "109.187.217.200"}""" json_response = self.response_class("http://www.example.com", body=json_body) assert json_response.json() == {"ip": "109.187.217.200"} text_body = b"""text""" text_response = self.response_class("http://www.example.com", body=text_body) with pytest.raises( ValueError, match=r"(Expecting value|Unexpected '<'): line 1" ): text_response.json() def test_json_response_non_utf8(self): response = self.response_class( "http://www.example.com", body='{"message": "café"}'.encode("cp1252"), headers={"Content-Type": "application/json"}, ) assert response.json() == {"message": "café"} def test_json_response_wrong_charset(self): response = self.response_class( "http://www.example.com", body='{"message": "café"}'.encode(), headers={"Content-Type": "application/json; charset=iso-8859-1"}, ) assert response.json() == {"message": "café"} def test_cache_json_response(self): json_valid_bodies = [b"""{"ip": "109.187.217.200"}""", b"""null"""] for json_body in json_valid_bodies: json_response = self.response_class( "http://www.example.com", body=json_body ) with mock.patch("json.loads") as mock_json: for _ in range(2): json_response.json() mock_json.assert_called_once_with(json_body) class TestHtmlResponse(TestTextResponse): response_class = HtmlResponse def test_html_encoding(self): body = b"""Some page Price: \xa3100' """ r1 = self.response_class("http://www.example.com", body=body) self._assert_response_values(r1, "iso-8859-1", body) body = b""" Price: \xa3100 """ r2 = self.response_class("http://www.example.com", body=body) self._assert_response_values(r2, "iso-8859-1", body) # for conflicting declarations headers must take precedence body = b"""Some page Price: \xa3100' """ r3 = self.response_class( "http://www.example.com", body=body, headers={"Content-type": ["text/html; charset=iso-8859-1"]}, ) self._assert_response_values(r3, "iso-8859-1", body) # make sure replace() preserves the encoding of the original response body = b"New body \xa3" r4 = r3.replace(body=body) self._assert_response_values(r4, "iso-8859-1", body) def test_html5_meta_charset(self): body = b"""Some pagebla bla""" r1 = self.response_class("http://www.example.com", body=body) self._assert_response_values(r1, "gb2312", body) class TestXmlResponse(TestTextResponse): response_class = XmlResponse def test_xml_encoding(self): body = b"" r1 = self.response_class("http://www.example.com", body=body) self._assert_response_values(r1, self.response_class._DEFAULT_ENCODING, body) body = b"""""" r2 = self.response_class("http://www.example.com", body=body) self._assert_response_values(r2, "iso-8859-1", body) # make sure replace() preserves the explicit encoding passed in the __init__ method body = b"""""" r3 = self.response_class("http://www.example.com", body=body, encoding="utf-8") body2 = b"New body" r4 = r3.replace(body=body2) self._assert_response_values(r4, "utf-8", body2) def test_replace_encoding(self): # make sure replace() keeps the previous encoding unless overridden explicitly body = b"""""" body2 = b"""""" r5 = self.response_class("http://www.example.com", body=body) r6 = r5.replace(body=body2) r7 = r5.replace(body=body2, encoding="utf-8") self._assert_response_values(r5, "iso-8859-1", body) self._assert_response_values(r6, "iso-8859-1", body2) self._assert_response_values(r7, "utf-8", body2) def test_selector(self): body = b'value' response = self.response_class("http://www.example.com", body=body) assert isinstance(response.selector, Selector) assert response.selector.type == "xml" assert response.selector is response.selector # property is cached assert response.selector.response is response assert response.selector.xpath("//elem/text()").getall() == ["value"] def test_selector_shortcuts(self): body = b'value' response = self.response_class("http://www.example.com", body=body) assert ( response.xpath("//elem/text()").getall() == response.selector.xpath("//elem/text()").getall() ) def test_selector_shortcuts_kwargs(self): body = b""" value """ response = self.response_class("http://www.example.com", body=body) assert ( response.xpath( "//s:elem/text()", namespaces={"s": "http://scrapy.org"} ).getall() == response.selector.xpath( "//s:elem/text()", namespaces={"s": "http://scrapy.org"} ).getall() ) response.selector.register_namespace("s2", "http://scrapy.org") assert ( response.xpath( "//s1:elem/text()", namespaces={"s1": "http://scrapy.org"} ).getall() == response.selector.xpath("//s2:elem/text()").getall() ) class CustomResponse(TextResponse): attributes = (*TextResponse.attributes, "foo", "bar") def __init__(self, *args, **kwargs) -> None: self.foo = kwargs.pop("foo", None) self.bar = kwargs.pop("bar", None) self.lost = kwargs.pop("lost", None) super().__init__(*args, **kwargs) class TestCustomResponse(TestTextResponse): response_class = CustomResponse def test_copy(self) -> None: super().test_copy() r1 = self.response_class( url="https://example.org", status=200, foo="foo", bar="bar", lost="lost", ) r2 = r1.copy() assert isinstance(r2, self.response_class) assert r1.foo == r2.foo assert r1.bar == r2.bar assert r1.lost == "lost" assert r2.lost is None def test_replace(self) -> None: super().test_replace() r1 = self.response_class( url="https://example.org", status=200, foo="foo", bar="bar", lost="lost", ) r2 = r1.replace(foo="new-foo", bar="new-bar", lost="new-lost") assert isinstance(r2, self.response_class) assert r1.foo == "foo" assert r1.bar == "bar" assert r1.lost == "lost" assert r2.foo == "new-foo" assert r2.bar == "new-bar" assert r2.lost == "new-lost" r3 = r1.replace(foo="new-foo", bar="new-bar") assert isinstance(r3, self.response_class) assert r1.foo == "foo" assert r1.bar == "bar" assert r1.lost == "lost" assert r3.foo == "new-foo" assert r3.bar == "new-bar" assert r3.lost is None r4 = r1.replace(foo="new-foo") assert isinstance(r4, self.response_class) assert r1.foo == "foo" assert r1.bar == "bar" assert r1.lost == "lost" assert r4.foo == "new-foo" assert r4.bar == "bar" assert r4.lost is None with pytest.raises( TypeError, match=r"__init__\(\) got an unexpected keyword argument 'unknown'", ): r1.replace(unknown="unknown") scrapy-scrapy-5569de3/tests/test_item.py000066400000000000000000000232311525051012400203650ustar00rootroot00000000000000from abc import ABCMeta from typing import Any from unittest import mock import pytest from scrapy.item import Field, Item, ItemMeta class TestItem: def test_simple(self): class TestItem(Item): name = Field() i = TestItem() i["name"] = "name" assert i["name"] == "name" def test_init(self): class TestItem(Item): name = Field() i = TestItem() with pytest.raises(KeyError): i["name"] i2 = TestItem(name="john doe") assert i2["name"] == "john doe" i3 = TestItem({"name": "john doe"}) assert i3["name"] == "john doe" i4 = TestItem(i3) assert i4["name"] == "john doe" with pytest.raises(KeyError): TestItem({"name": "john doe", "other": "foo"}) def test_invalid_field(self): class TestItem(Item): pass i = TestItem() with pytest.raises(KeyError): i["field"] = "text" with pytest.raises(KeyError): i["field"] def test_delitem(self): class TestItem(Item): name = Field() i = TestItem(name="John") del i["name"] with pytest.raises(KeyError): i["name"] with pytest.raises(KeyError): del i["name"] def test_repr(self): class TestItem(Item): name = Field() number = Field() i = TestItem() i["name"] = "John Doe" i["number"] = 123 itemrepr = repr(i) assert itemrepr == "{'name': 'John Doe', 'number': 123}" i2 = eval(itemrepr) assert i2["name"] == "John Doe" assert i2["number"] == 123 def test_private_attr(self): class TestItem(Item): name = Field() i = TestItem() i._private = "test" assert i._private == "test" def test_raise_getattr(self): class TestItem(Item): name = Field() i = TestItem() with pytest.raises(AttributeError): i.name def test_raise_setattr(self): class TestItem(Item): name = Field() i = TestItem() with pytest.raises(AttributeError): i.name = "john" # type: ignore[assignment] def test_custom_methods(self): class TestItem(Item): name = Field() def get_name(self) -> Any: return self["name"] def change_name(self, name: str) -> None: self["name"] = name i = TestItem() with pytest.raises(KeyError): i.get_name() i["name"] = "lala" assert i.get_name() == "lala" i.change_name("other") assert i.get_name() == "other" def test_metaclass(self): class TestItem(Item): name = Field() keys = Field() # type: ignore[assignment] values = Field() # type: ignore[assignment] i = TestItem() i["name"] = "John" assert list(i.keys()) == ["name"] # type: ignore[operator] assert list(i.values()) == ["John"] # type: ignore[operator] i["keys"] = "Keys" i["values"] = "Values" assert sorted(i.keys()) == ["keys", "name", "values"] # type: ignore[operator] assert sorted(i.values()) == ["John", "Keys", "Values"] # type: ignore[operator] def test_metaclass_with_fields_attribute(self): class TestItem(Item): fields = {"new": Field(default="X")} item = TestItem(new="New") assert list(item.keys()) == ["new"] assert list(item.values()) == ["New"] def test_fields_order(self): class TestItem(Item): name = Field() keys = Field() # type: ignore[assignment] values = Field() # type: ignore[assignment] assert list(TestItem.fields) == ["name", "keys", "values"] def test_fields_order_inheritance(self): class ParentItem(Item): name = Field() keys = Field() # type: ignore[assignment] values = Field() # type: ignore[assignment] class TestItem(ParentItem): extra = Field() keys = Field(serializer=str) # Inherited fields come first, in their definition order, followed by # the fields newly defined in the subclass. A redefined field keeps the # position of its first definition while taking the new metadata. assert list(TestItem.fields) == ["name", "keys", "values", "extra"] assert TestItem.fields["keys"] == {"serializer": str} def test_metaclass_inheritance(self): class ParentItem(Item): name = Field() keys = Field() # type: ignore[assignment] values = Field() # type: ignore[assignment] class TestItem(ParentItem): keys = Field() i = TestItem() i["keys"] = 3 assert list(i.keys()) == ["keys"] # type: ignore[operator] assert list(i.values()) == [3] # type: ignore[operator] def test_metaclass_multiple_inheritance_simple(self): class A(Item): fields = {"load": Field(default="A")} save = Field(default="A") class B(A): pass class C(Item): fields = {"load": Field(default="C")} save = Field(default="C") class D(B, C): pass item = D(save="X", load="Y") assert item["save"] == "X" assert item["load"] == "Y" assert D.fields == {"load": {"default": "A"}, "save": {"default": "A"}} # D class inverted class E(C, B): pass assert E(save="X")["save"] == "X" assert E(load="X")["load"] == "X" assert E.fields == {"load": {"default": "C"}, "save": {"default": "C"}} def test_metaclass_multiple_inheritance_diamond(self): class A(Item): fields = {"update": Field(default="A")} save = Field(default="A") load = Field(default="A") class B(A): pass class C(A): fields = {"update": Field(default="C")} save = Field(default="C") class D(B, C): fields = {"update": Field(default="D")} load = Field(default="D") assert D(save="X")["save"] == "X" assert D(load="X")["load"] == "X" assert D.fields == { "save": {"default": "C"}, "load": {"default": "D"}, "update": {"default": "D"}, } # D class inverted class E(C, B): load = Field(default="E") assert E(save="X")["save"] == "X" assert E(load="X")["load"] == "X" assert E.fields == { "save": {"default": "C"}, "load": {"default": "E"}, "update": {"default": "C"}, } def test_metaclass_multiple_inheritance_without_metaclass(self): class A(Item): fields = {"load": Field(default="A")} save = Field(default="A") class B(A): pass class C: fields = {"load": Field(default="C")} not_allowed = Field(default="not_allowed") save = Field(default="C") class D(B, C): pass with pytest.raises(KeyError): D(not_allowed="value") assert D(save="X")["save"] == "X" assert D.fields == {"save": {"default": "A"}, "load": {"default": "A"}} # D class inverted class E(C, B): pass with pytest.raises(KeyError): E(not_allowed="value") assert E(save="X")["save"] == "X" assert E.fields == {"save": {"default": "A"}, "load": {"default": "A"}} def test_to_dict(self): class TestItem(Item): name = Field() i = TestItem() i["name"] = "John" assert dict(i) == {"name": "John"} def test_copy(self): class TestItem(Item): name = Field() item = TestItem({"name": "lower"}) copied_item = item.copy() assert id(item) != id(copied_item) copied_item["name"] = copied_item["name"].upper() assert item["name"] != copied_item["name"] def test_deepcopy(self): class TestItem(Item): tags = Field() item = TestItem({"tags": ["tag1"]}) copied_item = item.deepcopy() item["tags"].append("tag2") assert item["tags"] != copied_item["tags"] class TestItemMeta: def test_new_method_propagates_classcell(self): new_mock = mock.Mock(side_effect=ABCMeta.__new__) base = ItemMeta.__bases__[0] with mock.patch.object(base, "__new__", new_mock): class MyItem(Item): def f(self): # For rationale of this see: # https://github.com/python/cpython/blob/ee1a81b77444c6715cbe610e951c655b6adab88b/Lib/test/test_super.py#L222 return __class__ # type: ignore[name-defined] MyItem() (first_call, second_call) = new_mock.call_args_list[-2:] *_, attrs = first_call[0] assert "__classcell__" not in attrs *_, attrs = second_call[0] assert "__classcell__" in attrs class TestItemMetaClassCellRegression: def test_item_meta_classcell_regression(self): class MyItem(Item, metaclass=ItemMeta): 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 # defining 'MyItem' as super().__init__(*args, **kwargs) scrapy-scrapy-5569de3/tests/test_link.py000066400000000000000000000033641525051012400203710ustar00rootroot00000000000000from __future__ import annotations import pytest from scrapy.link import Link def _assert_same_links(link1: Link, link2: Link) -> None: assert link1 == link2 assert hash(link1) == hash(link2) def _assert_different_links(link1: Link, link2: Link) -> None: assert link1 != link2 assert hash(link1) != hash(link2) def test_eq_and_hash() -> None: l1 = Link("http://www.example.com") l2 = Link("http://www.example.com/other") l3 = Link("http://www.example.com") _assert_same_links(l1, l1) _assert_different_links(l1, l2) _assert_same_links(l1, l3) l4 = Link("http://www.example.com", text="test") l5 = Link("http://www.example.com", text="test2") l6 = Link("http://www.example.com", text="test") _assert_same_links(l4, l4) _assert_different_links(l4, l5) _assert_same_links(l4, l6) l7 = Link( "http://www.example.com", text="test", fragment="something", nofollow=False ) l8 = Link( "http://www.example.com", text="test", fragment="something", nofollow=False ) l9 = Link( "http://www.example.com", text="test", fragment="something", nofollow=True ) l10 = Link("http://www.example.com", text="test", fragment="other", nofollow=False) _assert_same_links(l7, l8) _assert_different_links(l7, l9) _assert_different_links(l7, l10) def test_repr() -> None: l1 = Link( "http://www.example.com", text="test", fragment="something", nofollow=True ) l2 = eval(repr(l1)) _assert_same_links(l1, l2) def test_bytes_url() -> None: with pytest.raises(TypeError): Link(b"http://www.example.com/\xc2\xa3") # type: ignore[arg-type] def test_eq_non_link() -> None: url = "http://example.com" assert Link(url) != url scrapy-scrapy-5569de3/tests/test_linkextractors.py000066400000000000000000001033741525051012400225120ustar00rootroot00000000000000from __future__ import annotations import pickle import re from typing import Any from w3lib.url import canonicalize_url from scrapy.http import HtmlResponse, TextResponse, XmlResponse from scrapy.link import Link from scrapy.linkextractors import lxmlhtml from scrapy.linkextractors.lxmlhtml import LxmlLinkExtractor, LxmlParserLinkExtractor from tests import get_testdata class TestLxmlLinkExtractor: def setup_method(self): body = get_testdata("link_extractor", "linkextractor.html") self.response = HtmlResponse(url="http://example.com/index", body=body) def test_urls_type(self): """Test that the resulting urls are str objects""" lx = LxmlLinkExtractor() assert all( isinstance(link.url, str) for link in lx.extract_links(self.response) ) def test_extract_all_links(self): lx = LxmlLinkExtractor() page4_url = "http://example.com/page%204.html" assert list(lx.extract_links(self.response)) == [ Link(url="http://example.com/sample1.html", text=""), Link(url="http://example.com/sample2.html", text="sample 2"), Link(url="http://example.com/sample3.html", text="sample 3 text"), Link( url="http://example.com/sample3.html#foo", text="sample 3 repetition with fragment", ), Link(url="http://www.google.com/something", text=""), Link(url="http://example.com/innertag.html", text="inner tag"), Link(url=page4_url, text="href with whitespaces"), ] def test_extract_filter_allow(self): lx = LxmlLinkExtractor(allow=("sample",)) assert list(lx.extract_links(self.response)) == [ Link(url="http://example.com/sample1.html", text=""), Link(url="http://example.com/sample2.html", text="sample 2"), Link(url="http://example.com/sample3.html", text="sample 3 text"), Link( url="http://example.com/sample3.html#foo", text="sample 3 repetition with fragment", ), ] def test_extract_filter_allow_with_duplicates(self): lx = LxmlLinkExtractor(allow=("sample",), unique=False) assert list(lx.extract_links(self.response)) == [ Link(url="http://example.com/sample1.html", text=""), Link(url="http://example.com/sample2.html", text="sample 2"), Link(url="http://example.com/sample3.html", text="sample 3 text"), Link( url="http://example.com/sample3.html", text="sample 3 repetition", ), Link( url="http://example.com/sample3.html", text="sample 3 repetition", ), Link( url="http://example.com/sample3.html#foo", text="sample 3 repetition with fragment", ), ] def test_extract_filter_allow_with_duplicates_canonicalize(self): lx = LxmlLinkExtractor(allow=("sample",), unique=False, canonicalize=True) assert list(lx.extract_links(self.response)) == [ Link(url="http://example.com/sample1.html", text=""), Link(url="http://example.com/sample2.html", text="sample 2"), Link(url="http://example.com/sample3.html", text="sample 3 text"), Link( url="http://example.com/sample3.html", text="sample 3 repetition", ), Link( url="http://example.com/sample3.html", text="sample 3 repetition", ), Link( url="http://example.com/sample3.html", text="sample 3 repetition with fragment", ), ] def test_extract_filter_allow_no_duplicates_canonicalize(self): lx = LxmlLinkExtractor(allow=("sample",), unique=True, canonicalize=True) assert list(lx.extract_links(self.response)) == [ Link(url="http://example.com/sample1.html", text=""), Link(url="http://example.com/sample2.html", text="sample 2"), Link(url="http://example.com/sample3.html", text="sample 3 text"), ] def test_extract_filter_allow_and_deny(self): lx = LxmlLinkExtractor(allow=("sample",), deny=("3",)) assert list(lx.extract_links(self.response)) == [ Link(url="http://example.com/sample1.html", text=""), Link(url="http://example.com/sample2.html", text="sample 2"), ] def test_extract_filter_allowed_domains(self): lx = LxmlLinkExtractor(allow_domains=("google.com",)) assert list(lx.extract_links(self.response)) == [ Link(url="http://www.google.com/something", text=""), ] def test_extraction_using_single_values(self): """Test the extractor's behaviour among different situations""" lx = LxmlLinkExtractor(allow="sample") assert list(lx.extract_links(self.response)) == [ Link(url="http://example.com/sample1.html", text=""), Link(url="http://example.com/sample2.html", text="sample 2"), Link(url="http://example.com/sample3.html", text="sample 3 text"), Link( url="http://example.com/sample3.html#foo", text="sample 3 repetition with fragment", ), ] lx = LxmlLinkExtractor(allow="sample", deny="3") assert list(lx.extract_links(self.response)) == [ Link(url="http://example.com/sample1.html", text=""), Link(url="http://example.com/sample2.html", text="sample 2"), ] lx = LxmlLinkExtractor(allow_domains="google.com") assert list(lx.extract_links(self.response)) == [ Link(url="http://www.google.com/something", text=""), ] lx = LxmlLinkExtractor(deny_domains="example.com") assert 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'""" html = b"""Page title """ response = HtmlResponse("http://example.org/somepage/index.html", body=html) lx = LxmlLinkExtractor() assert lx.extract_links(response) == [ Link(url="http://example.org/about.html", text="About us"), Link(url="http://example.org/follow.html", text="Follow this link"), Link( url="http://example.org/nofollow.html", text="Dont follow this one", nofollow=True, ), Link( url="http://example.org/nofollow2.html", text="Choose to follow or not", ), Link( url="http://google.com/something", text="External link not to follow", nofollow=True, ), ] def test_matches(self): url1 = "http://lotsofstuff.com/stuff1/index" url2 = "http://evenmorestuff.com/uglystuff/index" lx = LxmlLinkExtractor(allow=(r"stuff1",)) assert lx.matches(url1) assert not lx.matches(url2) lx = LxmlLinkExtractor(deny=(r"uglystuff",)) assert lx.matches(url1) assert not lx.matches(url2) lx = LxmlLinkExtractor(allow_domains=("evenmorestuff.com",)) assert not lx.matches(url1) assert lx.matches(url2) lx = LxmlLinkExtractor(deny_domains=("lotsofstuff.com",)) assert not lx.matches(url1) assert lx.matches(url2) lx = LxmlLinkExtractor( allow=["blah1"], deny=["blah2"], allow_domains=["blah1.com"], deny_domains=["blah2.com"], ) assert lx.matches("http://blah1.com/blah1") assert not lx.matches("http://blah1.com/blah2") assert not lx.matches("http://blah2.com/blah1") assert not lx.matches("http://blah2.com/blah2") def test_restrict_xpaths(self): lx = LxmlLinkExtractor(restrict_xpaths=('//div[@id="subwrapper"]',)) assert list(lx.extract_links(self.response)) == [ Link(url="http://example.com/sample1.html", text=""), Link(url="http://example.com/sample2.html", text="sample 2"), ] def test_restrict_xpaths_encoding(self): """Test restrict_xpaths with encodings""" html = b"""Page title

    Item 12

    """ response = HtmlResponse( "http://example.org/somepage/index.html", body=html, encoding="windows-1252", ) lx = LxmlLinkExtractor(restrict_xpaths="//div[@class='links']") assert lx.extract_links(response) == [ Link(url="http://example.org/about.html", text="About us\xa3") ] def test_restrict_xpaths_with_html_entities(self): html = b'

    text

    ' response = HtmlResponse( "http://example.org/somepage/index.html", body=html, encoding="iso8859-15", ) links = LxmlLinkExtractor(restrict_xpaths="//p").extract_links(response) assert links == [ Link(url="http://example.org/%E2%99%A5/you?c=%A4", text="text") ] def test_restrict_xpaths_concat_in_handle_data(self): """html entities cause SGMLParser to call handle_data hook twice""" body = b"""
    >\xbe\xa9<\xb6\xab""" response = HtmlResponse("http://example.org", body=body, encoding="gb18030") lx = LxmlLinkExtractor(restrict_xpaths="//div") assert lx.extract_links(response) == [ Link( url="http://example.org/foo", text=">\u4eac<\u4e1c", fragment="", nofollow=False, ) ] def test_restrict_css(self): lx = LxmlLinkExtractor(restrict_css=("#subwrapper a",)) assert lx.extract_links(self.response) == [ Link(url="http://example.com/sample2.html", text="sample 2") ] def test_restrict_css_and_restrict_xpaths_together(self): lx = LxmlLinkExtractor( restrict_xpaths=('//div[@id="subwrapper"]',), restrict_css=("#subwrapper + a",), ) assert list(lx.extract_links(self.response)) == [ Link(url="http://example.com/sample1.html", text=""), Link(url="http://example.com/sample2.html", text="sample 2"), Link(url="http://example.com/sample3.html", text="sample 3 text"), ] def test_area_tag_with_unicode_present(self): body = b"""\xbe\xa9""" response = HtmlResponse("http://example.org", body=body, encoding="utf-8") lx = LxmlLinkExtractor() lx.extract_links(response) lx.extract_links(response) lx.extract_links(response) assert lx.extract_links(response) == [ Link( url="http://example.org/foo", text="", fragment="", nofollow=False, ) ] def test_encoded_url(self): body = b"""
    BinB""" response = HtmlResponse("http://known.fm/AC%2FDC/", body=body, encoding="utf8") lx = LxmlLinkExtractor() assert lx.extract_links(response) == [ Link( url="http://known.fm/AC%2FDC/?page=2", text="BinB", fragment="", nofollow=False, ), ] def test_encoded_url_in_restricted_xpath(self): body = b"""
    BinB""" response = HtmlResponse("http://known.fm/AC%2FDC/", body=body, encoding="utf8") lx = LxmlLinkExtractor(restrict_xpaths="//div") assert lx.extract_links(response) == [ Link( url="http://known.fm/AC%2FDC/?page=2", text="BinB", fragment="", nofollow=False, ), ] def test_ignored_extensions(self): # jpg is ignored by default html = b"""asd and """ response = HtmlResponse("http://example.org/", body=html) lx = LxmlLinkExtractor() assert lx.extract_links(response) == [ Link(url="http://example.org/page.html", text="asd"), ] # override denied extensions lx = LxmlLinkExtractor(deny_extensions=["html"]) assert lx.extract_links(response) == [ Link(url="http://example.org/photo.jpg"), ] def test_process_value(self): """Test restrict_xpaths with encodings""" html = b""" Text About us """ response = HtmlResponse( "http://example.org/somepage/index.html", body=html, encoding="windows-1252", ) def process_value(value): m = re.search(r"javascript:goToPage\('(.*?)'", value) return m.group(1) if m else None lx = LxmlLinkExtractor(process_value=process_value) assert lx.extract_links(response) == [ Link(url="http://example.org/other/page.html", text="Text") ] def test_base_url_with_restrict_xpaths(self): html = b"""Page title

    Item 12

    """ response = HtmlResponse("http://example.org/somepage/index.html", body=html) lx = LxmlLinkExtractor(restrict_xpaths="//p") assert lx.extract_links(response) == [ Link(url="http://otherdomain.com/base/item/12.html", text="Item 12") ] def test_attrs(self): lx = LxmlLinkExtractor(attrs="href") page4_url = "http://example.com/page%204.html" assert lx.extract_links(self.response) == [ Link(url="http://example.com/sample1.html", text=""), Link(url="http://example.com/sample2.html", text="sample 2"), Link(url="http://example.com/sample3.html", text="sample 3 text"), Link( url="http://example.com/sample3.html#foo", text="sample 3 repetition with fragment", ), Link(url="http://www.google.com/something", text=""), Link(url="http://example.com/innertag.html", text="inner tag"), Link(url=page4_url, text="href with whitespaces"), ] lx = LxmlLinkExtractor( attrs=("href", "src"), tags=("a", "area", "img"), deny_extensions=() ) assert lx.extract_links(self.response) == [ Link(url="http://example.com/sample1.html", text=""), Link(url="http://example.com/sample2.html", text="sample 2"), Link(url="http://example.com/sample2.jpg", text=""), Link(url="http://example.com/sample3.html", text="sample 3 text"), Link( url="http://example.com/sample3.html#foo", text="sample 3 repetition with fragment", ), Link(url="http://www.google.com/something", text=""), Link(url="http://example.com/innertag.html", text="inner tag"), Link(url=page4_url, text="href with whitespaces"), ] lx = LxmlLinkExtractor(attrs=None) assert lx.extract_links(self.response) == [] def test_tags(self): html = ( b'' b'sample 2' ) response = HtmlResponse("http://example.com/index.html", body=html) lx = LxmlLinkExtractor(tags=None) assert lx.extract_links(response) == [] lx = LxmlLinkExtractor() assert lx.extract_links(response) == [ Link(url="http://example.com/sample1.html", text=""), Link(url="http://example.com/sample2.html", text="sample 2"), ] lx = LxmlLinkExtractor(tags="area") assert lx.extract_links(response) == [ Link(url="http://example.com/sample1.html", text=""), ] lx = LxmlLinkExtractor(tags="a") assert lx.extract_links(response) == [ Link(url="http://example.com/sample2.html", text="sample 2"), ] lx = LxmlLinkExtractor( tags=("a", "img"), attrs=("href", "src"), deny_extensions=() ) assert lx.extract_links(response) == [ Link(url="http://example.com/sample2.html", text="sample 2"), Link(url="http://example.com/sample2.jpg", text=""), ] def test_tags_attrs(self): html = b""" """ response = HtmlResponse("http://example.com/index.html", body=html) lx = LxmlLinkExtractor(tags="div", attrs="data-url") assert lx.extract_links(response) == [ Link( url="http://example.com/get?id=1", text="Item 1", fragment="", nofollow=False, ), Link( url="http://example.com/get?id=2", text="Item 2", fragment="", nofollow=False, ), ] lx = LxmlLinkExtractor(tags=("div",), attrs=("data-url",)) assert lx.extract_links(response) == [ Link( url="http://example.com/get?id=1", text="Item 1", fragment="", nofollow=False, ), Link( url="http://example.com/get?id=2", text="Item 2", fragment="", nofollow=False, ), ] def test_tags_attrs_wildcard_and_deny(self): html = b""" a
    div
    span

    p

    """ response = HtmlResponse("http://example.com/index.html", body=html) def urls(**kwargs: Any) -> list[str]: lx = LxmlLinkExtractor(**kwargs) return [link.url for link in lx.extract_links(response)] # Default behavior is unchanged: only the listed tags and attributes. assert urls() == ["http://example.com/a.html"] # "*" as a tag matches every tag. assert urls(tags="*") == [ "http://example.com/a.html", "http://example.com/span.html", ] # "*" as an attribute matches every attribute. assert urls(tags="*", attrs="*") == [ "http://example.com/a.html", "http://example.com/div.html", "http://example.com/span.html", "http://example.com/p.html", ] # deny_tags excludes tags from the wildcard. assert urls(tags="*", attrs="*", deny_tags="div") == [ "http://example.com/a.html", "http://example.com/span.html", "http://example.com/p.html", ] # deny_attrs excludes attributes from the wildcard. assert urls(tags="*", attrs="*", deny_attrs="data-url") == [ "http://example.com/a.html", "http://example.com/span.html", ] # deny_tags also applies when tags are listed explicitly. assert urls(tags=("a", "span"), attrs="*", deny_tags="span") == [ "http://example.com/a.html", ] # The wildcard for one parameter is independent of the other. assert urls(tags="a", attrs="*") == ["http://example.com/a.html"] def test_xhtml(self): xhtml = b""" XHTML document title """ response: TextResponse = HtmlResponse( "http://example.com/index.xhtml", body=xhtml ) lx = LxmlLinkExtractor() assert lx.extract_links(response) == [ Link( url="http://example.com/about.html", text="About us", fragment="", nofollow=False, ), Link( url="http://example.com/follow.html", text="Follow this link", fragment="", nofollow=False, ), Link( url="http://example.com/nofollow.html", text="Dont follow this one", fragment="", nofollow=True, ), Link( url="http://example.com/nofollow2.html", text="Choose to follow or not", fragment="", nofollow=False, ), Link( url="http://google.com/something", text="External link not to follow", nofollow=True, ), ] response = XmlResponse("http://example.com/index.xhtml", body=xhtml) lx = LxmlLinkExtractor() assert lx.extract_links(response) == [ Link( url="http://example.com/about.html", text="About us", fragment="", nofollow=False, ), Link( url="http://example.com/follow.html", text="Follow this link", fragment="", nofollow=False, ), Link( url="http://example.com/nofollow.html", text="Dont follow this one", fragment="", nofollow=True, ), Link( url="http://example.com/nofollow2.html", text="Choose to follow or not", fragment="", nofollow=False, ), Link( url="http://google.com/something", text="External link not to follow", nofollow=True, ), ] def test_link_wrong_href(self): html = b""" Item 1 Item 2 Item 3 """ response = HtmlResponse("http://example.org/index.html", body=html) lx = LxmlLinkExtractor() assert list(lx.extract_links(response)) == [ Link( url="http://example.org/item1.html", text="Item 1", nofollow=False, ), Link( url="http://example.org/item3.html", text="Item 3", nofollow=False, ), ] def test_ftp_links(self): body = b""" """ response = HtmlResponse( "http://www.example.com/index.html", body=body, encoding="utf8" ) lx = LxmlLinkExtractor() assert lx.extract_links(response) == [ Link( url="ftp://www.external.com/", text="An Item", fragment="", nofollow=False, ), ] def test_pickle_extractor(self): lx = LxmlLinkExtractor() assert isinstance(pickle.loads(pickle.dumps(lx)), LxmlLinkExtractor) 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 = LxmlLinkExtractor(restrict_css=("div",)) response = HtmlResponse( "https://example.com", body=b"""
    a1 b1
    a2 b2
    """, ) actual = lx.extract_links(response) assert 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 = LxmlLinkExtractor(restrict_css=("div",), canonicalize=True) response = HtmlResponse( "https://example.com", body=b"""
    a1 b1
    a2 b2
    """, ) actual = lx.extract_links(response) assert 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 = LxmlLinkExtractor(restrict_css=("div",), unique=False) response = HtmlResponse( "https://example.com", body=b"""
    a1 b1
    a2 b2
    """, ) actual = lx.extract_links(response) assert 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 = LxmlLinkExtractor(restrict_css=("div",), unique=False, canonicalize=True) response = HtmlResponse( "https://example.com", body=b"""
    a1 b1
    a2 b2
    """, ) actual = lx.extract_links(response) assert 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"), ] def test_canonicalize_once_per_link(self, monkeypatch): calls = [] def counting_canonicalize_url(url, *args, **kwargs): calls.append(url) return canonicalize_url(url, *args, **kwargs) monkeypatch.setattr(lxmlhtml, "canonicalize_url", counting_canonicalize_url) response = HtmlResponse( "https://example.com", body=b"".join(b'x' % i for i in range(10)), ) lx = LxmlLinkExtractor(canonicalize=True) assert lx.extract_links(response) == [ Link(url="https://example.com/p?a=1&b=2", text="x") ] assert len(calls) == 10 def test_link_restrict_text(self): html = b""" Pic of a cat Pic of a dog Pic of a cow """ response = HtmlResponse("http://example.org/index.html", body=html) # Simple text inclusion test lx = LxmlLinkExtractor(restrict_text="dog") assert list(lx.extract_links(response)) == [ Link( url="http://example.org/item2.html", text="Pic of a dog", nofollow=False, ), ] # Unique regex test lx = LxmlLinkExtractor(restrict_text=r"of.*dog") assert list(lx.extract_links(response)) == [ Link( url="http://example.org/item2.html", text="Pic of a dog", nofollow=False, ), ] # Multiple regex test lx = LxmlLinkExtractor(restrict_text=[r"of.*dog", r"of.*cat"]) assert list(lx.extract_links(response)) == [ Link( url="http://example.org/item1.html", text="Pic of a cat", nofollow=False, ), Link( url="http://example.org/item2.html", text="Pic of a dog", nofollow=False, ), ] def test_skip_bad_links(self): html = b""" Why would you do this? Good Link Good Link 2 """ response = HtmlResponse("http://example.org/index.html", body=html) lx = LxmlLinkExtractor() assert list(lx.extract_links(response)) == [ Link( url="http://example.org/item2.html", text="Good Link", nofollow=False, ), Link( url="http://example.org/item3.html", text="Good Link 2", nofollow=False, ), ] def test_link_allowed_is_false_with_empty_url(self): bad_link = Link("") assert not LxmlLinkExtractor()._link_allowed(bad_link) def test_link_allowed_is_false_with_bad_url_prefix(self): bad_link = Link("htp://should_be_http.example") assert not LxmlLinkExtractor()._link_allowed(bad_link) def test_link_allowed_is_false_with_missing_url_prefix(self): bad_link = Link("should_have_prefix.example") assert not LxmlLinkExtractor()._link_allowed(bad_link) class TestLxmlParserLinkExtractor: def test_extract_links(self): html = b'Link' response = HtmlResponse("http://example.com/", body=html) lx = LxmlParserLinkExtractor() assert lx.extract_links(response) == [ Link(url="http://example.com/page.html", text="Link", nofollow=False), ] def test_deduplicates_by_canonical_url(self): # With canonicalized=False, the default, URLs are canonicalized to # decide whether two extracted links are the same one. html = ( b'1' b'2' ) response = HtmlResponse("http://example.com/", body=html) lx = LxmlParserLinkExtractor(unique=True) assert lx.extract_links(response) == [ Link(url="http://example.com/page.html?b=2&a=1", text="1", nofollow=False), ] def test_strip_false(self): # With strip=False, trailing whitespace on a relative href survives urljoin # and is visible to process_value (safe_url_string cleans it up afterward). # Here process_value rejects URLs that still carry trailing whitespace, # demonstrating the difference from strip=True. def reject_trailing_whitespace(url): return None if url != url.rstrip() else url html = b'Link' response = HtmlResponse("http://example.com/", body=html) lx_strip = LxmlParserLinkExtractor( strip=True, process=reject_trailing_whitespace ) assert lx_strip.extract_links(response) == [ Link(url="http://example.com/page.html", text="Link", nofollow=False), ] lx_no_strip = LxmlParserLinkExtractor( strip=False, process=reject_trailing_whitespace ) assert lx_no_strip.extract_links(response) == [] scrapy-scrapy-5569de3/tests/test_loader.py000066400000000000000000000466251525051012400207110ustar00rootroot00000000000000from __future__ import annotations import dataclasses import attr import pytest from itemadapter import ItemAdapter from itemloaders.processors import Compose, Identity, MapCompose, TakeFirst from scrapy.http import HtmlResponse, Response from scrapy.item import Field, Item from scrapy.loader import ItemLoader from scrapy.selector import Selector # test items class NameItem(Item): name = Field() class SummaryItem(NameItem): url = Field() summary = Field() class NestedItem(Item): name = Field() name_div = Field() name_value = Field() url = Field() image = Field() @attr.s class AttrsNameItem: name = attr.ib(default="") @dataclasses.dataclass class NameDataClass: name: list[str] = dataclasses.field(default_factory=list) # test item loaders class NameItemLoader(ItemLoader): default_item_class = SummaryItem class NestedItemLoader(ItemLoader): default_item_class = NestedItem class ProcessorItemLoader(NameItemLoader): name_in = MapCompose(lambda v: v.title()) class TestBasicItemLoader: def test_add_value_on_unknown_field(self): il = ProcessorItemLoader() with pytest.raises(KeyError): il.add_value("wrong_field", ["lala", "lolo"]) def test_load_item_using_default_loader(self): i = SummaryItem() i["summary"] = "lala" il = ItemLoader(item=i) il.add_value("name", "marta") item = il.load_item() assert item is i assert item["summary"] == ["lala"] assert item["name"] == ["marta"] def test_load_item_using_custom_loader(self): il = ProcessorItemLoader() il.add_value("name", "marta") item = il.load_item() assert item["name"] == ["Marta"] class InitializationTestMixin: item_class: type def test_keep_single_value(self): """Loaded item should contain values from the initial item""" input_item = self.item_class(name="foo") il = ItemLoader(item=input_item) loaded_item = il.load_item() assert isinstance(loaded_item, self.item_class) assert ItemAdapter(loaded_item).asdict() == {"name": ["foo"]} def test_keep_list(self): """Loaded item should contain values from the initial item""" input_item = self.item_class(name=["foo", "bar"]) il = ItemLoader(item=input_item) loaded_item = il.load_item() assert isinstance(loaded_item, self.item_class) assert ItemAdapter(loaded_item).asdict() == {"name": ["foo", "bar"]} def test_add_value_singlevalue_singlevalue(self): """Values added after initialization should be appended""" input_item = self.item_class(name="foo") il = ItemLoader(item=input_item) il.add_value("name", "bar") loaded_item = il.load_item() assert isinstance(loaded_item, self.item_class) assert ItemAdapter(loaded_item).asdict() == {"name": ["foo", "bar"]} def test_add_value_singlevalue_list(self): """Values added after initialization should be appended""" input_item = self.item_class(name="foo") il = ItemLoader(item=input_item) il.add_value("name", ["item", "loader"]) loaded_item = il.load_item() assert isinstance(loaded_item, self.item_class) assert ItemAdapter(loaded_item).asdict() == {"name": ["foo", "item", "loader"]} def test_add_value_list_singlevalue(self): """Values added after initialization should be appended""" input_item = self.item_class(name=["foo", "bar"]) il = ItemLoader(item=input_item) il.add_value("name", "qwerty") loaded_item = il.load_item() assert isinstance(loaded_item, self.item_class) assert ItemAdapter(loaded_item).asdict() == {"name": ["foo", "bar", "qwerty"]} def test_add_value_list_list(self): """Values added after initialization should be appended""" input_item = self.item_class(name=["foo", "bar"]) il = ItemLoader(item=input_item) il.add_value("name", ["item", "loader"]) loaded_item = il.load_item() assert isinstance(loaded_item, self.item_class) assert ItemAdapter(loaded_item).asdict() == { "name": ["foo", "bar", "item", "loader"] } def test_get_output_value_singlevalue(self): """Getting output value must not remove value from item""" input_item = self.item_class(name="foo") il = ItemLoader(item=input_item) assert il.get_output_value("name") == ["foo"] loaded_item = il.load_item() assert isinstance(loaded_item, self.item_class) assert ItemAdapter(loaded_item).asdict() == {"name": ["foo"]} def test_get_output_value_list(self): """Getting output value must not remove value from item""" input_item = self.item_class(name=["foo", "bar"]) il = ItemLoader(item=input_item) assert il.get_output_value("name") == ["foo", "bar"] loaded_item = il.load_item() assert isinstance(loaded_item, self.item_class) assert ItemAdapter(loaded_item).asdict() == {"name": ["foo", "bar"]} def test_values_single(self): """Values from initial item must be added to loader._values""" input_item = self.item_class(name="foo") il = ItemLoader(item=input_item) assert il._values.get("name") == ["foo"] def test_values_list(self): """Values from initial item must be added to loader._values""" input_item = self.item_class(name=["foo", "bar"]) il = ItemLoader(item=input_item) assert il._values.get("name") == ["foo", "bar"] class TestInitializationFromDict(InitializationTestMixin): item_class = dict class TestInitializationFromItem(InitializationTestMixin): item_class = NameItem class TestInitializationFromAttrsItem(InitializationTestMixin): item_class = AttrsNameItem class TestInitializationFromDataClass(InitializationTestMixin): item_class = NameDataClass class BaseNoInputReprocessingLoader(ItemLoader): title_in = MapCompose(str.upper) title_out = TakeFirst() class NoInputReprocessingItem(Item): title = Field() class NoInputReprocessingItemLoader(BaseNoInputReprocessingLoader): default_item_class = NoInputReprocessingItem class TestNoInputReprocessingFromItem: """ Loaders initialized from loaded items must not reprocess fields (Item instances) """ def test_avoid_reprocessing_with_initial_values_single(self): il = NoInputReprocessingItemLoader(item=NoInputReprocessingItem(title="foo")) il_loaded = il.load_item() assert il_loaded == {"title": "foo"} assert NoInputReprocessingItemLoader(item=il_loaded).load_item() == { "title": "foo" } def test_avoid_reprocessing_with_initial_values_list(self): il = NoInputReprocessingItemLoader( item=NoInputReprocessingItem(title=["foo", "bar"]) ) il_loaded = il.load_item() assert il_loaded == {"title": "foo"} assert NoInputReprocessingItemLoader(item=il_loaded).load_item() == { "title": "foo" } def test_avoid_reprocessing_without_initial_values_single(self): il = NoInputReprocessingItemLoader() il.add_value("title", "FOO") il_loaded = il.load_item() assert il_loaded == {"title": "FOO"} assert NoInputReprocessingItemLoader(item=il_loaded).load_item() == { "title": "FOO" } def test_avoid_reprocessing_without_initial_values_list(self): il = NoInputReprocessingItemLoader() il.add_value("title", ["foo", "bar"]) il_loaded = il.load_item() assert il_loaded == {"title": "FOO"} assert NoInputReprocessingItemLoader(item=il_loaded).load_item() == { "title": "FOO" } class TestOutputProcessorItem: def test_output_processor(self): class TempItem(Item): temp = Field() def __init__(self, *args, **kwargs): super().__init__(self, *args, **kwargs) self.setdefault("temp", 0.3) class TempLoader(ItemLoader): default_item_class = TempItem default_input_processor = Identity() default_output_processor = Compose(TakeFirst()) loader = TempLoader() item = loader.load_item() assert isinstance(item, TempItem) assert dict(item) == {"temp": 0.3} class TestSelectortemLoader: response = HtmlResponse( url="", encoding="utf-8", body=b"""
    marta

    paragraph

    homepage Scrapy """, ) def test_init_method(self): l = ProcessorItemLoader() assert l.selector is None def test_init_method_errors(self): l = ProcessorItemLoader() with pytest.raises(RuntimeError): l.add_xpath("url", "//a/@href") with pytest.raises(RuntimeError): l.replace_xpath("url", "//a/@href") with pytest.raises(RuntimeError): l.get_xpath("//a/@href") with pytest.raises(RuntimeError): l.add_css("name", "#name::text") with pytest.raises(RuntimeError): l.replace_css("name", "#name::text") with pytest.raises(RuntimeError): l.get_css("#name::text") def test_init_method_with_selector(self): sel = Selector(text="
    marta
    ") l = ProcessorItemLoader(selector=sel) assert l.selector is sel l.add_xpath("name", "//div/text()") assert l.get_output_value("name") == ["Marta"] def test_init_method_with_selector_css(self): sel = Selector(text="
    marta
    ") l = ProcessorItemLoader(selector=sel) assert l.selector is sel l.add_css("name", "div::text") assert l.get_output_value("name") == ["Marta"] def test_init_method_with_base_response(self): """Selector should be None after initialization""" response = Response("https://scrapy.org") l = ProcessorItemLoader(response=response) # type: ignore[arg-type] assert l.selector is None def test_init_method_with_response(self): l = ProcessorItemLoader(response=self.response) assert l.selector l.add_xpath("name", "//div/text()") assert l.get_output_value("name") == ["Marta"] def test_init_method_with_response_css(self): l = ProcessorItemLoader(response=self.response) assert l.selector l.add_css("name", "div::text") assert l.get_output_value("name") == ["Marta"] l.add_css("url", "a::attr(href)") assert l.get_output_value("url") == ["http://www.scrapy.org"] # combining/accumulating CSS selectors and XPath expressions l.add_xpath("name", "//div/text()") assert l.get_output_value("name") == ["Marta", "Marta"] l.add_xpath("url", "//img/@src") assert l.get_output_value("url") == [ "http://www.scrapy.org", "/images/logo.png", ] def test_add_xpath_re(self): l = ProcessorItemLoader(response=self.response) l.add_xpath("name", "//div/text()", re="ma") assert l.get_output_value("name") == ["Ma"] def test_replace_xpath(self): l = ProcessorItemLoader(response=self.response) assert l.selector l.add_xpath("name", "//div/text()") assert l.get_output_value("name") == ["Marta"] l.replace_xpath("name", "//p/text()") assert l.get_output_value("name") == ["Paragraph"] l.replace_xpath("name", ["//p/text()", "//div/text()"]) assert l.get_output_value("name") == ["Paragraph", "Marta"] def test_get_xpath(self): l = ProcessorItemLoader(response=self.response) assert l.get_xpath("//p/text()") == ["paragraph"] assert l.get_xpath("//p/text()", TakeFirst()) == "paragraph" assert l.get_xpath("//p/text()", TakeFirst(), re="pa") == "pa" assert l.get_xpath(["//p/text()", "//div/text()"]) == ["paragraph", "marta"] def test_replace_xpath_multi_fields(self): l = ProcessorItemLoader(response=self.response) l.add_xpath(None, "//div/text()", TakeFirst(), lambda x: {"name": x}) assert l.get_output_value("name") == ["Marta"] l.replace_xpath(None, "//p/text()", TakeFirst(), lambda x: {"name": x}) assert l.get_output_value("name") == ["Paragraph"] def test_replace_xpath_re(self): l = ProcessorItemLoader(response=self.response) assert l.selector l.add_xpath("name", "//div/text()") assert l.get_output_value("name") == ["Marta"] l.replace_xpath("name", "//div/text()", re="ma") assert l.get_output_value("name") == ["Ma"] def test_add_css_re(self): l = ProcessorItemLoader(response=self.response) l.add_css("name", "div::text", re="ma") assert l.get_output_value("name") == ["Ma"] l.add_css("url", "a::attr(href)", re="http://(.+)") assert l.get_output_value("url") == ["www.scrapy.org"] def test_replace_css(self): l = ProcessorItemLoader(response=self.response) assert l.selector l.add_css("name", "div::text") assert l.get_output_value("name") == ["Marta"] l.replace_css("name", "p::text") assert l.get_output_value("name") == ["Paragraph"] l.replace_css("name", ["p::text", "div::text"]) assert l.get_output_value("name") == ["Paragraph", "Marta"] l.add_css("url", "a::attr(href)", re="http://(.+)") assert l.get_output_value("url") == ["www.scrapy.org"] l.replace_css("url", "img::attr(src)") assert l.get_output_value("url") == ["/images/logo.png"] def test_get_css(self): l = ProcessorItemLoader(response=self.response) assert l.get_css("p::text") == ["paragraph"] assert l.get_css("p::text", TakeFirst()) == "paragraph" assert l.get_css("p::text", TakeFirst(), re="pa") == "pa" assert l.get_css(["p::text", "div::text"]) == ["paragraph", "marta"] assert l.get_css(["a::attr(href)", "img::attr(src)"]) == [ "http://www.scrapy.org", "/images/logo.png", ] def test_replace_css_multi_fields(self): l = ProcessorItemLoader(response=self.response) l.add_css(None, "div::text", TakeFirst(), lambda x: {"name": x}) assert l.get_output_value("name") == ["Marta"] l.replace_css(None, "p::text", TakeFirst(), lambda x: {"name": x}) assert l.get_output_value("name") == ["Paragraph"] l.add_css(None, "a::attr(href)", TakeFirst(), lambda x: {"url": x}) assert l.get_output_value("url") == ["http://www.scrapy.org"] l.replace_css(None, "img::attr(src)", TakeFirst(), lambda x: {"url": x}) assert l.get_output_value("url") == ["/images/logo.png"] def test_replace_css_re(self): l = ProcessorItemLoader(response=self.response) assert l.selector l.add_css("url", "a::attr(href)") assert l.get_output_value("url") == ["http://www.scrapy.org"] l.replace_css("url", "a::attr(href)", re=r"http://www\.(.+)") assert l.get_output_value("url") == ["scrapy.org"] class TestSubselectorLoader: response = HtmlResponse( url="", encoding="utf-8", body=b"""
    marta

    paragraph

    """, ) def test_nested_xpath(self): l = NestedItemLoader(response=self.response) nl = l.nested_xpath("//header") assert nl.selector is not None nl.add_xpath("name", "div/text()") nl.add_css("name_div", "#id") nl.add_value("name_value", nl.selector.xpath('div[@id = "id"]/text()').getall()) assert l.get_output_value("name") == ["marta"] assert l.get_output_value("name_div") == ['
    marta
    '] assert l.get_output_value("name_value") == ["marta"] assert l.get_output_value("name") == nl.get_output_value("name") assert l.get_output_value("name_div") == nl.get_output_value("name_div") assert l.get_output_value("name_value") == nl.get_output_value("name_value") def test_nested_css(self): l = NestedItemLoader(response=self.response) nl = l.nested_css("header") assert nl.selector is not None nl.add_xpath("name", "div/text()") nl.add_css("name_div", "#id") nl.add_value("name_value", nl.selector.xpath('div[@id = "id"]/text()').getall()) assert l.get_output_value("name") == ["marta"] assert l.get_output_value("name_div") == ['
    marta
    '] assert l.get_output_value("name_value") == ["marta"] assert l.get_output_value("name") == nl.get_output_value("name") assert l.get_output_value("name_div") == nl.get_output_value("name_div") assert l.get_output_value("name_value") == nl.get_output_value("name_value") def test_nested_replace(self): l = NestedItemLoader(response=self.response) nl1 = l.nested_xpath("//footer") nl2 = nl1.nested_xpath("a") l.add_xpath("url", "//footer/a/@href") assert l.get_output_value("url") == ["http://www.scrapy.org"] nl1.replace_xpath("url", "img/@src") assert l.get_output_value("url") == ["/images/logo.png"] nl2.replace_xpath("url", "@href") assert l.get_output_value("url") == ["http://www.scrapy.org"] def test_nested_ordering(self): l = NestedItemLoader(response=self.response) nl1 = l.nested_xpath("//footer") nl2 = nl1.nested_xpath("a") nl1.add_xpath("url", "img/@src") l.add_xpath("url", "//footer/a/@href") nl2.add_xpath("url", "text()") l.add_xpath("url", "//footer/a/@href") assert l.get_output_value("url") == [ "/images/logo.png", "http://www.scrapy.org", "homepage", "http://www.scrapy.org", ] def test_nested_load_item(self): l = NestedItemLoader(response=self.response) nl1 = l.nested_xpath("//footer") nl2 = nl1.nested_xpath("img") l.add_xpath("name", "//header/div/text()") nl1.add_xpath("url", "a/@href") nl2.add_xpath("image", "@src") item = l.load_item() assert item is l.item assert item is nl1.item assert item is nl2.item assert item["name"] == ["marta"] assert item["url"] == ["http://www.scrapy.org"] assert item["image"] == ["/images/logo.png"] # Functions as processors def function_processor_strip(iterable): return [x.strip() for x in iterable] def function_processor_upper(iterable): return [x.upper() for x in iterable] class FunctionProcessorItem(Item): foo = Field( input_processor=function_processor_strip, output_processor=function_processor_upper, ) class FunctionProcessorItemLoader(ItemLoader): default_item_class = FunctionProcessorItem class TestFunctionProcessor: def test_processor_defined_in_item(self): lo = FunctionProcessorItemLoader() lo.add_value("foo", " bar ") lo.add_value("foo", [" asdf ", " qwerty "]) assert dict(lo.load_item()) == {"foo": ["BAR", "ASDF", "QWERTY"]} scrapy-scrapy-5569de3/tests/test_logformatter.py000066400000000000000000000274521525051012400221450ustar00rootroot00000000000000from __future__ import annotations import logging from typing import TYPE_CHECKING, Any, cast import pytest from twisted.python.failure import Failure from scrapy.exceptions import DropItem from scrapy.http import Request, Response from scrapy.item import Field, Item from scrapy.logformatter import LogFormatter from scrapy.spiders import Spider from scrapy.utils.misc import build_from_crawler from scrapy.utils.test import get_crawler from tests.spiders import ItemSpider from tests.utils.decorators import coroutine_test if TYPE_CHECKING: from tests.mockserver.http import MockServer class CustomItem(Item): name = Field() def __str__(self): return f"name: {self['name']}" class TestLogFormatter: def setup_method(self): crawler = get_crawler() self.formatter = build_from_crawler(LogFormatter, crawler) self.spider = Spider.from_crawler(crawler, "default") def test_crawled_without_referer(self): req = Request("http://www.example.com") res = Response("http://www.example.com") logkws = self.formatter.crawled(req, res, self.spider) logline = logkws["msg"] % logkws["args"] assert logline == "Crawled (200) (referer: None)" def test_crawled_with_referer(self): req = Request( "http://www.example.com", headers={"referer": "http://example.com"} ) res = Response("http://www.example.com", flags=["cached"]) logkws = self.formatter.crawled(req, res, self.spider) logline = logkws["msg"] % logkws["args"] assert ( logline == "Crawled (200) (referer: http://example.com) ['cached']" ) def test_flags_in_request(self): req = Request("http://www.example.com", flags=["test", "flag"]) res = Response("http://www.example.com") logkws = self.formatter.crawled(req, res, self.spider) logline = logkws["msg"] % logkws["args"] assert ( logline == "Crawled (200) ['test', 'flag'] (referer: None)" ) def test_dropped(self): item: dict[str, Any] = {} exception = Exception("\u2018") response = Response("http://www.example.com") logkws = self.formatter.dropped(item, exception, response, self.spider) logline = logkws["msg"] % logkws["args"] lines = logline.splitlines() assert all(isinstance(x, str) for x in lines) assert lines == ["Dropped: \u2018", "{}"] def test_dropitem_default_log_level(self): item: dict[str, Any] = {} exception = DropItem("Test drop") response = Response("http://www.example.com") spider = Spider.from_crawler(get_crawler(Spider), "foo") logkws = self.formatter.dropped(item, exception, response, spider) assert logkws["level"] == logging.WARNING spider.crawler.settings.frozen = False spider.crawler.settings["DEFAULT_DROPITEM_LOG_LEVEL"] = logging.INFO spider.crawler.settings.frozen = True logkws = self.formatter.dropped(item, exception, response, spider) assert logkws["level"] == logging.INFO spider.crawler.settings.frozen = False spider.crawler.settings["DEFAULT_DROPITEM_LOG_LEVEL"] = "INFO" spider.crawler.settings.frozen = True logkws = self.formatter.dropped(item, exception, response, spider) assert logkws["level"] == logging.INFO spider.crawler.settings.frozen = False spider.crawler.settings["DEFAULT_DROPITEM_LOG_LEVEL"] = 10 spider.crawler.settings.frozen = True logkws = self.formatter.dropped(item, exception, response, spider) assert logkws["level"] == logging.DEBUG spider.crawler.settings.frozen = False spider.crawler.settings["DEFAULT_DROPITEM_LOG_LEVEL"] = 0 spider.crawler.settings.frozen = True logkws = self.formatter.dropped(item, exception, response, spider) assert logkws["level"] == logging.NOTSET unsupported_value = object() spider.crawler.settings.frozen = False spider.crawler.settings["DEFAULT_DROPITEM_LOG_LEVEL"] = unsupported_value spider.crawler.settings.frozen = True logkws = self.formatter.dropped(item, exception, response, spider) assert logkws["level"] == unsupported_value with pytest.raises(TypeError): logging.log(logkws["level"], "message") # noqa: LOG015 def test_dropitem_custom_log_level(self): item: dict[str, Any] = {} response = Response("http://www.example.com") exception = DropItem("Test drop", log_level="INFO") logkws = self.formatter.dropped(item, exception, response, self.spider) assert logkws["level"] == logging.INFO exception = DropItem("Test drop", log_level="ERROR") logkws = self.formatter.dropped(item, exception, response, self.spider) assert logkws["level"] == logging.ERROR def test_item_error(self): # In practice, the complete traceback is shown by passing the # 'exc_info' argument to the logging function item = {"key": "value"} exception = Exception() response = Response("http://www.example.com") logkws = self.formatter.item_error(item, exception, response, self.spider) logline = logkws["msg"] % logkws["args"] assert logline == "Error processing {'key': 'value'}" def test_spider_error(self): # In practice, the complete traceback is shown by passing the # 'exc_info' argument to the logging function failure = Failure(Exception()) request = Request( "http://www.example.com", headers={"Referer": "http://example.org"} ) response = Response("http://www.example.com", request=request) logkws = self.formatter.spider_error(failure, request, response, self.spider) logline = logkws["msg"] % logkws["args"] assert ( logline == "Spider error processing (referer: http://example.org)" ) def test_download_error_short(self): # In practice, the complete traceback is shown by passing the # 'exc_info' argument to the logging function failure = Failure(Exception()) request = Request("http://www.example.com") logkws = self.formatter.download_error(failure, request, self.spider) logline = logkws["msg"] % logkws["args"] assert logline == "Error downloading " def test_download_error_long(self): # In practice, the complete traceback is shown by passing the # 'exc_info' argument to the logging function failure = Failure(Exception()) request = Request("http://www.example.com") logkws = self.formatter.download_error( failure, request, self.spider, "Some message" ) logline = logkws["msg"] % logkws["args"] assert logline == "Error downloading : Some message" def test_scraped(self): item = CustomItem() item["name"] = "\xa3" response = Response("http://www.example.com") logkws = self.formatter.scraped(item, response, self.spider) logline = logkws["msg"] % logkws["args"] lines = logline.splitlines() assert all(isinstance(x, str) for x in lines) assert lines == ["Scraped from <200 http://www.example.com>", "name: \xa3"] class LogFormatterSubclass(LogFormatter): def crawled(self, request, response, spider): kwargs = super().crawled(request, response, spider) CRAWLEDMSG = "Crawled (%(status)s) %(request)s (referer: %(referer)s) %(flags)s" log_args = cast("dict[str, Any]", kwargs["args"]) log_args["flags"] = str(request.flags) return { "level": kwargs["level"], "msg": CRAWLEDMSG, "args": log_args, } class TestLogformatterSubclass(TestLogFormatter): def setup_method(self): crawler = get_crawler(Spider) self.formatter = build_from_crawler(LogFormatterSubclass, crawler) self.spider = Spider.from_crawler(crawler, "default") def test_crawled_without_referer(self): req = Request("http://www.example.com") res = Response("http://www.example.com") logkws = self.formatter.crawled(req, res, self.spider) logline = logkws["msg"] % logkws["args"] assert ( logline == "Crawled (200) (referer: None) []" ) def test_crawled_with_referer(self): req = Request( "http://www.example.com", headers={"referer": "http://example.com"}, flags=["cached"], ) res = Response("http://www.example.com") logkws = self.formatter.crawled(req, res, self.spider) logline = logkws["msg"] % logkws["args"] assert ( logline == "Crawled (200) (referer: http://example.com) ['cached']" ) def test_flags_in_request(self): req = Request("http://www.example.com", flags=["test", "flag"]) res = Response("http://www.example.com") logkws = self.formatter.crawled(req, res, self.spider) logline = logkws["msg"] % logkws["args"] assert ( logline == "Crawled (200) (referer: None) ['test', 'flag']" ) class SkipMessagesLogFormatter(LogFormatter): def crawled(self, *args, **kwargs): return None def scraped(self, *args, **kwargs): return None def dropped(self, *args, **kwargs): return None class DropSomeItemsPipeline: drop = True def process_item(self, item): if self.drop: self.drop = False raise DropItem("Ignoring item") self.drop = True class TestShowOrSkipMessages: def setup_method(self): self.base_settings: dict[str, Any] = { "LOG_LEVEL": "DEBUG", "ITEM_PIPELINES": { DropSomeItemsPipeline: 300, }, } @coroutine_test async def test_show_messages( self, caplog: pytest.LogCaptureFixture, mockserver: MockServer ) -> None: crawler = get_crawler(ItemSpider, self.base_settings) with caplog.at_level(logging.DEBUG): await crawler.crawl_async(mockserver=mockserver) assert "Scraped from <200 http://127.0.0.1:" in caplog.text assert "Crawled (200) None: crawler = get_crawler(ItemSpider, self.base_settings) with caplog.at_level(logging.DEBUG): await crawler.crawl_async(mockserver=mockserver) scraped_record = next( r for r in caplog.records if r.message.startswith("Scraped from") ) dropped_record = next( r for r in caplog.records if r.message.startswith("Dropped: Ignoring item") ) assert hasattr(scraped_record, "item") assert isinstance(cast("Any", dropped_record).item, (Item, dict)) @coroutine_test async def test_skip_messages( self, caplog: pytest.LogCaptureFixture, mockserver: MockServer ) -> None: settings = self.base_settings.copy() settings["LOG_FORMATTER"] = SkipMessagesLogFormatter crawler = get_crawler(ItemSpider, settings) with caplog.at_level(logging.DEBUG): await crawler.crawl_async(mockserver=mockserver) assert "Scraped from <200 http://127.0.0.1:" not in caplog.text assert "Crawled (200) Crawler: crawler = get_crawler(SimpleSpider) crawler.spider = crawler._create_spider("spidey") return crawler @pytest.fixture def stats(crawler: Crawler) -> StatsCollector: stats = crawler.stats stats.set_value("response_received_count", 4802) stats.set_value("item_scraped_count", 3201) return stats @coroutine_test async def test_stats_calculations(crawler: Crawler, stats: StatsCollector) -> None: logstats = build_from_crawler(LogStats, crawler) with pytest.raises(AttributeError): logstats.pagesprev with pytest.raises(AttributeError): logstats.itemsprev assert crawler.spider logstats.spider_opened(crawler.spider) assert logstats.pagesprev == 4802 assert logstats.itemsprev == 3201 logstats.calculate_stats() assert logstats.items == 3201 assert logstats.pages == 4802 assert logstats.irate == 0.0 assert logstats.prate == 0.0 assert logstats.pagesprev == 4802 assert logstats.itemsprev == 3201 # Simulate what happens after a minute stats.set_value("response_received_count", 5187) stats.set_value("item_scraped_count", 3492) logstats.calculate_stats() assert logstats.items == 3492 assert logstats.pages == 5187 assert logstats.irate == 291.0 assert logstats.prate == 385.0 assert logstats.pagesprev == 5187 assert logstats.itemsprev == 3492 # Simulate when spider closes after running for 30 mins stats.set_value("start_time", datetime.fromtimestamp(1655100172)) stats.set_value("finish_time", datetime.fromtimestamp(1655101972)) logstats.spider_closed(crawler.spider, "test reason") assert stats.get_value("responses_per_minute") == 172.9 assert stats.get_value("items_per_minute") == 116.4 def test_stats_calculations_no_time(crawler: Crawler, stats: StatsCollector) -> None: """The stat values should be None since the start and finish time are not available. """ logstats = build_from_crawler(LogStats, crawler) assert crawler.spider logstats.spider_closed(crawler.spider, "test reason") assert stats.get_value("responses_per_minute") is None assert stats.get_value("items_per_minute") is None def test_stats_calculation_no_elapsed_time( crawler: Crawler, stats: StatsCollector ) -> None: """The stat values should be None since the elapsed time is 0.""" logstats = build_from_crawler(LogStats, crawler) stats.set_value("start_time", datetime.fromtimestamp(1655100172)) stats.set_value("finish_time", datetime.fromtimestamp(1655100172)) assert crawler.spider logstats.spider_closed(crawler.spider, "test reason") assert stats.get_value("responses_per_minute") is None assert stats.get_value("items_per_minute") is None scrapy-scrapy-5569de3/tests/test_mail.py000066400000000000000000000123251525051012400203530ustar00rootroot00000000000000from email.charset import Charset from io import BytesIO import pytest from twisted.internet import defer from twisted.internet._sslverify import ClientTLSOptions pytestmark = pytest.mark.filterwarnings( "ignore:The scrapy.mail module is deprecated:scrapy.exceptions.ScrapyDeprecationWarning" ) from scrapy.mail import MailSender # noqa: E402 @pytest.mark.requires_reactor # MailSender requires a reactor class TestMailSender: def _catch_mail_sent(self, **kwargs): self.catched_msg = {**kwargs} def test_send(self): mailsender = MailSender(debug=True) mailsender.send( to=["test@scrapy.org"], subject="subject", body="body", _callback=self._catch_mail_sent, ) assert self.catched_msg assert self.catched_msg["to"] == ["test@scrapy.org"] assert self.catched_msg["subject"] == "subject" assert self.catched_msg["body"] == "body" msg = self.catched_msg["msg"] assert msg["to"] == "test@scrapy.org" assert msg["subject"] == "subject" assert msg.get_payload() == "body" assert msg.get("Content-Type") == "text/plain" def test_send_single_values_to_and_cc(self): mailsender = MailSender(debug=True) mailsender.send( to="test@scrapy.org", subject="subject", body="body", cc="test@scrapy.org", _callback=self._catch_mail_sent, ) def test_send_html(self): mailsender = MailSender(debug=True) mailsender.send( to=["test@scrapy.org"], subject="subject", body="

    body

    ", mimetype="text/html", _callback=self._catch_mail_sent, ) msg = self.catched_msg["msg"] assert msg.get_payload() == "

    body

    " assert msg.get("Content-Type") == "text/html" def test_send_attach(self): attach = BytesIO() attach.write(b"content") attach.seek(0) attachs = [("attachment", "text/plain", attach)] mailsender = MailSender(debug=True) mailsender.send( to=["test@scrapy.org"], subject="subject", body="body", attachs=attachs, _callback=self._catch_mail_sent, ) assert self.catched_msg assert self.catched_msg["to"] == ["test@scrapy.org"] assert self.catched_msg["subject"] == "subject" assert self.catched_msg["body"] == "body" msg = self.catched_msg["msg"] assert msg["to"] == "test@scrapy.org" assert msg["subject"] == "subject" payload = msg.get_payload() assert isinstance(payload, list) assert len(payload) == 2 text, attach = payload assert text.get_payload(decode=True) == b"body" assert text.get_charset() == Charset("us-ascii") assert attach.get_payload(decode=True) == b"content" def test_send_utf8(self): subject = "sübjèçt" body = "bödÿ-àéïöñß" mailsender = MailSender(debug=True) mailsender.send( to=["test@scrapy.org"], subject=subject, body=body, charset="utf-8", _callback=self._catch_mail_sent, ) assert self.catched_msg assert self.catched_msg["subject"] == subject assert self.catched_msg["body"] == body msg = self.catched_msg["msg"] assert msg["subject"] == subject assert msg.get_payload(decode=True).decode("utf-8") == body assert msg.get_charset() == Charset("utf-8") assert msg.get("Content-Type") == 'text/plain; charset="utf-8"' def test_send_attach_utf8(self): subject = "sübjèçt" body = "bödÿ-àéïöñß" attach = BytesIO() attach.write(body.encode("utf-8")) attach.seek(0) attachs = [("attachment", "text/plain", attach)] mailsender = MailSender(debug=True) mailsender.send( to=["test@scrapy.org"], subject=subject, body=body, attachs=attachs, charset="utf-8", _callback=self._catch_mail_sent, ) assert self.catched_msg assert self.catched_msg["subject"] == subject assert self.catched_msg["body"] == body msg = self.catched_msg["msg"] assert msg["subject"] == subject assert msg.get_charset() == Charset("utf-8") assert msg.get("Content-Type") == 'multipart/mixed; charset="utf-8"' payload = msg.get_payload() assert isinstance(payload, list) assert len(payload) == 2 text, attach = payload assert text.get_payload(decode=True).decode("utf-8") == body assert text.get_charset() == Charset("utf-8") assert attach.get_payload(decode=True).decode("utf-8") == body def test_create_sender_factory_with_host(self): mailsender = MailSender(debug=False, smtphost="smtp.testhost.com") factory = mailsender._create_sender_factory( to_addrs=["test@scrapy.org"], msg=BytesIO(b"test"), d=defer.Deferred() ) context = factory.buildProtocol("test@scrapy.org").context assert isinstance(context, ClientTLSOptions) scrapy-scrapy-5569de3/tests/test_middleware.py000066400000000000000000000060301525051012400215420ustar00rootroot00000000000000from __future__ import annotations from typing import TYPE_CHECKING import pytest from scrapy import Spider from scrapy.exceptions import NotConfigured, ScrapyDeprecationWarning from scrapy.middleware import MiddlewareManager from scrapy.utils.misc import build_from_crawler from scrapy.utils.test import get_crawler if TYPE_CHECKING: from scrapy.crawler import Crawler class M1: def open_spider(self, spider): pass def close_spider(self, spider): pass def process(self, response, request): pass class M2: def open_spider(self, spider): pass def close_spider(self, spider): pass class M3: def process(self, response, request): pass class MOff: def open_spider(self, spider): pass def close_spider(self, spider): pass def __init__(self): raise NotConfigured("foo") class MyMiddlewareManager(MiddlewareManager): component_name = "my" @classmethod def _get_mwlist_from_settings(cls, settings): return [M1, MOff, M3] def _add_middleware(self, mw): if hasattr(mw, "open_spider"): self.methods["open_spider"].append(mw.open_spider) if hasattr(mw, "close_spider"): self.methods["close_spider"].appendleft(mw.close_spider) if hasattr(mw, "process"): self.methods["process"].append(mw.process) @pytest.fixture def crawler() -> Crawler: return get_crawler(Spider) def test_init(crawler: Crawler) -> None: m1, m2, m3 = M1(), M2(), M3() mwman = MyMiddlewareManager(m1, m2, m3, crawler=crawler) assert list(mwman.methods["open_spider"]) == [m1.open_spider, m2.open_spider] assert list(mwman.methods["close_spider"]) == [m2.close_spider, m1.close_spider] assert list(mwman.methods["process"]) == [m1.process, m3.process] assert mwman.crawler == crawler def test_methods(crawler: Crawler) -> None: mwman = MyMiddlewareManager(M1(), M2(), M3(), crawler=crawler) assert [x.__self__.__class__ for x in mwman.methods["open_spider"]] == [M1, M2] # type: ignore[union-attr] assert [x.__self__.__class__ for x in mwman.methods["close_spider"]] == [M2, M1] # type: ignore[union-attr] assert [x.__self__.__class__ for x in mwman.methods["process"]] == [M1, M3] # type: ignore[union-attr] def test_enabled(crawler: Crawler) -> None: m1, m2, m3 = M1(), M2(), M3() mwman = MyMiddlewareManager(m1, m2, m3, crawler=crawler) assert mwman.middlewares == (m1, m2, m3) def test_enabled_from_settings(crawler: Crawler) -> None: crawler = get_crawler() mwman = build_from_crawler(MyMiddlewareManager, crawler) classes = [x.__class__ for x in mwman.middlewares] assert classes == [M1, M3] assert mwman.crawler == crawler def test_no_crawler() -> None: m1, m2, m3 = M1(), M2(), M3() with pytest.warns( ScrapyDeprecationWarning, match="was called without the crawler argument" ): mwman = MyMiddlewareManager(m1, m2, m3) assert mwman.middlewares == (m1, m2, m3) assert mwman.crawler is None scrapy-scrapy-5569de3/tests/test_pipeline_crawl.py000066400000000000000000000214141525051012400224250ustar00rootroot00000000000000from __future__ import annotations import logging import shutil from pathlib import Path from tempfile import mkdtemp from typing import TYPE_CHECKING, Any import pytest from w3lib.url import add_or_replace_parameter from scrapy import Spider, signals from scrapy.utils.misc import load_object from scrapy.utils.test import get_crawler from tests.mockserver.http import MockServer from tests.spiders import SimpleSpider from tests.utils.decorators import coroutine_test if TYPE_CHECKING: from scrapy.crawler import Crawler class MediaDownloadSpider(SimpleSpider): name = "mediadownload" media_key: str media_urls_key: str def _process_url(self, url: str) -> str: return url def parse(self, response): self.logger.info(response.headers) self.logger.info(response.text) item = { self.media_key: [], self.media_urls_key: [ self._process_url(response.urljoin(href)) for href in response.xpath( '//table[thead/tr/th="Filename"]/tbody//a/@href' ).getall() ], } yield item class BrokenLinksMediaDownloadSpider(MediaDownloadSpider): name = "brokenmedia" def _process_url(self, url: str) -> str: return url + ".foo" class RedirectedMediaDownloadSpider(MediaDownloadSpider): name = "redirectedmedia" def _process_url(self, url: str) -> str: assert self.mockserver return add_or_replace_parameter( self.mockserver.url("/redirect-to"), "goto", url ) class TestFileDownloadCrawl: mockserver: MockServer pipeline_class = "scrapy.pipelines.files.FilesPipeline" store_setting_key = "FILES_STORE" media_key = "files" media_urls_key = "file_urls" expected_checksums: set[str] | None = { "5547178b89448faf0015a13f904c936e", "c2281c83670e31d8aaab7cb642b824db", "ed3f6538dc15d4d9179dae57319edc5f", } @classmethod def setup_class(cls): cls.mockserver = MockServer() cls.mockserver.__enter__() @classmethod def teardown_class(cls): cls.mockserver.__exit__(None, None, None) def setup_method(self): # prepare a directory for storing files self.tmpmediastore = Path(mkdtemp()) self.settings = { "ITEM_PIPELINES": {self.pipeline_class: 1}, self.store_setting_key: str(self.tmpmediastore), } self.items = [] def teardown_method(self): shutil.rmtree(self.tmpmediastore) self.items = [] def _on_item_scraped(self, item): self.items.append(item) def _create_crawler( self, spider_class: type[Spider], settings: dict[str, Any] | None = None ) -> Crawler: if settings is None: settings = self.settings crawler = get_crawler(spider_class, settings) crawler.signals.connect(self._on_item_scraped, signals.item_scraped) return crawler def _assert_files_downloaded(self, items: list[Any], logs: str) -> None: assert len(items) == 1 assert self.media_key in items[0] # check that logs show the expected number of successful file downloads file_dl_success = "File (downloaded): Downloaded file from" assert logs.count(file_dl_success) == 3 # check that the images/files status is `downloaded` for item in items: for i in item[self.media_key]: assert i["status"] == "downloaded" # check that the images/files checksums are what we know they should be if self.expected_checksums is not None: checksums = {i["checksum"] for item in items for i in item[self.media_key]} assert checksums == self.expected_checksums # check that the image files where actually written to the media store for item in items: for i in item[self.media_key]: assert (self.tmpmediastore / i["path"]).exists() def _assert_files_download_failure( self, crawler: Crawler, items: list[Any], code: int, logs: str ) -> None: # check that the item does NOT have the "images/files" field populated assert len(items) == 1 assert self.media_key in items[0] assert not items[0][self.media_key] # check that there was 1 successful fetch and 3 other responses with non-200 code assert crawler.stats.get_value("downloader/request_method_count/GET") == 4 assert crawler.stats.get_value("downloader/response_count") == 4 assert crawler.stats.get_value("downloader/response_status_count/200") == 1 assert crawler.stats.get_value(f"downloader/response_status_count/{code}") == 3 # check that logs do show the failure on the file downloads file_dl_failure = f"File (code: {code}): Error downloading file from" assert logs.count(file_dl_failure) == 3 # check that no files were written to the media store assert not list(self.tmpmediastore.iterdir()) @coroutine_test async def test_download_media(self, caplog: pytest.LogCaptureFixture) -> None: crawler = self._create_crawler(MediaDownloadSpider) with caplog.at_level(logging.DEBUG): await crawler.crawl_async( self.mockserver.url("/static/files/images/"), media_key=self.media_key, media_urls_key=self.media_urls_key, ) self._assert_files_downloaded(self.items, caplog.text) @coroutine_test async def test_download_media_wrong_urls( self, caplog: pytest.LogCaptureFixture ) -> None: crawler = self._create_crawler(BrokenLinksMediaDownloadSpider) with caplog.at_level(logging.DEBUG): await crawler.crawl_async( self.mockserver.url("/static/files/images/"), media_key=self.media_key, media_urls_key=self.media_urls_key, ) self._assert_files_download_failure(crawler, self.items, 404, caplog.text) @coroutine_test async def test_download_media_redirected_default_failure( self, caplog: pytest.LogCaptureFixture ): crawler = self._create_crawler(RedirectedMediaDownloadSpider) with caplog.at_level(logging.DEBUG): await crawler.crawl_async( self.mockserver.url("/static/files/images/"), media_key=self.media_key, media_urls_key=self.media_urls_key, mockserver=self.mockserver, ) self._assert_files_download_failure(crawler, self.items, 302, caplog.text) @coroutine_test async def test_download_media_redirected_allowed( self, caplog: pytest.LogCaptureFixture ) -> None: settings = { **self.settings, "MEDIA_ALLOW_REDIRECTS": True, } crawler = self._create_crawler(RedirectedMediaDownloadSpider, settings) with caplog.at_level(logging.DEBUG): await crawler.crawl_async( self.mockserver.url("/static/files/images/"), media_key=self.media_key, media_urls_key=self.media_urls_key, mockserver=self.mockserver, ) self._assert_files_downloaded(self.items, caplog.text) assert crawler.stats.get_value("downloader/response_status_count/302") == 3 @coroutine_test async def test_download_media_file_path_error( self, caplog: pytest.LogCaptureFixture ) -> None: cls = load_object(self.pipeline_class) class ExceptionRaisingMediaPipeline(cls): # type: ignore[misc,valid-type] def file_path(self, request, response=None, info=None, *, item=None): return 1 / 0 settings = { **self.settings, "ITEM_PIPELINES": {ExceptionRaisingMediaPipeline: 1}, } crawler = self._create_crawler(MediaDownloadSpider, settings) with caplog.at_level(logging.DEBUG): await crawler.crawl_async( self.mockserver.url("/static/files/images/"), media_key=self.media_key, media_urls_key=self.media_urls_key, mockserver=self.mockserver, ) assert "ZeroDivisionError" in caplog.text pillow_available: bool try: from PIL import Image # noqa: F401 except ImportError: pillow_available = False else: pillow_available = True @pytest.mark.skipif( not pillow_available, reason="Missing Python Imaging Library, install https://pypi.org/pypi/Pillow", ) class TestImageDownloadCrawl(TestFileDownloadCrawl): pipeline_class = "scrapy.pipelines.images.ImagesPipeline" store_setting_key = "IMAGES_STORE" media_key = "images" media_urls_key = "image_urls" # somehow checksums for images are different for Python 3.3 expected_checksums = None scrapy-scrapy-5569de3/tests/test_pipeline_files.py000066400000000000000000001340301525051012400224160ustar00rootroot00000000000000import base64 import dataclasses import logging import mimetypes import random import re import time from abc import ABC, abstractmethod from datetime import datetime from ftplib import FTP from io import BytesIO from pathlib import Path from posixpath import split from shutil import rmtree from tempfile import mkdtemp from typing import Any from unittest import mock from unittest.mock import MagicMock import attr import pytest from itemadapter import ItemAdapter from twisted.internet.defer import Deferred from twisted.python.failure import Failure from scrapy.crawler import Crawler from scrapy.exceptions import IgnoreRequest, NotConfigured, ScrapyDeprecationWarning from scrapy.http import Request, Response from scrapy.item import Field, Item from scrapy.pipelines import files from scrapy.pipelines.files import ( FilesPipeline, FSFilesStore, FTPFilesStore, GCSFilesStore, S3FilesStore, ) from scrapy.pipelines.media import FileException, _MediaRequestFiltered from scrapy.settings import Settings from scrapy.utils.asyncio import call_later from scrapy.utils.defer import maybe_deferred_to_future from scrapy.utils.misc import build_from_crawler from scrapy.utils.spider import DefaultSpider from scrapy.utils.test import get_crawler from tests.mockserver.ftp import MockFTPServer from tests.utils.decorators import coroutine_test, inline_callbacks_test from .utils.cloud import mock_google_cloud_storage from .utils.media_pipelines import DUMMY_SPIDER_INFO, mocked_download_func def get_ftp_content_and_delete( path: str, host: str, port: int, username: str, password: str, use_active_mode: bool = False, ) -> bytes: with FTP() as ftp: ftp.connect(host, port) ftp.login(username, password) if use_active_mode: ftp.set_pasv(False) ftp_data: list[bytes] = [] def buffer_data(data: bytes) -> None: ftp_data.append(data) ftp.retrbinary(f"RETR {path}", buffer_data) dirname, filename = split(path) ftp.cwd(dirname) ftp.delete(filename) return b"".join(ftp_data) class DeferredFSFilesStore(FSFilesStore): """A simple store with persist_file() returning a deferred.""" def persist_file(self, path, buf, info, meta=None, headers=None): deferred: Deferred[None] = Deferred() # short-hand super() doesn't work in nested functions parent_persist_file = super().persist_file def cb(): parent_persist_file(path, buf, info, meta=meta, headers=headers) deferred.callback(None) call_later(0.5, cb) return deferred class TestFilesPipeline: def setup_method(self): self.tempdir = mkdtemp() self.pipeline = self._create_pipeline(FilesPipeline) def teardown_method(self): rmtree(self.tempdir) def _create_pipeline( self, pipeline_cls: type[FilesPipeline], settings: dict[str, Any] | None = None, ) -> FilesPipeline: crawler = get_crawler( DefaultSpider, {"FILES_STORE": self.tempdir, **(settings or {})} ) crawler.spider = crawler._create_spider() crawler.engine = MagicMock(download_async=mocked_download_func) pipeline = build_from_crawler(pipeline_cls, crawler) pipeline.open_spider() return pipeline def test_file_path_query_parameters(self): file_path = self.pipeline.file_path req1 = Request("http://foo.bar/baz.txt?fizz") assert file_path(req1) == "full/a2b4913a62f65445aeae2bac08cd8c3b41d7195e.txt" req2 = Request("http://foo.bar/get_img.foo?file=photo.jpg") assert file_path(req2) == "full/7fc9461c9fd836515bea6983373097203a7d748e.jpg" def test_file_path(self): file_path = self.pipeline.file_path assert ( file_path(Request("https://dev.mydeco.com/mydeco.pdf")) == "full/c9b564df929f4bc635bdd19fde4f3d4847c757c5.pdf" ) assert ( file_path( Request( "http://www.maddiebrown.co.uk///catalogue-items//image_54642_12175_95307.txt" ) ) == "full/4ce274dd83db0368bafd7e406f382ae088e39219.txt" ) assert ( file_path( Request("https://dev.mydeco.com/two/dirs/with%20spaces%2Bsigns.doc") ) == "full/94ccc495a17b9ac5d40e3eabf3afcb8c2c9b9e1a.doc" ) assert ( file_path( Request( "http://www.dfsonline.co.uk/get_prod_image?img=status_0907_mdm.jpg" ) ) == "full/c67f916ff9d542e822dedf38f9fcb146d1faba78.jpg" ) assert ( file_path(Request("http://www.dorma.co.uk/images/product_details/2532/")) == "full/97ee6f8a46cbbb418ea91502fd24176865cf39b2" ) assert ( file_path(Request("http://www.dorma.co.uk/images/product_details/2532")) == "full/244e0dd7d96a3b7b01f54eded250c9e272577aa1" ) assert ( file_path( Request("http://www.dorma.co.uk/images/product_details/2532"), response=Response("http://www.dorma.co.uk/images/product_details/2532"), info=object(), # type: ignore[arg-type] ) == "full/244e0dd7d96a3b7b01f54eded250c9e272577aa1" ) assert ( file_path( Request( "http://www.dfsonline.co.uk/get_prod_image?img=status_0907_mdm.jpg.bohaha" ) ) == "full/e75f2fa260521b56f6b6a867447b8002d00b5841" ) assert ( file_path( Request( "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAR0AAACxCAMAAADOHZloAAACClBMVEX/\ //+F0tzCwMK76ZKQ21AMqr7oAAC96JvD5aWM2kvZ78J0N7fmAAC46Y4Ap7y" ) ) == "full/178059cbeba2e34120a67f2dc1afc3ecc09b61cb.png" ) def test_fs_store(self): assert isinstance(self.pipeline.store, FSFilesStore) assert self.pipeline.store.basedir == self.tempdir path = "some/image/key.jpg" fullpath = Path(self.tempdir, "some", "image", "key.jpg") assert self.pipeline.store._get_filesystem_path(path) == fullpath @coroutine_test async def test_file_not_expired(self): item_url = "http://example.com/file.pdf" item = _create_item_with_files(item_url) with ( mock.patch.object(FilesPipeline, "inc_stats", return_value=True), mock.patch.object( FSFilesStore, "stat_file", return_value={"checksum": "abc", "last_modified": time.time()}, ), mock.patch.object( FilesPipeline, "get_media_requests", return_value=[_prepare_request_object(item_url)], ), ): result = await self.pipeline.process_item(item) assert result["files"][0]["checksum"] == "abc" assert result["files"][0]["status"] == "uptodate" @coroutine_test async def test_file_expired(self): item_url = "http://example.com/file2.pdf" item = _create_item_with_files(item_url) with ( mock.patch.object( FSFilesStore, "stat_file", return_value={ "checksum": "abc", "last_modified": time.time() - (self.pipeline.expires * 60 * 60 * 24 * 2), }, ), mock.patch.object( FilesPipeline, "get_media_requests", return_value=[_prepare_request_object(item_url)], ), mock.patch.object(FilesPipeline, "inc_stats", return_value=True), ): result = await self.pipeline.process_item(item) assert result["files"][0]["checksum"] != "abc" assert result["files"][0]["status"] == "downloaded" @coroutine_test async def test_file_cached(self): item_url = "http://example.com/file3.pdf" item = _create_item_with_files(item_url) with ( mock.patch.object(FilesPipeline, "inc_stats", return_value=True), mock.patch.object( FSFilesStore, "stat_file", return_value={ "checksum": "abc", "last_modified": time.time() - (self.pipeline.expires * 60 * 60 * 24 * 2), }, ), mock.patch.object( FilesPipeline, "get_media_requests", return_value=[_prepare_request_object(item_url, flags=["cached"])], ), ): result = await self.pipeline.process_item(item) assert result["files"][0]["checksum"] != "abc" assert result["files"][0]["status"] == "cached" @coroutine_test async def test_file_stat_without_last_modified(self) -> None: """A stat result without a last modification time forces a download.""" item_url = "http://example.com/file4.pdf" item = _create_item_with_files(item_url) with ( mock.patch.object(FilesPipeline, "inc_stats", return_value=True), mock.patch.object( FSFilesStore, "stat_file", return_value={"checksum": "abc"} ), mock.patch.object( FilesPipeline, "get_media_requests", return_value=[_prepare_request_object(item_url)], ), ): result = await self.pipeline.process_item(item) assert result["files"][0]["checksum"] != "abc" assert result["files"][0]["status"] == "downloaded" @coroutine_test async def test_file_empty_content(self, caplog: pytest.LogCaptureFixture) -> None: item_url = "http://example.com/empty.pdf" item = _create_item_with_files(item_url) request = Request( item_url, meta={"response": Response(item_url, status=200, body=b"")} ) with ( caplog.at_level(logging.WARNING), mock.patch.object( FilesPipeline, "get_media_requests", return_value=[request] ), ): result = await self.pipeline.process_item(item) assert result["files"] == [] assert "File (empty-content): Empty file from" in caplog.text @coroutine_test async def test_file_downloaded_file_exception( self, caplog: pytest.LogCaptureFixture ) -> None: """A FileException from file_downloaded() is logged as a warning and kept as is.""" class FailingFilesPipeline(FilesPipeline): def file_downloaded(self, response, request, info, *, item=None): raise FileException("boom") item_url = "http://example.com/file5.pdf" item = _create_item_with_files(item_url) pipeline = self._create_pipeline(FailingFilesPipeline) with ( caplog.at_level(logging.WARNING), mock.patch.object( FilesPipeline, "get_media_requests", return_value=[_prepare_request_object(item_url)], ), ): result = await pipeline.process_item(item) assert result["files"] == [] records = [ r for r in caplog.records if "Error processing file" in r.getMessage() ] assert len(records) == 1 assert records[0].levelname == "WARNING" assert "boom" in records[0].getMessage() @coroutine_test async def test_file_downloaded_unknown_error( self, caplog: pytest.LogCaptureFixture ) -> None: """Any other exception from file_downloaded() is logged as an error and reported as a FileException.""" class FailingFilesPipeline(FilesPipeline): def file_downloaded(self, response, request, info, *, item=None): raise RuntimeError("boom") item_url = "http://example.com/file6.pdf" item = _create_item_with_files(item_url) pipeline = self._create_pipeline(FailingFilesPipeline) with ( caplog.at_level(logging.WARNING), mock.patch.object( FilesPipeline, "get_media_requests", return_value=[_prepare_request_object(item_url)], ), ): result = await pipeline.process_item(item) assert result["files"] == [] records = [ r for r in caplog.records if "Error processing file" in r.getMessage() ] assert len(records) == 1 assert records[0].levelname == "ERROR" exc_info = records[0].exc_info assert exc_info is not None assert exc_info[0] is RuntimeError @coroutine_test async def test_async_store(self) -> None: """Test that async persist_file() works and is awaited.""" self.pipeline.store = DeferredFSFilesStore(self.tempdir) item_url = "http://example.com/file.pdf" item = _create_item_with_files(item_url) with ( mock.patch.object(FilesPipeline, "inc_stats", return_value=True), mock.patch.object( FilesPipeline, "get_media_requests", return_value=[_prepare_request_object(item_url)], ), ): result = await self.pipeline.process_item(item) assert result["files"][0]["status"] == "downloaded" assert result["files"][0]["checksum"] # check that the file was written by persist_file() path = Path(self.tempdir) / result["files"][0]["path"] assert path.exists() assert path.read_bytes() == b"data" def test_file_path_from_item(self): """ Custom file path based on item data, overriding default implementation """ class CustomFilesPipeline(FilesPipeline): def file_path(self, request, response=None, info=None, item=None) -> str: return f"full/{item.get('path')}" file_path = build_from_crawler( CustomFilesPipeline, get_crawler(None, {"FILES_STORE": self.tempdir}) ).file_path item = {"path": "path-to-store-file"} request = Request("http://example.com") assert file_path(request, item=item) == "full/path-to-store-file" @coroutine_test async def test_file_path_from_response(self) -> None: """file_path() may build the path out of the response, e.g. to get the file extension from a response header, as long as FILES_EXPIRES is 0 to disable the up-to-date check, which runs before the download and hence cannot reach the same path.""" class ContentTypeFilesPipeline(FilesPipeline): def file_path(self, request, response=None, info=None, *, item=None): path = super().file_path(request, response, info, item=item) if response is None: return path content_type = response.headers["Content-Type"].decode() return path + (mimetypes.guess_extension(content_type) or "") item_url = "http://example.com/download?id=1" item = _create_item_with_files(item_url) pipeline = self._create_pipeline(ContentTypeFilesPipeline, {"FILES_EXPIRES": 0}) request = _prepare_request_object( item_url, headers={"Content-Type": "application/pdf"} ) with ( mock.patch.object(FilesPipeline, "inc_stats", return_value=True), # A fresh file at the response-less path is ignored thanks to # FILES_EXPIRES being 0. mock.patch.object( FSFilesStore, "stat_file", return_value={"checksum": "abc", "last_modified": time.time() - 1}, ), mock.patch.object( FilesPipeline, "get_media_requests", return_value=[request] ), ): result = await pipeline.process_item(item) file_info = result["files"][0] assert file_info["status"] == "downloaded" assert file_info["path"].endswith(".pdf") assert (Path(self.tempdir) / file_info["path"]).read_bytes() == b"data" def test_media_failed_filtered_request( self, caplog: pytest.LogCaptureFixture ) -> None: """A filtered media request (IgnoreRequest) is reported as a _MediaRequestFiltered exception and logged at the DEBUG level, instead of as a download error with a traceback.""" request = Request("http://example.com/file.pdf") reason = "Filtered offsite request to 'example.com'" failure = Failure(IgnoreRequest(reason)) with ( caplog.at_level(logging.DEBUG), pytest.raises(_MediaRequestFiltered, match=re.escape(reason)), ): self.pipeline.media_failed(failure, request, self.pipeline.spiderinfo) assert len(caplog.records) == 1 record = caplog.records[0] assert record.levelname == "DEBUG" assert record.exc_info is None assert reason in record.getMessage() def test_media_failed_download_error( self, caplog: pytest.LogCaptureFixture ) -> None: """A genuine download error is reported as a FileException and logged as a warning.""" request = Request("http://example.com/file.pdf") failure = Failure(Exception("boom")) with caplog.at_level(logging.WARNING), pytest.raises(FileException): self.pipeline.media_failed(failure, request, self.pipeline.spiderinfo) assert len(caplog.records) == 1 assert caplog.records[0].levelname == "WARNING" @coroutine_test async def test_process_item_filtered_request( self, caplog: pytest.LogCaptureFixture ) -> None: """A filtered (e.g. offsite) media request is processed as a failed result without being logged as an error with a traceback.""" item_url = "http://example.com/file.pdf" item = _create_item_with_files(item_url) request = Request( item_url, meta={ "response": IgnoreRequest("Filtered offsite request to 'example.com'") }, ) with ( caplog.at_level(logging.DEBUG), mock.patch.object( FilesPipeline, "get_media_requests", return_value=[request] ), ): result = await self.pipeline.process_item(item) assert result["files"] == [] assert not any(r.levelname in ("WARNING", "ERROR") for r in caplog.records) assert any( "Filtered offsite request to 'example.com'" in r.getMessage() for r in caplog.records ) @pytest.mark.parametrize( "bad_type", [ "http://example.com/file.pdf", ("http://example.com/file.pdf",), {"url": "http://example.com/file.pdf"}, 123, None, ], ) def test_rejects_non_list_file_urls(self, tmp_path, bad_type): pipeline = build_from_crawler( FilesPipeline, get_crawler(None, {"FILES_STORE": str(tmp_path)}) ) item = ItemWithFiles() item["file_urls"] = bad_type with pytest.raises(TypeError, match="file_urls must be a list of URLs"): list(pipeline.get_media_requests(item, None)) # type: ignore[arg-type] class TestFilesPipelineFieldsMixin(ABC): @property @abstractmethod def item_class(self) -> Any: raise NotImplementedError def test_item_fields_default(self, tmp_path): url = "http://www.example.com/files/1.txt" item = self.item_class(name="item1", file_urls=[url]) pipeline = build_from_crawler( FilesPipeline, get_crawler(None, {"FILES_STORE": tmp_path}) ) requests = list(pipeline.get_media_requests(item, None)) # type: ignore[arg-type] assert requests[0].url == url results = [(True, {"url": url})] item = pipeline.item_completed(results, item, None) # type: ignore[arg-type] files = ItemAdapter(item).get("files") assert files == [results[0][1]] assert isinstance(item, self.item_class) def test_item_fields_override_settings(self, tmp_path): url = "http://www.example.com/files/1.txt" item = self.item_class(name="item1", custom_file_urls=[url]) pipeline = build_from_crawler( FilesPipeline, get_crawler( None, { "FILES_STORE": tmp_path, "FILES_URLS_FIELD": "custom_file_urls", "FILES_RESULT_FIELD": "custom_files", }, ), ) requests = list(pipeline.get_media_requests(item, None)) # type: ignore[arg-type] assert requests[0].url == url results = [(True, {"url": url})] item = pipeline.item_completed(results, item, None) # type: ignore[arg-type] custom_files = ItemAdapter(item).get("custom_files") assert custom_files == [results[0][1]] assert isinstance(item, self.item_class) class TestFilesPipelineFieldsDict(TestFilesPipelineFieldsMixin): item_class = dict class FilesPipelineTestItem(Item): name = Field() # default fields file_urls = Field() files = Field() # overridden fields custom_file_urls = Field() custom_files = Field() class TestFilesPipelineFieldsItem(TestFilesPipelineFieldsMixin): item_class = FilesPipelineTestItem @dataclasses.dataclass class FilesPipelineTestDataClass: name: str # default fields file_urls: list[str] = dataclasses.field(default_factory=list) files: list[dict[str, str]] = dataclasses.field(default_factory=list) # overridden fields custom_file_urls: list[str] = dataclasses.field(default_factory=list) custom_files: list[dict[str, str]] = dataclasses.field(default_factory=list) class TestFilesPipelineFieldsDataClass(TestFilesPipelineFieldsMixin): item_class = FilesPipelineTestDataClass @attr.s class FilesPipelineTestAttrsItem: name = attr.ib(default="") # default fields file_urls: list[str] = attr.ib(default=list) files: list[dict[str, str]] = attr.ib(default=list) # overridden fields custom_file_urls: list[str] = attr.ib(default=list) custom_files: list[dict[str, str]] = attr.ib(default=list) class TestFilesPipelineFieldsAttrsItem(TestFilesPipelineFieldsMixin): item_class = FilesPipelineTestAttrsItem class TestFilesPipelineCustomSettings: default_cls_settings = { "EXPIRES": 90, "FILES_URLS_FIELD": "file_urls", "FILES_RESULT_FIELD": "files", } file_cls_attr_settings_map = { ("EXPIRES", "FILES_EXPIRES", "expires"), ("FILES_URLS_FIELD", "FILES_URLS_FIELD", "files_urls_field"), ("FILES_RESULT_FIELD", "FILES_RESULT_FIELD", "files_result_field"), } def _generate_fake_settings( self, tmp_path: Path, prefix: str | None = None ) -> dict[str, Any]: def random_string() -> str: return "".join([chr(random.randint(97, 123)) for _ in range(10)]) settings = { "FILES_EXPIRES": random.randint(100, 1000), "FILES_URLS_FIELD": random_string(), "FILES_RESULT_FIELD": random_string(), "FILES_STORE": tmp_path, } if not prefix: return settings return { prefix.upper() + "_" + k if k != "FILES_STORE" else k: v for k, v in settings.items() } def _generate_fake_pipeline(self) -> type[FilesPipeline]: class UserDefinedFilePipeline(FilesPipeline): EXPIRES = 1001 FILES_URLS_FIELD = "alfa" FILES_RESULT_FIELD = "beta" return UserDefinedFilePipeline def test_different_settings_for_different_instances(self, tmp_path): """ If there are different instances with different settings they should keep different settings. """ custom_settings = self._generate_fake_settings(tmp_path) another_pipeline = build_from_crawler( FilesPipeline, get_crawler(None, custom_settings) ) one_pipeline = build_from_crawler( FilesPipeline, get_crawler(None, {"FILES_STORE": tmp_path}) ) for pipe_attr, settings_attr, pipe_ins_attr in self.file_cls_attr_settings_map: default_value = self.default_cls_settings[pipe_attr] assert getattr(one_pipeline, pipe_attr) == default_value custom_value = custom_settings[settings_attr] assert default_value != custom_value assert getattr(another_pipeline, pipe_ins_attr) == custom_value def test_subclass_attributes_preserved_if_no_settings(self, tmp_path): """ If subclasses override class attributes and there are no special settings those values should be kept. """ pipe_cls = self._generate_fake_pipeline() pipe = build_from_crawler( pipe_cls, get_crawler(None, {"FILES_STORE": tmp_path}) ) for pipe_attr, _, pipe_ins_attr in self.file_cls_attr_settings_map: custom_value = getattr(pipe, pipe_ins_attr) assert custom_value != self.default_cls_settings[pipe_attr] assert getattr(pipe, pipe_ins_attr) == getattr(pipe, pipe_attr) def test_subclass_attrs_preserved_custom_settings(self, tmp_path): """ If file settings are defined but they are not defined for subclass settings should be preserved. """ pipeline_cls = self._generate_fake_pipeline() settings = self._generate_fake_settings(tmp_path) pipeline = build_from_crawler(pipeline_cls, get_crawler(None, settings)) for pipe_attr, settings_attr, pipe_ins_attr in self.file_cls_attr_settings_map: value = getattr(pipeline, pipe_ins_attr) setting_value = settings.get(settings_attr) assert value != self.default_cls_settings[pipe_attr] assert value == setting_value def test_no_custom_settings_for_subclasses(self, tmp_path): """ If there are no settings for subclass and no subclass attributes, pipeline should use attributes of base class. """ class UserDefinedFilesPipeline(FilesPipeline): pass user_pipeline = build_from_crawler( UserDefinedFilesPipeline, get_crawler(None, {"FILES_STORE": tmp_path}) ) for pipe_attr, _, pipe_ins_attr in self.file_cls_attr_settings_map: # Values from settings for custom pipeline should be set on pipeline instance. custom_value = self.default_cls_settings.get(pipe_attr.upper()) assert getattr(user_pipeline, pipe_ins_attr) == custom_value def test_custom_settings_for_subclasses(self, tmp_path): """ If there are custom settings for subclass and NO class attributes, pipeline should use custom settings. """ class UserDefinedFilesPipeline(FilesPipeline): pass prefix = UserDefinedFilesPipeline.__name__.upper() settings = self._generate_fake_settings(tmp_path, prefix=prefix) user_pipeline = build_from_crawler( UserDefinedFilesPipeline, get_crawler(None, settings) ) for pipe_attr, settings_attr, pipe_inst_attr in self.file_cls_attr_settings_map: # Values from settings for custom pipeline should be set on pipeline instance. custom_value = settings.get(prefix + "_" + settings_attr) assert custom_value != self.default_cls_settings[pipe_attr] assert getattr(user_pipeline, pipe_inst_attr) == custom_value def test_custom_settings_and_class_attrs_for_subclasses(self, tmp_path): """ If there are custom settings for subclass AND class attributes setting keys are preferred and override attributes. """ pipeline_cls = self._generate_fake_pipeline() prefix = pipeline_cls.__name__.upper() settings = self._generate_fake_settings(tmp_path, prefix=prefix) user_pipeline = build_from_crawler(pipeline_cls, get_crawler(None, settings)) for ( pipe_cls_attr, settings_attr, pipe_inst_attr, ) in self.file_cls_attr_settings_map: custom_value = settings.get(prefix + "_" + settings_attr) assert custom_value != self.default_cls_settings[pipe_cls_attr] assert getattr(user_pipeline, pipe_inst_attr) == custom_value def test_cls_attrs_with_DEFAULT_prefix(self, tmp_path): class UserDefinedFilesPipeline(FilesPipeline): DEFAULT_FILES_RESULT_FIELD = "this" DEFAULT_FILES_URLS_FIELD = "that" pipeline = build_from_crawler( UserDefinedFilesPipeline, get_crawler(None, {"FILES_STORE": tmp_path}) ) assert ( pipeline.files_result_field == UserDefinedFilesPipeline.DEFAULT_FILES_RESULT_FIELD ) assert ( pipeline.files_urls_field == UserDefinedFilesPipeline.DEFAULT_FILES_URLS_FIELD ) def test_user_defined_subclass_default_key_names(self, tmp_path): """Test situation when user defines subclass of FilesPipeline, but uses attribute names for default pipeline (without prefixing them with pipeline class name). """ settings = self._generate_fake_settings(tmp_path) class UserPipe(FilesPipeline): pass pipeline_cls = build_from_crawler(UserPipe, get_crawler(None, settings)) for _, settings_attr, pipe_inst_attr in self.file_cls_attr_settings_map: expected_value = settings.get(settings_attr) assert getattr(pipeline_cls, pipe_inst_attr) == expected_value def test_file_pipeline_using_pathlike_objects(self, tmp_path): class CustomFilesPipelineWithPathLikeDir(FilesPipeline): def file_path(self, request, response=None, info=None, *, item=None) -> str: return str(Path("subdir") / Path(request.url).name) pipeline = build_from_crawler( CustomFilesPipelineWithPathLikeDir, get_crawler(None, {"FILES_STORE": tmp_path}), ) request = Request("http://example.com/image01.jpg") assert pipeline.file_path(request) == str(Path("subdir/image01.jpg")) class TestFSFilesStore: def test_constructor_with_pathlike_object(self, tmp_path: Path) -> None: assert FSFilesStore(tmp_path).basedir == str(tmp_path) def test_constructor_with_uri(self, tmp_path: Path) -> None: assert FSFilesStore(f"file://{tmp_path}").basedir == str(tmp_path) def test_stat_file(self, tmp_path: Path) -> None: store = FSFilesStore(tmp_path) store.persist_file("full/filename", BytesIO(b"data"), DUMMY_SPIDER_INFO) stat = store.stat_file("full/filename", DUMMY_SPIDER_INFO) assert stat["checksum"] == "8d777f385d3dfec8815d20f7496026dc" assert stat["last_modified"] == pytest.approx(time.time(), abs=60) def test_stat_missing_file(self, tmp_path: Path) -> None: store = FSFilesStore(tmp_path) assert store.stat_file("full/filename", DUMMY_SPIDER_INFO) == {} @pytest.mark.requires_botocore class TestS3FilesStore: @inline_callbacks_test def test_persist(self): bucket = "mybucket" key = "export.csv" uri = f"s3://{bucket}/{key}" buffer = mock.MagicMock() meta = {"foo": "bar"} path = "" content_type = "image/png" store = S3FilesStore(uri) from botocore.stub import Stubber # noqa: PLC0415 with Stubber(store.s3_client) as stub: stub.add_response( "put_object", expected_params={ "ACL": S3FilesStore.POLICY, "Body": buffer, "Bucket": bucket, "CacheControl": S3FilesStore.HEADERS["Cache-Control"], "ContentType": content_type, "Key": key, "Metadata": meta, }, service_response={}, ) yield store.persist_file( path, buffer, info=DUMMY_SPIDER_INFO, meta=meta, headers={"Content-Type": content_type}, ) stub.assert_no_pending_responses() # The call to read does not happen with Stubber assert buffer.method_calls == [mock.call.seek(0)] @inline_callbacks_test def test_persist_without_headers(self): """Without custom headers only the default ones are sent.""" bucket = "mybucket" key = "export.csv" buffer = mock.MagicMock() store = S3FilesStore(f"s3://{bucket}/{key}") from botocore.stub import Stubber # noqa: PLC0415 with Stubber(store.s3_client) as stub: stub.add_response( "put_object", expected_params={ "ACL": S3FilesStore.POLICY, "Body": buffer, "Bucket": bucket, "CacheControl": S3FilesStore.HEADERS["Cache-Control"], "Key": key, "Metadata": {}, }, service_response={}, ) yield store.persist_file("", buffer, info=DUMMY_SPIDER_INFO) stub.assert_no_pending_responses() def test_missing_botocore(self): with ( mock.patch( "scrapy.pipelines.files.is_botocore_available", return_value=False ), pytest.raises(NotConfigured, match="missing botocore library"), ): S3FilesStore("s3://mybucket/key") def test_wrong_uri_scheme(self): with pytest.raises( ValueError, match=re.escape( "Incorrect URI scheme in ftp://mybucket/key, expected 's3'" ), ): S3FilesStore("ftp://mybucket/key") def test_unsupported_header(self): store = S3FilesStore("s3://mybucket/key") with pytest.raises( TypeError, match='Header "X-Custom" is not supported by botocore' ): store._headers_to_botocore_kwargs({"X-Custom": "value"}) @inline_callbacks_test def test_stat(self): bucket = "mybucket" key = "export.csv" uri = f"s3://{bucket}/{key}" checksum = "3187896a9657a28163abb31667df64c8" last_modified = datetime(2019, 12, 1) store = S3FilesStore(uri) from botocore.stub import Stubber # noqa: PLC0415 with Stubber(store.s3_client) as stub: stub.add_response( "head_object", expected_params={ "Bucket": bucket, "Key": key, }, service_response={ "ETag": f'"{checksum}"', "LastModified": last_modified, }, ) file_stats = yield store.stat_file("", info=DUMMY_SPIDER_INFO) assert file_stats == { "checksum": checksum, "last_modified": last_modified.timestamp(), } stub.assert_no_pending_responses() def test_default_max_pool_connections(self) -> None: store = S3FilesStore("s3://mybucket/prefix/") config: Any = store.s3_client.meta.config assert config.max_pool_connections == 10 @pytest.mark.parametrize( ("settings", "expected"), [ ({}, 10), ({"REACTOR_THREADPOOL_MAXSIZE": 20}, 20), ({"AWS_MAX_POOL_CONNECTIONS": 30}, 30), ({"AWS_MAX_POOL_CONNECTIONS": 30, "REACTOR_THREADPOOL_MAXSIZE": 20}, 30), ], ) def test_max_pool_connections( self, monkeypatch: pytest.MonkeyPatch, settings: dict[str, Any], expected: int ) -> None: # restores the value that FilesPipeline.from_crawler() sets on the class monkeypatch.setattr(S3FilesStore, "AWS_MAX_POOL_CONNECTIONS", None) crawler = get_crawler( settings_dict={"FILES_STORE": "s3://mybucket/prefix/", **settings} ) store = build_from_crawler(FilesPipeline, crawler).store assert isinstance(store, S3FilesStore) config: Any = store.s3_client.meta.config assert config.max_pool_connections == expected class TestGCSFilesStore: @staticmethod def build_gcs_files_store( *, permissions: tuple[str, ...] = ( "storage.objects.get", "storage.objects.create", ), ) -> tuple[GCSFilesStore, Any, Any]: """Build a :class:`GCSFilesStore` mock. Returns ``(store, bucket_mock, blob_mock)``. Skips the test if google-cloud-storage is not installed. ``permissions`` is what ``Bucket.test_iam_permissions`` will return. """ pytest.importorskip("google.cloud.storage") client_mock, bucket_mock, blob_mock = mock_google_cloud_storage() bucket_mock.test_iam_permissions.return_value = list(permissions) with mock.patch("google.cloud.storage.Client", return_value=client_mock): store = GCSFilesStore("gs://my_bucket/my_prefix/") return store, bucket_mock, blob_mock def test_init(self, caplog: pytest.LogCaptureFixture) -> None: with caplog.at_level(logging.WARNING): self.build_gcs_files_store() assert not caplog.records def test_get_perm_missing(self, caplog: pytest.LogCaptureFixture) -> None: self.build_gcs_files_store(permissions=("storage.objects.create",)) assert ( "No 'storage.objects.get' permission for GCS bucket my_bucket" in caplog.text ) def test_create_perm_missing(self, caplog: pytest.LogCaptureFixture) -> None: self.build_gcs_files_store(permissions=("storage.objects.get",)) assert ( "No 'storage.objects.create' permission for GCS bucket my_bucket" in caplog.text ) def test_update_stores(self, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(GCSFilesStore, "GCS_PROJECT_ID", None) monkeypatch.setattr(GCSFilesStore, "POLICY", None) settings = Settings( {"GCS_PROJECT_ID": "my-project", "FILES_STORE_GCS_ACL": "publicRead"} ) FilesPipeline._update_stores(settings) assert GCSFilesStore.GCS_PROJECT_ID == "my-project" assert GCSFilesStore.POLICY == "publicRead" # An empty FILES_STORE_GCS_ACL is normalised to None. settings = Settings({"GCS_PROJECT_ID": "my-project", "FILES_STORE_GCS_ACL": ""}) FilesPipeline._update_stores(settings) assert GCSFilesStore.POLICY is None @coroutine_test async def test_persist(self) -> None: store, bucket, blob = self.build_gcs_files_store() await maybe_deferred_to_future( store.persist_file( "full/filename", BytesIO(b"hello"), info=DUMMY_SPIDER_INFO, meta={"foo": 1}, headers={"Content-Type": "image/png"}, ) ) bucket.blob.assert_called_once_with("my_prefix/full/filename") assert blob.cache_control == GCSFilesStore.CACHE_CONTROL assert blob.metadata == {"foo": "1"} blob.upload_from_string.assert_called_once_with( data=b"hello", content_type="image/png", predefined_acl=store.POLICY, ) @coroutine_test async def test_persist_defaults(self) -> None: store, _, blob = self.build_gcs_files_store() await maybe_deferred_to_future( store.persist_file( "full/filename", BytesIO(b"hello"), info=DUMMY_SPIDER_INFO, ) ) blob.upload_from_string.assert_called_once_with( data=b"hello", content_type="application/octet-stream", predefined_acl=store.POLICY, ) assert blob.metadata == {} @coroutine_test async def test_stat(self) -> None: store, bucket, blob = self.build_gcs_files_store() checksum = "cdcda85605e46d0af6110752770dce3c" blob.md5_hash = base64.b64encode(bytes.fromhex(checksum)).decode() updated = datetime(2019, 12, 1) blob.updated = updated bucket.get_blob.return_value = blob stat = await maybe_deferred_to_future( store.stat_file("full/filename", info=DUMMY_SPIDER_INFO) ) bucket.get_blob.assert_called_once_with("my_prefix/full/filename") assert stat == { "checksum": checksum, "last_modified": time.mktime(updated.timetuple()), } @coroutine_test async def test_stat_missing_blob(self) -> None: store, bucket, _ = self.build_gcs_files_store() bucket.get_blob.return_value = None stat = await maybe_deferred_to_future( store.stat_file("full/filename", info=DUMMY_SPIDER_INFO) ) assert stat == {} @coroutine_test async def test_blob_path_consistency(self) -> None: """Test to make sure that paths used to store files is the same as the one used to get already uploaded files. """ store, bucket, _ = self.build_gcs_files_store() bucket.get_blob.return_value = None path = "full/my_data.txt" await maybe_deferred_to_future( store.persist_file(path, BytesIO(b""), info=DUMMY_SPIDER_INFO) ) await maybe_deferred_to_future(store.stat_file(path, info=DUMMY_SPIDER_INFO)) expected_blob_path = store.prefix + path bucket.blob.assert_called_with(expected_blob_path) bucket.get_blob.assert_called_with(expected_blob_path) class TestFTPFileStore: @inline_callbacks_test def test_persist(self): data = b"TestFTPFilesStore: \xe2\x98\x83" buf = BytesIO(data) meta = {"foo": "bar"} path = "full/filename" with MockFTPServer() as ftp_server: # normally set via FilesPipeline.from_crawler() FTPFilesStore.FTP_USERNAME = "anonymous" FTPFilesStore.FTP_PASSWORD = "guest" store = FTPFilesStore(ftp_server.url("/")) empty_dict = yield store.stat_file(path, info=DUMMY_SPIDER_INFO) assert empty_dict == {} yield store.persist_file( path, buf, info=DUMMY_SPIDER_INFO, meta=meta, headers=None ) stat = yield store.stat_file(path, info=DUMMY_SPIDER_INFO) assert "last_modified" in stat assert "checksum" in stat assert stat["checksum"] == "d113d66b2ec7258724a268bd88eef6b6" path = f"{store.basedir}/{path}" content = get_ftp_content_and_delete( path, store.host, store.port, store.username, store.password, bool(store.USE_ACTIVE_MODE), ) assert data == content @inline_callbacks_test def test_persist_active_mode(self, monkeypatch: pytest.MonkeyPatch): data = b"active mode" path = "full/filename" monkeypatch.setattr(FTPFilesStore, "FTP_USERNAME", "anonymous") monkeypatch.setattr(FTPFilesStore, "FTP_PASSWORD", "guest") monkeypatch.setattr(FTPFilesStore, "USE_ACTIVE_MODE", True) with MockFTPServer() as ftp_server: store = FTPFilesStore(ftp_server.url("/")) yield store.persist_file(path, BytesIO(data), info=DUMMY_SPIDER_INFO) stat = yield store.stat_file(path, info=DUMMY_SPIDER_INFO) assert stat["checksum"] == "ff1575649a39a27c13faa0d37c84bab3" def test_wrong_uri_scheme(self): with pytest.raises( ValueError, match=re.escape( "Incorrect URI scheme in http://example.com/, expected 'ftp'" ), ): FTPFilesStore("http://example.com/") class ItemWithFiles(Item): file_urls = Field() files = Field() def _create_item_with_files(*files: str) -> ItemWithFiles: item = ItemWithFiles() item["file_urls"] = files return item def _prepare_request_object( item_url: str, flags: list[str] | None = None, headers: dict[str, str] | None = None, ) -> Request: return Request( item_url, meta={ "response": Response( item_url, status=200, body=b"data", flags=flags, headers=headers ) }, ) # this is separate from the one in test_pipeline_media.py to specifically test FilesPipeline subclasses class TestBuildFromCrawler: def setup_method(self): self.tempdir = mkdtemp() self.crawler = get_crawler(None, {"FILES_STORE": self.tempdir}) def teardown_method(self): rmtree(self.tempdir) def test_simple(self): class Pipeline(FilesPipeline): pass pipe = build_from_crawler(Pipeline, self.crawler) assert pipe.crawler == self.crawler assert pipe._fingerprinter assert pipe.store def test_has_from_crawler_and_init(self): class Pipeline(FilesPipeline): _from_crawler_called = False @classmethod def from_crawler(cls, crawler: Crawler) -> "Pipeline": settings = crawler.settings store_uri = settings["FILES_STORE"] o = cls(store_uri, crawler=crawler) o._from_crawler_called = True return o pipe = build_from_crawler(Pipeline, self.crawler) assert pipe.crawler == self.crawler assert pipe._fingerprinter assert pipe.store assert pipe._from_crawler_called @pytest.mark.parametrize("store", [None, ""]) def test_files_pipeline_raises_notconfigured_when_files_store_invalid(store): settings = Settings() settings.clear() settings.set("FILES_STORE", store, priority="cmdline") crawler = get_crawler(settings_dict=dict(settings)) with pytest.raises(NotConfigured): build_from_crawler(FilesPipeline, crawler) def test_file_exception_deprecated_import(): with pytest.warns(ScrapyDeprecationWarning, match="FileException"): assert files.FileException is FileException with pytest.raises(AttributeError): files.nonexistent scrapy-scrapy-5569de3/tests/test_pipeline_images.py000066400000000000000000000613751525051012400225740ustar00rootroot00000000000000from __future__ import annotations import dataclasses import io import random import sys from abc import ABC, abstractmethod from pathlib import Path from shutil import rmtree from tempfile import mkdtemp from types import SimpleNamespace from typing import Any import attr import pytest from itemadapter import ItemAdapter from scrapy.exceptions import NotConfigured from scrapy.http import Request, Response from scrapy.item import Field, Item from scrapy.pipelines.files import GCSFilesStore, S3FilesStore, _md5sum from scrapy.pipelines.images import ImageException, ImagesPipeline from scrapy.utils.misc import build_from_crawler from scrapy.utils.test import get_crawler from tests.utils.decorators import coroutine_test from tests.utils.media_pipelines import DUMMY_SPIDER_INFO try: from PIL import Image except ImportError: pytest.skip( "Missing Python Imaging Library, install https://pypi.org/pypi/Pillow", allow_module_level=True, ) else: encoders = {"jpeg_encoder", "jpeg_decoder"} if not encoders.issubset(set(Image.core.__dict__)): # type: ignore[attr-defined] pytest.skip("Missing JPEG encoders", allow_module_level=True) class TestImagesPipeline: def setup_method(self): self.tempdir = mkdtemp() crawler = get_crawler(None, {"IMAGES_STORE": self.tempdir}) self.pipeline = build_from_crawler(ImagesPipeline, crawler) def teardown_method(self): rmtree(self.tempdir) def test_missing_pillow(self, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setitem(sys.modules, "PIL", None) with pytest.raises(NotConfigured, match="requires installing Pillow"): ImagesPipeline(self.tempdir, crawler=get_crawler()) def test_file_path(self): file_path = self.pipeline.file_path assert ( file_path(Request("https://dev.mydeco.com/mydeco.gif")) == "full/3fd165099d8e71b8a48b2683946e64dbfad8b52d.jpg" ) assert ( file_path( Request( "http://www.maddiebrown.co.uk///catalogue-items//image_54642_12175_95307.jpg" ) ) == "full/0ffcd85d563bca45e2f90becd0ca737bc58a00b2.jpg" ) assert ( file_path( Request("https://dev.mydeco.com/two/dirs/with%20spaces%2Bsigns.gif") ) == "full/b250e3a74fff2e4703e310048a5b13eba79379d2.jpg" ) assert ( file_path( Request( "http://www.dfsonline.co.uk/get_prod_image.php?img=status_0907_mdm.jpg" ) ) == "full/4507be485f38b0da8a0be9eb2e1dfab8a19223f2.jpg" ) assert ( file_path(Request("http://www.dorma.co.uk/images/product_details/2532/")) == "full/97ee6f8a46cbbb418ea91502fd24176865cf39b2.jpg" ) assert ( file_path(Request("http://www.dorma.co.uk/images/product_details/2532")) == "full/244e0dd7d96a3b7b01f54eded250c9e272577aa1.jpg" ) assert ( file_path( Request("http://www.dorma.co.uk/images/product_details/2532"), response=Response("http://www.dorma.co.uk/images/product_details/2532"), info=DUMMY_SPIDER_INFO, ) == "full/244e0dd7d96a3b7b01f54eded250c9e272577aa1.jpg" ) def test_thumbnail_name(self): thumb_path = self.pipeline.thumb_path name = "50" assert ( thumb_path(Request("file:///tmp/foo.jpg"), name) == "thumbs/50/38a86208c36e59d4404db9e37ce04be863ef0335.jpg" ) assert ( thumb_path(Request("file://foo.png"), name) == "thumbs/50/e55b765eba0ec7348e50a1df496040449071b96a.jpg" ) assert ( thumb_path(Request("file:///tmp/foo"), name) == "thumbs/50/0329ad83ebb8e93ea7c7906d46e9ed55f7349a50.jpg" ) assert ( thumb_path(Request("file:///tmp/some.name/foo"), name) == "thumbs/50/850233df65a5b83361798f532f1fc549cd13cbe9.jpg" ) assert ( thumb_path( Request("file:///tmp/some.name/foo"), name, response=Response("file:///tmp/some.name/foo"), info=DUMMY_SPIDER_INFO, ) == "thumbs/50/850233df65a5b83361798f532f1fc549cd13cbe9.jpg" ) def test_thumbnail_name_from_item(self): """ Custom thumbnail name based on item data, overriding default implementation """ class CustomImagesPipeline(ImagesPipeline): def thumb_path( self, request, thumb_id, response=None, info=None, item=None ) -> str: return f"thumb/{thumb_id}/{item.get('path')}" thumb_path = build_from_crawler( CustomImagesPipeline, get_crawler(None, {"IMAGES_STORE": self.tempdir}) ).thumb_path item = {"path": "path-to-store-file"} request = Request("http://example.com") assert ( thumb_path(request, "small", item=item) == "thumb/small/path-to-store-file" ) def test_get_images_exception(self): self.pipeline.min_width = 100 self.pipeline.min_height = 100 _, buf1 = _create_image("JPEG", "RGB", (50, 50), (0, 0, 0)) _, buf2 = _create_image("JPEG", "RGB", (150, 50), (0, 0, 0)) _, buf3 = _create_image("JPEG", "RGB", (50, 150), (0, 0, 0)) resp1 = Response(url="https://dev.mydeco.com/mydeco.gif", body=buf1.getvalue()) resp2 = Response(url="https://dev.mydeco.com/mydeco.gif", body=buf2.getvalue()) resp3 = Response(url="https://dev.mydeco.com/mydeco.gif", body=buf3.getvalue()) req = Request(url="https://dev.mydeco.com/mydeco.gif") with pytest.raises(ImageException): next( self.pipeline.get_images( response=resp1, request=req, info=DUMMY_SPIDER_INFO ) ) with pytest.raises(ImageException): next( self.pipeline.get_images( response=resp2, request=req, info=DUMMY_SPIDER_INFO ) ) with pytest.raises(ImageException): next( self.pipeline.get_images( response=resp3, request=req, info=DUMMY_SPIDER_INFO ) ) def test_get_images(self): self.pipeline.min_width = 0 self.pipeline.min_height = 0 self.pipeline.thumbs = {"small": (20, 20)} orig_im, buf = _create_image("JPEG", "RGB", (50, 50), (0, 0, 0)) _, orig_thumb_buf = _create_image("JPEG", "RGB", (20, 20), (0, 0, 0)) resp = Response(url="https://dev.mydeco.com/mydeco.gif", body=buf.getvalue()) req = Request(url="https://dev.mydeco.com/mydeco.gif") get_images_gen = self.pipeline.get_images( response=resp, request=req, info=DUMMY_SPIDER_INFO ) path, new_im, new_buf = next(get_images_gen) assert path == "full/3fd165099d8e71b8a48b2683946e64dbfad8b52d.jpg" assert orig_im.copy() == new_im assert buf.getvalue() == new_buf.getvalue() thumb_path, _, thumb_buf = next(get_images_gen) assert thumb_path == "thumbs/small/3fd165099d8e71b8a48b2683946e64dbfad8b52d.jpg" assert orig_thumb_buf.getvalue() == thumb_buf.getvalue() def test_get_transposed_images(self): orig_im = Image.new("RGB", (2, 2), (0, 0, 0)) orig_im.putpixel((1, 1), (255, 0, 0)) exif = orig_im.getexif() exif[274] = 3 buf = io.BytesIO() orig_im.save(buf, "PNG", exif=exif) buf.seek(0) resp = Response(url="https://dev.mydeco.com/mydeco.gif", body=buf.getvalue()) req = Request(url="https://dev.mydeco.com/mydeco.gif") get_images_gen = self.pipeline.get_images( response=resp, request=req, info=DUMMY_SPIDER_INFO ) path, new_im, _ = next(get_images_gen) assert path == "full/3fd165099d8e71b8a48b2683946e64dbfad8b52d.jpg" assert new_im.getpixel((0, 0)) == (255, 0, 0) @coroutine_test async def test_image_downloaded(self) -> None: """The image and its thumbnails are stored, and the checksum of the full-size image is returned.""" self.pipeline.thumbs = {"small": (20, 20)} _, buf = _create_image("JPEG", "RGB", (50, 50), (0, 0, 0)) url = "https://dev.mydeco.com/mydeco.gif" response = Response(url=url, body=buf.getvalue()) checksum = await self.pipeline.image_downloaded( response, Request(url=url), DUMMY_SPIDER_INFO ) buf.seek(0) assert checksum == _md5sum(buf) name = "3fd165099d8e71b8a48b2683946e64dbfad8b52d.jpg" assert Path(self.tempdir, "full", name).read_bytes() == buf.getvalue() assert Path(self.tempdir, "thumbs", "small", name).exists() def test_convert_image(self): SIZE = (100, 100) # straight forward case: RGB and JPEG COLOUR: tuple[int, ...] = (0, 127, 255) im, buf = _create_image("JPEG", "RGB", SIZE, COLOUR) converted, converted_buf = self.pipeline.convert_image(im, response_body=buf) assert converted.mode == "RGB" assert converted.getcolors() == [(10000, COLOUR)] # check that we don't convert JPEGs again assert converted_buf == buf # check that thumbnail keep image ratio thumbnail, _ = self.pipeline.convert_image( converted, size=(10, 25), response_body=converted_buf ) assert thumbnail.mode == "RGB" assert thumbnail.size == (10, 10) # transparency case: RGBA and PNG COLOUR = (0, 127, 255, 50) im, buf = _create_image("PNG", "RGBA", SIZE, COLOUR) converted, _ = self.pipeline.convert_image(im, response_body=buf) assert converted.mode == "RGB" assert converted.getcolors() == [(10000, (205, 230, 255))] # transparency case with palette: P and PNG COLOUR = (0, 127, 255, 50) im, buf = _create_image("PNG", "RGBA", SIZE, COLOUR) im = im.convert("P") converted, _ = self.pipeline.convert_image(im, response_body=buf) assert converted.mode == "RGB" assert converted.getcolors() == [(10000, (205, 230, 255))] def test_convert_image_legacy_resampling_filter( self, monkeypatch: pytest.MonkeyPatch ) -> None: """Pillow older than 9.1.0 has Image.ANTIALIAS instead of Image.Resampling.LANCZOS.""" # Image.LANCZOS is the only spelling that exists in every supported # Pillow version, but Pillow defines it dynamically, hence the ignore. monkeypatch.setattr( self.pipeline, "_Image", SimpleNamespace(ANTIALIAS=Image.LANCZOS), # type: ignore[attr-defined] ) im, buf = _create_image("JPEG", "RGB", (100, 100), (0, 127, 255)) thumbnail, _ = self.pipeline.convert_image(im, size=(10, 25), response_body=buf) assert thumbnail.size == (10, 10) @pytest.mark.parametrize( "bad_type", [ "http://example.com/file.jpg", ("http://example.com/file.jpg",), {"url": "http://example.com/file.jpg"}, 123, None, ], ) def test_rejects_non_list_image_urls(self, tmp_path, bad_type): pipeline = build_from_crawler( ImagesPipeline, get_crawler(None, {"IMAGES_STORE": str(tmp_path)}) ) item = ImagesPipelineTestItem() item["image_urls"] = bad_type with pytest.raises(TypeError, match="image_urls must be a list of URLs"): list(pipeline.get_media_requests(item, DUMMY_SPIDER_INFO)) class TestImagesPipelineFieldsMixin(ABC): @property @abstractmethod def item_class(self) -> Any: raise NotImplementedError def test_item_fields_default(self): url = "http://www.example.com/images/1.jpg" item = self.item_class(name="item1", image_urls=[url]) pipeline = build_from_crawler( ImagesPipeline, get_crawler(None, {"IMAGES_STORE": "s3://example/images/"}) ) requests = list(pipeline.get_media_requests(item, DUMMY_SPIDER_INFO)) assert requests[0].url == url results: Any = [(True, {"url": url})] item = pipeline.item_completed(results, item, DUMMY_SPIDER_INFO) images = ItemAdapter(item).get("images") assert images == [results[0][1]] assert isinstance(item, self.item_class) def test_item_fields_override_settings(self): url = "http://www.example.com/images/1.jpg" item = self.item_class(name="item1", custom_image_urls=[url]) pipeline = build_from_crawler( ImagesPipeline, get_crawler( None, { "IMAGES_STORE": "s3://example/images/", "IMAGES_URLS_FIELD": "custom_image_urls", "IMAGES_RESULT_FIELD": "custom_images", }, ), ) requests = list(pipeline.get_media_requests(item, DUMMY_SPIDER_INFO)) assert requests[0].url == url results: Any = [(True, {"url": url})] item = pipeline.item_completed(results, item, DUMMY_SPIDER_INFO) custom_images = ItemAdapter(item).get("custom_images") assert custom_images == [results[0][1]] assert isinstance(item, self.item_class) class TestImagesPipelineFieldsDict(TestImagesPipelineFieldsMixin): item_class = dict class ImagesPipelineTestItem(Item): name = Field() # default fields image_urls = Field() images = Field() # overridden fields custom_image_urls = Field() custom_images = Field() class TestImagesPipelineFieldsItem(TestImagesPipelineFieldsMixin): item_class = ImagesPipelineTestItem @dataclasses.dataclass class ImagesPipelineTestDataClass: name: str # default fields image_urls: list[str] = dataclasses.field(default_factory=list) images: list[dict[str, str]] = dataclasses.field(default_factory=list) # overridden fields custom_image_urls: list[str] = dataclasses.field(default_factory=list) custom_images: list[dict[str, str]] = dataclasses.field(default_factory=list) class TestImagesPipelineFieldsDataClass(TestImagesPipelineFieldsMixin): item_class = ImagesPipelineTestDataClass @attr.s class ImagesPipelineTestAttrsItem: name = attr.ib(default="") # default fields image_urls: list[str] = attr.ib(default=list) images: list[dict[str, str]] = attr.ib(default=list) # overridden fields custom_image_urls: list[str] = attr.ib(default=list) custom_images: list[dict[str, str]] = attr.ib(default=list) class TestImagesPipelineFieldsAttrsItem(TestImagesPipelineFieldsMixin): item_class = ImagesPipelineTestAttrsItem class TestImagesPipelineCustomSettings: img_cls_attribute_names = [ # Pipeline attribute names with corresponding setting names. ("EXPIRES", "IMAGES_EXPIRES"), ("MIN_WIDTH", "IMAGES_MIN_WIDTH"), ("MIN_HEIGHT", "IMAGES_MIN_HEIGHT"), ("IMAGES_URLS_FIELD", "IMAGES_URLS_FIELD"), ("IMAGES_RESULT_FIELD", "IMAGES_RESULT_FIELD"), ("THUMBS", "IMAGES_THUMBS"), ] # This should match what is defined in ImagesPipeline. default_pipeline_settings = { "MIN_WIDTH": 0, "MIN_HEIGHT": 0, "EXPIRES": 90, "THUMBS": {}, "IMAGES_URLS_FIELD": "image_urls", "IMAGES_RESULT_FIELD": "images", } def _generate_fake_settings( self, tmp_path: Path, prefix: str | None = None ) -> dict[str, Any]: """ :param prefix: string for setting keys :return: dictionary of image pipeline settings """ def random_string() -> str: return "".join([chr(random.randint(97, 123)) for _ in range(10)]) settings = { "IMAGES_EXPIRES": random.randint(100, 1000), "IMAGES_STORE": tmp_path, "IMAGES_RESULT_FIELD": random_string(), "IMAGES_URLS_FIELD": random_string(), "IMAGES_MIN_WIDTH": random.randint(1, 1000), "IMAGES_MIN_HEIGHT": random.randint(1, 1000), "IMAGES_THUMBS": { "small": (random.randint(1, 1000), random.randint(1, 1000)), "big": (random.randint(1, 1000), random.randint(1, 1000)), }, } if not prefix: return settings return { prefix.upper() + "_" + k if k != "IMAGES_STORE" else k: v for k, v in settings.items() } def _generate_fake_pipeline_subclass(self) -> type[ImagesPipeline]: """ :return: ImagePipeline class will all uppercase attributes set. """ class UserDefinedImagePipeline(ImagesPipeline): # Values should be in different range than fake_settings. MIN_WIDTH = random.randint(1000, 2000) MIN_HEIGHT = random.randint(1000, 2000) THUMBS = { "small": (random.randint(1000, 2000), random.randint(1000, 2000)), "big": (random.randint(1000, 2000), random.randint(1000, 2000)), } EXPIRES = random.randint(1000, 2000) IMAGES_URLS_FIELD = "field_one" IMAGES_RESULT_FIELD = "field_two" return UserDefinedImagePipeline def test_different_settings_for_different_instances(self, tmp_path): """ If there are two instances of ImagesPipeline class with different settings, they should have different settings. """ custom_settings = self._generate_fake_settings(tmp_path) default_sts_pipe = build_from_crawler( ImagesPipeline, get_crawler(None, {"IMAGES_STORE": tmp_path}) ) user_sts_pipe = build_from_crawler( ImagesPipeline, get_crawler(None, custom_settings) ) for pipe_attr, settings_attr in self.img_cls_attribute_names: expected_default_value = self.default_pipeline_settings.get(pipe_attr) custom_value = custom_settings.get(settings_attr) assert expected_default_value != custom_value assert ( getattr(default_sts_pipe, pipe_attr.lower()) == expected_default_value ) assert getattr(user_sts_pipe, pipe_attr.lower()) == custom_value def test_subclass_attrs_preserved_default_settings(self, tmp_path): """ If image settings are not defined at all subclass of ImagePipeline takes values from class attributes. """ pipeline_cls = self._generate_fake_pipeline_subclass() pipeline = build_from_crawler( pipeline_cls, get_crawler(None, {"IMAGES_STORE": tmp_path}) ) for pipe_attr, _ in self.img_cls_attribute_names: # Instance attribute (lowercase) must be equal to class attribute (uppercase). attr_value = getattr(pipeline, pipe_attr.lower()) assert attr_value != self.default_pipeline_settings[pipe_attr] assert attr_value == getattr(pipeline, pipe_attr) def test_subclass_attrs_preserved_custom_settings(self, tmp_path): """ If image settings are defined but they are not defined for subclass default values taken from settings should be preserved. """ pipeline_cls = self._generate_fake_pipeline_subclass() settings = self._generate_fake_settings(tmp_path) pipeline = build_from_crawler(pipeline_cls, get_crawler(None, settings)) for pipe_attr, settings_attr in self.img_cls_attribute_names: # Instance attribute (lowercase) must be equal to # value defined in settings. value = getattr(pipeline, pipe_attr.lower()) assert value != self.default_pipeline_settings[pipe_attr] setings_value = settings.get(settings_attr) assert value == setings_value def test_no_custom_settings_for_subclasses(self, tmp_path): """ If there are no settings for subclass and no subclass attributes, pipeline should use attributes of base class. """ class UserDefinedImagePipeline(ImagesPipeline): pass user_pipeline = build_from_crawler( UserDefinedImagePipeline, get_crawler(None, {"IMAGES_STORE": tmp_path}) ) for pipe_attr, _ in self.img_cls_attribute_names: # Values from settings for custom pipeline should be set on pipeline instance. custom_value = self.default_pipeline_settings.get(pipe_attr.upper()) assert getattr(user_pipeline, pipe_attr.lower()) == custom_value def test_custom_settings_for_subclasses(self, tmp_path): """ If there are custom settings for subclass and NO class attributes, pipeline should use custom settings. """ class UserDefinedImagePipeline(ImagesPipeline): pass prefix = UserDefinedImagePipeline.__name__.upper() settings = self._generate_fake_settings(tmp_path, prefix=prefix) user_pipeline = build_from_crawler( UserDefinedImagePipeline, get_crawler(None, settings) ) for pipe_attr, settings_attr in self.img_cls_attribute_names: # Values from settings for custom pipeline should be set on pipeline instance. custom_value = settings.get(prefix + "_" + settings_attr) assert custom_value != self.default_pipeline_settings[pipe_attr] assert getattr(user_pipeline, pipe_attr.lower()) == custom_value def test_custom_settings_and_class_attrs_for_subclasses(self, tmp_path): """ If there are custom settings for subclass AND class attributes setting keys are preferred and override attributes. """ pipeline_cls = self._generate_fake_pipeline_subclass() prefix = pipeline_cls.__name__.upper() settings = self._generate_fake_settings(tmp_path, prefix=prefix) user_pipeline = build_from_crawler(pipeline_cls, get_crawler(None, settings)) for pipe_attr, settings_attr in self.img_cls_attribute_names: custom_value = settings.get(prefix + "_" + settings_attr) assert custom_value != self.default_pipeline_settings[pipe_attr] assert getattr(user_pipeline, pipe_attr.lower()) == custom_value def test_cls_attrs_with_DEFAULT_prefix(self, tmp_path): class UserDefinedImagePipeline(ImagesPipeline): DEFAULT_IMAGES_URLS_FIELD = "something" DEFAULT_IMAGES_RESULT_FIELD = "something_else" pipeline = build_from_crawler( UserDefinedImagePipeline, get_crawler(None, {"IMAGES_STORE": tmp_path}) ) assert ( pipeline.images_result_field == UserDefinedImagePipeline.DEFAULT_IMAGES_RESULT_FIELD ) assert ( pipeline.images_urls_field == UserDefinedImagePipeline.DEFAULT_IMAGES_URLS_FIELD ) def test_user_defined_subclass_default_key_names(self, tmp_path): """Test situation when user defines subclass of ImagePipeline, but uses attribute names for default pipeline (without prefixing them with pipeline class name). """ settings = self._generate_fake_settings(tmp_path) class UserPipe(ImagesPipeline): pass pipeline_cls = build_from_crawler(UserPipe, get_crawler(None, settings)) for pipe_attr, settings_attr in self.img_cls_attribute_names: expected_value = settings.get(settings_attr) assert getattr(pipeline_cls, pipe_attr.lower()) == expected_value def test_images_store_s3_acl_setting_used(self, tmp_path): old_policy = S3FilesStore.POLICY try: crawler = get_crawler( None, { "IMAGES_STORE": tmp_path, "IMAGES_STORE_S3_ACL": "public-read", "FILES_STORE_S3_ACL": "private", }, ) build_from_crawler(ImagesPipeline, crawler) assert S3FilesStore.POLICY == "public-read" finally: S3FilesStore.POLICY = old_policy def test_images_store_gcs_acl_setting_used(self, tmp_path): old_policy = GCSFilesStore.POLICY try: crawler = get_crawler( None, { "IMAGES_STORE": tmp_path, "IMAGES_STORE_GCS_ACL": "authenticatedRead", "FILES_STORE_GCS_ACL": "", }, ) build_from_crawler(ImagesPipeline, crawler) assert GCSFilesStore.POLICY == "authenticatedRead" finally: GCSFilesStore.POLICY = old_policy def _create_image(format_: str, *a: Any, **kw: Any) -> tuple[Image.Image, io.BytesIO]: buf = io.BytesIO() Image.new(*a, **kw).save(buf, format_) buf.seek(0) return Image.open(buf), buf scrapy-scrapy-5569de3/tests/test_pipeline_media.py000066400000000000000000000566311525051012400224050ustar00rootroot00000000000000from __future__ import annotations import logging from typing import TYPE_CHECKING, Any, cast from unittest.mock import MagicMock import pytest from twisted.python.failure import Failure from scrapy import signals from scrapy.exceptions import ScrapyDeprecationWarning from scrapy.http import Request, Response from scrapy.pipelines.media import ( FileException, FileInfo, FileInfoOrError, MediaPipeline, _MediaRequestFiltered, ) from scrapy.utils.defer import _process_pending_io from scrapy.utils.log import failure_to_exc_info from scrapy.utils.misc import build_from_crawler from scrapy.utils.signal import disconnect_all from scrapy.utils.spider import DefaultSpider from scrapy.utils.test import get_crawler from tests.utils.decorators import coroutine_test from tests.utils.media_pipelines import mocked_download_func if TYPE_CHECKING: from collections.abc import Awaitable from twisted.internet.defer import Deferred from scrapy.crawler import Crawler class UserDefinedPipeline(MediaPipeline): def media_to_download( self, request: Request, info: MediaPipeline.SpiderInfo, *, item: Any = None ) -> Deferred[FileInfo | None] | None: return None def get_media_requests( self, item: Any, info: MediaPipeline.SpiderInfo ) -> list[Request]: return [] def media_downloaded( self, response: Response, request: Request, info: MediaPipeline.SpiderInfo, *, item: Any = None, ) -> FileInfo | Awaitable[FileInfo]: return cast("FileInfo", {}) def media_failed( self, failure: Failure, request: Request, info: MediaPipeline.SpiderInfo ) -> Failure: failure.raiseException() def file_path( self, request: Request, response: Response | None = None, info: MediaPipeline.SpiderInfo | None = None, *, item: Any = None, ) -> str: return "" class TestBaseMediaPipeline: pipeline_class = UserDefinedPipeline settings = None def setup_method(self): crawler = get_crawler(DefaultSpider, self.settings) crawler.spider = crawler._create_spider() crawler.engine = MagicMock(download_async=mocked_download_func) self.pipe = build_from_crawler(self.pipeline_class, crawler) self.pipe.open_spider() self.info = self.pipe.spiderinfo self.fingerprint = crawler.request_fingerprinter.fingerprint @property def mocked_pipe(self) -> MockedMediaPipeline: assert isinstance(self.pipe, MockedMediaPipeline) return self.pipe def teardown_method(self): for name, signal in vars(signals).items(): if not name.startswith("_"): disconnect_all(signal) def test_modify_media_request(self): request = Request("http://url") self.pipe._modify_media_request(request) assert request.meta == {"handle_httpstatus_all": True} def test_should_remove_req_res_references_before_caching_the_results(self): """Regression test case to prevent a memory leak in the Media Pipeline. The memory leak is triggered when an exception is raised when a Response scheduled by the Media Pipeline is being returned. For example, when a FileException('download-error') is raised because the Response status code is not 200 OK. It happens because we are keeping a reference to the Response object inside the FileException context. This is caused by the way Twisted return values from inline callbacks. It raises a custom exception encapsulating the original return value. The solution is to remove the exception context when this context is a _DefGen_Return instance, the BaseException used by Twisted to pass the returned value from those inline callbacks. Maybe there's a better and more reliable way to test the case described here, but it would be more complicated and involve running - or at least mocking - some async steps from the Media Pipeline. The current test case is simple and detects the problem very fast. On the other hand, it would not detect another kind of leak happening due to old object references being kept inside the Media Pipeline cache. This problem does not occur in Python 2.7 since we don't have Exception Chaining (https://www.python.org/dev/peps/pep-3134/). """ # Create sample pair of Request and Response objects request = Request("http://url") response = Response("http://url", body=b"", request=request) # Simulate the Media Pipeline behavior to produce a Twisted Failure try: # Simulate a Twisted inline callback returning a Response raise StopIteration(response) except StopIteration as exc: def_gen_return_exc = exc try: # Simulate the media_downloaded callback raising a FileException # This usually happens when the status code is not 200 OK raise FileException("download-error") except Exception as exc: file_exc = exc # Simulate Twisted capturing the FileException # It encapsulates the exception inside a Twisted Failure failure = Failure(file_exc) # The Failure should encapsulate a FileException ... assert failure.value == file_exc # ... and it should have the StopIteration exception set as its context assert failure.value.__context__ == def_gen_return_exc # Let's calculate the request fingerprint and fake some runtime data... fp = self.fingerprint(request) info = self.pipe.spiderinfo info.downloading.add(fp) info.waiting[fp] = [] # When calling the method that caches the Request's result ... self.pipe._cache_result_and_execute_waiters(failure, fp, info) # ... it should store the Twisted Failure ... downloaded = info.downloaded[fp] assert downloaded == failure # ... encapsulating the original FileException ... assert isinstance(downloaded, Failure) assert downloaded.value == file_exc # ... but it should not store the StopIteration exception on its context context = getattr(downloaded.value, "__context__", None) assert context is None def test_default_item_completed(self, caplog: pytest.LogCaptureFixture) -> None: item = {"name": "name"} assert self.pipe.item_completed([], item, self.info) is item # Check that failures are logged by default fail = Failure(Exception()) results: Any = [(True, 1), (False, fail)] caplog.clear() new_item = self.pipe.item_completed(results, item, self.info) assert new_item is item assert len(caplog.records) == 1 record = caplog.records[0] assert record.levelname == "ERROR" assert record.exc_info == failure_to_exc_info(fail) # disable failure logging and check again caplog.clear() self.pipe.LOG_FAILED_RESULTS = False new_item = self.pipe.item_completed(results, item, self.info) assert new_item is item assert len(caplog.records) == 0 def test_item_completed_filtered_request_not_logged( self, caplog: pytest.LogCaptureFixture ) -> None: """Filtered media requests (e.g. offsite ones) are not logged as errors by item_completed(), as they are not download errors.""" item = {"name": "name"} fail = Failure(_MediaRequestFiltered("Filtered offsite request")) results: Any = [(True, 1), (False, fail)] with caplog.at_level(logging.DEBUG): new_item = self.pipe.item_completed(results, item, self.info) assert new_item is item assert len(caplog.records) == 0 @coroutine_test async def test_default_process_item(self): item = {"name": "name"} new_item = await self.pipe.process_item(item) assert new_item is item class MockedMediaPipeline(UserDefinedPipeline): def __init__(self, *args: Any, crawler: Crawler, **kwargs: Any): super().__init__(*args, crawler=crawler, **kwargs) self._mockcalled: list[str] = [] def media_to_download( self, request: Request, info: MediaPipeline.SpiderInfo, *, item: Any = None ) -> Deferred[FileInfo | None] | None: self._mockcalled.append("media_to_download") if "result" in request.meta: return request.meta.get("result") return super().media_to_download(request, info) def get_media_requests( self, item: Any, info: MediaPipeline.SpiderInfo ) -> list[Request]: self._mockcalled.append("get_media_requests") return item.get("requests") # type: ignore[no-any-return] def media_downloaded( self, response: Response, request: Request, info: MediaPipeline.SpiderInfo, *, item: Any = None, ) -> FileInfo | Awaitable[FileInfo]: self._mockcalled.append("media_downloaded") return super().media_downloaded(response, request, info) def media_failed( self, failure: Failure, request: Request, info: MediaPipeline.SpiderInfo ) -> Failure: self._mockcalled.append("media_failed") return super().media_failed(failure, request, info) def item_completed( self, results: list[FileInfoOrError], item: Any, info: MediaPipeline.SpiderInfo ) -> Any: self._mockcalled.append("item_completed") item = super().item_completed(results, item, info) item["results"] = results return item class AsyncMediaDownloadedPipeline(MockedMediaPipeline): async def media_downloaded( # type: ignore[override] self, response: Response, request: Request, info: MediaPipeline.SpiderInfo, *, item: Any = None, ) -> FileInfo | Awaitable[FileInfo]: return super().media_downloaded(response, request, info) class TestMediaPipeline(TestBaseMediaPipeline): pipeline_class = MockedMediaPipeline def _errback(self, result): self.mocked_pipe._mockcalled.append("request_errback") return result @coroutine_test async def test_result_succeed(self): rsp = Response("http://url1") req = Request( "http://url1", meta={"response": rsp}, errback=self._errback, ) item = {"requests": req} new_item = await self.pipe.process_item(item) assert new_item["results"] == [(True, {})] assert self.mocked_pipe._mockcalled == [ "get_media_requests", "media_to_download", "media_downloaded", "item_completed", ] @coroutine_test async def test_result_failure(self): self.pipe.LOG_FAILED_RESULTS = False exc = Exception("foo") req = Request( "http://url1", meta={"response": exc}, errback=self._errback, ) item = {"requests": req} new_item = await self.pipe.process_item(item) assert len(new_item["results"]) == 1 assert new_item["results"][0][0] is False assert isinstance(new_item["results"][0][1], Failure) assert new_item["results"][0][1].value == exc assert self.mocked_pipe._mockcalled == [ "get_media_requests", "media_to_download", "media_failed", "request_errback", "item_completed", ] @coroutine_test async def test_mix_of_success_and_failure(self): self.pipe.LOG_FAILED_RESULTS = False rsp1 = Response("http://url1") req1 = Request("http://url1", meta={"response": rsp1}) exc = Exception("foo") req2 = Request("http://url2", meta={"response": exc}) item = {"requests": [req1, req2]} new_item = await self.pipe.process_item(item) assert len(new_item["results"]) == 2 assert new_item["results"][0] == (True, {}) assert new_item["results"][1][0] is False assert isinstance(new_item["results"][1][1], Failure) assert new_item["results"][1][1].value == exc m = self.mocked_pipe._mockcalled # only once assert m[0] == "get_media_requests" # first hook called assert m.count("get_media_requests") == 1 assert m.count("item_completed") == 1 assert m[-1] == "item_completed" # last hook called # twice, one per request assert m.count("media_to_download") == 2 # one to handle success and other for failure assert m.count("media_downloaded") == 1 assert m.count("media_failed") == 1 @coroutine_test async def test_get_media_requests(self): # returns single Request (without callback) req = Request("http://url") item = {"requests": req} # pass a single item new_item = await self.pipe.process_item(item) assert new_item is item assert self.fingerprint(req) in self.info.downloaded # returns iterable of Requests req1 = Request("http://url1") req2 = Request("http://url2") item = {"requests": iter([req1, req2])} # type: ignore[dict-item] new_item = await self.pipe.process_item(item) assert new_item is item assert self.fingerprint(req1) in self.info.downloaded assert self.fingerprint(req2) in self.info.downloaded @coroutine_test async def test_results_are_cached_across_multiple_items(self): rsp1 = Response("http://url1") req1 = Request("http://url1", meta={"response": rsp1}) item: dict[str, Any] = {"requests": req1} new_item = await self.pipe.process_item(item) assert new_item is item assert new_item["results"] == [(True, {})] # rsp2 is ignored, rsp1 must be in results because request fingerprints are the same req2 = Request( req1.url, meta={"response": Response("http://donot.download.me")} ) item = {"requests": req2} new_item = await self.pipe.process_item(item) assert new_item is item assert self.fingerprint(req1) == self.fingerprint(req2) assert new_item["results"] == [(True, {})] @coroutine_test async def test_failures_are_cached_across_multiple_items(self): self.pipe.LOG_FAILED_RESULTS = False exc = Exception("foo") req1 = Request("http://url1", meta={"response": exc}) new_item = await self.pipe.process_item({"requests": req1}) assert new_item["results"][0][1].value is exc # rsp2 is ignored, the cached failure must be reused because request # fingerprints are the same req2 = Request( req1.url, meta={"response": Response("http://donot.download.me")} ) new_item = await self.pipe.process_item({"requests": req2}) assert new_item["results"][0][0] is False assert new_item["results"][0][1].value is exc assert self.mocked_pipe._mockcalled.count("media_to_download") == 1 @coroutine_test async def test_cached_failure_calls_errback(self): """The errback of a request is called for a cached failure as well.""" self.pipe.LOG_FAILED_RESULTS = False exc = Exception("foo") await self.pipe.process_item( {"requests": Request("http://url1", meta={"response": exc})} ) def errback(failure): self.mocked_pipe._mockcalled.append("request_errback") return {"recovered": failure.value} req = Request("http://url1", errback=errback) new_item = await self.pipe.process_item({"requests": req}) assert new_item["results"] == [(True, {"recovered": exc})] assert self.mocked_pipe._mockcalled.count("request_errback") == 1 @coroutine_test async def test_results_are_cached_for_requests_of_single_item(self): rsp1 = Response("http://url1") req1 = Request("http://url1", meta={"response": rsp1}) req2 = Request( req1.url, meta={"response": Response("http://donot.download.me")} ) item: dict[str, Any] = {"requests": [req1, req2]} new_item = await self.pipe.process_item(item) assert new_item is item assert new_item["results"] == [(True, {}), (True, {})] @coroutine_test async def test_wait_if_request_is_downloading(self): def _check_downloading(response: Response) -> Response: fp = self.fingerprint(req1) assert fp in self.info.downloading assert fp in self.info.waiting assert fp not in self.info.downloaded assert len(self.info.waiting[fp]) == 2 return response rsp1 = Response("http://url") async def rsp1_func(): await _process_pending_io() _check_downloading(rsp1) async def rsp2_func(): pytest.fail("it must cache rsp1 result and must not try to redownload") req1 = Request("http://url", meta={"response": rsp1_func}) req2 = Request(req1.url, meta={"response": rsp2_func}) item = {"requests": [req1, req2]} new_item = await self.pipe.process_item(item) assert new_item["results"] == [(True, {}), (True, {})] @coroutine_test async def test_use_media_to_download_result(self): req = Request("http://url", meta={"result": "ITSME"}) item = {"requests": req} new_item = await self.pipe.process_item(item) assert new_item["results"] == [(True, "ITSME")] assert self.mocked_pipe._mockcalled == [ "get_media_requests", "media_to_download", "item_completed", ] def test_key_for_pipe(self): assert ( self.pipe._key_for_pipe("IMAGES", base_class_name="MediaPipeline") == "MOCKEDMEDIAPIPELINE_IMAGES" ) class TestAsyncMediaDownloaded(TestMediaPipeline): pipeline_class = AsyncMediaDownloadedPipeline def test_key_for_pipe(self): assert ( self.pipe._key_for_pipe("IMAGES", base_class_name="MediaPipeline") == "ASYNCMEDIADOWNLOADEDPIPELINE_IMAGES" ) class TestMediaPipelineAllowRedirectSettings: def _assert_request_no3xx( self, pipeline_class: type[MediaPipeline], settings: dict[str, Any] ) -> None: pipe = pipeline_class(crawler=get_crawler(None, settings)) request = Request("http://url") pipe._modify_media_request(request) assert "handle_httpstatus_list" in request.meta for status, check in [ (200, True), # These are the status codes we want # the downloader to handle itself (301, False), (302, False), (303, False), (307, False), (308, False), # we still want to get 4xx and 5xx (400, True), (404, True), (500, True), ]: if check: assert status in request.meta["handle_httpstatus_list"] else: assert status not in request.meta["handle_httpstatus_list"] def test_subclass_standard_setting(self): self._assert_request_no3xx(UserDefinedPipeline, {"MEDIA_ALLOW_REDIRECTS": True}) def test_subclass_specific_setting(self): self._assert_request_no3xx( UserDefinedPipeline, {"USERDEFINEDPIPELINE_MEDIA_ALLOW_REDIRECTS": True} ) class TestBuildFromCrawler: def setup_method(self): self.crawler = get_crawler(None, {"FILES_STORE": "/foo"}) def test_simple(self): class Pipeline(UserDefinedPipeline): pass pipe = build_from_crawler(Pipeline, self.crawler) assert pipe.crawler == self.crawler assert pipe._fingerprinter def test_has_from_crawler_and_init(self): class Pipeline(UserDefinedPipeline): _from_crawler_called = False def __init__(self, store_uri, settings, *, crawler): super().__init__(crawler=crawler) self._init_called = True @classmethod def from_crawler(cls, crawler: Crawler) -> Pipeline: settings = crawler.settings store_uri = settings["FILES_STORE"] o = cls(store_uri, settings=settings, crawler=crawler) o._from_crawler_called = True return o pipe = build_from_crawler(Pipeline, self.crawler) assert pipe.crawler == self.crawler assert pipe._fingerprinter assert pipe._from_crawler_called assert pipe._init_called def test_has_from_crawler(self): class Pipeline(UserDefinedPipeline): _from_crawler_called = False store_uri: str @classmethod def from_crawler(cls, crawler: Crawler) -> Pipeline: settings = crawler.settings o = super().from_crawler(crawler) o._from_crawler_called = True o.store_uri = settings["FILES_STORE"] return o pipe = build_from_crawler(Pipeline, self.crawler) assert pipe.crawler == self.crawler assert pipe._fingerprinter assert pipe._from_crawler_called class MediaFailedNonePipeline(MockedMediaPipeline): def media_failed( # type: ignore[override] self, failure: Failure, request: Request, info: MediaPipeline.SpiderInfo ) -> None: self._mockcalled.append("media_failed") class TestMediaFailedNone(TestBaseMediaPipeline): """Test what happens when media_failed() neither raises an exception nor returns a failure.""" pipeline_class = MediaFailedNonePipeline @coroutine_test async def test_result_none(self): req = Request("http://url1", meta={"response": Exception("foo")}) new_item = await self.pipe.process_item({"requests": req}) assert new_item["results"] == [(True, None)] assert self.mocked_pipe._mockcalled == [ "get_media_requests", "media_to_download", "media_failed", "item_completed", ] class MediaFailedFailurePipeline(MockedMediaPipeline): def media_failed( self, failure: Failure, request: Request, info: MediaPipeline.SpiderInfo ) -> Failure: self._mockcalled.append("media_failed") return failure # deprecated class TestMediaFailedFailure(TestBaseMediaPipeline): """Test that media_failed() can return a failure instead of raising.""" pipeline_class = MediaFailedFailurePipeline def _errback(self, result): self.mocked_pipe._mockcalled.append("request_errback") return result @coroutine_test async def test_result_failure(self): self.pipe.LOG_FAILED_RESULTS = False exc = Exception("foo") req = Request( "http://url1", meta={"response": exc}, errback=self._errback, ) item = {"requests": req} with pytest.warns( ScrapyDeprecationWarning, match="media_failed returned a Failure instance" ): new_item = await self.pipe.process_item(item) assert len(new_item["results"]) == 1 assert new_item["results"][0][0] is False assert isinstance(new_item["results"][0][1], Failure) assert new_item["results"][0][1].value == exc assert self.mocked_pipe._mockcalled == [ "get_media_requests", "media_to_download", "media_failed", "request_errback", "item_completed", ] scrapy-scrapy-5569de3/tests/test_pipelines.py000066400000000000000000000463461525051012400214330ustar00rootroot00000000000000import asyncio from typing import Any import pytest from twisted.internet.defer import Deferred, fail, succeed from scrapy import Request, Spider, signals from scrapy.crawler import Crawler from scrapy.exceptions import ScrapyDeprecationWarning from scrapy.pipelines import ItemPipelineManager from scrapy.utils.asyncio import call_later from scrapy.utils.conf import build_component_list from scrapy.utils.defer import deferred_to_future, maybe_deferred_to_future from scrapy.utils.misc import build_from_crawler from scrapy.utils.spider import DefaultSpider from scrapy.utils.test import get_crawler, get_from_asyncio_queue from tests.mockserver.http import MockServer from tests.utils.decorators import coroutine_test class SimplePipeline: def process_item(self, item): item["pipeline_passed"] = True return item class DeprecatedSpiderArgPipeline: def open_spider(self, spider): pass def close_spider(self, spider): pass def process_item(self, item, spider): item["pipeline_passed"] = True return item class DeferredPipeline: def cb(self, item): item["pipeline_passed"] = True return item def open_spider(self): return succeed(None) def close_spider(self): return succeed(None) def process_item(self, item): d: Deferred[Any] = Deferred() d.addCallback(self.cb) d.callback(item) return d class AsyncDefPipeline: async def process_item(self, item): d: Deferred[Any] = Deferred() call_later(0, d.callback, None) await maybe_deferred_to_future(d) item["pipeline_passed"] = True return item class AsyncDefAsyncioPipeline: async def process_item(self, item): d: Deferred[Any] = Deferred() loop = asyncio.get_event_loop() loop.call_later(0, d.callback, None) await deferred_to_future(d) await asyncio.sleep(0.2) item["pipeline_passed"] = await get_from_asyncio_queue(True) return item class AsyncDefNotAsyncioPipeline: async def process_item(self, item): d1: Deferred[Any] = Deferred() from twisted.internet import reactor reactor.callLater(0, d1.callback, None) await d1 d2: Deferred[Any] = Deferred() reactor.callLater(0, d2.callback, None) await maybe_deferred_to_future(d2) item["pipeline_passed"] = True return item class ProcessItemExceptionPipeline: def process_item(self, item): raise ValueError("process_item error") class ProcessItemExceptionDeferredPipeline: def process_item(self, item): return fail(ValueError("process_item error")) class ProcessItemExceptionAsyncPipeline: async def process_item(self, item): raise ValueError("process_item error") class OpenSpiderExceptionPipeline: def open_spider(self): raise ValueError("open_spider error") class OpenSpiderExceptionDeferredPipeline: def open_spider(self): return fail(ValueError("open_spider error")) class OpenSpiderExceptionAsyncPipeline: async def open_spider(self): raise ValueError("open_spider error") class ItemSpider(Spider): name = "itemspider" mockserver: MockServer async def start(self): yield Request(self.mockserver.url("/status?n=200")) def parse(self, response): return {"field": 42} class TestPipeline: def _on_item_scraped(self, item): assert isinstance(item, dict) assert item.get("pipeline_passed") self.items.append(item) def _create_crawler(self, pipeline_class: type) -> Crawler: settings = { "ITEM_PIPELINES": {pipeline_class: 1}, } crawler = get_crawler(ItemSpider, settings) crawler.signals.connect(self._on_item_scraped, signals.item_scraped) self.items: list[Any] = [] return crawler @pytest.mark.parametrize( "pipeline_class", [ SimplePipeline, AsyncDefPipeline, pytest.param(AsyncDefAsyncioPipeline, marks=pytest.mark.only_asyncio), pytest.param( AsyncDefNotAsyncioPipeline, marks=pytest.mark.only_not_asyncio ), ], ) @coroutine_test async def test_pipeline(self, mockserver: MockServer, pipeline_class: type) -> None: crawler = self._create_crawler(pipeline_class) await crawler.crawl_async(mockserver=mockserver) assert len(self.items) == 1 @coroutine_test async def test_pipeline_deferred(self, mockserver: MockServer) -> None: crawler = self._create_crawler(DeferredPipeline) with ( pytest.warns( ScrapyDeprecationWarning, match="DeferredPipeline.open_spider returned a Deferred", ), pytest.warns( ScrapyDeprecationWarning, match="DeferredPipeline.close_spider returned a Deferred", ), pytest.warns( ScrapyDeprecationWarning, match="DeferredPipeline.process_item returned a Deferred", ), ): await crawler.crawl_async(mockserver=mockserver) assert len(self.items) == 1 @coroutine_test async def test_deprecated_spider_arg(self, mockserver: MockServer) -> None: crawler = self._create_crawler(DeprecatedSpiderArgPipeline) with ( pytest.warns( ScrapyDeprecationWarning, match=r"DeprecatedSpiderArgPipeline.open_spider\(\) requires a spider argument", ), pytest.warns( ScrapyDeprecationWarning, match=r"DeprecatedSpiderArgPipeline.close_spider\(\) requires a spider argument", ), pytest.warns( ScrapyDeprecationWarning, match=r"DeprecatedSpiderArgPipeline.process_item\(\) requires a spider argument", ), ): await crawler.crawl_async(mockserver=mockserver) assert len(self.items) == 1 @pytest.mark.parametrize( "pipeline_class", [ ProcessItemExceptionPipeline, pytest.param( ProcessItemExceptionDeferredPipeline, marks=pytest.mark.filterwarnings( "ignore::scrapy.exceptions.ScrapyDeprecationWarning" ), ), ProcessItemExceptionAsyncPipeline, ], ) @coroutine_test async def test_process_item_exception( self, caplog: pytest.LogCaptureFixture, mockserver: MockServer, pipeline_class: type, ) -> None: crawler = self._create_crawler(pipeline_class) await crawler.crawl_async(mockserver=mockserver) assert "Error processing {'field': 42}" in caplog.text assert "process_item error" in caplog.text @pytest.mark.parametrize( "pipeline_class", [ OpenSpiderExceptionPipeline, pytest.param( OpenSpiderExceptionDeferredPipeline, marks=pytest.mark.filterwarnings( "ignore::scrapy.exceptions.ScrapyDeprecationWarning" ), ), OpenSpiderExceptionAsyncPipeline, ], ) @coroutine_test async def test_open_spider_exception( self, mockserver: MockServer, pipeline_class: type ) -> None: crawler = self._create_crawler(pipeline_class) with pytest.raises(ValueError, match="open_spider error"): await crawler.crawl_async(mockserver=mockserver) class TestCustomPipelineManager: @coroutine_test async def test_deprecated_process_item_spider_arg(self) -> None: class CustomPipelineManager(ItemPipelineManager): def process_item(self, item: Any, spider: Spider) -> Deferred[Any]: # pylint: disable=useless-parent-delegation return super().process_item(item, spider) crawler = get_crawler(DefaultSpider) crawler.spider = crawler._create_spider() itemproc = build_from_crawler(CustomPipelineManager, crawler) with pytest.warns( ScrapyDeprecationWarning, match=r"CustomPipelineManager.process_item\(\) is deprecated, use process_item_async\(\)", ): itemproc.process_item({}, crawler.spider) @coroutine_test async def test_integration_recommended(self, mockserver: MockServer) -> None: class CustomPipelineManager(ItemPipelineManager): async def process_item_async(self, item): return await super().process_item_async(item) items = [] def _on_item_scraped(item): assert isinstance(item, dict) assert item.get("pipeline_passed") items.append(item) crawler = get_crawler( ItemSpider, { "ITEM_PROCESSOR": CustomPipelineManager, "ITEM_PIPELINES": {SimplePipeline: 1}, }, ) crawler.spider = crawler._create_spider() crawler.signals.connect(_on_item_scraped, signals.item_scraped) await crawler.crawl_async(mockserver=mockserver) assert len(items) == 1 @coroutine_test async def test_integration_no_async_subclass(self, mockserver: MockServer) -> None: class CustomPipelineManager(ItemPipelineManager): def open_spider(self, spider): with pytest.warns( ScrapyDeprecationWarning, match=r"CustomPipelineManager.open_spider\(\) is deprecated, use open_spider_async\(\)", ): return super().open_spider(spider) def close_spider(self, spider): with pytest.warns( ScrapyDeprecationWarning, match=r"CustomPipelineManager.close_spider\(\) is deprecated, use close_spider_async\(\)", ): return super().close_spider(spider) def process_item(self, item, spider): with pytest.warns( ScrapyDeprecationWarning, match=r"CustomPipelineManager.process_item\(\) is deprecated, use process_item_async\(\)", ): return super().process_item(item, spider) items = [] def _on_item_scraped(item): assert isinstance(item, dict) assert item.get("pipeline_passed") items.append(item) crawler = get_crawler( ItemSpider, { "ITEM_PROCESSOR": CustomPipelineManager, "ITEM_PIPELINES": {SimplePipeline: 1}, }, ) crawler.spider = crawler._create_spider() crawler.signals.connect(_on_item_scraped, signals.item_scraped) with ( pytest.warns( ScrapyDeprecationWarning, match=r"CustomPipelineManager overrides open_spider\(\) but doesn't override open_spider_async\(\)", ), pytest.warns( ScrapyDeprecationWarning, match=r"CustomPipelineManager overrides close_spider\(\) but doesn't override close_spider_async\(\)", ), pytest.warns( ScrapyDeprecationWarning, match=r"CustomPipelineManager overrides process_item\(\) but doesn't override process_item_async\(\)", ), ): await crawler.crawl_async(mockserver=mockserver) assert len(items) == 1 @coroutine_test async def test_integration_no_async_not_subclass( self, mockserver: MockServer ) -> None: class CustomPipelineManager: def __init__(self, crawler): self.pipelines = [ p() for p in build_component_list( crawler.settings.getwithbase("ITEM_PIPELINES") ) ] @classmethod def from_crawler(cls, crawler): return cls(crawler) def open_spider(self, spider): return succeed(None) def close_spider(self, spider): return succeed(None) def process_item(self, item, spider): for pipeline in self.pipelines: item = pipeline.process_item(item) return succeed(item) items = [] def _on_item_scraped(item): assert isinstance(item, dict) assert item.get("pipeline_passed") items.append(item) crawler = get_crawler( ItemSpider, { "ITEM_PROCESSOR": CustomPipelineManager, "ITEM_PIPELINES": {SimplePipeline: 1}, }, ) crawler.spider = crawler._create_spider() crawler.signals.connect(_on_item_scraped, signals.item_scraped) with ( pytest.warns( ScrapyDeprecationWarning, match=r"CustomPipelineManager doesn't define a open_spider_async\(\) method", ), pytest.warns( ScrapyDeprecationWarning, match=r"CustomPipelineManager doesn't define a close_spider_async\(\) method", ), pytest.warns( ScrapyDeprecationWarning, match=r"CustomPipelineManager doesn't define a process_item_async\(\) method", ), ): await crawler.crawl_async(mockserver=mockserver) assert len(items) == 1 class TestMiddlewareManagerSpider: """Tests for the deprecated spider arg handling in MiddlewareManager. Here because MiddlewareManager doesn't have methods that could take a spider arg.""" @pytest.fixture def crawler(self) -> Crawler: return get_crawler(Spider) @coroutine_test async def test_deprecated_spider_arg_no_crawler_spider( self, crawler: Crawler ) -> None: """Crawler is provided, but doesn't have a spider, the methods raise an exception. The instance passed to a deprecated method is ignored.""" mwman = ItemPipelineManager(crawler=crawler) with ( pytest.warns( ScrapyDeprecationWarning, match=r"DeprecatedSpiderArgPipeline.open_spider\(\) requires a spider argument", ), pytest.warns( ScrapyDeprecationWarning, match=r"DeprecatedSpiderArgPipeline.close_spider\(\) requires a spider argument", ), pytest.warns( ScrapyDeprecationWarning, match=r"DeprecatedSpiderArgPipeline.process_item\(\) requires a spider argument", ), ): mwman._add_middleware(DeprecatedSpiderArgPipeline()) with ( pytest.warns( ScrapyDeprecationWarning, match=r"ItemPipelineManager.open_spider\(\) is deprecated, use open_spider_async\(\) instead", ), pytest.raises( ValueError, match=r"ItemPipelineManager needs to access self\.crawler\.spider but it is None", ), ): await maybe_deferred_to_future(mwman.open_spider(DefaultSpider())) with pytest.raises( ValueError, match=r"ItemPipelineManager needs to access self\.crawler\.spider but it is None", ): await mwman.open_spider_async() with ( pytest.warns( ScrapyDeprecationWarning, match=r"ItemPipelineManager.close_spider\(\) is deprecated, use close_spider_async\(\) instead", ), pytest.raises( ValueError, match=r"ItemPipelineManager needs to access self\.crawler\.spider but it is None", ), ): await maybe_deferred_to_future(mwman.close_spider(DefaultSpider())) with pytest.raises( ValueError, match=r"ItemPipelineManager needs to access self\.crawler\.spider but it is None", ): await mwman.close_spider_async() @coroutine_test async def test_deprecated_spider_arg_with_crawler(self, crawler: Crawler) -> None: """Crawler is provided and has a spider, works. The instance passed to a deprecated method is ignored, even if mismatched.""" mwman = ItemPipelineManager(crawler=crawler) crawler.spider = crawler._create_spider("foo") with pytest.warns( ScrapyDeprecationWarning, match=r"ItemPipelineManager.open_spider\(\) is deprecated, use open_spider_async\(\) instead", ): await maybe_deferred_to_future(mwman.open_spider(DefaultSpider())) with pytest.warns( ScrapyDeprecationWarning, match=r"ItemPipelineManager.close_spider\(\) is deprecated, use close_spider_async\(\) instead", ): await maybe_deferred_to_future(mwman.close_spider(DefaultSpider())) @coroutine_test async def test_deprecated_spider_arg_without_crawler(self) -> None: """The first instance passed to a deprecated method is used. Mismatched ones raise an error.""" with pytest.warns( ScrapyDeprecationWarning, match="was called without the crawler argument", ): mwman = ItemPipelineManager() spider = DefaultSpider() with pytest.warns( ScrapyDeprecationWarning, match=r"ItemPipelineManager.open_spider\(\) is deprecated, use open_spider_async\(\) instead", ): await maybe_deferred_to_future(mwman.open_spider(spider)) with ( pytest.warns( ScrapyDeprecationWarning, match=r"ItemPipelineManager.close_spider\(\) is deprecated, use close_spider_async\(\) instead", ), pytest.raises( RuntimeError, match="Different instances of Spider were passed" ), ): await maybe_deferred_to_future(mwman.close_spider(DefaultSpider())) with pytest.warns( ScrapyDeprecationWarning, match=r"ItemPipelineManager.close_spider\(\) is deprecated, use close_spider_async\(\) instead", ): await maybe_deferred_to_future(mwman.close_spider(spider)) @coroutine_test async def test_no_spider_arg_without_crawler(self) -> None: """If no crawler and no spider arg, raise an error.""" with pytest.warns( ScrapyDeprecationWarning, match="was called without the crawler argument", ): mwman = ItemPipelineManager() with ( pytest.warns( ScrapyDeprecationWarning, match=r"DeprecatedSpiderArgPipeline.open_spider\(\) requires a spider argument", ), pytest.warns( ScrapyDeprecationWarning, match=r"DeprecatedSpiderArgPipeline.close_spider\(\) requires a spider argument", ), pytest.warns( ScrapyDeprecationWarning, match=r"DeprecatedSpiderArgPipeline.process_item\(\) requires a spider argument", ), ): mwman._add_middleware(DeprecatedSpiderArgPipeline()) with ( pytest.raises( ValueError, match="has no known Spider instance", ), ): await mwman.open_spider_async() scrapy-scrapy-5569de3/tests/test_poet.py000066400000000000000000000010521525051012400203730ustar00rootroot00000000000000"""Tests that make sure parts needed for the scrapy-poet stack work.""" from typing import get_type_hints from scrapy import Spider from scrapy.spiders import CrawlSpider, CSVFeedSpider, SitemapSpider, XMLFeedSpider def test_callbacks(): """Making sure annotations on all non-abstract callbacks can be resolved.""" for cb in [ Spider._parse, CrawlSpider._parse, CrawlSpider._callback, XMLFeedSpider._parse, CSVFeedSpider._parse, SitemapSpider._parse_sitemap, ]: get_type_hints(cb) scrapy-scrapy-5569de3/tests/test_pqueues.py000066400000000000000000000303201525051012400211130ustar00rootroot00000000000000import tempfile from typing import Any from unittest.mock import Mock import pytest import queuelib from scrapy.core.downloader import Downloader from scrapy.http.request import Request from scrapy.pqueues import DownloaderAwarePriorityQueue, ScrapyPriorityQueue, _path_safe from scrapy.spiders import Spider from scrapy.squeues import FifoMemoryQueue, PickleFifoDiskQueue from scrapy.utils.misc import build_from_crawler, load_object from scrapy.utils.test import get_crawler from tests.utils.downloader import MockDownloader def _pop(queue: ScrapyPriorityQueue | DownloaderAwarePriorityQueue) -> Request: request = queue.pop() assert request is not None return request def _peek(queue: ScrapyPriorityQueue | DownloaderAwarePriorityQueue) -> Request: request = queue.peek() assert request is not None return request class TestPriorityQueue: def setup_method(self): self.crawler = get_crawler(Spider) self.spider = self.crawler._create_spider("foo") def test_queue_push_pop_one(self): temp_dir = tempfile.mkdtemp() queue = build_from_crawler( ScrapyPriorityQueue, self.crawler, FifoMemoryQueue, temp_dir ) assert queue.pop() is None assert len(queue) == 0 req1 = Request("https://example.org/1", priority=1) queue.push(req1) assert len(queue) == 1 dequeued = _pop(queue) assert len(queue) == 0 assert dequeued.url == req1.url assert dequeued.priority == req1.priority assert not queue.close() def test_no_peek_raises(self): if hasattr(queuelib.queue.FifoMemoryQueue, "peek"): pytest.skip("queuelib.queue.FifoMemoryQueue.peek is defined") temp_dir = tempfile.mkdtemp() queue = build_from_crawler( ScrapyPriorityQueue, self.crawler, FifoMemoryQueue, temp_dir ) queue.push(Request("https://example.org")) with pytest.raises( NotImplementedError, match="The underlying queue class does not implement 'peek'", ): queue.peek() queue.close() def test_peek(self): if not hasattr(queuelib.queue.FifoMemoryQueue, "peek"): pytest.skip("queuelib.queue.FifoMemoryQueue.peek is undefined") temp_dir = tempfile.mkdtemp() queue = build_from_crawler( ScrapyPriorityQueue, self.crawler, FifoMemoryQueue, temp_dir ) assert len(queue) == 0 assert queue.peek() is None req1 = Request("https://example.org/1") req2 = Request("https://example.org/2") req3 = Request("https://example.org/3") queue.push(req1) queue.push(req2) queue.push(req3) assert len(queue) == 3 assert _peek(queue).url == req1.url assert _pop(queue).url == req1.url assert len(queue) == 2 assert _peek(queue).url == req2.url assert _pop(queue).url == req2.url assert len(queue) == 1 assert _peek(queue).url == req3.url assert _pop(queue).url == req3.url assert not queue.close() def test_init_prios_with_start_queue(self): temp_dir = tempfile.mkdtemp() queue = build_from_crawler( ScrapyPriorityQueue, self.crawler, PickleFifoDiskQueue, temp_dir, start_queue_cls=PickleFifoDiskQueue, ) req = Request("https://example.org/", meta={"is_start_request": True}) queue.push(req) startprios = queue.close() queue2 = build_from_crawler( ScrapyPriorityQueue, self.crawler, PickleFifoDiskQueue, temp_dir, startprios, start_queue_cls=PickleFifoDiskQueue, ) assert len(queue2) == 1 assert _pop(queue2).url == req.url queue2.close() def test_queue_push_pop_priorities(self): temp_dir = tempfile.mkdtemp() queue = build_from_crawler( ScrapyPriorityQueue, self.crawler, FifoMemoryQueue, temp_dir, [-1, -2, -3] ) assert queue.pop() is None assert len(queue) == 0 req1 = Request("https://example.org/1", priority=1) req2 = Request("https://example.org/2", priority=2) req3 = Request("https://example.org/3", priority=3) queue.push(req1) queue.push(req2) queue.push(req3) assert len(queue) == 3 dequeued = _pop(queue) assert len(queue) == 2 assert dequeued.url == req3.url assert dequeued.priority == req3.priority assert set(queue.close()) == {-1, -2} class TestDownloaderAwarePriorityQueue: def setup_method(self): crawler = get_crawler(Spider) self.downloader = MockDownloader() crawler.engine = Mock(downloader=self.downloader) self.queue = build_from_crawler( DownloaderAwarePriorityQueue, crawler, downstream_queue_cls=FifoMemoryQueue, key="foo/bar", ) def teardown_method(self): self.queue.close() def test_push_pop(self): assert len(self.queue) == 0 assert self.queue.pop() is None req1 = Request("http://www.example.com/1") req2 = Request("http://www.example.com/2") req3 = Request("http://www.example.com/3") self.queue.push(req1) self.queue.push(req2) self.queue.push(req3) assert len(self.queue) == 3 assert _pop(self.queue).url == req1.url assert len(self.queue) == 2 assert _pop(self.queue).url == req2.url assert len(self.queue) == 1 assert _pop(self.queue).url == req3.url assert len(self.queue) == 0 assert self.queue.pop() is None def test_no_peek_raises(self): if hasattr(queuelib.queue.FifoMemoryQueue, "peek"): pytest.skip("queuelib.queue.FifoMemoryQueue.peek is defined") self.queue.push(Request("https://example.org")) with pytest.raises( NotImplementedError, match="The underlying queue class does not implement 'peek'", ): self.queue.peek() def test_peek(self): if not hasattr(queuelib.queue.FifoMemoryQueue, "peek"): pytest.skip("queuelib.queue.FifoMemoryQueue.peek is undefined") assert len(self.queue) == 0 req1 = Request("https://example.org/1") req2 = Request("https://example.org/2") req3 = Request("https://example.org/3") self.queue.push(req1) self.queue.push(req2) self.queue.push(req3) assert len(self.queue) == 3 assert _peek(self.queue).url == req1.url assert _pop(self.queue).url == req1.url assert len(self.queue) == 2 assert _peek(self.queue).url == req2.url assert _pop(self.queue).url == req2.url assert len(self.queue) == 1 assert _peek(self.queue).url == req3.url assert _pop(self.queue).url == req3.url assert self.queue.peek() is None def test_tie_breaking_rotates_slots(self): # No active downloads are tracked in the downloader, so every slot has # the same score and tie-breaking must not starve a slot. req_a1 = Request("https://example.org/a1") req_a1.meta[Downloader.DOWNLOAD_SLOT] = "slot-a" req_b1 = Request("https://example.org/b1") req_b1.meta[Downloader.DOWNLOAD_SLOT] = "slot-b" req_a2 = Request("https://example.org/a2") req_a2.meta[Downloader.DOWNLOAD_SLOT] = "slot-a" req_b2 = Request("https://example.org/b2") req_b2.meta[Downloader.DOWNLOAD_SLOT] = "slot-b" for request in (req_a1, req_b1, req_a2, req_b2): self.queue.push(request) slots = [ _pop(self.queue).meta[Downloader.DOWNLOAD_SLOT], _pop(self.queue).meta[Downloader.DOWNLOAD_SLOT], _pop(self.queue).meta[Downloader.DOWNLOAD_SLOT], _pop(self.queue).meta[Downloader.DOWNLOAD_SLOT], ] assert slots == ["slot-a", "slot-b", "slot-a", "slot-b"] def test_tie_breaking_keeps_rotation_after_selected_slot_is_deleted(self): # If the selected slot becomes empty, rotation should continue from # that slot marker to avoid restarting from the smallest slot. req_a1 = Request("https://example.org/a1") req_a1.meta[Downloader.DOWNLOAD_SLOT] = "slot-a" req_a2 = Request("https://example.org/a2") req_a2.meta[Downloader.DOWNLOAD_SLOT] = "slot-a" req_b1 = Request("https://example.org/b1") req_b1.meta[Downloader.DOWNLOAD_SLOT] = "slot-b" req_c1 = Request("https://example.org/c1") req_c1.meta[Downloader.DOWNLOAD_SLOT] = "slot-c" for request in (req_a1, req_a2, req_b1, req_c1): self.queue.push(request) slots = [ _pop(self.queue).meta[Downloader.DOWNLOAD_SLOT], _pop(self.queue).meta[Downloader.DOWNLOAD_SLOT], _pop(self.queue).meta[Downloader.DOWNLOAD_SLOT], _pop(self.queue).meta[Downloader.DOWNLOAD_SLOT], ] assert slots == ["slot-a", "slot-b", "slot-c", "slot-a"] def test_pop_prefers_slot_with_fewer_active_downloads(self): req_a = Request("https://example.org/a") req_a.meta[Downloader.DOWNLOAD_SLOT] = "slot-a" req_b = Request("https://example.org/b") req_b.meta[Downloader.DOWNLOAD_SLOT] = "slot-b" req_c = Request("https://example.org/c") req_c.meta[Downloader.DOWNLOAD_SLOT] = "slot-c" for req in (req_a, req_b, req_c): self.queue.push(req) self.downloader.increment("slot-a") self.downloader.increment("slot-c") popped = _pop(self.queue) assert popped.url == req_b.url def test_contains(self): req = Request("https://example.org/") req.meta[Downloader.DOWNLOAD_SLOT] = "example-slot" assert "example-slot" not in self.queue self.queue.push(req) assert "example-slot" in self.queue assert "other-slot" not in self.queue def test_slot_directory_removed_when_slot_drains(tmp_path): crawler = get_crawler(Spider) crawler.spider = crawler._create_spider("foo") crawler.engine = Mock(downloader=MockDownloader()) queue = build_from_crawler( DownloaderAwarePriorityQueue, crawler, downstream_queue_cls=PickleFifoDiskQueue, key=str(tmp_path), ) request = Request("https://example.org/1") slot_dir = tmp_path / _path_safe("example.org") queue.push(request) assert slot_dir.is_dir() assert _pop(queue).url == request.url assert not slot_dir.exists() queue.push(request) assert slot_dir.is_dir() queue.close() @pytest.mark.parametrize( ("input_", "output"), [ # By default, start requests are FIFO, other requests are LIFO. ([{}, {}], [2, 1]), ([{"start": True}, {"start": True}], [1, 2]), # Priority matters. ([{"priority": 1}, {"start": True}], [1, 2]), ([{}, {"start": True, "priority": 1}], [2, 1]), # For the same priority, start requests pop last. ([{}, {"start": True}], [1, 2]), ([{"start": True}, {}], [2, 1]), ], ) def test_pop_order(input_, output): def make_url(index: int) -> str: return f"https://toscrape.com/{index}" def make_request(index: int, data: dict[str, Any]) -> Request: meta: dict[str, Any] = {} if data.get("start", False): meta["is_start_request"] = True return Request( url=make_url(index), priority=data.get("priority", 0), meta=meta, ) input_requests = [ make_request(index, data) for index, data in enumerate(input_, start=1) ] expected_output_urls = [make_url(index) for index in output] crawler = get_crawler(Spider) settings = crawler.settings queue = build_from_crawler( ScrapyPriorityQueue, crawler, downstream_queue_cls=load_object(settings["SCHEDULER_MEMORY_QUEUE"]), key="", start_queue_cls=load_object(settings["SCHEDULER_START_MEMORY_QUEUE"]), ) for request in input_requests: queue.push(request) actual_output_urls = [] while popped := queue.pop(): actual_output_urls.append(popped.url) assert actual_output_urls == expected_output_urls scrapy-scrapy-5569de3/tests/test_request_attribute_binding.py000066400000000000000000000201261525051012400246740ustar00rootroot00000000000000from __future__ import annotations import logging from typing import TYPE_CHECKING from scrapy import Request, signals from scrapy.http.response import Response from scrapy.utils.test import get_crawler from tests.mockserver.http import MockServer from tests.spiders import SingleRequestSpider from tests.utils.decorators import coroutine_test, inline_callbacks_test if TYPE_CHECKING: import pytest OVERRIDDEN_URL = "https://example.org" class ProcessResponseMiddleware: def process_response(self, request, response): return response.replace(request=Request(OVERRIDDEN_URL)) class RaiseExceptionRequestMiddleware: def process_request(self, request): 1 / 0 return request class CatchExceptionOverrideRequestMiddleware: def process_exception(self, request, exception): return Response( url="http://localhost/", body=b"Caught " + exception.__class__.__name__.encode("utf-8"), request=Request(OVERRIDDEN_URL), ) class CatchExceptionDoNotOverrideRequestMiddleware: def process_exception(self, request, exception): return Response( url="http://localhost/", body=b"Caught " + exception.__class__.__name__.encode("utf-8"), ) class AlternativeCallbacksSpider(SingleRequestSpider): name = "alternative_callbacks_spider" def alt_callback(self, response, foo=None): self.logger.info("alt_callback was invoked with foo=%s", foo) class AlternativeCallbacksMiddleware: def __init__(self, crawler): self.crawler = crawler @classmethod def from_crawler(cls, crawler): return cls(crawler) def process_response(self, request, response): new_request = request.replace( url=OVERRIDDEN_URL, callback=self.crawler.spider.alt_callback, cb_kwargs={"foo": "bar"}, ) return response.replace(request=new_request) class TestCrawl: mockserver: MockServer @classmethod def setup_class(cls): cls.mockserver = MockServer() cls.mockserver.__enter__() @classmethod def teardown_class(cls): cls.mockserver.__exit__(None, None, None) @inline_callbacks_test def test_response_200(self): url = self.mockserver.url("/status?n=200") crawler = get_crawler(SingleRequestSpider) yield crawler.crawl(seed=url, mockserver=self.mockserver) assert isinstance(crawler.spider, SingleRequestSpider) response = crawler.spider.meta["responses"][0] assert response.request.url == url @inline_callbacks_test def test_response_error(self): for status in ("404", "500"): url = self.mockserver.url(f"/status?n={status}") crawler = get_crawler(SingleRequestSpider) yield crawler.crawl(seed=url, mockserver=self.mockserver) assert isinstance(crawler.spider, SingleRequestSpider) failure = crawler.spider.meta["failure"] response = failure.value.response assert failure.request.url == url assert response.request.url == url @inline_callbacks_test def test_downloader_middleware_raise_exception(self): url = self.mockserver.url("/status?n=200") crawler = get_crawler( SingleRequestSpider, { "DOWNLOADER_MIDDLEWARES": { RaiseExceptionRequestMiddleware: 590, }, }, ) yield crawler.crawl(seed=url, mockserver=self.mockserver) assert isinstance(crawler.spider, SingleRequestSpider) failure = crawler.spider.meta["failure"] assert failure.request.url == url assert isinstance(failure.value, ZeroDivisionError) @coroutine_test async def test_downloader_middleware_override_request_in_process_response( self, caplog: pytest.LogCaptureFixture ) -> None: """ Downloader middleware which returns a response with an specific 'request' attribute. * The spider callback should receive the overridden response.request * Handlers listening to the response_received signal should receive the overridden response.request * The "crawled" log message should show the overridden response.request """ signal_params = {} def signal_handler(response, request, spider): signal_params["response"] = response signal_params["request"] = request url = self.mockserver.url("/status?n=200") crawler = get_crawler( SingleRequestSpider, { "DOWNLOADER_MIDDLEWARES": { ProcessResponseMiddleware: 595, } }, ) crawler.signals.connect(signal_handler, signal=signals.response_received) with caplog.at_level(logging.DEBUG): await crawler.crawl_async(seed=url, mockserver=self.mockserver) assert isinstance(crawler.spider, SingleRequestSpider) response = crawler.spider.meta["responses"][0] assert response.request.url == OVERRIDDEN_URL assert signal_params["response"].url == url assert signal_params["request"].url == OVERRIDDEN_URL assert ( "scrapy.core.engine", logging.DEBUG, f"Crawled (200) (referer: None)", ) in caplog.record_tuples @inline_callbacks_test def test_downloader_middleware_override_in_process_exception(self): """ An exception is raised but caught by the next middleware, which returns a Response with a specific 'request' attribute. The spider callback should receive the overridden response.request """ url = self.mockserver.url("/status?n=200") crawler = get_crawler( SingleRequestSpider, { "DOWNLOADER_MIDDLEWARES": { RaiseExceptionRequestMiddleware: 590, CatchExceptionOverrideRequestMiddleware: 595, }, }, ) yield crawler.crawl(seed=url, mockserver=self.mockserver) assert isinstance(crawler.spider, SingleRequestSpider) response = crawler.spider.meta["responses"][0] assert response.body == b"Caught ZeroDivisionError" assert response.request.url == OVERRIDDEN_URL @inline_callbacks_test def test_downloader_middleware_do_not_override_in_process_exception(self): """ An exception is raised but caught by the next middleware, which returns a Response without a specific 'request' attribute. The spider callback should receive the original response.request """ url = self.mockserver.url("/status?n=200") crawler = get_crawler( SingleRequestSpider, { "DOWNLOADER_MIDDLEWARES": { RaiseExceptionRequestMiddleware: 590, CatchExceptionDoNotOverrideRequestMiddleware: 595, }, }, ) yield crawler.crawl(seed=url, mockserver=self.mockserver) assert isinstance(crawler.spider, SingleRequestSpider) response = crawler.spider.meta["responses"][0] assert response.body == b"Caught ZeroDivisionError" assert response.request.url == url @coroutine_test async def test_downloader_middleware_alternative_callback( self, caplog: pytest.LogCaptureFixture ) -> None: """ Downloader middleware which returns a response with a specific 'request' attribute, with an alternative callback """ crawler = get_crawler( AlternativeCallbacksSpider, { "DOWNLOADER_MIDDLEWARES": { AlternativeCallbacksMiddleware: 595, } }, ) url = self.mockserver.url("/status?n=200") with caplog.at_level(logging.INFO): await crawler.crawl_async(seed=url, mockserver=self.mockserver) assert ( "alternative_callbacks_spider", logging.INFO, "alt_callback was invoked with foo=bar", ) in caplog.record_tuples scrapy-scrapy-5569de3/tests/test_request_cb_kwargs.py000066400000000000000000000157311525051012400231470ustar00rootroot00000000000000from __future__ import annotations import logging from typing import TYPE_CHECKING from scrapy.http import Request from scrapy.utils.test import get_crawler from tests.spiders import MockServerSpider from tests.utils.decorators import coroutine_test if TYPE_CHECKING: import pytest from tests.mockserver.http import MockServer class InjectArgumentsDownloaderMiddleware: """ Make sure downloader middlewares are able to update the keyword arguments """ def process_request(self, request): if request.callback.__name__ == "parse_downloader_mw": request.cb_kwargs["from_process_request"] = True def process_response(self, request, response): if request.callback.__name__ == "parse_downloader_mw": request.cb_kwargs["from_process_response"] = True return response class InjectArgumentsSpiderMiddleware: """ Make sure spider middlewares are able to update the keyword arguments """ async def process_start(self, start): async for request in start: if request.callback.__name__ == "parse_spider_mw": request.cb_kwargs["from_process_start"] = True yield request def process_spider_input(self, response): request = response.request if request.callback.__name__ == "parse_spider_mw": request.cb_kwargs["from_process_spider_input"] = True async def process_spider_output(self, response, result): async for element in result: if ( isinstance(element, Request) and element.callback and element.callback.__name__ == "parse_spider_mw_2" ): element.cb_kwargs["from_process_spider_output"] = True yield element class KeywordArgumentsSpider(MockServerSpider): name = "kwargs" custom_settings = { "DOWNLOADER_MIDDLEWARES": { InjectArgumentsDownloaderMiddleware: 750, }, "SPIDER_MIDDLEWARES": { InjectArgumentsSpiderMiddleware: 750, }, } checks: list[bool] = [] def _inc_checks(self, count: int = 1) -> None: self.crawler.stats.inc_value("boolean_checks", count) async def start(self): assert self.mockserver data = {"key": "value", "number": 123, "callback": "some_callback"} yield Request(self.mockserver.url("/first"), self.parse_first, cb_kwargs=data) yield Request( self.mockserver.url("/general_with"), self.parse_general, cb_kwargs=data ) yield Request(self.mockserver.url("/general_without"), self.parse_general) yield Request(self.mockserver.url("/no_kwargs"), self.parse_no_kwargs) yield Request( self.mockserver.url("/default"), self.parse_default, cb_kwargs=data ) yield Request( self.mockserver.url("/takes_less"), self.parse_takes_less, cb_kwargs=data ) yield Request( self.mockserver.url("/takes_more"), self.parse_takes_more, cb_kwargs=data ) yield Request(self.mockserver.url("/downloader_mw"), self.parse_downloader_mw) yield Request(self.mockserver.url("/spider_mw"), self.parse_spider_mw) def parse_first(self, response, key, number): assert self.mockserver self.checks.append(key == "value") self.checks.append(number == 123) self._inc_checks(2) yield response.follow( self.mockserver.url("/two"), self.parse_second, cb_kwargs={"new_key": "new_value"}, ) def parse_second(self, response, new_key): self.checks.append(new_key == "new_value") self._inc_checks() def parse_general(self, response, **kwargs): if response.url.endswith("/general_with"): self.checks.append(kwargs["key"] == "value") self.checks.append(kwargs["number"] == 123) self.checks.append(kwargs["callback"] == "some_callback") self._inc_checks(3) elif response.url.endswith("/general_without"): self.checks.append(kwargs == {}) self._inc_checks() def parse_no_kwargs(self, response): self.checks.append(response.url.endswith("/no_kwargs")) self._inc_checks() def parse_default(self, response, key, number=None, default=99): self.checks.append(response.url.endswith("/default")) self.checks.append(key == "value") self.checks.append(number == 123) self.checks.append(default == 99) self._inc_checks(4) def parse_takes_less(self, response, key, callback): """ Should raise TypeError: parse_takes_less() got an unexpected keyword argument 'number' """ def parse_takes_more(self, response, key, number, callback, other): """ Should raise TypeError: parse_takes_more() missing 1 required positional argument: 'other' """ def parse_downloader_mw( self, response, from_process_request, from_process_response ): self.checks.append(bool(from_process_request)) self.checks.append(bool(from_process_response)) self._inc_checks(2) def parse_spider_mw(self, response, from_process_spider_input, from_process_start): assert self.mockserver self.checks.append(bool(from_process_spider_input)) self.checks.append(bool(from_process_start)) self._inc_checks(2) return Request(self.mockserver.url("/spider_mw_2"), self.parse_spider_mw_2) def parse_spider_mw_2(self, response, from_process_spider_output): self.checks.append(bool(from_process_spider_output)) self._inc_checks() class TestCallbackKeywordArguments: @coroutine_test async def test_callback_kwargs( self, caplog: pytest.LogCaptureFixture, mockserver: MockServer ) -> None: crawler = get_crawler(KeywordArgumentsSpider) with caplog.at_level(logging.ERROR): await crawler.crawl_async(mockserver=mockserver) assert isinstance(crawler.spider, KeywordArgumentsSpider) assert all(crawler.spider.checks) assert len(crawler.spider.checks) == crawler.stats.get_value("boolean_checks") # check exceptions for argument mismatch exceptions = {} for line in caplog.records: for key in ("takes_less", "takes_more"): if key in line.getMessage(): exceptions[key] = line takes_less_exc_info = exceptions["takes_less"].exc_info assert takes_less_exc_info is not None assert takes_less_exc_info[0] is TypeError assert str(takes_less_exc_info[1]).endswith( "parse_takes_less() got an unexpected keyword argument 'number'" ) takes_more_exc_info = exceptions["takes_more"].exc_info assert takes_more_exc_info is not None assert takes_more_exc_info[0] is TypeError assert str(takes_more_exc_info[1]).endswith( "parse_takes_more() missing 1 required positional argument: 'other'" ) scrapy-scrapy-5569de3/tests/test_request_dict.py000066400000000000000000000162241525051012400221260ustar00rootroot00000000000000from __future__ import annotations from typing import TYPE_CHECKING, Any import pytest from scrapy import Request, Spider from scrapy.http import JsonRequest, Response from scrapy.utils.request import request_from_dict if TYPE_CHECKING: from twisted.python.failure import Failure class CustomRequest(Request): pass @pytest.fixture def spider() -> MethodsSpider: return MethodsSpider() def _assert_serializes_ok(request: Request, spider: Spider | None = None) -> None: d = request.to_dict(spider=spider) request2 = request_from_dict(d, spider=spider) _assert_same_request(request, request2) def _assert_same_request(r1: Request, r2: Request) -> None: assert r1.__class__ == r2.__class__ assert r1.url == r2.url assert r1.callback == r2.callback assert r1.errback == r2.errback assert r1.method == r2.method assert r1.body == r2.body assert r1.headers == r2.headers assert r1.cookies == r2.cookies assert r1.meta == r2.meta assert r1.cb_kwargs == r2.cb_kwargs assert r1.encoding == r2.encoding assert r1._encoding == r2._encoding assert r1.priority == r2.priority assert r1.dont_filter == r2.dont_filter assert r1.flags == r2.flags if isinstance(r1, JsonRequest): assert isinstance(r2, JsonRequest) assert r1.dumps_kwargs == r2.dumps_kwargs def test_basic() -> None: r = Request("http://www.example.com") _assert_serializes_ok(r) def test_all_attributes(spider: MethodsSpider) -> None: r = Request( url="http://www.example.com", callback=spider.parse_item, errback=spider.handle_error, method="POST", body=b"some body", headers={"content-encoding": "text/html; charset=latin-1"}, cookies={"currency": "руб"}, encoding="latin-1", priority=20, meta={"a": "b"}, cb_kwargs={"k": "v"}, flags=["testFlag"], ) _assert_serializes_ok(r, spider=spider) def test_latin1_body() -> None: r = Request("http://www.example.com", body=b"\xa3") _assert_serializes_ok(r) def test_utf8_body() -> None: r = Request("http://www.example.com", body=b"\xc2\xa3") _assert_serializes_ok(r) def test_request_class(spider: MethodsSpider) -> None: r1 = CustomRequest("http://www.example.com") _assert_serializes_ok(r1, spider=spider) r2 = JsonRequest("http://www.example.com", dumps_kwargs={"indent": 4}) _assert_serializes_ok(r2, spider=spider) def test_callback_serialization(spider: MethodsSpider) -> None: r = Request( "http://www.example.com", callback=spider.parse_item, errback=spider.handle_error, ) _assert_serializes_ok(r, spider=spider) def test_reference_callback_serialization(spider: MethodsSpider) -> None: r = Request( "http://www.example.com", callback=spider.parse_item_reference, # type: ignore[arg-type,misc] errback=spider.handle_error_reference, # type: ignore[arg-type,misc] ) _assert_serializes_ok(r, spider=spider) request_dict = r.to_dict(spider=spider) assert request_dict["callback"] == "parse_item_reference" assert request_dict["errback"] == "handle_error_reference" def test_private_reference_callback_serialization(spider: MethodsSpider) -> None: r = Request( "http://www.example.com", callback=spider._MethodsSpider__parse_item_reference, # type: ignore[attr-defined] errback=spider._MethodsSpider__handle_error_reference, # type: ignore[attr-defined] ) _assert_serializes_ok(r, spider=spider) request_dict = r.to_dict(spider=spider) assert request_dict["callback"] == "_MethodsSpider__parse_item_reference" assert request_dict["errback"] == "_MethodsSpider__handle_error_reference" def test_private_callback_serialization(spider: MethodsSpider) -> None: r = Request( "http://www.example.com", callback=spider._MethodsSpider__parse_item_private, # type: ignore[attr-defined] errback=spider.handle_error, ) _assert_serializes_ok(r, spider=spider) def test_mixin_private_callback_serialization(spider: MethodsSpider) -> None: r = Request( "http://www.example.com", callback=spider._SpiderMixin__mixin_callback, # type: ignore[attr-defined] errback=spider.handle_error, ) _assert_serializes_ok(r, spider=spider) def test_delegated_callback_serialization(spider: MethodsSpider) -> None: r = Request( "http://www.example.com", callback=spider.delegated_callback, errback=spider.handle_error, ) _assert_serializes_ok(r, spider=spider) def test_unserializable_callback1(spider: MethodsSpider) -> None: r = Request("http://www.example.com", callback=lambda x: x) # type: ignore[misc] with pytest.raises( ValueError, match="is not an instance method in: None: r = Request("http://www.example.com", callback=spider.parse_item) with pytest.raises(ValueError, match="is not an instance method in: None"): r.to_dict(spider=None) def test_unserializable_callback3() -> None: """Parser method is removed or replaced dynamically.""" class MySpider(Spider): name = "my_spider" def parse(self, response: Response) -> None: pass spider = MySpider() r = Request("http://www.example.com", callback=spider.parse) spider.parse = None # type: ignore[method-assign,assignment] with pytest.raises(ValueError, match="is not an instance method in: None: """Callback method is not available in the spider passed to from_dict""" spider = SpiderDelegation() r = Request("http://www.example.com", callback=spider.delegated_callback) d = r.to_dict(spider=spider) # type: ignore[arg-type] with pytest.raises( ValueError, match="Method 'delegated_callback' not found in: None: pass class SpiderDelegation: def delegated_callback(self, response: Response) -> None: pass def parse_item(response: Response) -> None: pass def handle_error(failure: Failure) -> None: pass def private_parse_item(response: Response) -> None: pass def private_handle_error(failure: Failure) -> None: pass class MethodsSpider(Spider, SpiderMixin): name = "test" parse_item_reference = parse_item handle_error_reference = handle_error __parse_item_reference = private_parse_item __handle_error_reference = private_handle_error def __init__(self, **kwargs: Any) -> None: super().__init__(**kwargs) self.delegated_callback = SpiderDelegation().delegated_callback def parse_item(self, response: Response) -> None: pass def handle_error(self, failure: Failure) -> None: pass def __parse_item_private( # pylint: disable=unused-private-member self, response: Response ) -> None: pass scrapy-scrapy-5569de3/tests/test_request_left.py000066400000000000000000000041051525051012400221300ustar00rootroot00000000000000from __future__ import annotations from typing import TYPE_CHECKING, Any from scrapy.signals import request_left_downloader from scrapy.spiders import Spider from scrapy.utils.test import get_crawler from tests.utils.decorators import inline_callbacks_test if TYPE_CHECKING: from scrapy import Request from scrapy.crawler import Crawler from tests.mockserver.http import MockServer class SignalCatcherSpider(Spider): name = "signal_catcher" def __init__(self, crawler: Crawler, url: str, *args: Any, **kwargs: Any): super().__init__(*args, **kwargs) crawler.signals.connect(self.on_request_left, signal=request_left_downloader) self.caught_times = 0 self.start_urls = [url] @classmethod def from_crawler( cls, crawler: Crawler, *args: Any, **kwargs: Any ) -> SignalCatcherSpider: return cls(crawler, *args, **kwargs) def on_request_left(self, request: Request, spider: Spider) -> None: self.caught_times += 1 @inline_callbacks_test def test_success(mockserver: MockServer): crawler = get_crawler(SignalCatcherSpider) yield crawler.crawl(mockserver.url("/status?n=200")) assert isinstance(crawler.spider, SignalCatcherSpider) assert crawler.spider.caught_times == 1 @inline_callbacks_test def test_timeout(mockserver: MockServer): crawler = get_crawler(SignalCatcherSpider, {"DOWNLOAD_TIMEOUT": 0.1}) yield crawler.crawl(mockserver.url("/delay?n=0.2")) assert isinstance(crawler.spider, SignalCatcherSpider) assert crawler.spider.caught_times == 1 @inline_callbacks_test def test_disconnect(mockserver: MockServer): crawler = get_crawler(SignalCatcherSpider) yield crawler.crawl(mockserver.url("/drop")) assert isinstance(crawler.spider, SignalCatcherSpider) assert crawler.spider.caught_times == 1 @inline_callbacks_test def test_noconnect(): crawler = get_crawler(SignalCatcherSpider) yield crawler.crawl("http://thereisdefinetelynosuchdomain.com") assert isinstance(crawler.spider, SignalCatcherSpider) assert crawler.spider.caught_times == 1 scrapy-scrapy-5569de3/tests/test_resolver.py000066400000000000000000000111251525051012400212670ustar00rootroot00000000000000from __future__ import annotations from unittest.mock import Mock import pytest from twisted.internet.address import IPv4Address, IPv6Address from scrapy.resolver import CachingHostnameResolver, CachingThreadedResolver, dnscache from scrapy.utils.defer import maybe_deferred_to_future from scrapy.utils.misc import build_from_crawler from scrapy.utils.test import get_crawler from tests.utils.decorators import coroutine_test @pytest.fixture(autouse=True) def reset_dnscache(): original_limit = dnscache.limit dnscache.clear() yield dnscache.clear() dnscache.limit = original_limit def test_caching_threaded_resolver_dnscache_disabled(): crawler = get_crawler(settings_dict={"DNSCACHE_ENABLED": False}) build_from_crawler(CachingThreadedResolver, crawler, Mock()) assert dnscache.limit == 0 @coroutine_test async def test_caching_threaded_resolver_getHostByName_cache_hit(): resolver = CachingThreadedResolver(Mock(), cache_size=10, timeout=5.0) dnscache["example.com"] = "1.2.3.4" result = await maybe_deferred_to_future(resolver.getHostByName("example.com")) assert result == "1.2.3.4" def test_caching_hostname_resolver_dnscache_disabled(): crawler = get_crawler(settings_dict={"DNSCACHE_ENABLED": False}) build_from_crawler(CachingHostnameResolver, crawler, Mock()) assert dnscache.limit == 0 def test_caching_hostname_resolver_no_addresses_not_cached(): def fake_resolve(receiver, *_): receiver.resolutionBegan(Mock()) receiver.resolutionComplete() return receiver reactor = Mock() reactor.nameResolver.resolveHostName.side_effect = fake_resolve resolver = CachingHostnameResolver(reactor, cache_size=10) resolver.resolveHostName(Mock(), "example.com") assert "example.com" not in dnscache def test_caching_hostname_resolver_cached_addresses_have_no_port(): def fake_resolve(receiver, *_): receiver.resolutionBegan(Mock()) receiver.addressResolved(IPv4Address("TCP", "1.2.3.4", 80)) receiver.addressResolved(IPv6Address("TCP", "::1", 80)) receiver.resolutionComplete() return receiver reactor = Mock() reactor.nameResolver.resolveHostName.side_effect = fake_resolve receiver = Mock() resolver = CachingHostnameResolver(reactor, cache_size=10) resolver.resolveHostName(receiver, "example.com", portNumber=80) # The port requested on a cache miss is passed through unchanged, but it is # not part of what gets cached. resolved_ports = [ call.args[0].port for call in receiver.addressResolved.call_args_list ] assert resolved_ports == [80, 80] assert [address.port for address in dnscache["example.com"]] == [0, 0] def test_caching_hostname_resolver_cache_hit_without_port(): cached_addresses = [ IPv4Address("TCP", "1.2.3.4", 0), IPv6Address("TCP", "::1", 0), ] dnscache["example.com"] = cached_addresses receiver = Mock() resolver = CachingHostnameResolver(Mock(), cache_size=10) resolver.resolveHostName(receiver, "example.com") # Cached addresses already use the requested port, so they are reused as is. resolved_addresses = [ call.args[0] for call in receiver.addressResolved.call_args_list ] assert all( resolved is cached for resolved, cached in zip(resolved_addresses, cached_addresses, strict=True) ) def test_caching_hostname_resolver_cache_hit_uses_requested_port(): dnscache["example.com"] = [ IPv4Address("TCP", "1.2.3.4", 0), IPv6Address("TCP", "::1", 0), ] receiver = Mock() resolver = CachingHostnameResolver(Mock(), cache_size=10) resolver.resolveHostName(receiver, "example.com", portNumber=443) resolved_addresses = [ call.args[0] for call in receiver.addressResolved.call_args_list ] assert resolved_addresses == [ IPv4Address("TCP", "1.2.3.4", 443), IPv6Address("TCP", "::1", 443), ] # The cached addresses must not be mutated in place. assert [address.port for address in dnscache["example.com"]] == [0, 0] def test_caching_hostname_resolver_dnscache_disabled_rejects_storage(): def fake_resolve(receiver, *_): receiver.resolutionBegan(Mock()) receiver.addressResolved(IPv4Address("TCP", "1.2.3.4", 80)) receiver.resolutionComplete() return receiver reactor = Mock() reactor.nameResolver.resolveHostName.side_effect = fake_resolve resolver = CachingHostnameResolver(reactor, cache_size=0) resolver.resolveHostName(Mock(), "example.com") assert "example.com" not in dnscache assert len(dnscache) == 0 scrapy-scrapy-5569de3/tests/test_response_dict.py000066400000000000000000000062621525051012400222750ustar00rootroot00000000000000from __future__ import annotations from ipaddress import ip_address from scrapy import Request from scrapy.http import HtmlResponse, Response, TextResponse from scrapy.utils.response import response_from_dict class CustomResponse(TextResponse): attributes: tuple[str, ...] = (*TextResponse.attributes, "custom") def __init__(self, *args, custom: str | None = None, **kwargs): self.custom = custom super().__init__(*args, **kwargs) class DynamicResponse(Response): """Response subclass that extends :attr:`~scrapy.http.Response.attributes` on instances, as plugins that support several Scrapy versions do.""" def __init__(self, *args, custom: str | None = None, **kwargs): self.custom = custom super().__init__(*args, **kwargs) self.attributes = (*self.attributes, "custom") def test_basic() -> None: """The class of plain responses is not stored, it is guessed back from the response data.""" response = Response("https://example.com", body=b"\x00\x01") assert "_class" not in response.to_dict() response2 = response_from_dict(response.to_dict()) assert response2.__class__ is Response assert response2.url == response.url def test_all_attributes() -> None: response = HtmlResponse( url="https://example.com", status=201, headers={"Content-Type": "text/html; charset=latin-1"}, body=b"\xa3", flags=["testFlag"], encoding="latin-1", ip_address=ip_address("127.0.0.1"), protocol="h2", ) response2 = response_from_dict(response.to_dict()) assert response2.__class__ is HtmlResponse for attribute in HtmlResponse.attributes: if attribute in {"request", "certificate"}: continue assert getattr(response2, attribute) == getattr(response, attribute) def test_custom_attributes() -> None: response = CustomResponse("https://example.com", custom="value") response2 = response_from_dict(response.to_dict()) assert isinstance(response2, CustomResponse) assert response2.custom == "value" def test_custom_instance_attributes() -> None: response = DynamicResponse("https://example.com", custom="value") response2 = response_from_dict(response.to_dict()) assert isinstance(response2, DynamicResponse) assert response2.custom == "value" def test_crawl_attributes_left_out() -> None: response = Response( "https://example.com", request=Request("https://example.com"), certificate=object(), ) d = response.to_dict() assert "request" not in d assert "certificate" not in d response2 = response_from_dict(d) assert response2.request is None assert response2.certificate is None def test_unknown_class() -> None: """Dicts that do not indicate a response class, e.g. cache entries written by older Scrapy versions, get a class based on their data.""" response2 = response_from_dict( { "url": "https://example.com", "status": 200, "headers": {b"Content-Type": [b"text/plain"]}, "body": b"foo", } ) assert response2.__class__ is TextResponse assert response2.text == "foo" scrapy-scrapy-5569de3/tests/test_responsetypes.py000066400000000000000000000113451525051012400223550ustar00rootroot00000000000000from typing import Any from scrapy.http import ( Headers, HtmlResponse, JsonResponse, Response, TextResponse, XmlResponse, ) from scrapy.responsetypes import responsetypes def test_from_filename() -> None: mappings = [ ("data.bin", Response), ("file.txt", TextResponse), ("file.xml.gz", Response), ("file.xml", XmlResponse), ("file.html", HtmlResponse), ("file.unknownext", Response), ] for source, cls in mappings: retcls = responsetypes.from_filename(source) assert retcls is cls, f"{source} ==> {retcls} != {cls}" def test_from_content_disposition() -> None: mappings = [ (b'attachment; filename="data.xml"', XmlResponse), (b"attachment; filename=data.xml", XmlResponse), ("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, ), ] for source, cls in mappings: retcls = responsetypes.from_content_disposition(source) assert retcls is cls, f"{source!r} ==> {retcls} != {cls}" def test_from_content_disposition_no_filename() -> None: assert responsetypes.from_content_disposition(b"attachment") is Response def test_from_content_type() -> None: mappings: list[tuple[str | bytes, type[Response]]] = [ ("text/html; charset=UTF-8", HtmlResponse), ("text/xml; charset=UTF-8", XmlResponse), ("application/xhtml+xml; charset=UTF-8", HtmlResponse), ("application/vnd.wap.xhtml+xml; charset=utf-8", HtmlResponse), ("application/xml; charset=UTF-8", XmlResponse), ("application/octet-stream", Response), ("application/json; encoding=UTF8;charset=UTF-8", JsonResponse), ("application/x-json; encoding=UTF8;charset=UTF-8", JsonResponse), ("application/json-amazonui-streaming;charset=UTF-8", JsonResponse), (b"application/x-download; filename=\x80dummy.txt", Response), ] for source, cls in mappings: retcls = responsetypes.from_content_type(source) assert retcls is cls, f"{source!r} ==> {retcls} != {cls}" def test_from_body() -> None: mappings = [ (b"\x03\x02\xdf\xdd\x23", Response), (b"Some plain text\ndata with tabs\t and null bytes\0", TextResponse), (b"Hello", HtmlResponse), # https://codersblock.com/blog/the-smallest-valid-html5-page/ (b"\n.", HtmlResponse), (b' {retcls} != {cls}" def test_from_headers() -> None: mappings = [ ({"Content-Type": ["text/html; charset=utf-8"]}, HtmlResponse), ( { "Content-Type": ["text/html; charset=utf-8"], "Content-Encoding": ["gzip"], }, Response, ), ( { "Content-Type": ["application/octet-stream"], "Content-Disposition": ["attachment; filename=data.txt"], }, TextResponse, ), ] for source, cls in mappings: source = Headers(source) retcls = responsetypes.from_headers(source) assert retcls is cls, f"{source} ==> {retcls} != {cls}" def test_from_args() -> None: # TODO: add more tests that check precedence between the different arguments mappings: list[tuple[dict[str, Any], type[Response]]] = [ ({"url": "http://www.example.com/data.csv"}, TextResponse), # headers takes precedence over url ( { "headers": Headers({"Content-Type": ["text/html; charset=utf-8"]}), "url": "http://www.example.com/item/", }, HtmlResponse, ), ( { "headers": Headers( {"Content-Disposition": ['attachment; filename="data.xml.gz"']} ), "url": "http://www.example.com/page/", }, Response, ), ] for source, cls in mappings: retcls = responsetypes.from_args(**source) assert retcls is cls, f"{source} ==> {retcls} != {cls}" def test_custom_mime_types_loaded() -> None: # check that mime.types files shipped with scrapy are loaded assert responsetypes.mimetypes.guess_type("x.scrapytest")[0] == "x-scrapy/test" scrapy-scrapy-5569de3/tests/test_robotstxt_interface.py000066400000000000000000000177011525051012400235240ustar00rootroot00000000000000from __future__ import annotations from typing import TYPE_CHECKING, cast import pytest from scrapy.robotstxt import ( ProtegoRobotParser, PythonRobotParser, RerpRobotParser, RobotParser, decode_robotstxt, ) from scrapy.utils._deps_compat import STDLIB_IMPROVED_ROBOTFILEPARSER from scrapy.utils.misc import build_from_crawler from tests.utils.robotstxt import rerp_available if TYPE_CHECKING: # typing.Self requires Python 3.11 from typing_extensions import Self from scrapy.crawler import Crawler # The parser backends only use the crawler to get the spider to log with. NO_CRAWLER = cast("Crawler", None) class BaseRobotParserTest: parser_cls: type[RobotParser] def _setUp(self, parser_cls: type[RobotParser]) -> None: self.parser_cls = parser_cls def _parse(self, robotstxt_body: bytes) -> RobotParser: return build_from_crawler(self.parser_cls, NO_CRAWLER, robotstxt_body) def test_allowed(self): robotstxt_robotstxt_body = ( b"User-agent: * \nDisallow: /disallowed \nAllow: /allowed \nCrawl-delay: 10" ) rp = self._parse(robotstxt_robotstxt_body) assert rp.allowed("https://www.site.local/allowed", "*") assert not rp.allowed("https://www.site.local/disallowed", "*") def test_allowed_wildcards(self) -> None: robotstxt_robotstxt_body = b"""User-agent: first Disallow: /disallowed/*/end$ User-agent: second Allow: /*allowed Disallow: / """ rp = self._parse(robotstxt_robotstxt_body) assert rp.allowed("https://www.site.local/disallowed", "first") assert not rp.allowed("https://www.site.local/disallowed/xyz/end", "first") assert not rp.allowed("https://www.site.local/disallowed/abc/end", "first") assert rp.allowed("https://www.site.local/disallowed/xyz/endinglater", "first") assert rp.allowed("https://www.site.local/allowed", "second") assert rp.allowed("https://www.site.local/is_still_allowed", "second") assert rp.allowed("https://www.site.local/is_allowed_too", "second") def test_length_based_precedence(self) -> None: robotstxt_robotstxt_body = b"User-agent: * \nDisallow: / \nAllow: /page" rp = self._parse(robotstxt_robotstxt_body) assert rp.allowed("https://www.site.local/page", "*") def test_order_based_precedence(self) -> None: robotstxt_robotstxt_body = b"User-agent: * \nDisallow: / \nAllow: /page" rp = self._parse(robotstxt_robotstxt_body) assert not rp.allowed("https://www.site.local/page", "*") def test_empty_response(self): """empty response should equal 'allow all'""" rp = self._parse(b"") assert rp.allowed("https://site.local/", "*") assert rp.allowed("https://site.local/", "chrome") assert rp.allowed("https://site.local/index.html", "*") assert rp.allowed("https://site.local/disallowed", "*") def test_garbage_response(self): """garbage response should be discarded, equal 'allow all'""" robotstxt_robotstxt_body = b"GIF89a\xd3\x00\xfe\x00\xa2" rp = self._parse(robotstxt_robotstxt_body) assert rp.allowed("https://site.local/", "*") assert rp.allowed("https://site.local/", "chrome") assert rp.allowed("https://site.local/index.html", "*") assert rp.allowed("https://site.local/disallowed", "*") def test_crawl_delay(self): robotstxt_body = b"User-agent: *\nDisallow: /private\nCrawl-delay: 10\n" rp = self._parse(robotstxt_body) assert rp.crawl_delay("*") == 10.0 def test_crawl_delay_unset(self): robotstxt_body = b"User-agent: *\nDisallow: /private\n" rp = self._parse(robotstxt_body) assert rp.crawl_delay("*") is None def test_unicode_url_and_useragent(self): robotstxt_robotstxt_body = """ User-Agent: * Disallow: /admin/ Disallow: /static/ # taken from https://en.wikipedia.org/robots.txt 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() rp = self._parse(robotstxt_robotstxt_body) assert rp.allowed("https://site.local/", "*") assert not rp.allowed("https://site.local/admin/", "*") assert not rp.allowed("https://site.local/static/", "*") assert rp.allowed("https://site.local/admin/", "UnicödeBöt") assert not rp.allowed("https://site.local/wiki/K%C3%A4ytt%C3%A4j%C3%A4:", "*") assert not rp.allowed("https://site.local/wiki/Käyttäjä:", "*") assert rp.allowed("https://site.local/some/randome/page.html", "*") assert not rp.allowed("https://site.local/some/randome/page.html", "UnicödeBöt") class TestRobotParser: def test_crawl_delay_unsupported(self): class AllowAllRobotParser(RobotParser): @classmethod def from_crawler(cls, crawler: Crawler, robotstxt_body: bytes) -> Self: return cls() def allowed(self, url: str | bytes, user_agent: str | bytes) -> bool: return True rp = build_from_crawler( AllowAllRobotParser, NO_CRAWLER, b"User-agent: *\nCrawl-delay: 10\n" ) assert rp.crawl_delay("*") is None class TestDecodeRobotsTxt: 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 ) assert 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) assert 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) assert decoded_content == "User-agent: *\nDisallow: /\n" # UTF-8 BOM at the beginning of the file ignored def test_decode_utf8_bom(self): robotstxt_body = b"\xef\xbb\xbfUser-agent: *\nDisallow: /\n" decoded_content = decode_robotstxt(robotstxt_body, spider=None) assert decoded_content == "User-agent: *\nDisallow: /\n" class TestPythonRobotParser(BaseRobotParserTest): def setup_method(self): super()._setUp(PythonRobotParser) @pytest.mark.skipif( not STDLIB_IMPROVED_ROBOTFILEPARSER, reason="RobotFileParser from this Python version does not support length based directives precedence.", ) def test_length_based_precedence(self) -> None: super().test_length_based_precedence() @pytest.mark.skipif( STDLIB_IMPROVED_ROBOTFILEPARSER, reason="RobotFileParser from this Python version does not support order based directives precedence.", ) def test_order_based_precedence(self) -> None: super().test_order_based_precedence() @pytest.mark.skipif( not STDLIB_IMPROVED_ROBOTFILEPARSER, reason="RobotFileParser from this Python version does not support wildcards.", ) def test_allowed_wildcards(self) -> None: super().test_allowed_wildcards() @pytest.mark.skipif(not rerp_available(), reason="Rerp parser is not installed") class TestRerpRobotParser(BaseRobotParserTest): def setup_method(self): super()._setUp(RerpRobotParser) def test_length_based_precedence(self) -> None: pytest.skip("Rerp does not support length based directives precedence.") class TestProtegoRobotParser(BaseRobotParserTest): def setup_method(self): super()._setUp(ProtegoRobotParser) def test_order_based_precedence(self) -> None: pytest.skip("Protego does not support order based directives precedence.") scrapy-scrapy-5569de3/tests/test_scheduler.py000066400000000000000000000274321525051012400214140ustar00rootroot00000000000000from __future__ import annotations import warnings from abc import ABC, abstractmethod from contextlib import AbstractAsyncContextManager, asynccontextmanager from typing import TYPE_CHECKING from unittest.mock import Mock import pytest from scrapy.core.downloader import Downloader from scrapy.core.scheduler import Scheduler from scrapy.crawler import Crawler from scrapy.dupefilters import BaseDupeFilter from scrapy.exceptions import ScrapyDeprecationWarning from scrapy.http import Request from scrapy.spiders import Spider from scrapy.utils.defer import ensure_awaitable from scrapy.utils.misc import build_from_crawler, load_object from scrapy.utils.test import get_crawler from tests.mockserver.http import MockServer from tests.utils.decorators import coroutine_test, inline_callbacks_test from tests.utils.downloader import MockDownloader if TYPE_CHECKING: from collections.abc import AsyncGenerator, Callable from pathlib import Path class MockCrawler(Crawler): def __init__(self, priority_queue_cls: str, jobdir: Path | None): settings = { "SCHEDULER_DEBUG": False, "SCHEDULER_DISK_QUEUE": "scrapy.squeues.PickleLifoDiskQueue", "SCHEDULER_MEMORY_QUEUE": "scrapy.squeues.LifoMemoryQueue", "SCHEDULER_PRIORITY_QUEUE": priority_queue_cls, "JOBDIR": str(jobdir) if jobdir is not None else None, "DUPEFILTER_CLASS": "scrapy.dupefilters.BaseDupeFilter", } super().__init__(Spider, settings) self.engine = Mock(downloader=MockDownloader()) self.stats = load_object(self.settings["STATS_CLASS"])(self) @asynccontextmanager async def create_scheduler( priority_queue_cls: str, jobdir: Path | None ) -> AsyncGenerator[Scheduler]: mock_crawler = MockCrawler(priority_queue_cls, jobdir) scheduler = build_from_crawler(Scheduler, mock_crawler) spider = Spider.from_crawler(mock_crawler, name="spider") await ensure_awaitable(scheduler.open(spider)) try: yield scheduler finally: await ensure_awaitable(scheduler.close("finished")) await mock_crawler.stop_async() mock_crawler.engine.downloader.close() _PRIORITIES = [ ("http://foo.com/a", -2), ("http://foo.com/d", 1), ("http://foo.com/b", -1), ("http://foo.com/c", 0), ("http://foo.com/e", 2), ] _URLS = {"http://foo.com/a", "http://foo.com/b", "http://foo.com/c"} class TestSchedulerBase(ABC): reopen = False @property @abstractmethod def priority_queue_cls(self) -> str: raise NotImplementedError @pytest.fixture def jobdir(self) -> Path | None: return None def create_scheduler( self, jobdir: Path | None ) -> AbstractAsyncContextManager[Scheduler]: return create_scheduler(self.priority_queue_cls, jobdir) @asynccontextmanager async def create_scheduler_for_assertions( self, jobdir: Path | None, setup: Callable[[Scheduler], None] ) -> AsyncGenerator[Scheduler]: if self.reopen: async with self.create_scheduler(jobdir) as scheduler: setup(scheduler) async with self.create_scheduler(jobdir) as scheduler: yield scheduler else: async with self.create_scheduler(jobdir) as scheduler: setup(scheduler) yield scheduler class SchedulerTestMixin(TestSchedulerBase): @coroutine_test async def test_length(self, jobdir: Path | None) -> None: def _setup(scheduler: Scheduler) -> None: assert not scheduler.has_pending_requests() assert len(scheduler) == 0 for url in _URLS: scheduler.enqueue_request(Request(url)) async with self.create_scheduler_for_assertions(jobdir, _setup) as scheduler: assert scheduler.has_pending_requests() assert len(scheduler) == len(_URLS) @coroutine_test async def test_dequeue(self, jobdir: Path | None) -> None: def _setup(scheduler: Scheduler) -> None: for url in _URLS: scheduler.enqueue_request(Request(url)) urls = set() async with self.create_scheduler_for_assertions(jobdir, _setup) as scheduler: while scheduler.has_pending_requests(): request = scheduler.next_request() assert request is not None urls.add(request.url) assert urls == _URLS @coroutine_test async def test_dequeue_priorities(self, jobdir: Path | None) -> None: def _setup(scheduler: Scheduler) -> None: for url, priority in _PRIORITIES: scheduler.enqueue_request(Request(url, priority=priority)) priorities = [] async with self.create_scheduler_for_assertions(jobdir, _setup) as scheduler: while scheduler.has_pending_requests(): request = scheduler.next_request() assert request is not None priorities.append(request.priority) assert priorities == sorted([x[1] for x in _PRIORITIES], key=lambda x: -x) class TestSchedulerInMemoryBase(SchedulerTestMixin): pass class TestSchedulerOnDiskBase(SchedulerTestMixin): reopen = True @pytest.fixture def jobdir(self, tmp_path: Path) -> Path | None: return tmp_path class TestSchedulerInMemory(TestSchedulerInMemoryBase): priority_queue_cls = "scrapy.pqueues.ScrapyPriorityQueue" class TestSchedulerOnDisk(TestSchedulerOnDiskBase): priority_queue_cls = "scrapy.pqueues.ScrapyPriorityQueue" _URLS_WITH_SLOTS = [ ("http://foo.com/a", "a"), ("http://foo.com/b", "a"), ("http://foo.com/c", "b"), ("http://foo.com/d", "b"), ("http://foo.com/e", "c"), ("http://foo.com/f", "c"), ] class TestMigration: @coroutine_test async def test_migration(self, tmp_path: Path) -> None: async with create_scheduler( "scrapy.pqueues.ScrapyPriorityQueue", tmp_path ) as prev_scheduler: for url in _URLS: prev_scheduler.enqueue_request(Request(url)) with pytest.raises( ValueError, match="DownloaderAwarePriorityQueue accepts ``slot_startprios`` as a dict", ): async with create_scheduler( "scrapy.pqueues.DownloaderAwarePriorityQueue", tmp_path ): pass def _is_scheduling_fair(enqueued_slots: list[str], dequeued_slots: list[str]) -> bool: """ We enqueued same number of requests for every slot. Assert correct order, e.g. >>> enqueued = ['a', 'b', 'c'] * 2 >>> correct = ['a', 'c', 'b', 'b', 'a', 'c'] >>> incorrect = ['a', 'a', 'b', 'c', 'c', 'b'] >>> _is_scheduling_fair(enqueued, correct) True >>> _is_scheduling_fair(enqueued, incorrect) False """ if len(dequeued_slots) != len(enqueued_slots): return False slots_number = len(set(enqueued_slots)) for i in range(0, len(dequeued_slots), slots_number): part = dequeued_slots[i : i + slots_number] if len(part) != len(set(part)): return False return True class DownloaderAwareSchedulerTestMixin(TestSchedulerBase): reopen = False priority_queue_cls = "scrapy.pqueues.DownloaderAwarePriorityQueue" @coroutine_test async def test_logic(self, jobdir: Path | None) -> None: def _setup(scheduler: Scheduler) -> None: for url, slot in _URLS_WITH_SLOTS: request = Request(url) request.meta[Downloader.DOWNLOAD_SLOT] = slot scheduler.enqueue_request(request) def _assert(scheduler: Scheduler) -> None: dequeued_slots: list[str] = [] requests: list[Request] = [] assert scheduler.crawler downloader = scheduler.crawler.engine.downloader assert isinstance(downloader, MockDownloader) while scheduler.has_pending_requests(): request = scheduler.next_request() assert request is not None slot = downloader.get_slot_key(request) dequeued_slots.append(slot) downloader.increment(slot) requests.append(request) for request in requests: slot = downloader.get_slot_key(request) downloader.decrement(slot) assert _is_scheduling_fair([s for u, s in _URLS_WITH_SLOTS], dequeued_slots) assert sum(len(s.active) for s in downloader.slots.values()) == 0 if self.reopen: async with self.create_scheduler(jobdir) as scheduler: _setup(scheduler) async with self.create_scheduler(jobdir) as scheduler: _assert(scheduler) else: async with self.create_scheduler(jobdir) as scheduler: _setup(scheduler) _assert(scheduler) class TestSchedulerWithDownloaderAwareInMemory( DownloaderAwareSchedulerTestMixin, TestSchedulerInMemoryBase ): pass class TestSchedulerWithDownloaderAwareOnDisk( DownloaderAwareSchedulerTestMixin, TestSchedulerOnDiskBase ): reopen = True class StartUrlsSpider(Spider): def __init__(self, start_urls): self.start_urls = start_urls super().__init__(name="StartUrlsSpider") def parse(self, response): pass class TestIntegrationWithDownloaderAwareInMemory: def setup_method(self): self.crawler = get_crawler( spidercls=StartUrlsSpider, settings_dict={ "SCHEDULER_PRIORITY_QUEUE": "scrapy.pqueues.DownloaderAwarePriorityQueue", "DUPEFILTER_CLASS": "scrapy.dupefilters.BaseDupeFilter", }, ) @inline_callbacks_test def test_integration_downloader_aware_priority_queue(self): with MockServer() as mockserver: url = mockserver.url("/status?n=200", is_secure=False) start_urls = [url] * 6 yield self.crawler.crawl(start_urls) assert self.crawler.stats.get_value("downloader/response_count") == len( start_urls ) class TestIncompatibility: def _incompatible(self) -> None: settings = { "SCHEDULER_PRIORITY_QUEUE": "scrapy.pqueues.DownloaderAwarePriorityQueue", "CONCURRENT_REQUESTS_PER_IP": 1, } crawler = get_crawler(Spider, settings) scheduler = build_from_crawler(Scheduler, crawler) spider = Spider.from_crawler(crawler, name="spider") scheduler.open(spider) def test_incompatibility(self): with warnings.catch_warnings(): warnings.filterwarnings( "ignore", category=ScrapyDeprecationWarning, message="The CONCURRENT_REQUESTS_PER_IP setting is deprecated", ) with pytest.raises( ValueError, match="does not support CONCURRENT_REQUESTS_PER_IP" ): self._incompatible() def test_scheduler_without_crawler() -> None: """The crawler argument of Scheduler is optional.""" scheduler = Scheduler(BaseDupeFilter()) assert scheduler.crawler is None @coroutine_test async def test_unserializable_requests( tmp_path: Path, caplog: pytest.LogCaptureFixture ) -> None: """Requests that cannot be serialized fall back to the memory queue.""" class UnserializableSpider(Spider): name = "unserializable" async def start(self): for index in range(2): yield Request(f"data:,{index}", meta={"callable": lambda: None}) crawler = get_crawler( UnserializableSpider, {"JOBDIR": str(tmp_path), "SCHEDULER_DEBUG": True} ) await crawler.crawl_async() assert crawler.stats.get_value("scheduler/unserializable") == 2 assert crawler.stats.get_value("scheduler/enqueued/memory") == 2 assert caplog.text.count("Unable to serialize request") == 1 scrapy-scrapy-5569de3/tests/test_scheduler_base.py000066400000000000000000000124451525051012400224040ustar00rootroot00000000000000from __future__ import annotations import logging from typing import TYPE_CHECKING, Any from urllib.parse import urljoin import pytest from twisted.internet import defer 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.utils.decorators import coroutine_test, inline_callbacks_test if TYPE_CHECKING: from tests.mockserver.http import MockServer PATHS = ["/a", "/b", "/c"] URLS = [urljoin("https://example.org", p) for p in PATHS] class MinimalScheduler: def __init__(self) -> None: self.requests: dict[bytes, Request] = {} def has_pending_requests(self) -> bool: return bool(self.requests) def enqueue_request(self, request: Request) -> bool: fp = fingerprint(request) if fp not in self.requests: self.requests[fp] = request return True return False def next_request(self) -> Request | None: if self.has_pending_requests(): _, request = self.requests.popitem() return request return None class SimpleScheduler(MinimalScheduler): def open(self, spider: Spider) -> defer.Deferred[str]: return defer.succeed("open") def close(self, reason: str) -> defer.Deferred[str]: return defer.succeed("close") def __len__(self) -> int: return len(self.requests) class PathsSpider(Spider): name = "paths" def __init__(self, mockserver, *args, **kwargs): super().__init__(*args, **kwargs) self.start_urls = [mockserver.url(path) for path in PATHS] def parse(self, response): return {"path": urlparse_cached(response).path} class InterfaceCheckMixin: scheduler: Any def test_scheduler_class(self): assert isinstance(self.scheduler, BaseScheduler) assert issubclass(self.scheduler.__class__, BaseScheduler) class TestBaseScheduler(InterfaceCheckMixin): def setup_method(self): self.scheduler = BaseScheduler() # type: ignore[abstract] def test_methods(self): assert self.scheduler.open(Spider("foo")) is None assert self.scheduler.close("finished") is None with pytest.raises(NotImplementedError): self.scheduler.has_pending_requests() with pytest.raises(NotImplementedError): self.scheduler.enqueue_request(Request("https://example.org")) with pytest.raises(NotImplementedError): self.scheduler.next_request() class TestMinimalScheduler(InterfaceCheckMixin): def setup_method(self): self.scheduler = MinimalScheduler() def test_open_close(self): with pytest.raises(AttributeError): self.scheduler.open(Spider("foo")) with pytest.raises(AttributeError): self.scheduler.close("finished") def test_len(self): with pytest.raises(AttributeError): self.scheduler.__len__() with pytest.raises(TypeError): len(self.scheduler) def test_enqueue_dequeue(self): assert not self.scheduler.has_pending_requests() for url in URLS: assert self.scheduler.enqueue_request(Request(url)) assert not self.scheduler.enqueue_request(Request(url)) assert self.scheduler.has_pending_requests() dequeued = [] while self.scheduler.has_pending_requests(): request = self.scheduler.next_request() dequeued.append(request.url) assert set(dequeued) == set(URLS) assert not self.scheduler.has_pending_requests() class TestSimpleScheduler(InterfaceCheckMixin): def setup_method(self): self.scheduler = SimpleScheduler() @inline_callbacks_test def test_enqueue_dequeue(self): open_result = yield self.scheduler.open(Spider("foo")) assert open_result == "open" assert not self.scheduler.has_pending_requests() for url in URLS: assert self.scheduler.enqueue_request(Request(url)) assert not self.scheduler.enqueue_request(Request(url)) assert self.scheduler.has_pending_requests() assert len(self.scheduler) == len(URLS) dequeued = [] while self.scheduler.has_pending_requests(): request = self.scheduler.next_request() dequeued.append(request.url) assert set(dequeued) == set(URLS) assert not self.scheduler.has_pending_requests() assert len(self.scheduler) == 0 close_result = yield self.scheduler.close("") assert close_result == "close" class TestMinimalSchedulerCrawl: scheduler_cls = MinimalScheduler @coroutine_test async def test_crawl( self, caplog: pytest.LogCaptureFixture, mockserver: MockServer ) -> None: settings = { "SCHEDULER": self.scheduler_cls, } with caplog.at_level(logging.DEBUG): crawler = get_crawler(PathsSpider, settings) await crawler.crawl_async(mockserver) for path in PATHS: assert f"{{'path': '{path}'}}" in caplog.text assert f"'item_scraped_count': {len(PATHS)}" in caplog.text class TestSimpleSchedulerCrawl(TestMinimalSchedulerCrawl): scheduler_cls = SimpleScheduler scrapy-scrapy-5569de3/tests/test_scrapy__getattr__.py000066400000000000000000000010751525051012400231210ustar00rootroot00000000000000from __future__ import annotations import pytest from scrapy.exceptions import ScrapyDeprecationWarning def test_deprecated_concurrent_requests_per_ip_attribute() -> None: with pytest.warns( ScrapyDeprecationWarning, match=r"scrapy\.settings\.default_settings\.CONCURRENT_REQUESTS_PER_IP attribute is deprecated", ): from scrapy.settings.default_settings import ( # noqa: PLC0415 CONCURRENT_REQUESTS_PER_IP, ) assert CONCURRENT_REQUESTS_PER_IP is not None assert isinstance(CONCURRENT_REQUESTS_PER_IP, int) scrapy-scrapy-5569de3/tests/test_selector.py000066400000000000000000000230401525051012400212450ustar00rootroot00000000000000import weakref import pytest from scrapy.http import HtmlResponse, JsonResponse, TextResponse, XmlResponse from scrapy.selector import Selector from scrapy.utils._deps_compat import PARSEL_SUPPORTS_JMESPATH class TestSelector: def test_simple_selection(self): """Simple selector tests""" body = b"

    " response = TextResponse(url="http://example.com", body=body, encoding="utf-8") sel = Selector(response) xl = sel.xpath("//input") assert len(xl) == 2 for x in xl: assert isinstance(x, Selector) assert sel.xpath("//input").getall() == [x.get() for x in sel.xpath("//input")] assert [x.get() for x in sel.xpath("//input[@name='a']/@name")] == ["a"] assert [ x.get() for x in sel.xpath( "number(concat(//input[@name='a']/@value, //input[@name='b']/@value))" ) ] == ["12.0"] assert sel.xpath("concat('xpath', 'rules')").getall() == ["xpathrules"] assert [ x.get() for x in sel.xpath( "concat(//input[@name='a']/@value, //input[@name='b']/@value)" ) ] == ["12"] def test_root_base_url(self): body = b'' url = "http://example.com" response = TextResponse(url=url, body=body, encoding="utf-8") sel = Selector(response) assert url == sel.root.base def test_flavor_detection(self): text = b'

    Hello

    ' sel = Selector(XmlResponse("http://example.com", body=text, encoding="utf-8")) assert sel.type == "xml" assert sel.xpath("//div").getall() == [ '

    Hello

    ' ] sel = Selector(HtmlResponse("http://example.com", body=text, encoding="utf-8")) assert sel.type == "html" assert sel.xpath("//div").getall() == [ '

    Hello

    ' ] @pytest.mark.skipif( not PARSEL_SUPPORTS_JMESPATH, reason="parsel < 1.8 doesn't support json" ) def test_flavor_detection_json(self) -> None: response = JsonResponse( "http://example.com", body=b'{"a": "b"}', encoding="utf-8" ) assert Selector(response).type == "json" assert response.jmespath("a").get() == "b" @pytest.mark.skipif( not PARSEL_SUPPORTS_JMESPATH, reason="parsel < 1.8 doesn't support json" ) def test_flavor_detection_json_with_html_body(self) -> None: body = b"

    Hello

    " response = JsonResponse("http://example.com", body=body, encoding="utf-8") assert Selector(response).type == "json" html_response = response.replace(cls=HtmlResponse) assert Selector(html_response).type == "html" assert html_response.css("p::text").get() == "Hello" def test_flavor_detection_text(self) -> None: response = TextResponse( "http://example.com", body=b"

    Hello

    ", encoding="utf-8" ) assert Selector(response).type == "html" def test_http_header_encoding_precedence(self): # '\xa3' = pound symbol in unicode # '\xc2\xa3' = pound symbol in utf-8 # '\xa3' = pound symbol in latin-1 (iso-8859-1) meta = ( '' ) head = f"{meta}" body_content = '\xa3' body = f"{body_content}" html = f"{head}{body}" encoding = "utf-8" html_utf8 = html.encode(encoding) headers = {"Content-Type": ["text/html; charset=utf-8"]} response = HtmlResponse( url="http://example.com", headers=headers, body=html_utf8 ) x = Selector(response) assert x.xpath("//span[@id='blank']/text()").getall() == ["\xa3"] def test_badly_encoded_body(self): # \xe9 alone isn't valid utf8 sequence r1 = TextResponse( "http://www.example.com", body=b"

    an Jos\xe9 de

    ", encoding="utf-8", ) Selector(r1).xpath("//text()").getall() def test_weakref_slots(self): """Check that classes are using slots and are weak-referenceable""" x = Selector(text="") weakref.ref(x) assert not hasattr(x, "__dict__"), ( f"{x.__class__.__name__} does not use __slots__" ) def test_selector_bad_args(self): with pytest.raises(ValueError, match="received both response and text"): Selector(TextResponse(url="http://example.com", body=b""), text="") @pytest.mark.skipif( not PARSEL_SUPPORTS_JMESPATH, reason="parsel < 1.8 doesn't support jmespath" ) class TestJMESPath: def test_json_has_html(self) -> None: """Sometimes the information is returned in a json wrapper""" body = """ { "content": [ { "name": "A", "value": "a" }, { "name": { "age": 18 }, "value": "b" }, { "name": "C", "value": "c" }, { "name": "D", "value": "
    d
    " } ], "html": "
    def
    " } """ resp = TextResponse(url="http://example.com", body=body, encoding="utf-8") assert ( resp.jmespath("html").get() == "
    def
    " ) assert resp.jmespath("html").xpath("//div/a/text()").getall() == ["a", "b", "d"] assert resp.jmespath("html").css("div > b").getall() == ["f"] assert resp.jmespath("content").jmespath("name.age").get() == 18 # type: ignore[comparison-overlap] def test_html_has_json(self) -> None: body = """

    Information

    { "user": [ { "name": "A", "age": 18 }, { "name": "B", "age": 32 }, { "name": "C", "age": 22 }, { "name": "D", "age": 25 } ], "total": 4, "status": "ok" }
    """ resp = TextResponse(url="http://example.com", body=body, encoding="utf-8") assert resp.xpath("//div/content/text()").jmespath("user[*].name").getall() == [ "A", "B", "C", "D", ] assert resp.xpath("//div/content").jmespath("user[*].name").getall() == [ "A", "B", "C", "D", ] assert resp.xpath("//div/content").jmespath("total").get() == 4 # type: ignore[comparison-overlap] def test_jmestpath_with_re(self) -> None: body = """

    Information

    { "user": [ { "name": "A", "age": 18 }, { "name": "B", "age": 32 }, { "name": "C", "age": 22 }, { "name": "D", "age": 25 } ], "total": 4, "status": "ok" }
    """ resp = TextResponse(url="http://example.com", body=body, encoding="utf-8") assert resp.xpath("//div/content/text()").jmespath("user[*].name").re( r"(\w+)" ) == ["A", "B", "C", "D"] assert resp.xpath("//div/content").jmespath("user[*].name").re(r"(\w+)") == [ "A", "B", "C", "D", ] assert resp.xpath("//div/content").jmespath("unavailable").re(r"(\d+)") == [] assert ( resp.xpath("//div/content").jmespath("unavailable").re_first(r"(\d+)") is None ) assert resp.xpath("//div/content").jmespath("user[*].age.to_string(@)").re( r"(\d+)" ) == ["18", "32", "22", "25"] @pytest.mark.skipif(PARSEL_SUPPORTS_JMESPATH, reason="parsel >= 1.8 supports jmespath") def test_jmespath_not_available() -> None: body = """ { "website": {"name": "Example"} } """ resp = TextResponse(url="http://example.com", body=body, encoding="utf-8") with pytest.raises(AttributeError): resp.jmespath("website.name").get() scrapy-scrapy-5569de3/tests/test_settings/000077500000000000000000000000001525051012400207145ustar00rootroot00000000000000scrapy-scrapy-5569de3/tests/test_settings/__init__.py000066400000000000000000001311041525051012400230250ustar00rootroot00000000000000# pylint: disable=unsubscriptable-object,unsupported-membership-test # (too many false positives) import logging from unittest import mock import pytest from scrapy.core.downloader.handlers.file import FileDownloadHandler from scrapy.exceptions import ScrapyDeprecationWarning from scrapy.settings import ( SETTINGS_PRIORITIES, BaseSettings, Settings, SettingsAttribute, get_settings_priority, ) from scrapy.settings import default_settings as scrapy_default_settings from scrapy.utils.misc import build_from_crawler, load_object from scrapy.utils.test import get_crawler from . import default_settings NON_COMPONENT_PRIORITY_DICT_BASE_SETTING_NAMES = { "DOWNLOAD_HANDLERS_BASE", "FEED_EXPORTERS_BASE", "FEED_STORAGES_BASE", } class TestSettingsGlobalFuncs: def test_get_settings_priority(self): for prio_str, prio_num in SETTINGS_PRIORITIES.items(): assert get_settings_priority(prio_str) == prio_num assert get_settings_priority(99) == 99 class TestSettingsAttribute: def setup_method(self): self.attribute = SettingsAttribute("value", 10) def test_set_greater_priority(self): self.attribute.set("value2", 20) assert self.attribute.value == "value2" assert self.attribute.priority == 20 def test_set_equal_priority(self): self.attribute.set("value2", 10) assert self.attribute.value == "value2" assert self.attribute.priority == 10 def test_set_less_priority(self): self.attribute.set("value2", 0) assert self.attribute.value == "value" assert self.attribute.priority == 10 def test_overwrite_basesettings(self): original_dict = {"one": 10, "two": 20} original_settings = BaseSettings(original_dict, 0) attribute = SettingsAttribute(original_settings, 0) new_dict = {"three": 11, "four": 21} attribute.set(new_dict, 10) assert isinstance(attribute.value, BaseSettings) assert set(attribute.value) == set(new_dict) assert set(original_settings) == set(original_dict) new_settings = BaseSettings({"five": 12}, 0) attribute.set(new_settings, 0) # Insufficient priority assert set(attribute.value) == set(new_dict) attribute.set(new_settings, 10) assert set(attribute.value) == set(new_settings) def test_repr(self): assert repr(self.attribute) == "" class TestBaseSettings: def setup_method(self): self.settings = BaseSettings() def test_setdefault_not_existing_value(self): settings = BaseSettings() value = settings.setdefault("TEST_OPTION", "value") assert settings["TEST_OPTION"] == "value" assert value == "value" assert value is not None def test_setdefault_existing_value(self): settings = BaseSettings({"TEST_OPTION": "value"}) value = settings.setdefault("TEST_OPTION", None) assert settings["TEST_OPTION"] == "value" assert value == "value" def test_set_new_attribute(self): self.settings.set("TEST_OPTION", "value", 0) assert "TEST_OPTION" in self.settings.attributes attr = self.settings.attributes["TEST_OPTION"] assert isinstance(attr, SettingsAttribute) assert attr.value == "value" assert attr.priority == 0 def test_set_settingsattribute(self): myattr = SettingsAttribute(0, 30) # Note priority 30 self.settings.set("TEST_ATTR", myattr, 10) assert self.settings.get("TEST_ATTR") == 0 assert self.settings.getpriority("TEST_ATTR") == 30 def test_set_instance_identity_on_update(self): attr = SettingsAttribute("value", 0) self.settings.attributes = {"TEST_OPTION": attr} self.settings.set("TEST_OPTION", "othervalue", 10) assert "TEST_OPTION" in self.settings.attributes assert attr is self.settings.attributes["TEST_OPTION"] 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, ): self.settings.attributes = {"TEST_OPTION": attr} for priority in (0, 10, 20): self.settings.set("TEST_OPTION", "othervalue", priority) mock_set.assert_called_once_with("othervalue", priority) assert not mock_setattr.called mock_set.reset_mock() mock_setattr.reset_mock() def test_setitem(self): settings = BaseSettings() settings.set("key", "a", "default") settings["key"] = "b" assert settings["key"] == "b" assert settings.getpriority("key") == 20 settings["key"] = "c" assert settings["key"] == "c" settings["key2"] = "x" assert "key2" in settings assert settings["key2"] == "x" assert settings.getpriority("key2") == 20 def test_setdict_alias(self): with mock.patch.object(self.settings, "set") as mock_set: self.settings.setdict({"TEST_1": "value1", "TEST_2": "value2"}, 10) assert mock_set.call_count == 2 calls = [ mock.call("TEST_1", "value1", 10), mock.call("TEST_2", "value2", 10), ] mock_set.assert_has_calls(calls, any_order=True) def test_setmodule_only_load_uppercase_vars(self): class ModuleMock: UPPERCASE_VAR = "value" MIXEDcase_VAR = "othervalue" lowercase_var = "anothervalue" self.settings.attributes = {} self.settings.setmodule(ModuleMock(), 10) # type: ignore[arg-type] assert "UPPERCASE_VAR" in self.settings.attributes assert "MIXEDcase_VAR" not in self.settings.attributes assert "lowercase_var" not in self.settings.attributes assert len(self.settings.attributes) == 1 def test_setmodule_alias(self): with mock.patch.object(self.settings, "set") as mock_set: self.settings.setmodule(default_settings, 10) mock_set.assert_any_call("TEST_DEFAULT", "defvalue", 10) mock_set.assert_any_call("TEST_DICT", {"key": "val"}, 10) def test_setmodule_by_path(self): self.settings.attributes = {} self.settings.setmodule(default_settings, 10) ctrl_attributes = self.settings.attributes.copy() self.settings.attributes = {} self.settings.setmodule("tests.test_settings.default_settings", 10) assert set(self.settings.attributes) == set(ctrl_attributes) for key in ctrl_attributes: attr = self.settings.attributes[key] ctrl_attr = ctrl_attributes[key] assert attr.value == ctrl_attr.value assert attr.priority == ctrl_attr.priority def test_update(self): settings = BaseSettings({"key_lowprio": 0}, priority=0) settings.set("key_highprio", 10, priority=50) custom_settings = BaseSettings( {"key_lowprio": 1, "key_highprio": 11}, priority=30 ) custom_settings.set("newkey_one", None, priority=50) custom_dict = {"key_lowprio": 2, "key_highprio": 12, "newkey_two": None} settings.update(custom_dict, priority=20) assert settings["key_lowprio"] == 2 assert settings.getpriority("key_lowprio") == 20 assert settings["key_highprio"] == 10 assert "newkey_two" in settings assert settings.getpriority("newkey_two") == 20 settings.update(custom_settings) assert settings["key_lowprio"] == 1 assert settings.getpriority("key_lowprio") == 30 assert settings["key_highprio"] == 10 assert "newkey_one" in settings assert settings.getpriority("newkey_one") == 50 settings.update({"key_lowprio": 3}, priority=20) assert settings["key_lowprio"] == 1 @pytest.mark.xfail( raises=TypeError, reason="BaseSettings.update doesn't support kwargs input" ) def test_update_kwargs(self): settings = BaseSettings({"key": 0}) settings.update(key=1) # type: ignore[call-arg] # pylint: disable=unexpected-keyword-arg def test_update_iterable(self): settings = BaseSettings({"key": 0}, priority=0) settings.update([("key", 1)], priority=10) assert settings["key"] == 1 assert settings.getpriority("key") == 10 def test_update_jsonstring(self): settings = BaseSettings({"number": 0, "dict": BaseSettings({"key": "val"})}) settings.update('{"number": 1, "newnumber": 2}') assert settings["number"] == 1 assert settings["newnumber"] == 2 settings.set("dict", '{"key": "newval", "newkey": "newval2"}') assert settings["dict"]["key"] == "newval" assert settings["dict"]["newkey"] == "newval2" def test_delete(self): settings = BaseSettings({"key": None}) settings.set("key_highprio", None, priority=50) settings.delete("key") settings.delete("key_highprio") assert "key" not in settings assert "key_highprio" in settings del settings["key_highprio"] assert "key_highprio" not in settings with pytest.raises(KeyError): settings.delete("notkey") with pytest.raises(KeyError): del settings["notkey"] def test_get(self): test_configuration = { "TEST_ENABLED1": "1", "TEST_ENABLED2": True, "TEST_ENABLED3": 1, "TEST_ENABLED4": "True", "TEST_ENABLED5": "true", "TEST_ENABLED_WRONG": "on", "TEST_DISABLED1": "0", "TEST_DISABLED2": False, "TEST_DISABLED3": 0, "TEST_DISABLED4": "False", "TEST_DISABLED5": "false", "TEST_DISABLED_WRONG": "off", "TEST_INT1": 123, "TEST_INT2": "123", "TEST_FLOAT1": 123.45, "TEST_FLOAT2": "123.45", "TEST_LIST1": ["one", "two"], "TEST_LIST2": "one,two", "TEST_LIST3": "", "TEST_STR": "value", "TEST_DICT1": {"key1": "val1", "ke2": 3}, "TEST_DICT2": '{"key1": "val1", "ke2": 3}', } settings = self.settings settings.attributes = { key: SettingsAttribute(value, 0) for key, value in test_configuration.items() } assert settings.getbool("TEST_ENABLED1") assert settings.getbool("TEST_ENABLED2") assert settings.getbool("TEST_ENABLED3") assert settings.getbool("TEST_ENABLED4") assert settings.getbool("TEST_ENABLED5") assert not settings.getbool("TEST_ENABLEDx") assert settings.getbool("TEST_ENABLEDx", True) assert not settings.getbool("TEST_DISABLED1") assert not settings.getbool("TEST_DISABLED2") assert not settings.getbool("TEST_DISABLED3") assert not settings.getbool("TEST_DISABLED4") assert not settings.getbool("TEST_DISABLED5") assert settings.getint("TEST_INT1") == 123 assert settings.getint("TEST_INT2") == 123 assert settings.getint("TEST_INTx") == 0 assert settings.getint("TEST_INTx", 45) == 45 assert settings.getfloat("TEST_FLOAT1") == 123.45 assert settings.getfloat("TEST_FLOAT2") == 123.45 assert settings.getfloat("TEST_FLOATx") == 0.0 assert settings.getfloat("TEST_FLOATx", 55.0) == 55.0 assert settings.getlist("TEST_LIST1") == ["one", "two"] assert settings.getlist("TEST_LIST2") == ["one", "two"] assert settings.getlist("TEST_LIST3") == [] assert settings.getlist("TEST_LISTx") == [] assert settings.getlist("TEST_LISTx", ["default"]) == ["default"] assert settings["TEST_STR"] == "value" assert settings.get("TEST_STR") == "value" assert settings["TEST_STRx"] is None assert settings.get("TEST_STRx") is None assert settings.get("TEST_STRx", "default") == "default" assert settings.getdict("TEST_DICT1") == {"key1": "val1", "ke2": 3} assert settings.getdict("TEST_DICT2") == {"key1": "val1", "ke2": 3} assert settings.getdict("TEST_DICT3") == {} assert settings.getdict("TEST_DICT3", {"key1": 5}) == {"key1": 5} with pytest.raises( ValueError, match=r"dictionary update sequence element #0 has length 3; 2 is required|sequence of pairs expected", ): settings.getdict("TEST_LIST1") with pytest.raises( ValueError, match="Supported values for boolean settings are" ): settings.getbool("TEST_ENABLED_WRONG") with pytest.raises( ValueError, match="Supported values for boolean settings are" ): settings.getbool("TEST_DISABLED_WRONG") def test_getpriority(self): settings = BaseSettings({"key": "value"}, priority=99) assert settings.getpriority("key") == 99 assert settings.getpriority("nonexistentkey") is None def test_getwithbase(self): s = BaseSettings( { "TEST_BASE": BaseSettings({"1": 1, "2": 2}, "project"), "TEST": BaseSettings({"1": 10, "3": 30}, "default"), "HASNOBASE": BaseSettings({"3": 3000}, "default"), } ) s["TEST"].set("2", 200, "cmdline") assert set(s.getwithbase("TEST")) == {"1", "2", "3"} assert set(s.getwithbase("HASNOBASE")) == set(s["HASNOBASE"]) assert s.getwithbase("NONEXISTENT") == {} def test_maxpriority(self): # Empty settings should return 'default' assert self.settings.maxpriority() == 0 self.settings.set("A", 0, 10) self.settings.set("B", 0, 30) assert self.settings.maxpriority() == 30 def test_copy(self): values = { "TEST_BOOL": True, "TEST_LIST": ["one", "two"], "TEST_LIST_OF_LISTS": [ ["first_one", "first_two"], ["second_one", "second_two"], ], } self.settings.setdict(values) copy = self.settings.copy() self.settings.set("TEST_BOOL", False) assert copy.get("TEST_BOOL") test_list = self.settings.get("TEST_LIST") test_list.append("three") assert copy.get("TEST_LIST") == ["one", "two"] test_list_of_lists = self.settings.get("TEST_LIST_OF_LISTS") test_list_of_lists[0].append("first_three") assert copy.get("TEST_LIST_OF_LISTS")[0] == ["first_one", "first_two"] def test_copy_to_dict(self): s = BaseSettings( { "TEST_STRING": "a string", "TEST_LIST": [1, 2], "TEST_BOOLEAN": False, "TEST_BASE": BaseSettings({"foo": 1, "bar": 2}, "project"), "TEST": BaseSettings({"foo": 10, "baz": 30}, "default"), "HASNOBASE": BaseSettings({"baz": 3000}, "default"), } ) assert s.copy_to_dict() == { "HASNOBASE": {"baz": 3000}, "TEST": {"foo": 10, "baz": 30}, "TEST_BASE": {"foo": 1, "bar": 2}, "TEST_LIST": [1, 2], "TEST_BOOLEAN": False, "TEST_STRING": "a string", } def test_freeze(self): self.settings.freeze() with pytest.raises( TypeError, match="Trying to modify an immutable Settings object" ): self.settings.set("TEST_BOOL", False) def test_frozencopy(self): frozencopy = self.settings.frozencopy() assert frozencopy.frozen assert frozencopy is not self.settings def test_getwithbase_for_dotted_keys(self): settings = BaseSettings( { "FEED_EXPORTERS_BASE": BaseSettings({"json": "foo"}), "FEED_EXPORTERS": BaseSettings({"csv.gz": "bar"}), } ) value = settings.getwithbase("FEED_EXPORTERS") assert isinstance(value, BaseSettings) assert dict(value) == { "json": "foo", "csv.gz": "bar", } @pytest.mark.parametrize( ("key", "exception"), [ pytest.param(1, TypeError, id="type-error"), pytest.param("foo", ValueError, id="value-error"), pytest.param("csv.gz", NameError, id="name-error"), ], ) def test_get_component_priority_dict_with_base_handles_load_object_exceptions( self, key, exception ): with pytest.raises(exception): load_object(key) settings = BaseSettings( { "FOO": BaseSettings({key: 1}), } ) value = settings.get_component_priority_dict_with_base("FOO") assert isinstance(value, BaseSettings) assert dict(value) == {key: 1} def test_get_component_priority_dict_with_base_override_none_by_type(self): settings = BaseSettings() setting_names = set() for k, v in scrapy_default_settings.__dict__.items(): if ( not k.endswith("_BASE") or k in NON_COMPONENT_PRIORITY_DICT_BASE_SETTING_NAMES ): continue settings[k] = v setting_name = k[: -len("_BASE")] setting_names.add(setting_name) settings[setting_name] = { load_object(import_path): None for import_path in v } for setting_name in setting_names: value = settings.get_component_priority_dict_with_base(setting_name) assert not dict(value) def test_get_component_priority_dict_with_base_override_value_by_type(self): settings = BaseSettings() setting_names = set() value = 0 for k, v in scrapy_default_settings.__dict__.items(): if ( not k.endswith("_BASE") or k in NON_COMPONENT_PRIORITY_DICT_BASE_SETTING_NAMES ): continue settings[k] = v setting_name = k[: -len("_BASE")] setting_names.add(setting_name) settings[setting_name] = { load_object(import_path): value for import_path in v } for setting_name in setting_names: assert ( settings.get_component_priority_dict_with_base(setting_name) == settings[setting_name] ) def test_getwithbase_for_non_component_priority_dicts(self): settings = BaseSettings() for base_name in NON_COMPONENT_PRIORITY_DICT_BASE_SETTING_NAMES: base_value = getattr(scrapy_default_settings, base_name) settings[base_name] = BaseSettings(base_value) assert len(base_value) >= 2 keys = list(base_value) values = list(base_value.values()) override = {keys[0]: values[1]} expected = dict(base_value) expected[keys[0]] = values[1] name = base_name[: -len("_BASE")] settings[name] = BaseSettings(override) value = settings.getwithbase(name) assert isinstance(value, BaseSettings) assert dict(value) == expected def test_get_component_priority_dict_with_base_warns_on_duplicate_import_paths( self, caplog ): settings = BaseSettings() settings["FOO"] = BaseSettings( { "scrapy.Request": 1, "scrapy.http.Request": 2, } ) with caplog.at_level(logging.WARNING): value = settings.get_component_priority_dict_with_base("FOO") assert isinstance(value, BaseSettings) assert dict(value) == {"scrapy.http.Request": 2} assert caplog.records, "Expected a warning to be logged" msg = caplog.records[0].message assert "scrapy.http.request.Request" in msg def test_get_component_priority_dict_with_base_warns_on_duplicate_mixed_type_and_path( self, caplog ): settings = BaseSettings() settings["FOO"] = BaseSettings( {Component1: 1, "tests.test_settings.Component1": 2} # type: ignore[arg-type] ) with caplog.at_level(logging.WARNING): value = settings.get_component_priority_dict_with_base("FOO") assert isinstance(value, BaseSettings) assert dict(value) == {"tests.test_settings.Component1": 2} assert caplog.records, "Expected a warning to be logged" msg = caplog.records[0].message assert "tests.test_settings.Component1" in msg def test_getdictorlist(self): settings = BaseSettings() # No value and no default → {} assert settings.getdictorlist("MISSING") == {} # String: valid JSON dict settings.set("S_DICT_STR", '{"key": "val"}') assert settings.getdictorlist("S_DICT_STR") == {"key": "val"} # String: valid JSON list settings.set("S_LIST_STR", '["a", "b"]') assert settings.getdictorlist("S_LIST_STR") == ["a", "b"] # String: invalid JSON → comma-split fallback settings.set("S_CSV", "a,b,c") assert settings.getdictorlist("S_CSV") == ["a", "b", "c"] # String: valid JSON but not dict or list → ValueError caught → comma-split settings.set("S_JSON_NUMBER", "123") assert settings.getdictorlist("S_JSON_NUMBER") == ["123"] # Tuple → list settings.set("S_TUPLE", ("x", "y")) assert settings.getdictorlist("S_TUPLE") == ["x", "y"] # Unsupported type → raises ValueError settings.set("S_INT", 42) with pytest.raises(ValueError, match="must be a dict, list, tuple, or string"): settings.getdictorlist("S_INT") # Dict value → deepcopy returned settings.set("S_DICT", {"key": "val"}) assert settings.getdictorlist("S_DICT") == {"key": "val"} # List value → deepcopy returned settings.set("S_LIST", ["a", "b"]) assert settings.getdictorlist("S_LIST") == ["a", "b"] def test_repr_pretty_(self): settings = BaseSettings({"key": "value"}) mock_p = mock.Mock() settings._repr_pretty_(mock_p, cycle=False) assert mock_p.text.call_count == 1 mock_p.reset_mock() settings._repr_pretty_(mock_p, cycle=True) mock_p.text.assert_called_once_with(repr(settings)) def test_getwithbase_invalid_setting_name(self): settings = BaseSettings() with pytest.raises( ValueError, match="Base setting key must be a string, got 123" ): settings.getwithbase(123) # type: ignore[arg-type] def test_get_component_priority_dict_with_base_invalid_setting_name(self): settings = BaseSettings() with pytest.raises( ValueError, match="Base setting key must be a string, got 123" ): settings.get_component_priority_dict_with_base(123) # type: ignore[arg-type] class TestSettings: def setup_method(self): self.settings = Settings() @mock.patch.dict("scrapy.settings.SETTINGS_PRIORITIES", {"default": 10}) @mock.patch("scrapy.settings.default_settings", default_settings) def test_initial_defaults(self): settings = Settings() assert len(settings.attributes) == 2 assert "TEST_DEFAULT" in settings.attributes attr = settings.attributes["TEST_DEFAULT"] assert isinstance(attr, SettingsAttribute) assert attr.value == "defvalue" assert attr.priority == 10 @mock.patch.dict("scrapy.settings.SETTINGS_PRIORITIES", {}) @mock.patch("scrapy.settings.default_settings", {}) def test_initial_values(self): settings = Settings({"TEST_OPTION": "value"}, 10) assert len(settings.attributes) == 1 assert "TEST_OPTION" in settings.attributes attr = settings.attributes["TEST_OPTION"] assert isinstance(attr, SettingsAttribute) assert attr.value == "value" assert attr.priority == 10 @mock.patch("scrapy.settings.default_settings", default_settings) def test_autopromote_dicts(self): settings = Settings() mydict = settings.get("TEST_DICT") assert isinstance(mydict, BaseSettings) assert "key" in mydict assert mydict["key"] == "val" assert mydict.getpriority("key") == 0 @mock.patch("scrapy.settings.default_settings", default_settings) def test_getdict_autodegrade_basesettings(self): settings = Settings() mydict = settings.getdict("TEST_DICT") assert isinstance(mydict, dict) assert len(mydict) == 1 assert "key" in mydict assert mydict["key"] == "val" def test_passing_objects_as_values(self): class TestPipeline: def process_item(self, i): return i settings = Settings( { "ITEM_PIPELINES": { TestPipeline: 800, }, "DOWNLOAD_HANDLERS": { "ftp": FileDownloadHandler, }, } ) assert "ITEM_PIPELINES" in settings.attributes mypipeline, priority = settings.getdict("ITEM_PIPELINES").popitem() assert priority == 800 assert mypipeline == TestPipeline assert isinstance(mypipeline(), TestPipeline) assert mypipeline().process_item("item") == "item" myhandler = settings.getdict("DOWNLOAD_HANDLERS").pop("ftp") assert myhandler == FileDownloadHandler myhandler_instance = build_from_crawler(myhandler, get_crawler()) assert isinstance(myhandler_instance, FileDownloadHandler) assert hasattr(myhandler_instance, "download_request") def test_pop_item_with_default_value(self): settings = Settings() with pytest.raises(KeyError): settings.pop("DUMMY_CONFIG") dummy_config_value = settings.pop("DUMMY_CONFIG", "dummy_value") assert dummy_config_value == "dummy_value" def test_pop_item_with_immutable_settings(self): settings = Settings( {"DUMMY_CONFIG": "dummy_value", "OTHER_DUMMY_CONFIG": "other_dummy_value"} ) assert settings.pop("DUMMY_CONFIG") == "dummy_value" settings.freeze() with pytest.raises( TypeError, match="Trying to modify an immutable Settings object" ): settings.pop("OTHER_DUMMY_CONFIG") @pytest.mark.parametrize( ("before", "name", "item", "after"), [ ({}, "FOO", "BAR", {"FOO": ["BAR"]}), ({"FOO": []}, "FOO", "BAR", {"FOO": ["BAR"]}), ({"FOO": ["BAR"]}, "FOO", "BAZ", {"FOO": ["BAR", "BAZ"]}), ({"FOO": ["BAR"]}, "FOO", "BAR", {"FOO": ["BAR"]}), ({"FOO": ""}, "FOO", "BAR", {"FOO": ["BAR"]}), ({"FOO": "BAR"}, "FOO", "BAR", {"FOO": "BAR"}), ({"FOO": "BAR"}, "FOO", "BAZ", {"FOO": ["BAR", "BAZ"]}), ({"FOO": "BAR,BAZ"}, "FOO", "BAZ", {"FOO": "BAR,BAZ"}), ({"FOO": "BAR,BAZ"}, "FOO", "QUX", {"FOO": ["BAR", "BAZ", "QUX"]}), ], ) def test_add_to_list(before, name, item, after): settings = BaseSettings(before, priority=0) settings.add_to_list(name, item) expected_priority = settings.getpriority(name) or 0 expected_settings = BaseSettings(after, priority=expected_priority) assert settings == expected_settings, ( f"{settings[name]=} != {expected_settings[name]=}" ) assert settings.getpriority(name) == expected_settings.getpriority(name) @pytest.mark.parametrize( ("before", "name", "item", "after"), [ ({}, "FOO", "BAR", ValueError), ({"FOO": ["BAR"]}, "FOO", "BAR", {"FOO": []}), ({"FOO": ["BAR"]}, "FOO", "BAZ", ValueError), ({"FOO": ["BAR", "BAZ"]}, "FOO", "BAR", {"FOO": ["BAZ"]}), ({"FOO": ""}, "FOO", "BAR", ValueError), ({"FOO": "[]"}, "FOO", "BAR", ValueError), ({"FOO": "BAR"}, "FOO", "BAR", {"FOO": []}), ({"FOO": "BAR"}, "FOO", "BAZ", ValueError), ({"FOO": "BAR,BAZ"}, "FOO", "BAR", {"FOO": ["BAZ"]}), ], ) def test_remove_from_list(before, name, item, after): settings = BaseSettings(before, priority=0) if isinstance(after, type) and issubclass(after, Exception): with pytest.raises(after): settings.remove_from_list(name, item) return settings.remove_from_list(name, item) expected_priority = settings.getpriority(name) or 0 expected_settings = BaseSettings(after, priority=expected_priority) assert settings == expected_settings, ( f"{settings[name]=} != {expected_settings[name]=}" ) assert settings.getpriority(name) == expected_settings.getpriority(name) def test_default_settings_all(): deprecated = {"DNS_RESOLVER"} assert scrapy_default_settings.__all__ == sorted( name for name in vars(scrapy_default_settings) if name.isupper() and name not in deprecated ) def test_deprecated_dns_resolver_setting(): settings = Settings() with pytest.warns( ScrapyDeprecationWarning, match="The DNS_RESOLVER setting is deprecated", ): settings.get("DNS_RESOLVER") def test_deprecated_concurrent_requests_per_ip_setting(): settings = Settings({"CONCURRENT_REQUESTS_PER_IP": 1}) with pytest.warns( ScrapyDeprecationWarning, match="The CONCURRENT_REQUESTS_PER_IP setting is deprecated", ): settings.get("CONCURRENT_REQUESTS_PER_IP") class Component1: pass Component1Alias = Component1 class Component1Subclass(Component1): pass Component1SubclassAlias = Component1Subclass class Component2: pass class Component3: pass class Component4: pass @pytest.mark.parametrize( ("before", "name", "old_cls", "new_cls", "priority", "after"), [ ({}, "FOO", Component1, Component2, None, KeyError), ( {"FOO": {Component1: 1}}, "FOO", Component1, Component2, None, {"FOO": {Component2: 1}}, ), ( {"FOO": {Component1: 1}}, "FOO", Component1, Component2, 2, {"FOO": {Component2: 2}}, ), ( {"FOO": {"tests.test_settings.Component1": 1}}, "FOO", Component1, Component2, None, {"FOO": {Component2: 1}}, ), ( {"FOO": {Component1Alias: 1}}, "FOO", Component1, Component2, None, {"FOO": {Component2: 1}}, ), ( {"FOO": {Component1Alias: 1}}, "FOO", Component1, Component2, 2, {"FOO": {Component2: 2}}, ), ( {"FOO": {"tests.test_settings.Component1Alias": 1}}, "FOO", Component1, Component2, None, {"FOO": {Component2: 1}}, ), ( {"FOO": {"tests.test_settings.Component1Alias": 1}}, "FOO", Component1, Component2, 2, {"FOO": {Component2: 2}}, ), ( { "FOO": { "tests.test_settings.Component1": 1, "tests.test_settings.Component1Alias": 2, } }, "FOO", Component1, Component2, None, {"FOO": {Component2: 2}}, ), ( { "FOO": { "tests.test_settings.Component1": 1, "tests.test_settings.Component1Alias": 2, } }, "FOO", Component1, Component2, 3, {"FOO": {Component2: 3}}, ), ( {"FOO": '{"tests.test_settings.Component1": 1}'}, "FOO", Component1, Component2, None, {"FOO": {Component2: 1}}, ), ( {"FOO": '{"tests.test_settings.Component1": 1}'}, "FOO", Component1, Component2, 2, {"FOO": {Component2: 2}}, ), ( {"FOO": '{"tests.test_settings.Component1Alias": 1}'}, "FOO", Component1, Component2, None, {"FOO": {Component2: 1}}, ), ( {"FOO": '{"tests.test_settings.Component1Alias": 1}'}, "FOO", Component1, Component2, 2, {"FOO": {Component2: 2}}, ), ( { "FOO": '{"tests.test_settings.Component1": 1, "tests.test_settings.Component1Alias": 2}' }, "FOO", Component1, Component2, None, {"FOO": {Component2: 2}}, ), ( { "FOO": '{"tests.test_settings.Component1": 1, "tests.test_settings.Component1Alias": 2}' }, "FOO", Component1, Component2, 3, {"FOO": {Component2: 3}}, ), # If old_cls has None as value, raise KeyError. ( {"FOO": {Component1: None}}, "FOO", Component1, Component2, None, KeyError, ), ( {"FOO": '{"tests.test_settings.Component1": null}'}, "FOO", Component1, Component2, None, KeyError, ), ( {"FOO": {Component1: None, "tests.test_settings.Component1": None}}, "FOO", Component1, Component2, None, KeyError, ), ( {"FOO": {Component1: 1, "tests.test_settings.Component1": None}}, "FOO", Component1, Component2, None, KeyError, ), ( {"FOO": {Component1: None, "tests.test_settings.Component1": 1}}, "FOO", Component1, Component2, None, KeyError, ), # Unrelated components are kept as is, as expected. ( { "FOO": { Component1: 1, "tests.test_settings.Component2": 2, Component3: 3, } }, "FOO", Component3, Component4, None, { "FOO": { Component1: 1, "tests.test_settings.Component2": 2, Component4: 3, } }, ), ], ) def test_replace_in_component_priority_dict( before, name, old_cls, new_cls, priority, after ): settings = BaseSettings(before, priority=0) if isinstance(after, type) and issubclass(after, Exception): with pytest.raises(after): settings.replace_in_component_priority_dict( name, old_cls, new_cls, priority ) return expected_priority = settings.getpriority(name) or 0 settings.replace_in_component_priority_dict(name, old_cls, new_cls, priority) expected_settings = BaseSettings(after, priority=expected_priority) assert settings == expected_settings assert settings.getpriority(name) == expected_settings.getpriority(name) @pytest.mark.parametrize( ("before", "name", "cls", "priority", "after"), [ # Set ({}, "FOO", Component1, None, {"FOO": {Component1: None}}), ({}, "FOO", Component1, 0, {"FOO": {Component1: 0}}), ({}, "FOO", Component1, 1, {"FOO": {Component1: 1}}), # Add ( {"FOO": {Component1: 0}}, "FOO", Component2, None, {"FOO": {Component1: 0, Component2: None}}, ), ( {"FOO": {Component1: 0}}, "FOO", Component2, 0, {"FOO": {Component1: 0, Component2: 0}}, ), ( {"FOO": {Component1: 0}}, "FOO", Component2, 1, {"FOO": {Component1: 0, Component2: 1}}, ), # Replace ( { "FOO": { Component1: None, "tests.test_settings.Component1": 0, "tests.test_settings.Component1Alias": 1, Component1Subclass: None, "tests.test_settings.Component1Subclass": 0, "tests.test_settings.Component1SubclassAlias": 1, } }, "FOO", Component1, None, { "FOO": { Component1: None, Component1Subclass: None, "tests.test_settings.Component1Subclass": 0, "tests.test_settings.Component1SubclassAlias": 1, } }, ), ( { "FOO": { Component1: 0, "tests.test_settings.Component1": 1, "tests.test_settings.Component1Alias": None, Component1Subclass: 0, "tests.test_settings.Component1Subclass": 1, "tests.test_settings.Component1SubclassAlias": None, } }, "FOO", Component1, 0, { "FOO": { Component1: 0, Component1Subclass: 0, "tests.test_settings.Component1Subclass": 1, "tests.test_settings.Component1SubclassAlias": None, } }, ), ( { "FOO": { Component1: 1, "tests.test_settings.Component1": None, "tests.test_settings.Component1Alias": 0, Component1Subclass: 1, "tests.test_settings.Component1Subclass": None, "tests.test_settings.Component1SubclassAlias": 0, } }, "FOO", Component1, 1, { "FOO": { Component1: 1, Component1Subclass: 1, "tests.test_settings.Component1Subclass": None, "tests.test_settings.Component1SubclassAlias": 0, } }, ), # String-based setting values ( {"FOO": '{"tests.test_settings.Component1": 0}'}, "FOO", Component2, None, {"FOO": {"tests.test_settings.Component1": 0, Component2: None}}, ), ( { "FOO": """{ "tests.test_settings.Component1": 0, "tests.test_settings.Component1Alias": 1, "tests.test_settings.Component1Subclass": 0, "tests.test_settings.Component1SubclassAlias": 1 }""" }, "FOO", Component1, None, { "FOO": { Component1: None, "tests.test_settings.Component1Subclass": 0, "tests.test_settings.Component1SubclassAlias": 1, } }, ), ], ) def test_set_in_component_priority_dict(before, name, cls, priority, after): settings = BaseSettings(before, priority=0) expected_priority = settings.getpriority(name) or 0 settings.set_in_component_priority_dict(name, cls, priority) expected_settings = BaseSettings(after, priority=expected_priority) assert settings == expected_settings assert settings.getpriority(name) == expected_settings.getpriority(name), ( f"{settings.getpriority(name)=} != {expected_settings.getpriority(name)=}" ) @pytest.mark.parametrize( ("before", "name", "cls", "priority", "after"), [ # Set ({}, "FOO", Component1, None, {"FOO": {Component1: None}}), ({}, "FOO", Component1, 0, {"FOO": {Component1: 0}}), ({}, "FOO", Component1, 1, {"FOO": {Component1: 1}}), # Add ( {"FOO": {Component1: 0}}, "FOO", Component2, None, {"FOO": {Component1: 0, Component2: None}}, ), ( {"FOO": {Component1: 0}}, "FOO", Component2, 0, {"FOO": {Component1: 0, Component2: 0}}, ), ( {"FOO": {Component1: 0}}, "FOO", Component2, 1, {"FOO": {Component1: 0, Component2: 1}}, ), # Keep ( { "FOO": { Component1: None, "tests.test_settings.Component1": 0, "tests.test_settings.Component1Alias": 1, Component1Subclass: None, "tests.test_settings.Component1Subclass": 0, "tests.test_settings.Component1SubclassAlias": 1, } }, "FOO", Component1, None, { "FOO": { Component1: None, "tests.test_settings.Component1": 0, "tests.test_settings.Component1Alias": 1, Component1Subclass: None, "tests.test_settings.Component1Subclass": 0, "tests.test_settings.Component1SubclassAlias": 1, } }, ), ( { "FOO": { Component1: 0, "tests.test_settings.Component1": 1, "tests.test_settings.Component1Alias": None, Component1Subclass: 0, "tests.test_settings.Component1Subclass": 1, "tests.test_settings.Component1SubclassAlias": None, } }, "FOO", Component1, 0, { "FOO": { Component1: 0, "tests.test_settings.Component1": 1, "tests.test_settings.Component1Alias": None, Component1Subclass: 0, "tests.test_settings.Component1Subclass": 1, "tests.test_settings.Component1SubclassAlias": None, } }, ), ( { "FOO": { Component1: 1, "tests.test_settings.Component1": None, "tests.test_settings.Component1Alias": 0, Component1Subclass: 1, "tests.test_settings.Component1Subclass": None, "tests.test_settings.Component1SubclassAlias": 0, } }, "FOO", Component1, 1, { "FOO": { Component1: 1, "tests.test_settings.Component1": None, "tests.test_settings.Component1Alias": 0, Component1Subclass: 1, "tests.test_settings.Component1Subclass": None, "tests.test_settings.Component1SubclassAlias": 0, } }, ), # String-based setting values ( {"FOO": '{"tests.test_settings.Component1": 0}'}, "FOO", Component2, None, {"FOO": {"tests.test_settings.Component1": 0, Component2: None}}, ), ( { "FOO": """{ "tests.test_settings.Component1": 0, "tests.test_settings.Component1Alias": 1, "tests.test_settings.Component1Subclass": 0, "tests.test_settings.Component1SubclassAlias": 1 }""" }, "FOO", Component1, None, { "FOO": """{ "tests.test_settings.Component1": 0, "tests.test_settings.Component1Alias": 1, "tests.test_settings.Component1Subclass": 0, "tests.test_settings.Component1SubclassAlias": 1 }""" }, ), ], ) def test_setdefault_in_component_priority_dict(before, name, cls, priority, after): settings = BaseSettings(before, priority=0) expected_priority = settings.getpriority(name) or 0 settings.setdefault_in_component_priority_dict(name, cls, priority) expected_settings = BaseSettings(after, priority=expected_priority) assert settings == expected_settings assert settings.getpriority(name) == expected_settings.getpriority(name) scrapy-scrapy-5569de3/tests/test_settings/default_settings.py000066400000000000000000000000661525051012400246340ustar00rootroot00000000000000TEST_DEFAULT = "defvalue" TEST_DICT = {"key": "val"} scrapy-scrapy-5569de3/tests/test_signalmanager.py000066400000000000000000000006701525051012400222410ustar00rootroot00000000000000from scrapy.signalmanager import SignalManager def test_disconnect_all() -> None: signal = object() sender = object() sm = SignalManager(sender) calls = [] def handler(): calls.append(1) sm.connect(handler, signal) sm.send_catch_log(signal) assert calls == [1] sm.disconnect_all(signal) sm.send_catch_log(signal) assert calls == [1] # handler no longer called after disconnect_all scrapy-scrapy-5569de3/tests/test_signals.py000066400000000000000000000031171525051012400210700ustar00rootroot00000000000000from __future__ import annotations from typing import TYPE_CHECKING import pytest from scrapy import Request, Spider, signals from scrapy.utils.test import get_crawler, get_from_asyncio_queue from tests.utils.decorators import coroutine_test if TYPE_CHECKING: from tests.mockserver.http import MockServer class ItemSpider(Spider): name = "itemspider" mockserver: MockServer async def start(self): for index in range(10): yield Request( self.mockserver.url(f"/status?n=200&id={index}"), meta={"index": index} ) def parse(self, response): return {"index": response.meta["index"]} class TestMain: @coroutine_test async def test_scheduler_empty(self): crawler = get_crawler() calls = [] def track_call(): calls.append(object()) crawler.signals.connect(track_call, signals.scheduler_empty) await crawler.crawl_async() assert len(calls) >= 1 class TestMockServer: def setup_method(self): self.items = [] async def _on_item_scraped(self, item): item = await get_from_asyncio_queue(item) self.items.append(item) @pytest.mark.only_asyncio @coroutine_test async def test_simple_pipeline(self, mockserver: MockServer) -> None: crawler = get_crawler(ItemSpider) crawler.signals.connect(self._on_item_scraped, signals.item_scraped) await crawler.crawl_async(mockserver=mockserver) assert len(self.items) == 10 for index in range(10): assert {"index": index} in self.items scrapy-scrapy-5569de3/tests/test_spider.py000066400000000000000000000176101525051012400207210ustar00rootroot00000000000000from __future__ import annotations from typing import TYPE_CHECKING import pytest from scrapy.http import Request, Response, TextResponse, XmlResponse from scrapy.spiders import CSVFeedSpider, Spider, XMLFeedSpider from tests import get_testdata from tests.spiders import RawResponseSpider from tests.utils.bases.spider import TestSpiderBase from tests.utils.crawl import crawl_items from tests.utils.decorators import coroutine_test if TYPE_CHECKING: from tests.mockserver.http import MockServer class RawFeedSpider(RawResponseSpider): content_type = "text/xml" async def start(self): yield Request(self.raw_url) class TestSpider(TestSpiderBase): spider_class = Spider class TestXMLFeedSpider(TestSpiderBase): spider_class = XMLFeedSpider def test_register_namespace(self): body = b""" http://www.example.com/Special-Offers.html2009-08-16 http://www.example.com/2009-08-16 """ response = XmlResponse(url="http://example.com/sitemap.xml", body=body) class _XMLSpider(self.spider_class): # type: ignore[name-defined,misc] itertag = "url" namespaces = ( ("a", "http://www.google.com/schemas/sitemap/0.84"), ("b", "http://www.example.com/schemas/extras/1.0"), ) def parse_node(self, response, selector): yield { "loc": selector.xpath("a:loc/text()").getall(), "updated": selector.xpath("b:updated/text()").getall(), "other": selector.xpath("other/@value").getall(), "custom": selector.xpath("other/@b:custom").getall(), } for iterator in ("iternodes", "xml"): spider = _XMLSpider("example", iterator=iterator) output = list(spider._parse(response)) assert len(output) == 2, iterator assert output == [ { "loc": ["http://www.example.com/Special-Offers.html"], "updated": ["2009-08-16"], "custom": ["fuu"], "other": ["bar"], }, { "loc": [], "updated": ["2009-08-16"], "other": ["foo"], "custom": [], }, ], iterator @coroutine_test async def test_parse_node_uses_parse_item(self, mockserver: MockServer): # parse_node falls back to parse_item for backward compatibility. class _Spider(RawFeedSpider, self.spider_class): # type: ignore[name-defined,misc] itertag = "item" def raw_body(self): return "1" def parse_item(self, response, selector): return {"id": selector.xpath("id/text()").get()} items, _ = await crawl_items(_Spider, mockserver) assert items == [{"id": "1"}] @coroutine_test async def test_parse_node_not_defined(self, mockserver: MockServer): class _Spider(RawFeedSpider, self.spider_class): # type: ignore[name-defined,misc] itertag = "item" def raw_body(self): return "1" items, crawler = await crawl_items(_Spider, mockserver) assert items == [] assert crawler.stats.get_value("spider_exceptions/NotImplementedError") == 1 @coroutine_test async def test_html_iterator(self, mockserver: MockServer): class _Spider(RawFeedSpider, self.spider_class): # type: ignore[name-defined,misc] iterator = "html" itertag = "item" content_type = "text/html" def raw_body(self): return ( "1" "2" ) def parse_node(self, response, selector): return {"id": selector.xpath("id/text()").get()} items, _ = await crawl_items(_Spider, mockserver) assert items == [{"id": "1"}, {"id": "2"}] @coroutine_test async def test_unsupported_iterator(self, mockserver: MockServer): class _Spider(RawFeedSpider, self.spider_class): # type: ignore[name-defined,misc] iterator = "unsupported" def raw_body(self): return "" def parse_node(self, response, selector): return {} items, crawler = await crawl_items(_Spider, mockserver) assert items == [] assert crawler.stats.get_value("spider_exceptions/NotSupported") == 1 @pytest.mark.parametrize("feed_iterator", ["xml", "html"]) @coroutine_test async def test_non_text_response(self, feed_iterator: str, mockserver: MockServer): # The xml and html iterators require a text response. class _Spider(RawFeedSpider, self.spider_class): # type: ignore[name-defined,misc] content_type = "application/octet-stream" iterator = feed_iterator def raw_body(self): # A binary (non-text) body, so the response is a plain Response. return "\x00\x01\x02\x03" def parse_node(self, response, selector): return {} items, crawler = await crawl_items(_Spider, mockserver) assert items == [] assert crawler.stats.get_value("spider_exceptions/ValueError") == 1 class TestCSVFeedSpider(TestSpiderBase): spider_class = CSVFeedSpider def test_parse_rows(self): body = get_testdata("feeds", "feed-sample6.csv") response = Response("http://example.org/dummy.csv", body=body) class _CrawlSpider(self.spider_class): # type: ignore[name-defined,misc] name = "test" delimiter = "," quotechar = "'" def parse_row(self, response, row): return row spider = _CrawlSpider() rows = list(spider.parse_rows(response)) assert rows[0] == {"id": "1", "name": "alpha", "value": "foobar"} assert len(rows) == 4 @coroutine_test async def test_parse(self, mockserver: MockServer): class _Spider(RawFeedSpider, self.spider_class): # type: ignore[name-defined,misc] content_type = "text/csv" delimiter = "," quotechar = "'" def raw_body(self): return get_testdata("feeds", "feed-sample6.csv").decode() def parse_row(self, response, row): return row items, _ = await crawl_items(_Spider, mockserver) assert items[0] == {"id": "1", "name": "alpha", "value": "foobar"} assert len(items) == 4 @coroutine_test async def test_parse_row_not_defined(self, mockserver: MockServer): class _Spider(RawFeedSpider, self.spider_class): # type: ignore[name-defined,misc] content_type = "text/csv" def raw_body(self): return "id\n1\n" items, crawler = await crawl_items(_Spider, mockserver) assert items == [] assert crawler.stats.get_value("spider_exceptions/NotImplementedError") == 1 class TestNoParseMethodSpider: spider_class = Spider def test_undefined_parse_method(self): spider = self.spider_class("example.com") text = b"Random text" resp = TextResponse(url="http://www.example.com/random_url", body=text) exc_msg = "Spider.parse callback is not defined" with pytest.raises(NotImplementedError, match=exc_msg): spider.parse(resp) scrapy-scrapy-5569de3/tests/test_spider_crawl.py000066400000000000000000000305541525051012400221130ustar00rootroot00000000000000from __future__ import annotations import re import warnings from typing import TYPE_CHECKING, Any import pytest from w3lib.url import safe_url_string from scrapy.exceptions import ScrapyDeprecationWarning from scrapy.http import HtmlResponse, Request, TextResponse from scrapy.linkextractors import LinkExtractor from scrapy.spiders import CrawlSpider, Rule, Spider from scrapy.utils.test import get_crawler from tests.utils.bases.spider import TestSpiderBase from tests.utils.decorators import coroutine_test if TYPE_CHECKING: from scrapy.http import Response from scrapy.http.request import CallbackT URL = "https://www.example.com" class TestCrawlSpider(TestSpiderBase): test_body = b"""Page title

    Item 12

    """ spider_class = CrawlSpider def test_rule_without_link_extractor(self): response = HtmlResponse( "http://example.org/somepage/index.html", body=self.test_body ) class _CrawlSpider(self.spider_class): # type: ignore[name-defined,misc] name = "test" allowed_domains = ["example.org"] rules = (Rule(),) spider = _CrawlSpider() output = list(spider._requests_to_follow(response)) assert len(output) == 3 assert all(isinstance(r, Request) for r in output) assert [r.url for r in output] == [ "http://example.org/somepage/item/12.html", "http://example.org/about.html", "http://example.org/nofollow.html", ] def test_process_links(self): response = HtmlResponse( "http://example.org/somepage/index.html", body=self.test_body ) class _CrawlSpider(self.spider_class): # type: ignore[name-defined,misc] name = "test" allowed_domains = ["example.org"] rules = (Rule(LinkExtractor(), process_links="dummy_process_links"),) def dummy_process_links(self, links): return links spider = _CrawlSpider() output = list(spider._requests_to_follow(response)) assert len(output) == 3 assert all(isinstance(r, Request) for r in output) assert [r.url for r in output] == [ "http://example.org/somepage/item/12.html", "http://example.org/about.html", "http://example.org/nofollow.html", ] def test_process_links_filter(self): response = HtmlResponse( "http://example.org/somepage/index.html", body=self.test_body ) class _CrawlSpider(self.spider_class): # type: ignore[name-defined,misc] name = "test" allowed_domains = ["example.org"] rules = (Rule(LinkExtractor(), process_links="filter_process_links"),) _test_regex = re.compile("nofollow") def filter_process_links(self, links): return [link for link in links if not self._test_regex.search(link.url)] spider = _CrawlSpider() output = list(spider._requests_to_follow(response)) assert len(output) == 2 assert all(isinstance(r, Request) for r in output) assert [r.url for r in output] == [ "http://example.org/somepage/item/12.html", "http://example.org/about.html", ] def test_process_links_generator(self): response = HtmlResponse( "http://example.org/somepage/index.html", body=self.test_body ) class _CrawlSpider(self.spider_class): # type: ignore[name-defined,misc] name = "test" allowed_domains = ["example.org"] rules = (Rule(LinkExtractor(), process_links="dummy_process_links"),) def dummy_process_links(self, links): yield from links spider = _CrawlSpider() output = list(spider._requests_to_follow(response)) assert len(output) == 3 assert all(isinstance(r, Request) for r in output) assert [r.url for r in output] == [ "http://example.org/somepage/item/12.html", "http://example.org/about.html", "http://example.org/nofollow.html", ] def test_process_request(self): response = HtmlResponse( "http://example.org/somepage/index.html", body=self.test_body ) def process_request_change_domain(request, response): return request.replace(url=request.url.replace(".org", ".com")) class _CrawlSpider(self.spider_class): # type: ignore[name-defined,misc] name = "test" allowed_domains = ["example.org"] rules = ( Rule(LinkExtractor(), process_request=process_request_change_domain), ) spider = _CrawlSpider() output = list(spider._requests_to_follow(response)) assert len(output) == 3 assert all(isinstance(r, Request) for r in output) assert [r.url for r in output] == [ "http://example.com/somepage/item/12.html", "http://example.com/about.html", "http://example.com/nofollow.html", ] def test_process_request_with_response(self): response = HtmlResponse( "http://example.org/somepage/index.html", body=self.test_body ) def process_request_meta_response_class(request, response): request.meta["response_class"] = response.__class__.__name__ return request class _CrawlSpider(self.spider_class): # type: ignore[name-defined,misc] name = "test" allowed_domains = ["example.org"] rules = ( Rule( LinkExtractor(), process_request=process_request_meta_response_class ), ) spider = _CrawlSpider() output = list(spider._requests_to_follow(response)) assert len(output) == 3 assert all(isinstance(r, Request) for r in output) assert [r.url for r in output] == [ "http://example.org/somepage/item/12.html", "http://example.org/about.html", "http://example.org/nofollow.html", ] assert [r.meta["response_class"] for r in output] == [ "HtmlResponse", "HtmlResponse", "HtmlResponse", ] def test_process_request_instance_method(self): response = HtmlResponse( "http://example.org/somepage/index.html", body=self.test_body ) class _CrawlSpider(self.spider_class): # type: ignore[name-defined,misc] name = "test" allowed_domains = ["example.org"] rules = (Rule(LinkExtractor(), process_request="process_request_upper"),) def process_request_upper(self, request, response): return request.replace(url=request.url.upper()) spider = _CrawlSpider() output = list(spider._requests_to_follow(response)) assert len(output) == 3 assert all(isinstance(r, Request) for r in output) assert [r.url for r in output] == [ safe_url_string("http://EXAMPLE.ORG/SOMEPAGE/ITEM/12.HTML"), safe_url_string("http://EXAMPLE.ORG/ABOUT.HTML"), safe_url_string("http://EXAMPLE.ORG/NOFOLLOW.HTML"), ] def test_process_request_instance_method_with_response(self): response = HtmlResponse( "http://example.org/somepage/index.html", body=self.test_body ) class _CrawlSpider(self.spider_class): # type: ignore[name-defined,misc] name = "test" allowed_domains = ["example.org"] rules = ( Rule( LinkExtractor(), process_request="process_request_meta_response_class", ), ) def process_request_meta_response_class(self, request, response): request.meta["response_class"] = response.__class__.__name__ return request spider = _CrawlSpider() output = list(spider._requests_to_follow(response)) assert len(output) == 3 assert all(isinstance(r, Request) for r in output) assert [r.url for r in output] == [ "http://example.org/somepage/item/12.html", "http://example.org/about.html", "http://example.org/nofollow.html", ] assert [r.meta["response_class"] for r in output] == [ "HtmlResponse", "HtmlResponse", "HtmlResponse", ] @pytest.mark.filterwarnings("ignore::scrapy.exceptions.ScrapyDeprecationWarning") def test_follow_links_attribute_population(self): crawler = get_crawler() spider = self.spider_class.from_crawler(crawler, "example.com") assert hasattr(spider, "_follow_links") assert spider._follow_links settings_dict = {"CRAWLSPIDER_FOLLOW_LINKS": False} crawler = get_crawler(settings_dict=settings_dict) spider = self.spider_class.from_crawler(crawler, "example.com") assert hasattr(spider, "_follow_links") assert not spider._follow_links def test_parse_response_use(self): class _CrawlSpider(CrawlSpider): name = "test" _follow_links = False with warnings.catch_warnings(): warnings.simplefilter("error", category=ScrapyDeprecationWarning) spider = _CrawlSpider() with pytest.warns( ScrapyDeprecationWarning, match=r"CrawlSpider\._parse_response method is deprecated", ): spider._parse_response(TextResponse(URL, body=b""), None, {}) def test_parse_response_override(self): class _CrawlSpider(CrawlSpider): def _parse_response( self, response: Response, callback: CallbackT | None, cb_kwargs: dict[str, Any], follow: bool = True, ) -> Any: pass name = "test" _follow_links = False with pytest.warns( ScrapyDeprecationWarning, match=r"CrawlSpider\._parse_response method, which the", ): spider = _CrawlSpider() with warnings.catch_warnings(): warnings.simplefilter("error", category=ScrapyDeprecationWarning) spider._parse_response(TextResponse(URL, body=b""), None, {}) def test_parse_with_rules(self): class _CrawlSpider(CrawlSpider): name = "test" with warnings.catch_warnings(): warnings.simplefilter("error", category=ScrapyDeprecationWarning) spider = _CrawlSpider() spider.parse_with_rules(TextResponse(URL, body=b""), None, {}) @coroutine_test async def test_parse_with_rules_without_callback(self): response = HtmlResponse( "http://example.org/somepage/index.html", body=self.test_body ) class _CrawlSpider(CrawlSpider): name = "test" allowed_domains = ["example.org"] rules = (Rule(),) spider = _CrawlSpider.from_crawler(get_crawler(_CrawlSpider)) results = [ r async for r in spider.parse_with_rules(response, None, {}, follow=True) ] assert [r.url for r in results] == [ "http://example.org/somepage/item/12.html", "http://example.org/about.html", "http://example.org/nofollow.html", ] @coroutine_test async def test_parse_with_rules_without_following(self): response = HtmlResponse( "http://example.org/somepage/index.html", body=self.test_body ) item = {"name": "item"} class _CrawlSpider(CrawlSpider): name = "test" allowed_domains = ["example.org"] rules = (Rule(),) def callback(response: Response) -> list[Any]: return [item] spider = _CrawlSpider.from_crawler(get_crawler(_CrawlSpider)) results = [ r async for r in spider.parse_with_rules(response, callback, {}, follow=False) ] assert results == [item] class TestDeprecation: def test_crawl_spider(self): assert issubclass(CrawlSpider, Spider) assert isinstance(CrawlSpider(name="foo"), Spider) scrapy-scrapy-5569de3/tests/test_spider_sitemap.py000066400000000000000000000472121525051012400224440ustar00rootroot00000000000000from __future__ import annotations import gzip import re from datetime import datetime from io import BytesIO from logging import WARNING from pathlib import Path from typing import TYPE_CHECKING import pytest from scrapy.http import HtmlResponse, Request, Response, TextResponse, XmlResponse from scrapy.spiders import SitemapSpider from scrapy.utils.test import get_crawler from tests import tests_datadir from tests.spiders import RawResponseSpider from tests.utils.bases.spider import TestSpiderBase from tests.utils.crawl import crawl_items from tests.utils.decorators import coroutine_test if TYPE_CHECKING: from tests.mockserver.http import MockServer class RawSitemapSpider(RawResponseSpider): """Feeds :meth:`raw_body` to :class:`~scrapy.spiders.SitemapSpider` as a sitemap, so that it is fetched and followed through a regular crawl. Subclasses build the document in :meth:`raw_body`, typically using :attr:`mockserver` to point ```` entries at real endpoints. """ content_type = "application/xml" async def start(self): self.sitemap_urls = [self.raw_url] async for request in super().start(): yield request class TestSitemapSpider(TestSpiderBase): spider_class = SitemapSpider BODY = b"SITEMAP" f = BytesIO() g = gzip.GzipFile(fileobj=f, mode="w+b") g.write(BODY) g.close() GZBODY = f.getvalue() def assertSitemapBody(self, response: Response, body: bytes | None) -> None: crawler = get_crawler() spider = self.spider_class.from_crawler(crawler, "example.com") assert spider._get_sitemap_body(response) == body def test_get_sitemap_body(self): r: Response = XmlResponse(url="http://www.example.com/", body=self.BODY) self.assertSitemapBody(r, self.BODY) r = HtmlResponse(url="http://www.example.com/", body=self.BODY) self.assertSitemapBody(r, None) r = Response(url="http://www.example.com/favicon.ico", body=self.BODY) self.assertSitemapBody(r, None) r = XmlResponse(url="http://www.example.com/", body=b"") self.assertSitemapBody(r, b"") def test_get_sitemap_body_gzip_headers(self): r = Response( 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) def test_get_sitemap_body_xml_url(self): r = TextResponse(url="http://www.example.com/sitemap.xml", body=self.BODY) 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, request=Request("http://www.example.com/sitemap"), ) self.assertSitemapBody(r, self.BODY) # .xml.gz but body decoded by HttpCompression middleware already r = Response(url="http://www.example.com/sitemap.xml.gz", body=self.BODY) self.assertSitemapBody(r, self.BODY) def test_get_sitemap_urls_from_robotstxt(self): robots = b"""# Sitemap files Sitemap: http://example.com/sitemap.xml Sitemap: http://example.com/sitemap-product-index.xml Sitemap: HTTP://example.com/sitemap-uppercase.xml Sitemap: /sitemap-relative-url.xml """ r = TextResponse(url="http://www.example.com/robots.txt", body=robots) spider = self.spider_class("example.com") assert [req.url for req in spider._parse_sitemap(r)] == [ "http://example.com/sitemap.xml", "http://example.com/sitemap-product-index.xml", "http://example.com/sitemap-uppercase.xml", "http://www.example.com/sitemap-relative-url.xml", ] def test_get_sitemap_urls_from_robotstxt_skips_invalid_utf8_urls(self): robots = ( b"User-agent: *\n" b"Sitemap: http://example.com/\xff.xml\n" b"Sitemap: http://example.com/ok.xml\n" ) r = TextResponse(url="http://www.example.com/robots.txt", body=robots) spider = self.spider_class("example.com") assert [req.url for req in spider._parse_sitemap(r)] == [ "http://example.com/ok.xml", ] def test_alternate_url_locs(self): sitemap = b""" http://www.example.com/english/ """ r = TextResponse(url="http://www.example.com/sitemap.xml", body=sitemap) spider = self.spider_class("example.com") assert [req.url for req in spider._parse_sitemap(r)] == [ "http://www.example.com/english/" ] spider.sitemap_alternate_links = True assert [req.url for req in spider._parse_sitemap(r)] == [ "http://www.example.com/english/", "http://www.example.com/deutsch/", "http://www.example.com/schweiz-deutsch/", "http://www.example.com/italiano/", ] def test_sitemap_filter(self): sitemap = b""" http://www.example.com/english/ 2010-01-01 http://www.example.com/portuguese/ 2005-01-01 """ class FilteredSitemapSpider(self.spider_class): # type: ignore[name-defined,misc] def sitemap_filter(self, entries): for entry in entries: date_time = datetime.strptime(entry["lastmod"], "%Y-%m-%d") if date_time.year > 2008: yield entry r = TextResponse(url="http://www.example.com/sitemap.xml", body=sitemap) spider = self.spider_class("example.com") assert [req.url for req in spider._parse_sitemap(r)] == [ "http://www.example.com/english/", "http://www.example.com/portuguese/", ] spider = FilteredSitemapSpider("example.com") assert [req.url for req in spider._parse_sitemap(r)] == [ "http://www.example.com/english/" ] def test_sitemap_filter_with_alternate_links(self): sitemap = b""" http://www.example.com/english/article_1/ 2010-01-01 http://www.example.com/english/article_2/ 2015-01-01 """ class FilteredSitemapSpider(self.spider_class): # type: ignore[name-defined,misc] def sitemap_filter(self, entries): for entry in entries: alternate_links = entry.get("alternate", ()) for link in alternate_links: if "/deutsch/" in link: entry["loc"] = link yield entry r = TextResponse(url="http://www.example.com/sitemap.xml", body=sitemap) spider = self.spider_class("example.com") assert [req.url for req in spider._parse_sitemap(r)] == [ "http://www.example.com/english/article_1/", "http://www.example.com/english/article_2/", ] spider = FilteredSitemapSpider("example.com") assert [req.url for req in spider._parse_sitemap(r)] == [ "http://www.example.com/deutsch/article_1/" ] def test_sitemapindex_filter(self): sitemap = b""" http://www.example.com/sitemap1.xml 2004-01-01T20:00:00+00:00 http://www.example.com/sitemap2.xml 2005-01-01 """ class FilteredSitemapSpider(self.spider_class): # type: ignore[name-defined,misc] def sitemap_filter(self, entries): for entry in entries: date_time = datetime.strptime( entry["lastmod"].split("T")[0], "%Y-%m-%d" ) if date_time.year > 2004: yield entry r = TextResponse(url="http://www.example.com/sitemap.xml", body=sitemap) spider = self.spider_class("example.com") assert [req.url for req in spider._parse_sitemap(r)] == [ "http://www.example.com/sitemap1.xml", "http://www.example.com/sitemap2.xml", ] spider = FilteredSitemapSpider("example.com") assert [req.url for req in spider._parse_sitemap(r)] == [ "http://www.example.com/sitemap2.xml" ] @pytest.mark.parametrize( ("rule", "result"), [(r"english", ["http://www.example.com/english/"]), (r"nonexistent", [])], ) def test_sitemap_filter_with_rule(self, rule: str, result: list[str]): sitemap = b""" http://www.example.com/english/ http://www.example.com/portuguese/ """ r = TextResponse(url="http://www.example.com/sitemap.xml", body=sitemap) class _RuleSpider(self.spider_class): # type: ignore[name-defined,misc] sitemap_rules = [(rule, "parse")] spider = _RuleSpider("example.com") urls = [req.url for req in spider._parse_sitemap(r)] assert urls == result @coroutine_test async def test_sitemap_rules_with_callable(self, mockserver: MockServer): # A sitemap_rules entry may hold a callable instead of a method name. def parse_item(response): yield {"url": response.url} class _Spider(RawSitemapSpider, self.spider_class): # type: ignore[name-defined,misc] sitemap_rules = [("", parse_item)] def raw_body(self): assert self.mockserver loc = self.mockserver.url("/text") return ( '' '' f"{loc}" "" ) items, _ = await crawl_items(_Spider, mockserver) assert items == [{"url": mockserver.url("/text")}] @coroutine_test async def test_sitemap_empty_loc(self, mockserver: MockServer): class _Spider(RawSitemapSpider, self.spider_class): # type: ignore[name-defined,misc] def parse(self, response): yield {"url": response.url} def raw_body(self): assert self.mockserver loc = self.mockserver.url("/text") return ( '' '' "" f"{loc}" "" ) items, _ = await crawl_items(_Spider, mockserver) assert items == [{"url": mockserver.url("/text")}] def test_parse_sitemap_empty_body(self, caplog: pytest.LogCaptureFixture) -> None: r = XmlResponse(url="http://www.example.com/sitemap.xml", body=b"") spider = self.spider_class("example.com") caplog.clear() with caplog.at_level(WARNING): results = list(spider._parse_sitemap(r)) assert not results assert caplog.record_tuples == [ ( "scrapy.spiders.sitemap", WARNING, "Ignoring invalid sitemap: <200 http://www.example.com/sitemap.xml>", ) ] def test_parse_sitemap_not_sitemap(self): body = b""" sometext sometext2 """ r = XmlResponse(url="http://www.example.com/random.xml", body=body) spider = self.spider_class("example.com") results = list(spider._parse_sitemap(r)) assert not results @pytest.mark.parametrize( ("follow", "result"), [ (r"1.xml", ["http://www.example.com/sitemap1.xml"]), (re.compile(r"sitemap\d"), ["http://www.example.com/sitemap1.xml"]), (r"nonexistent", []), ], ) def test_sitemap_follow(self, follow, result): sitemap = b""" http://www.example.com/sitemap1.xml """ r = TextResponse(url="http://www.example.com/sitemap.xml", body=sitemap) class _FollowSpider(self.spider_class): # type: ignore[name-defined,misc] sitemap_follow = [follow] spider = _FollowSpider("example.com") urls = [req.url for req in spider._parse_sitemap(r)] assert urls == result 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) assert spider._get_sitemap_body(response) is None @pytest.mark.filterwarnings("ignore::scrapy.exceptions.ScrapyDeprecationWarning") def test_compression_bomb_spider_attr(self): class DownloadMaxSizeSpider(self.spider_class): # type: ignore[name-defined,misc] 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) assert spider._get_sitemap_body(response) is None 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) assert spider._get_sitemap_body(response) is None def test_download_warnsize_setting(self, caplog: pytest.LogCaptureFixture) -> None: 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) caplog.clear() with caplog.at_level(WARNING, logger="scrapy.spiders.sitemap"): spider._get_sitemap_body(response) assert caplog.record_tuples == [ ( "scrapy.spiders.sitemap", WARNING, ( "<200 https://example.com> body size after decompression " "(11511612 B) is larger than the download warning size " "(10000000 B)." ), ), ] @pytest.mark.filterwarnings("ignore::scrapy.exceptions.ScrapyDeprecationWarning") def test_download_warnsize_spider_attr( self, caplog: pytest.LogCaptureFixture ) -> None: class DownloadWarnSizeSpider(self.spider_class): # type: ignore[name-defined,misc] 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) caplog.clear() with caplog.at_level(WARNING, logger="scrapy.spiders.sitemap"): spider._get_sitemap_body(response) assert caplog.record_tuples == [ ( "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, caplog: pytest.LogCaptureFixture ) -> None: 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) caplog.clear() with caplog.at_level(WARNING, logger="scrapy.spiders.sitemap"): spider._get_sitemap_body(response) assert caplog.record_tuples == [ ( "scrapy.spiders.sitemap", WARNING, ( "<200 https://example.com> body size after decompression " "(11511612 B) is larger than the download warning size " "(10000000 B)." ), ), ] @coroutine_test async def test_sitemap_urls(self): class TestSpider(self.spider_class): # type: ignore[name-defined,misc] name = "test" sitemap_urls = ["https://toscrape.com/sitemap.xml"] crawler = get_crawler(TestSpider) spider = TestSpider.from_crawler(crawler) requests = [request async for request in spider.start()] assert len(requests) == 1 request = requests[0] assert request.url == "https://toscrape.com/sitemap.xml" assert request.dont_filter is False assert request.callback == spider._parse_sitemap scrapy-scrapy-5569de3/tests/test_spider_start.py000066400000000000000000000062421525051012400221350ustar00rootroot00000000000000from __future__ import annotations from asyncio import sleep from typing import TYPE_CHECKING, Any import pytest from scrapy import Spider, signals from scrapy.utils.asyncio import sleep as scrapy_sleep from scrapy.utils.defer import maybe_deferred_to_future from scrapy.utils.test import get_crawler from .utils import twisted_sleep from .utils.decorators import coroutine_test if TYPE_CHECKING: from collections.abc import AsyncIterator, Callable SLEEP_SECONDS = 0.1 ITEM_A = {"id": "a"} ITEM_B = {"id": "b"} async def _test_spider( spider: type[Spider], expected_items: list[Any] | None = None, settings: dict[str, Any] | None = None, *, ordered: bool = True, ) -> None: actual_items: list[Any] = [] expected_items = [] if expected_items is None else expected_items def track_item(item: Any, response: Any, spider: Spider) -> None: actual_items.append(item) crawler = get_crawler(spider, settings) crawler.signals.connect(track_item, signals.item_scraped) await crawler.crawl_async() assert crawler.stats.get_value("finish_reason") == "finished" if not ordered: actual_items = sorted(actual_items, key=repr) expected_items = sorted(expected_items, key=repr) assert actual_items == expected_items @coroutine_test async def test_start_urls() -> None: class TestSpider(Spider): name = "test" start_urls = ["data:,"] async def parse(self, response): yield ITEM_A await _test_spider(TestSpider, [ITEM_A]) @coroutine_test async def test_start() -> None: class TestSpider(Spider): name = "test" async def start(self): yield ITEM_A await _test_spider(TestSpider, [ITEM_A]) @coroutine_test async def test_start_subclass() -> None: class BaseSpider(Spider): async def start(self): yield ITEM_A class TestSpider(BaseSpider): name = "test" await _test_spider(TestSpider, [ITEM_A]) async def _test_start( start_: Callable[[Any], AsyncIterator[Any]], expected_items: list[Any] | None = None, ) -> None: class TestSpider(Spider): name = "test" start = start_ await _test_spider(TestSpider, expected_items) @pytest.mark.only_asyncio @coroutine_test async def test_asyncio_delayed() -> None: async def start(spider): await sleep(SLEEP_SECONDS) yield ITEM_A await _test_start(start, [ITEM_A]) @pytest.mark.requires_reactor # needs a reactor for twisted_sleep() @coroutine_test async def test_twisted_delayed() -> None: async def start(spider): await maybe_deferred_to_future(twisted_sleep(SLEEP_SECONDS)) yield ITEM_A await _test_start(start, [ITEM_A]) @coroutine_test async def test_slow_pipeline() -> None: class SlowPipeline: async def process_item(self, item): await scrapy_sleep(SLEEP_SECONDS) return item class TestSpider(Spider): name = "test" async def start(self): yield ITEM_A yield ITEM_B await _test_spider( TestSpider, [ITEM_A, ITEM_B], {"ITEM_PIPELINES": {SlowPipeline: 0}}, ordered=False, ) scrapy-scrapy-5569de3/tests/test_spiderloader/000077500000000000000000000000001525051012400215315ustar00rootroot00000000000000scrapy-scrapy-5569de3/tests/test_spiderloader/__init__.py000066400000000000000000000201141525051012400236400ustar00rootroot00000000000000import contextlib import shutil import sys from pathlib import Path from unittest import mock import pytest # ugly hack to avoid cyclic imports of scrapy.spiders when running this test # alone import scrapy from scrapy.crawler import CrawlerRunner from scrapy.http import Request from scrapy.settings import Settings from scrapy.spiderloader import DummySpiderLoader, SpiderLoader, get_spider_loader module_dir = Path(__file__).resolve().parent def _copytree(source: Path, target: Path): with contextlib.suppress(shutil.Error): shutil.copytree(source, target) @pytest.fixture def spider_loader_env(tmp_path): orig_spiders_dir = module_dir / "test_spiders" spiders_dir = tmp_path / "test_spiders_xxx" _copytree(orig_spiders_dir, spiders_dir) sys.path.append(str(tmp_path)) settings = Settings({"SPIDER_MODULES": ["test_spiders_xxx"]}) yield settings, spiders_dir sys.modules.pop("test_spiders_xxx", None) sys.path.remove(str(tmp_path)) @pytest.fixture def spider_loader(spider_loader_env): settings, _ = spider_loader_env return SpiderLoader.from_settings(settings) class TestSpiderLoader: def test_list(self, spider_loader): assert set(spider_loader.list()) == { "spider1", "spider2", "spider3", "spider4", } def test_load(self, spider_loader): spider1 = spider_loader.load("spider1") assert spider1.__name__ == "Spider1" def test_find_by_request(self, spider_loader): assert spider_loader.find_by_request(Request("http://scrapy1.org/test")) == [ "spider1" ] assert spider_loader.find_by_request(Request("http://scrapy2.org/test")) == [ "spider2" ] assert set( spider_loader.find_by_request(Request("http://scrapy3.org/test")) ) == {"spider1", "spider2"} assert spider_loader.find_by_request(Request("http://scrapy999.org/test")) == [] assert spider_loader.find_by_request(Request("http://spider3.com")) == [] assert spider_loader.find_by_request( Request("http://spider3.com/onlythis") ) == ["spider3"] def test_load_spider_module(self): module = "tests.test_spiderloader.test_spiders.spider1" settings = Settings({"SPIDER_MODULES": [module]}) spider_loader = SpiderLoader.from_settings(settings) assert len(spider_loader._spiders) == 1 def test_load_spider_module_multiple(self): prefix = "tests.test_spiderloader.test_spiders." module = ",".join(prefix + s for s in ("spider1", "spider2")) settings = Settings({"SPIDER_MODULES": module}) spider_loader = SpiderLoader.from_settings(settings) assert len(spider_loader._spiders) == 2 def test_load_base_spider(self): module = "tests.test_spiderloader.test_spiders.spider0" settings = Settings({"SPIDER_MODULES": [module]}) spider_loader = SpiderLoader.from_settings(settings) assert len(spider_loader._spiders) == 0 def test_load_spider_module_from_addons(self): module = "tests.test_spiderloader.spiders_from_addons.spider0" class SpiderModuleAddon: @classmethod def update_pre_crawler_settings(cls, settings): settings.set( "SPIDER_MODULES", [module], "project", ) runner = CrawlerRunner({"ADDONS": {SpiderModuleAddon: 1}}) crawler = runner.create_crawler("spider_from_addon") assert issubclass(crawler.spidercls, scrapy.Spider) assert crawler.spidercls.name == "spider_from_addon" assert len(crawler.settings["SPIDER_MODULES"]) == 1 def test_crawler_runner_loading(self): module = "tests.test_spiderloader.test_spiders.spider1" runner = CrawlerRunner( { "SPIDER_MODULES": [module], } ) with pytest.raises(KeyError, match="Spider not found"): runner.create_crawler("spider2") crawler = runner.create_crawler("spider1") assert issubclass(crawler.spidercls, scrapy.Spider) assert crawler.spidercls.name == "spider1" def test_bad_spider_modules_exception(self): module = "tests.test_spiderloader.test_spiders.doesnotexist" settings = Settings({"SPIDER_MODULES": [module]}) with pytest.raises(ImportError): SpiderLoader.from_settings(settings) def test_bad_spider_modules_warning(self): module = "tests.test_spiderloader.test_spiders.doesnotexist" settings = Settings( {"SPIDER_MODULES": [module], "SPIDER_LOADER_WARN_ONLY": True} ) with pytest.warns(RuntimeWarning, match="Could not load spiders from module"): spider_loader = SpiderLoader.from_settings(settings) spiders = spider_loader.list() assert not spiders def test_syntax_error_exception(self): module = "tests.test_spiderloader.test_spiders.spider1" settings = Settings({"SPIDER_MODULES": [module]}) with mock.patch.object(SpiderLoader, "_load_spiders") as m: m.side_effect = SyntaxError with pytest.raises(SyntaxError): SpiderLoader.from_settings(settings) def test_syntax_error_warning(self): module = "tests.test_spiderloader.test_spiders.spider1" settings = Settings( {"SPIDER_MODULES": [module], "SPIDER_LOADER_WARN_ONLY": True} ) with mock.patch.object(SpiderLoader, "_load_spiders") as m: m.side_effect = SyntaxError with pytest.warns( RuntimeWarning, match="Could not load spiders from module" ): spider_loader = SpiderLoader.from_settings(settings) spiders = spider_loader.list() assert not spiders class TestDuplicateSpiderNameLoader: def test_dupename_warning(self, spider_loader_env): settings, spiders_dir = spider_loader_env # copy 1 spider module so as to have duplicate spider name shutil.copyfile(spiders_dir / "spider3.py", spiders_dir / "spider3dupe.py") msg = r"""There are several spiders with the same name: Spider3 named 'spider3' \(in test_spiders_xxx\.spider3\) Spider3 named 'spider3' \(in test_spiders_xxx\.spider3dupe\) This can cause unexpected behavior\.""" with pytest.warns(UserWarning, match=msg): spider_loader = SpiderLoader.from_settings(settings) spiders = set(spider_loader.list()) assert spiders == {"spider1", "spider2", "spider3", "spider4"} def test_multiple_dupename_warning(self, spider_loader_env): settings, spiders_dir = spider_loader_env # copy 2 spider modules so as to have duplicate spider name shutil.copyfile(spiders_dir / "spider1.py", spiders_dir / "spider1dupe.py") shutil.copyfile(spiders_dir / "spider2.py", spiders_dir / "spider2dupe.py") msg = r"""There are several spiders with the same name: Spider1 named 'spider1' \(in test_spiders_xxx\.spider1\) Spider1 named 'spider1' \(in test_spiders_xxx\.spider1dupe\) Spider2 named 'spider2' \(in test_spiders_xxx\.spider2\) Spider2 named 'spider2' \(in test_spiders_xxx\.spider2dupe\) This can cause unexpected behavior\.""" with pytest.warns(UserWarning, match=msg): spider_loader = SpiderLoader.from_settings(settings) spiders = set(spider_loader.list()) assert spiders == {"spider1", "spider2", "spider3", "spider4"} class CustomSpiderLoader(SpiderLoader): pass def test_custom_spider_loader(): settings = Settings( { "SPIDER_LOADER_CLASS": CustomSpiderLoader, } ) spider_loader = get_spider_loader(settings) assert isinstance(spider_loader, CustomSpiderLoader) def test_dummy_spider_loader(spider_loader_env): settings, _ = spider_loader_env spider_loader = DummySpiderLoader.from_settings(settings) assert not spider_loader.list() with pytest.raises(KeyError): spider_loader.load("spider1") assert not spider_loader.find_by_request(Request("http://example.com")) scrapy-scrapy-5569de3/tests/test_spiderloader/spiders_from_addons/000077500000000000000000000000001525051012400255555ustar00rootroot00000000000000scrapy-scrapy-5569de3/tests/test_spiderloader/spiders_from_addons/__init__.py000066400000000000000000000000001525051012400276540ustar00rootroot00000000000000scrapy-scrapy-5569de3/tests/test_spiderloader/spiders_from_addons/spider0.py000066400000000000000000000002271525051012400274760ustar00rootroot00000000000000from scrapy.spiders import Spider class SpiderFromAddon(Spider): name = "spider_from_addon" allowed_domains = ["scrapy1.org", "scrapy3.org"] scrapy-scrapy-5569de3/tests/test_spiderloader/test_spiders/000077500000000000000000000000001525051012400242415ustar00rootroot00000000000000scrapy-scrapy-5569de3/tests/test_spiderloader/test_spiders/__init__.py000066400000000000000000000000001525051012400263400ustar00rootroot00000000000000scrapy-scrapy-5569de3/tests/test_spiderloader/test_spiders/nested/000077500000000000000000000000001525051012400255235ustar00rootroot00000000000000scrapy-scrapy-5569de3/tests/test_spiderloader/test_spiders/nested/__init__.py000066400000000000000000000000001525051012400276220ustar00rootroot00000000000000scrapy-scrapy-5569de3/tests/test_spiderloader/test_spiders/nested/spider4.py000066400000000000000000000003531525051012400274500ustar00rootroot00000000000000from scrapy.spiders import Spider class Spider4(Spider): name = "spider4" allowed_domains = ["spider4.com"] @classmethod def handles_request(cls, request): return request.url == "http://spider4.com/onlythis" scrapy-scrapy-5569de3/tests/test_spiderloader/test_spiders/spider0.py000066400000000000000000000001601525051012400261560ustar00rootroot00000000000000from scrapy.spiders import Spider class Spider0(Spider): allowed_domains = ["scrapy1.org", "scrapy3.org"] scrapy-scrapy-5569de3/tests/test_spiderloader/test_spiders/spider1.py000066400000000000000000000002051525051012400261570ustar00rootroot00000000000000from scrapy.spiders import Spider class Spider1(Spider): name = "spider1" allowed_domains = ["scrapy1.org", "scrapy3.org"] scrapy-scrapy-5569de3/tests/test_spiderloader/test_spiders/spider2.py000066400000000000000000000002051525051012400261600ustar00rootroot00000000000000from scrapy.spiders import Spider class Spider2(Spider): name = "spider2" allowed_domains = ["scrapy2.org", "scrapy3.org"] scrapy-scrapy-5569de3/tests/test_spiderloader/test_spiders/spider3.py000066400000000000000000000003531525051012400261650ustar00rootroot00000000000000from scrapy.spiders import Spider class Spider3(Spider): name = "spider3" allowed_domains = ["spider3.com"] @classmethod def handles_request(cls, request): return request.url == "http://spider3.com/onlythis" scrapy-scrapy-5569de3/tests/test_spidermiddleware.py000066400000000000000000000437501525051012400227630ustar00rootroot00000000000000from __future__ import annotations from collections.abc import AsyncIterator, Iterable from inspect import isasyncgen from typing import TYPE_CHECKING, Any, cast from unittest import mock import pytest from twisted.internet import defer from scrapy.core.spidermw import SpiderMiddlewareManager from scrapy.exceptions import ScrapyDeprecationWarning, _InvalidOutput from scrapy.http import Request, Response from scrapy.spiders import Spider from scrapy.utils.asyncgen import collect_asyncgen from scrapy.utils.asyncio import call_later from scrapy.utils.defer import maybe_deferred_to_future from scrapy.utils.misc import build_from_crawler from scrapy.utils.spider import DefaultSpider from scrapy.utils.test import get_crawler from tests.utils.decorators import coroutine_test if TYPE_CHECKING: from twisted.python.failure import Failure from scrapy.crawler import Crawler class TestSpiderMiddleware: def setup_method(self) -> None: self.request = Request("http://example.com/index.html") self.response = Response(self.request.url, request=self.request) self.crawler = get_crawler(Spider, {"SPIDER_MIDDLEWARES_BASE": {}}) self.crawler.spider = self.crawler._create_spider("foo") self.mwman = build_from_crawler(SpiderMiddlewareManager, self.crawler) async def _scrape_response(self) -> Any: """Execute spider mw manager's scrape_response_async method and return the result. Raise exception in case of failure. """ async def scrape_func( response: Response | Failure, request: Request ) -> Iterable[Any]: return mock.MagicMock() return await self.mwman.scrape_response_async( scrape_func, self.response, self.request ) class TestProcessSpiderInputInvalidOutput(TestSpiderMiddleware): """Invalid return value for process_spider_input method""" @coroutine_test async def test_invalid_process_spider_input(self): class InvalidProcessSpiderInputMiddleware: def process_spider_input(self, response): return 1 self.mwman._add_middleware(InvalidProcessSpiderInputMiddleware()) with pytest.raises(_InvalidOutput): await self._scrape_response() class TestProcessSpiderExceptionInvalidOutput(TestSpiderMiddleware): """Invalid return value for process_spider_exception method""" @coroutine_test async def test_invalid_process_spider_exception(self): class InvalidProcessSpiderOutputExceptionMiddleware: def process_spider_exception(self, response, exception): return 1 class RaiseExceptionProcessSpiderOutputMiddleware: async def process_spider_output(self, response, result): raise RuntimeError yield # pylint: disable=unreachable self.mwman._add_middleware(InvalidProcessSpiderOutputExceptionMiddleware()) self.mwman._add_middleware(RaiseExceptionProcessSpiderOutputMiddleware()) it = await self._scrape_response() with pytest.raises(_InvalidOutput): await collect_asyncgen(it) class TestProcessSpiderExceptionReRaise(TestSpiderMiddleware): """Re raise the exception by returning None""" @coroutine_test async def test_process_spider_exception_return_none(self): class ProcessSpiderExceptionReturnNoneMiddleware: def process_spider_exception(self, response, exception): return None class RaiseExceptionProcessSpiderOutputMiddleware: async def process_spider_output(self, response, result): 1 / 0 yield self.mwman._add_middleware(ProcessSpiderExceptionReturnNoneMiddleware()) self.mwman._add_middleware(RaiseExceptionProcessSpiderOutputMiddleware()) it = await self._scrape_response() with pytest.raises(ZeroDivisionError): await collect_asyncgen(it) class TestBaseAsyncSpiderMiddleware(TestSpiderMiddleware): """Helpers for testing sync, async and mixed middlewares. Should work for process_spider_output and, when it's supported, process_start. """ ITEM_TYPE: type | tuple[type, ...] RESULT_COUNT = 3 # to simplify checks, let everything return 3 objects @staticmethod def _construct_mw_setting( *mw_classes: type[Any], start_index: int | None = None ) -> dict[type[Any], int]: if start_index is None: start_index = 10 return {i: c for c, i in enumerate(mw_classes, start=start_index)} def _callback(self) -> Any: yield {"foo": 1} yield {"foo": 2} yield {"foo": 3} async def _scrape_func( self, response: Response | Failure, request: Request ) -> Iterable[Any] | AsyncIterator[Any]: return cast("Iterable[Any] | AsyncIterator[Any]", self._callback()) async def _get_middleware_result( self, *mw_classes: type[Any], start_index: int | None = None ) -> Any: setting = self._construct_mw_setting(*mw_classes, start_index=start_index) self.crawler = get_crawler( Spider, {"SPIDER_MIDDLEWARES_BASE": {}, "SPIDER_MIDDLEWARES": setting} ) self.crawler.spider = self.crawler._create_spider("foo") self.mwman = build_from_crawler(SpiderMiddlewareManager, self.crawler) return await self.mwman.scrape_response_async( self._scrape_func, self.response, self.request ) async def _test_asyncgen_base( self, *mw_classes: type[Any], start_index: int | None = None, ) -> None: result = await self._get_middleware_result(*mw_classes, start_index=start_index) assert isinstance(result, AsyncIterator) result_list = await collect_asyncgen(result) assert len(result_list) == self.RESULT_COUNT assert isinstance(result_list[0], self.ITEM_TYPE) class ProcessSpiderOutputSyncMiddleware: def process_spider_output(self, response, result): yield from result class ProcessSpiderOutputAsyncGenMiddleware: async def process_spider_output(self, response, result): async for r in result: yield r class ProcessSpiderOutputUniversalMiddleware: def process_spider_output(self, response, result): yield from result async def process_spider_output_async(self, response, result): async for r in result: yield r class ProcessSpiderExceptionSimpleIterableMiddleware: def process_spider_exception(self, response, exception): yield {"foo": 1} yield {"foo": 2} yield {"foo": 3} class ProcessSpiderExceptionAsyncIteratorMiddleware: async def process_spider_exception(self, response, exception): yield {"foo": 1} d: defer.Deferred[None] = defer.Deferred() call_later(0, d.callback, None) await maybe_deferred_to_future(d) yield {"foo": 2} yield {"foo": 3} class TestProcessSpiderOutputSimple(TestBaseAsyncSpiderMiddleware): """process_spider_output tests for simple callbacks""" ITEM_TYPE = dict MW_SYNC = ProcessSpiderOutputSyncMiddleware MW_ASYNCGEN = ProcessSpiderOutputAsyncGenMiddleware MW_UNIVERSAL = ProcessSpiderOutputUniversalMiddleware @coroutine_test async def test_sync(self): """Unsupported sync mw""" with pytest.raises( TypeError, match=r"doesn't support asynchronous spider output" ): await self._get_middleware_result(self.MW_SYNC) @coroutine_test async def test_asyncgen(self): """Asyncgen mw""" await self._test_asyncgen_base(self.MW_ASYNCGEN) @coroutine_test async def test_universal(self): """Universal mw""" await self._test_asyncgen_base(self.MW_UNIVERSAL) @coroutine_test async def test_universal_asyncgen(self): """Universal mw -> asyncgen mw""" await self._test_asyncgen_base(self.MW_ASYNCGEN, self.MW_UNIVERSAL) @coroutine_test async def test_asyncgen_universal(self): """Asyncgen mw -> universal mw""" await self._test_asyncgen_base(self.MW_UNIVERSAL, self.MW_ASYNCGEN) class TestProcessSpiderOutputAsyncGen(TestProcessSpiderOutputSimple): """process_spider_output tests for async generator callbacks""" async def _callback(self) -> Any: for item in super()._callback(): yield item class ProcessStartSimpleMiddleware: async def process_start(self, start): async for item_or_request in start: yield item_or_request class TestProcessStartSimple(TestBaseAsyncSpiderMiddleware): """process_start tests for simple start""" ITEM_TYPE = (Request, dict) MW_SIMPLE = ProcessStartSimpleMiddleware async def _get_processed_start( self, *mw_classes: type[Any] ) -> AsyncIterator[Any] | None: class TestSpider(Spider): name = "test" async def start(self): for i in range(2): yield Request(f"https://example.com/{i}", dont_filter=True) yield {"name": "test item"} setting = self._construct_mw_setting(*mw_classes) self.crawler = get_crawler( TestSpider, {"SPIDER_MIDDLEWARES_BASE": {}, "SPIDER_MIDDLEWARES": setting} ) self.crawler.spider = self.crawler._create_spider() self.mwman = build_from_crawler(SpiderMiddlewareManager, self.crawler) return await self.mwman.process_start() @coroutine_test async def test_simple(self): """Simple mw""" start = await self._get_processed_start(self.MW_SIMPLE) assert isasyncgen(start) start_list = await collect_asyncgen(start) assert len(start_list) == self.RESULT_COUNT assert isinstance(start_list[0], self.ITEM_TYPE) class UniversalMiddlewareNoSync: async def process_spider_output_async(self, response, result): yield class UniversalMiddlewareBothSync: def process_spider_output(self, response, result): yield def process_spider_output_async(self, response, result): yield class UniversalMiddlewareBothAsync: async def process_spider_output(self, response, result): yield async def process_spider_output_async(self, response, result): yield class TestUniversalMiddlewareManager: @pytest.fixture def crawler(self) -> Crawler: return get_crawler(Spider) @pytest.fixture def mwman(self, crawler: Crawler) -> SpiderMiddlewareManager: return build_from_crawler(SpiderMiddlewareManager, crawler) def test_simple_mw(self, mwman: SpiderMiddlewareManager) -> None: mw = ProcessSpiderOutputSyncMiddleware() with pytest.raises( TypeError, match=r"doesn't support asynchronous spider output" ): mwman._add_middleware(mw) def test_async_mw(self, mwman: SpiderMiddlewareManager) -> None: mw = ProcessSpiderOutputAsyncGenMiddleware() mwman._add_middleware(mw) assert ( mwman.methods["process_spider_output"][0] == mw.process_spider_output # pylint: disable=comparison-with-callable ) def test_universal_mw(self, mwman: SpiderMiddlewareManager) -> None: mw = ProcessSpiderOutputUniversalMiddleware() mwman._add_middleware(mw) assert ( mwman.methods["process_spider_output"][0] == mw.process_spider_output_async # pylint: disable=comparison-with-callable ) def test_universal_mw_no_sync( self, mwman: SpiderMiddlewareManager, caplog: pytest.LogCaptureFixture ) -> None: mwman._add_middleware(UniversalMiddlewareNoSync()) assert ( "UniversalMiddlewareNoSync has process_spider_output_async()" " without process_spider_output" in caplog.text ) assert mwman.methods["process_spider_output"][0] is None def test_universal_mw_both_sync( self, mwman: SpiderMiddlewareManager, caplog: pytest.LogCaptureFixture ) -> None: mw = UniversalMiddlewareBothSync() mwman._add_middleware(mw) assert ( "UniversalMiddlewareBothSync.process_spider_output_async " "is not an async generator function" in caplog.text ) assert ( mwman.methods["process_spider_output"][0] == mw.process_spider_output # pylint: disable=comparison-with-callable ) def test_universal_mw_both_async( self, mwman: SpiderMiddlewareManager, caplog: pytest.LogCaptureFixture ) -> None: mwman._add_middleware(UniversalMiddlewareBothAsync()) assert ( "UniversalMiddlewareBothAsync.process_spider_output " "is an async generator function while process_spider_output_async() exists" in caplog.text ) assert mwman.methods["process_spider_output"][0] is None class TestBuiltinMiddlewareSimple(TestBaseAsyncSpiderMiddleware): ITEM_TYPE = dict MW_ASYNCGEN = ProcessSpiderOutputAsyncGenMiddleware MW_UNIVERSAL = ProcessSpiderOutputUniversalMiddleware async def _get_middleware_result( self, *mw_classes: type[Any], start_index: int | None = None ) -> Any: setting = self._construct_mw_setting(*mw_classes, start_index=start_index) self.crawler = get_crawler(Spider, {"SPIDER_MIDDLEWARES": setting}) self.crawler.spider = self.crawler._create_spider("foo") self.mwman = build_from_crawler(SpiderMiddlewareManager, self.crawler) return await self.mwman.scrape_response_async( self._scrape_func, self.response, self.request ) @coroutine_test async def test_just_builtin(self): await self._test_asyncgen_base() @coroutine_test async def test_builtin_async(self): """Upgrade""" await self._test_asyncgen_base(self.MW_ASYNCGEN, start_index=1000) @coroutine_test async def test_builtin_universal(self): await self._test_asyncgen_base(self.MW_UNIVERSAL, start_index=1000) @coroutine_test async def test_async_builtin(self): """Upgrade""" await self._test_asyncgen_base(self.MW_ASYNCGEN) @coroutine_test async def test_universal_builtin(self): await self._test_asyncgen_base(self.MW_UNIVERSAL) class TestBuiltinMiddlewareAsyncGen(TestBuiltinMiddlewareSimple): async def _callback(self) -> Any: for item in super()._callback(): yield item class TestProcessSpiderException(TestBaseAsyncSpiderMiddleware): ITEM_TYPE = dict MW_ASYNCGEN = ProcessSpiderOutputAsyncGenMiddleware MW_EXC_SIMPLE = ProcessSpiderExceptionSimpleIterableMiddleware MW_EXC_ASYNCGEN = ProcessSpiderExceptionAsyncIteratorMiddleware def _callback(self) -> Any: 1 / 0 @coroutine_test async def test_exc_simple(self): """Simple exc mw""" await self._test_asyncgen_base(self.MW_EXC_SIMPLE) @coroutine_test async def test_exc_async(self): """Async exc mw""" await self._test_asyncgen_base(self.MW_EXC_ASYNCGEN) @coroutine_test async def test_exc_async_async(self): """Async exc mw -> async output mw""" await self._test_asyncgen_base(self.MW_ASYNCGEN, self.MW_EXC_ASYNCGEN) @coroutine_test async def test_exc_simple_async(self): """Simple exc mw -> async output mw; upgrade""" await self._test_asyncgen_base(self.MW_ASYNCGEN, self.MW_EXC_SIMPLE) class TestDeprecatedSpiderArg(TestSpiderMiddleware): @coroutine_test async def test_deprecated_mw_spider_arg(self): class DeprecatedSpiderArgMiddleware1: def process_spider_input(self, response, spider): return None async def process_spider_output(self, response, result, spider): 1 / 0 yield class DeprecatedSpiderArgMiddleware2: def process_spider_exception(self, response, exception, spider): return [] with pytest.warns( ScrapyDeprecationWarning, match=r"process_spider_exception\(\) requires a spider argument", ): self.mwman._add_middleware(DeprecatedSpiderArgMiddleware2()) with ( pytest.warns( ScrapyDeprecationWarning, match=r"process_spider_input\(\) requires a spider argument", ), pytest.warns( ScrapyDeprecationWarning, match=r"process_spider_output\(\) requires a spider argument", ), ): self.mwman._add_middleware(DeprecatedSpiderArgMiddleware1()) it = await self._scrape_response() await collect_asyncgen(it) @coroutine_test async def test_deprecated_mwman_spider_arg(self): with pytest.warns( ScrapyDeprecationWarning, match=r"Passing a spider argument to SpiderMiddlewareManager.process_start\(\)" r" is deprecated and the passed value is ignored", ): await self.mwman.process_start(DefaultSpider()) @coroutine_test async def test_deprecated_mwman_spider_arg_no_crawler(self): with pytest.warns( ScrapyDeprecationWarning, match=r"MiddlewareManager.__init__\(\) was called without the crawler argument", ): mwman = SpiderMiddlewareManager() with pytest.warns( ScrapyDeprecationWarning, match=r"Passing a spider argument to SpiderMiddlewareManager.process_start\(\)" r" is deprecated, SpiderMiddlewareManager should be instantiated with a Crawler", ): await mwman.process_start(DefaultSpider()) @coroutine_test async def test_scrape_response_no_crawler(self): with pytest.warns( ScrapyDeprecationWarning, match=r"MiddlewareManager.__init__\(\) was called without the crawler argument", ): mwman = SpiderMiddlewareManager() async def scrape_func( response: Response | Failure, request: Request ) -> Iterable[Any]: return [] with pytest.raises(RuntimeError, match="created without a crawler"): await mwman.scrape_response_async(scrape_func, self.response, self.request) scrapy-scrapy-5569de3/tests/test_spidermiddleware_base.py000066400000000000000000000112271525051012400237470ustar00rootroot00000000000000from __future__ import annotations from typing import TYPE_CHECKING, Any import pytest from scrapy import Request, Spider from scrapy.http import Response from scrapy.spidermiddlewares.base import BaseSpiderMiddleware from scrapy.utils.asyncgen import as_async_generator, collect_asyncgen from scrapy.utils.misc import build_from_crawler from scrapy.utils.test import get_crawler from tests.utils.decorators import coroutine_test if TYPE_CHECKING: from scrapy.crawler import Crawler @pytest.fixture def crawler() -> Crawler: return get_crawler(Spider) @coroutine_test async def test_trivial(crawler: Crawler) -> None: class TrivialSpiderMiddleware(BaseSpiderMiddleware): pass mw = build_from_crawler(TrivialSpiderMiddleware, crawler) assert hasattr(mw, "crawler") assert mw.crawler is crawler test_req = Request("data:,") spider_output = [test_req, {"foo": "bar"}] for processed in [ list(mw.process_spider_output(Response("data:,"), spider_output)), await collect_asyncgen(mw.process_start(as_async_generator(spider_output))), ]: assert processed == [test_req, {"foo": "bar"}] @coroutine_test async def test_processed_request(crawler: Crawler) -> None: class ProcessReqSpiderMiddleware(BaseSpiderMiddleware): def get_processed_request( self, request: Request, response: Response | None ) -> Request | None: if request.url == "data:2,": return None if request.url == "data:3,": return Request("data:30,") return request mw = build_from_crawler(ProcessReqSpiderMiddleware, crawler) test_req1 = Request("data:1,") test_req2 = Request("data:2,") test_req3 = Request("data:3,") spider_output = [test_req1, {"foo": "bar"}, test_req2, test_req3] for processed in [ list(mw.process_spider_output(Response("data:,"), spider_output)), await collect_asyncgen( mw.process_spider_output_async( Response("data:,"), as_async_generator(spider_output) ) ), await collect_asyncgen(mw.process_start(as_async_generator(spider_output))), ]: assert len(processed) == 3 assert isinstance(processed[0], Request) assert processed[0].url == "data:1," assert processed[1] == {"foo": "bar"} assert isinstance(processed[2], Request) assert processed[2].url == "data:30," @coroutine_test async def test_processed_item(crawler: Crawler) -> None: class ProcessItemSpiderMiddleware(BaseSpiderMiddleware): def get_processed_item(self, item: Any, response: Response | None) -> Any: if item["foo"] == 2: return None if item["foo"] == 3: item["foo"] = 30 return item mw = build_from_crawler(ProcessItemSpiderMiddleware, crawler) test_req = Request("data:,") spider_output = [{"foo": 1}, {"foo": 2}, test_req, {"foo": 3}] for processed in [ list(mw.process_spider_output(Response("data:,"), spider_output)), await collect_asyncgen(mw.process_start(as_async_generator(spider_output))), ]: assert processed == [{"foo": 1}, test_req, {"foo": 30}] @coroutine_test async def test_processed_both(crawler: Crawler) -> None: class ProcessBothSpiderMiddleware(BaseSpiderMiddleware): def get_processed_request( self, request: Request, response: Response | None ) -> Request | None: if request.url == "data:2,": return None if request.url == "data:3,": return Request("data:30,") return request def get_processed_item(self, item: Any, response: Response | None) -> Any: if item["foo"] == 2: return None if item["foo"] == 3: item["foo"] = 30 return item mw = build_from_crawler(ProcessBothSpiderMiddleware, crawler) test_req1 = Request("data:1,") test_req2 = Request("data:2,") test_req3 = Request("data:3,") spider_output = [ test_req1, {"foo": 1}, {"foo": 2}, test_req2, {"foo": 3}, test_req3, ] for processed in [ list(mw.process_spider_output(Response("data:,"), spider_output)), await collect_asyncgen(mw.process_start(as_async_generator(spider_output))), ]: assert len(processed) == 4 assert isinstance(processed[0], Request) assert processed[0].url == "data:1," assert processed[1] == {"foo": 1} assert processed[2] == {"foo": 30} assert isinstance(processed[3], Request) assert processed[3].url == "data:30," scrapy-scrapy-5569de3/tests/test_spidermiddleware_depth.py000066400000000000000000000104411525051012400241360ustar00rootroot00000000000000from __future__ import annotations from typing import TYPE_CHECKING import pytest from scrapy.http import Request, Response from scrapy.spidermiddlewares.depth import DepthMiddleware from scrapy.spiders import Spider from scrapy.utils.asyncgen import as_async_generator, collect_asyncgen from scrapy.utils.misc import build_from_crawler from scrapy.utils.test import get_crawler from tests.utils.decorators import coroutine_test if TYPE_CHECKING: from collections.abc import Generator from scrapy.crawler import Crawler from scrapy.statscollectors import StatsCollector @pytest.fixture def crawler() -> Crawler: return get_crawler(Spider, {"DEPTH_LIMIT": 1, "DEPTH_STATS_VERBOSE": True}) @pytest.fixture def stats(crawler: Crawler) -> Generator[StatsCollector]: crawler.stats.open_spider() yield crawler.stats crawler.stats.close_spider() @pytest.fixture def mw(crawler: Crawler) -> DepthMiddleware: return build_from_crawler(DepthMiddleware, crawler) def test_process_spider_output(mw: DepthMiddleware, stats: StatsCollector) -> None: req = Request("http://scrapytest.org") resp = Response("http://scrapytest.org") resp.request = req result = [Request("http://scrapytest.org")] out = list(mw.process_spider_output(resp, result)) assert out == result rdc = stats.get_value("request_depth_count/1") assert rdc == 1 req.meta["depth"] = 1 out2 = list(mw.process_spider_output(resp, result)) assert not out2 rdm = stats.get_value("request_depth_max") assert rdm == 1 def test_depth_reset(mw: DepthMiddleware, stats: StatsCollector) -> None: resp = Response("https://example.com") resp.request = Request("https://example.com", meta={"depth": 5}) result = [Request("https://example.com", meta={"depth_reset": True})] out = list(mw.process_spider_output(resp, result)) assert out == result assert out[0].meta["depth"] == 0 assert "depth_reset" not in out[0].meta assert stats.get_value("request_depth_count/0") == 1 def test_process_spider_output_no_response( mw: DepthMiddleware, stats: StatsCollector ) -> None: result = [Request("https://example.com")] out = list(mw.process_spider_output(None, result)) assert out == result assert "depth" not in out[0].meta assert stats.get_value("request_depth_count/0") is None @coroutine_test async def test_process_spider_output_async_no_response( mw: DepthMiddleware, stats: StatsCollector ) -> None: result = [Request("https://example.com")] out = await collect_asyncgen( mw.process_spider_output_async(None, as_async_generator(result)) ) assert out == result assert "depth" not in out[0].meta assert stats.get_value("request_depth_count/0") is None def test_ignored_logged_once( mw: DepthMiddleware, stats: StatsCollector, caplog: pytest.LogCaptureFixture ) -> None: resp = Response("http://example.com") resp.request = Request("http://example.com") resp.meta["depth"] = 1 result = [Request(f"http://example.com/{i}") for i in range(3)] with caplog.at_level("DEBUG", logger="scrapy.spidermiddlewares.depth"): assert not list(mw.process_spider_output(resp, result)) messages = [r.getMessage() for r in caplog.records] assert len(messages) == 1 assert "http://example.com/0" in messages[0] assert stats.get_value("depth/request_ignored_count") == 3 def test_priority_and_non_verbose_stats() -> None: crawler = get_crawler( Spider, {"DEPTH_LIMIT": 0, "DEPTH_STATS_VERBOSE": False, "DEPTH_PRIORITY": 10}, ) crawler.stats.open_spider() try: mw = build_from_crawler(DepthMiddleware, crawler) resp = Response("http://toscrape.com") resp.request = Request("http://toscrape.com") resp.request.meta["depth"] = 2 out = list(mw.process_spider_output(resp, [Request("http://toscrape.com")])) assert len(out) == 1 # priority is decremented by depth * DEPTH_PRIORITY assert out[0].priority == -30 assert out[0].meta["depth"] == 3 # non-verbose stats don't track per-depth counts but still track the max assert crawler.stats.get_value("request_depth_count/3") is None assert crawler.stats.get_value("request_depth_max") == 3 finally: crawler.stats.close_spider() scrapy-scrapy-5569de3/tests/test_spidermiddleware_httperror.py000066400000000000000000000221701525051012400250650ustar00rootroot00000000000000from __future__ import annotations import logging from typing import TYPE_CHECKING import pytest from scrapy.http import Request, Response from scrapy.spidermiddlewares.httperror import HttpError, HttpErrorMiddleware from scrapy.utils.misc import build_from_crawler from scrapy.utils.spider import DefaultSpider from scrapy.utils.test import get_crawler from tests.spiders import MockServerSpider from tests.utils.decorators import coroutine_test if TYPE_CHECKING: from tests.mockserver.http import MockServer class _HttpErrorSpider(MockServerSpider): name = "httperror" bypass_status_codes: set[int] = set() def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) assert self.mockserver self.start_urls = [ self.mockserver.url("/status?n=200"), self.mockserver.url("/status?n=404"), self.mockserver.url("/status?n=402"), self.mockserver.url("/status?n=500"), ] self.failed = set() self.skipped = set() self.parsed = set() async def start(self): for url in self.start_urls: yield Request(url, self.parse, errback=self.on_error) def parse(self, response: Response) -> None: self.parsed.add(response.url[-3:]) def on_error(self, failure): if isinstance(failure.value, HttpError): response = failure.value.response if response.status in self.bypass_status_codes: self.skipped.add(response.url[-3:]) return self.parse(response) # it assumes there is a response attached to failure self.failed.add(failure.value.response.url[-3:]) return failure req = Request("http://scrapytest.org") def _response(request: Request, status_code: int) -> Response: return Response(request.url, status=status_code, request=request) @pytest.fixture def res200() -> Response: return _response(req, 200) @pytest.fixture def res402() -> Response: return _response(req, 402) @pytest.fixture def res404() -> Response: return _response(req, 404) class TestHttpErrorMiddleware: @pytest.fixture def mw(self) -> HttpErrorMiddleware: crawler = get_crawler(DefaultSpider) crawler.spider = crawler._create_spider() return build_from_crawler(HttpErrorMiddleware, crawler) def test_process_spider_input( self, mw: HttpErrorMiddleware, res200: Response, res404: Response ) -> None: mw.process_spider_input(res200) with pytest.raises(HttpError): mw.process_spider_input(res404) def test_process_spider_exception( self, mw: HttpErrorMiddleware, res404: Response ) -> None: assert mw.process_spider_exception(res404, HttpError(res404)) == () assert mw.process_spider_exception(res404, Exception()) is None def test_handle_httpstatus_list( self, mw: HttpErrorMiddleware, res404: Response ) -> None: request = Request( "http://scrapytest.org", meta={"handle_httpstatus_list": [404]} ) res = _response(request, 404) mw.process_spider_input(res) assert mw.crawler.spider mw.crawler.spider.handle_httpstatus_list = [404] # type: ignore[attr-defined] mw.process_spider_input(res404) class TestHttpErrorMiddlewareSettings: """Similar test, but with settings""" @pytest.fixture def mw(self) -> HttpErrorMiddleware: crawler = get_crawler(DefaultSpider, {"HTTPERROR_ALLOWED_CODES": (402,)}) crawler.spider = crawler._create_spider() return build_from_crawler(HttpErrorMiddleware, crawler) def test_process_spider_input( self, mw: HttpErrorMiddleware, res200: Response, res402: Response, res404: Response, ) -> None: mw.process_spider_input(res200) with pytest.raises(HttpError): mw.process_spider_input(res404) mw.process_spider_input(res402) def test_meta_overrides_settings(self, mw: HttpErrorMiddleware) -> None: request = Request( "http://scrapytest.org", meta={"handle_httpstatus_list": [404]} ) res404 = _response(request, 404) res402 = _response(request, 402) mw.process_spider_input(res404) with pytest.raises(HttpError): mw.process_spider_input(res402) def test_spider_override_settings( self, mw: HttpErrorMiddleware, res402: Response, res404: Response ) -> None: assert mw.crawler.spider mw.crawler.spider.handle_httpstatus_list = [404] # type: ignore[attr-defined] mw.process_spider_input(res404) with pytest.raises(HttpError): mw.process_spider_input(res402) class TestHttpErrorMiddlewareHandleAll: @pytest.fixture def mw(self) -> HttpErrorMiddleware: crawler = get_crawler(DefaultSpider, {"HTTPERROR_ALLOW_ALL": True}) crawler.spider = crawler._create_spider() return build_from_crawler(HttpErrorMiddleware, crawler) def test_process_spider_input( self, mw: HttpErrorMiddleware, res200: Response, res404: Response, ) -> None: mw.process_spider_input(res200) mw.process_spider_input(res404) def test_meta_overrides_settings(self, mw: HttpErrorMiddleware) -> None: request = Request( "http://scrapytest.org", meta={"handle_httpstatus_list": [404]} ) res404 = _response(request, 404) res402 = _response(request, 402) mw.process_spider_input(res404) with pytest.raises(HttpError): mw.process_spider_input(res402) def test_httperror_allow_all_false(self) -> None: crawler = get_crawler(_HttpErrorSpider) mw = build_from_crawler(HttpErrorMiddleware, crawler) request_httpstatus_false = Request( "http://scrapytest.org", meta={"handle_httpstatus_all": False} ) request_httpstatus_true = Request( "http://scrapytest.org", meta={"handle_httpstatus_all": True} ) res404 = _response(request_httpstatus_false, 404) res402 = _response(request_httpstatus_true, 402) with pytest.raises(HttpError): mw.process_spider_input(res404) mw.process_spider_input(res402) class TestHttpErrorMiddlewareIntegrational: @coroutine_test async def test_middleware_works(self, mockserver: MockServer) -> None: crawler = get_crawler(_HttpErrorSpider) await crawler.crawl_async(mockserver=mockserver) assert isinstance(crawler.spider, _HttpErrorSpider) assert not crawler.spider.skipped assert crawler.spider.parsed == {"200"} assert crawler.spider.failed == {"404", "402", "500"} get_value = crawler.stats.get_value assert get_value("httperror/response_ignored_count") == 3 assert get_value("httperror/response_ignored_status_count/404") == 1 assert get_value("httperror/response_ignored_status_count/402") == 1 assert get_value("httperror/response_ignored_status_count/500") == 1 @coroutine_test async def test_logging( self, caplog: pytest.LogCaptureFixture, mockserver: MockServer ) -> None: crawler = get_crawler(_HttpErrorSpider) with caplog.at_level(logging.INFO): await crawler.crawl_async(mockserver=mockserver, bypass_status_codes={402}) assert isinstance(crawler.spider, _HttpErrorSpider) assert crawler.spider.parsed == {"200", "402"} assert crawler.spider.skipped == {"402"} assert crawler.spider.failed == {"404", "500"} assert "Ignoring response <404" in caplog.text assert "Ignoring response <500" in caplog.text assert "Ignoring response <200" not in caplog.text assert "Ignoring response <402" not in caplog.text @coroutine_test async def test_logging_level( self, caplog: pytest.LogCaptureFixture, mockserver: MockServer ) -> None: # HttpError logs ignored responses with level INFO crawler = get_crawler(_HttpErrorSpider) with caplog.at_level(logging.INFO): await crawler.crawl_async(mockserver=mockserver) assert isinstance(crawler.spider, _HttpErrorSpider) assert crawler.spider.parsed == {"200"} assert crawler.spider.failed == {"404", "402", "500"} assert "Ignoring response <402" in caplog.text assert "Ignoring response <404" in caplog.text assert "Ignoring response <500" in caplog.text assert "Ignoring response <200" not in caplog.text # with level WARNING, we shouldn't capture anything from HttpError caplog.clear() crawler = get_crawler(_HttpErrorSpider) with caplog.at_level(logging.WARNING): await crawler.crawl_async(mockserver=mockserver) assert isinstance(crawler.spider, _HttpErrorSpider) assert crawler.spider.parsed == {"200"} assert crawler.spider.failed == {"404", "402", "500"} assert "Ignoring response <402" not in caplog.text assert "Ignoring response <404" not in caplog.text assert "Ignoring response <500" not in caplog.text assert "Ignoring response <200" not in caplog.text scrapy-scrapy-5569de3/tests/test_spidermiddleware_metacopy.py000066400000000000000000000077701525051012400246660ustar00rootroot00000000000000from __future__ import annotations from logging import WARNING from typing import TYPE_CHECKING import pytest from scrapy.http import Request, Response from scrapy.spidermiddlewares.metacopy import MetaCopyDetectionMiddleware from scrapy.spiders import Spider from scrapy.utils.misc import build_from_crawler from scrapy.utils.test import get_crawler if TYPE_CHECKING: from scrapy.crawler import Crawler def make_response(url: str = "https://example.com") -> Response: response = Response(url) response.request = Request(url) return response @pytest.fixture def crawler() -> Crawler: return get_crawler(Spider) @pytest.fixture def mw(crawler: Crawler) -> MetaCopyDetectionMiddleware: return build_from_crawler(MetaCopyDetectionMiddleware, crawler) def process( mw: MetaCopyDetectionMiddleware, requests: list[Request], response: Response | None = None, ) -> list[Request]: if response is None: response = make_response() return list(mw.process_spider_output(response, requests)) def test_no_warning_for_clean_request( mw: MetaCopyDetectionMiddleware, caplog: pytest.LogCaptureFixture ) -> None: req = Request("https://example.com/1", meta={"my_key": "value"}) with caplog.at_level(WARNING): process(mw, [req]) assert not caplog.records def test_warns_on_internal_key( mw: MetaCopyDetectionMiddleware, caplog: pytest.LogCaptureFixture ) -> None: req = Request("https://example.com/1", meta={"retry_times": 1}) with caplog.at_level(WARNING): process(mw, [req]) assert len(caplog.records) == 1 assert "retry_times" in caplog.text assert "https://example.com/1" in caplog.text def test_warns_once_across_multiple_requests( mw: MetaCopyDetectionMiddleware, caplog: pytest.LogCaptureFixture ) -> None: reqs = [ Request("https://example.com/1", meta={"retry_times": 1}), Request("https://example.com/2", meta={"retry_times": 2}), ] with caplog.at_level(WARNING): process(mw, reqs) assert len(caplog.records) == 1 def test_reports_all_found_keys( mw: MetaCopyDetectionMiddleware, caplog: pytest.LogCaptureFixture ) -> None: req = Request( "https://example.com/1", meta={"retry_times": 1, "redirect_times": 2}, ) with caplog.at_level(WARNING): process(mw, [req]) assert "retry_times" in caplog.text assert "redirect_times" in caplog.text def test_includes_source_response_in_message( mw: MetaCopyDetectionMiddleware, caplog: pytest.LogCaptureFixture ) -> None: response = make_response("https://source.example.com") req = Request("https://example.com/1", meta={"retry_times": 1}) with caplog.at_level(WARNING): process(mw, [req], response=response) assert "https://source.example.com" in caplog.text def test_skip_keys_setting(caplog: pytest.LogCaptureFixture) -> None: with caplog.at_level(WARNING): crawler = get_crawler(Spider, {"META_COPY_WARN_SKIP_KEYS": ["retry_times"]}) mw = build_from_crawler(MetaCopyDetectionMiddleware, crawler) req = Request("https://example.com/1", meta={"retry_times": 1}) process(mw, [req]) assert not caplog.records def test_skip_keys_setting_partial(caplog: pytest.LogCaptureFixture) -> None: with caplog.at_level(WARNING): crawler = get_crawler(Spider, {"META_COPY_WARN_SKIP_KEYS": ["retry_times"]}) mw = build_from_crawler(MetaCopyDetectionMiddleware, crawler) req = Request( "https://example.com/1", meta={"retry_times": 1, "redirect_times": 2}, ) process(mw, [req]) assert len(caplog.records) == 1 assert "retry_times" not in caplog.text assert "redirect_times" in caplog.text def test_no_warning_for_start_requests( mw: MetaCopyDetectionMiddleware, caplog: pytest.LogCaptureFixture ) -> None: req = Request("https://example.com/1", meta={"retry_times": 1}) with caplog.at_level(WARNING): list(mw.process_spider_output(None, [req])) assert not caplog.records scrapy-scrapy-5569de3/tests/test_spidermiddleware_output_chain.py000066400000000000000000000426741525051012400255510ustar00rootroot00000000000000from __future__ import annotations import logging from typing import TYPE_CHECKING, Any from scrapy import Request, Spider from scrapy.utils.test import get_crawler from tests.mockserver.http import MockServer from tests.spiders import MockServerSpider from tests.utils.decorators import coroutine_test if TYPE_CHECKING: from collections.abc import AsyncIterator, Iterator import pytest from scrapy.http import Response class _BaseSpiderMiddleware: def __init__(self, crawler): self.crawler = crawler @classmethod def from_crawler(cls, crawler): return cls(crawler) class LogExceptionMiddleware(_BaseSpiderMiddleware): def process_spider_exception(self, response, exception): self.crawler.spider.logger.info( "Middleware: %s exception caught", exception.__class__.__name__ ) # ================================================================================ # (0) recover from an exception on a spider callback class RecoveryMiddleware(_BaseSpiderMiddleware): def process_spider_exception(self, response, exception): self.crawler.spider.logger.info( "Middleware: %s exception caught", exception.__class__.__name__ ) return [ {"from": "process_spider_exception"}, Request(response.url, meta={"dont_fail": True}, dont_filter=True), ] class RecoverySpider(MockServerSpider): name = "RecoverySpider" custom_settings = { "SPIDER_MIDDLEWARES_BASE": {}, "SPIDER_MIDDLEWARES": { RecoveryMiddleware: 10, }, } async def start(self) -> AsyncIterator[Any]: assert self.mockserver yield Request(self.mockserver.url("/status?n=200")) def parse(self, response: Response) -> Iterator[Any]: yield {"test": 1} self.logger.info("DONT_FAIL: %s", response.meta.get("dont_fail")) if not response.meta.get("dont_fail"): raise TabError class RecoveryAsyncGenSpider(RecoverySpider): name = "RecoveryAsyncGenSpider" async def parse(self, response: Response) -> AsyncIterator[Any]: # type: ignore[override] for r in super().parse(response): yield r # ================================================================================ # (1) exceptions from a spider middleware's process_spider_input method class FailProcessSpiderInputMiddleware(_BaseSpiderMiddleware): def process_spider_input(self, response): self.crawler.spider.logger.info("Middleware: will raise IndexError") raise IndexError class ProcessSpiderInputSpiderWithoutErrback(MockServerSpider): name = "ProcessSpiderInputSpiderWithoutErrback" custom_settings = { "SPIDER_MIDDLEWARES": { # spider FailProcessSpiderInputMiddleware: 8, LogExceptionMiddleware: 6, # engine } } async def start(self) -> AsyncIterator[Any]: assert self.mockserver yield Request(url=self.mockserver.url("/status?n=200"), callback=self.parse) def parse(self, response): return {"from": "callback"} class ProcessSpiderInputSpiderWithErrback(ProcessSpiderInputSpiderWithoutErrback): name = "ProcessSpiderInputSpiderWithErrback" async def start(self) -> AsyncIterator[Any]: assert self.mockserver yield Request( self.mockserver.url("/status?n=200"), self.parse, errback=self.errback ) def errback(self, failure): self.logger.info("Got a Failure on the Request errback") return {"from": "errback"} # ================================================================================ # (2) exceptions from a spider callback (generator) class GeneratorCallbackSpider(MockServerSpider): name = "GeneratorCallbackSpider" custom_settings = { "SPIDER_MIDDLEWARES": { LogExceptionMiddleware: 10, }, } async def start(self) -> AsyncIterator[Any]: assert self.mockserver yield Request(self.mockserver.url("/status?n=200")) def parse(self, response): yield {"test": 1} yield {"test": 2} raise ImportError class AsyncGeneratorCallbackSpider(GeneratorCallbackSpider): async def parse(self, response): yield {"test": 1} yield {"test": 2} raise ImportError # ================================================================================ # (2.1) exceptions from a spider callback (generator, middleware right after callback) class GeneratorCallbackSpiderMiddlewareRightAfterSpider(GeneratorCallbackSpider): name = "GeneratorCallbackSpiderMiddlewareRightAfterSpider" custom_settings = { "SPIDER_MIDDLEWARES": { LogExceptionMiddleware: 100000, }, } # ================================================================================ # (3) exceptions from a spider callback (not a generator) class NotGeneratorCallbackSpider(MockServerSpider): name = "NotGeneratorCallbackSpider" custom_settings = { "SPIDER_MIDDLEWARES": { LogExceptionMiddleware: 10, }, } async def start(self) -> AsyncIterator[Any]: assert self.mockserver yield Request(self.mockserver.url("/status?n=200")) def parse(self, response): return [{"test": 1}, {"test": 1 / 0}] # ================================================================================ # (3.1) exceptions from a spider callback (not a generator, middleware right after callback) class NotGeneratorCallbackSpiderMiddlewareRightAfterSpider(NotGeneratorCallbackSpider): name = "NotGeneratorCallbackSpiderMiddlewareRightAfterSpider" custom_settings = { "SPIDER_MIDDLEWARES": { LogExceptionMiddleware: 100000, }, } # ================================================================================ # (4) exceptions from a middleware process_spider_output method (generator) class _GeneratorDoNothingMiddleware(_BaseSpiderMiddleware): async def process_spider_output(self, response, result): async for r in result: r["processed"].append(f"{self.__class__.__name__}.process_spider_output") yield r def process_spider_exception(self, response, exception): method = f"{self.__class__.__name__}.process_spider_exception" self.crawler.spider.logger.info( "%s: %s caught", method, exception.__class__.__name__ ) class GeneratorFailMiddleware(_BaseSpiderMiddleware): async def process_spider_output(self, response, result): async for r in result: r["processed"].append(f"{self.__class__.__name__}.process_spider_output") yield r raise LookupError def process_spider_exception(self, response, exception): method = f"{self.__class__.__name__}.process_spider_exception" self.crawler.spider.logger.info( "%s: %s caught", method, exception.__class__.__name__ ) yield {"processed": [method]} class GeneratorDoNothingAfterFailureMiddleware(_GeneratorDoNothingMiddleware): pass class GeneratorRecoverMiddleware(_BaseSpiderMiddleware): async def process_spider_output(self, response, result): async for r in result: r["processed"].append(f"{self.__class__.__name__}.process_spider_output") yield r def process_spider_exception(self, response, exception): method = f"{self.__class__.__name__}.process_spider_exception" self.crawler.spider.logger.info( "%s: %s caught", method, exception.__class__.__name__ ) yield {"processed": [method]} class GeneratorDoNothingAfterRecoveryMiddleware(_GeneratorDoNothingMiddleware): pass class GeneratorOutputChainSpider(MockServerSpider): name = "GeneratorOutputChainSpider" custom_settings = { "SPIDER_MIDDLEWARES": { GeneratorFailMiddleware: 10, GeneratorDoNothingAfterFailureMiddleware: 8, GeneratorRecoverMiddleware: 5, GeneratorDoNothingAfterRecoveryMiddleware: 3, }, } async def start(self) -> AsyncIterator[Any]: assert self.mockserver yield Request(self.mockserver.url("/status?n=200")) def parse(self, response): yield {"processed": ["parse-first-item"]} yield {"processed": ["parse-second-item"]} # ================================================================================ # (5) an exception from a spider callback (generator) that no process_spider_exception # method handles, with middlewares that define a process_spider_output method class FirstUnhandledMiddleware(_GeneratorDoNothingMiddleware): pass class SecondUnhandledMiddleware(_GeneratorDoNothingMiddleware): pass class ThirdUnhandledMiddleware(_GeneratorDoNothingMiddleware): pass class UnhandledExceptionSpider(MockServerSpider): name = "UnhandledExceptionSpider" custom_settings = { "SPIDER_MIDDLEWARES": { FirstUnhandledMiddleware: 30, SecondUnhandledMiddleware: 20, ThirdUnhandledMiddleware: 10, }, } async def start(self): assert self.mockserver yield Request(self.mockserver.url("/status?n=200")) def parse(self, response): yield {"processed": ["parse-first-item"]} raise ImportError # ================================================================================ class TestSpiderMiddleware: mockserver: MockServer @classmethod def setup_class(cls): cls.mockserver = MockServer() cls.mockserver.__enter__() @classmethod def teardown_class(cls): cls.mockserver.__exit__(None, None, None) async def crawl_log( self, spider: type[Spider], caplog: pytest.LogCaptureFixture ) -> str: crawler = get_crawler(spider) caplog.clear() with caplog.at_level(logging.DEBUG): await crawler.crawl_async(mockserver=self.mockserver) return caplog.text @coroutine_test async def test_recovery(self, caplog: pytest.LogCaptureFixture) -> None: """ (0) Recover from an exception in a spider callback. The final item count should be 3 (one yielded from the callback method before the exception is raised, one directly from the recovery middleware and one from the spider when processing the request that was enqueued from the recovery middleware) """ log = await self.crawl_log(RecoverySpider, caplog) assert "Middleware: TabError exception caught" in log assert log.count("Middleware: TabError exception caught") == 1 assert "'item_scraped_count': 3" in log @coroutine_test async def test_recovery_asyncgen(self, caplog: pytest.LogCaptureFixture) -> None: """ Same as test_recovery but with an async callback. """ log = await self.crawl_log(RecoveryAsyncGenSpider, caplog) assert "Middleware: TabError exception caught" in log assert log.count("Middleware: TabError exception caught") == 1 assert "'item_scraped_count': 3" in log @coroutine_test async def test_process_spider_input_without_errback( self, caplog: pytest.LogCaptureFixture ) -> None: """ (1.1) An exception from the process_spider_input chain should be caught by the process_spider_exception chain from the start if the Request has no errback """ log1 = await self.crawl_log(ProcessSpiderInputSpiderWithoutErrback, caplog) assert "Middleware: will raise IndexError" in log1 assert "Middleware: IndexError exception caught" in log1 @coroutine_test async def test_process_spider_input_with_errback( self, caplog: pytest.LogCaptureFixture ) -> None: """ (1.2) An exception from the process_spider_input chain should not be caught by the process_spider_exception chain if the Request has an errback """ log1 = await self.crawl_log(ProcessSpiderInputSpiderWithErrback, caplog) assert "Middleware: IndexError exception caught" not in log1 assert "Middleware: will raise IndexError" in log1 assert "Got a Failure on the Request errback" in log1 assert "{'from': 'errback'}" in log1 assert "{'from': 'callback'}" not in log1 assert "'item_scraped_count': 1" in log1 @coroutine_test async def test_generator_callback(self, caplog: pytest.LogCaptureFixture) -> None: """ (2) An exception from a spider callback (returning a generator) should be caught by the process_spider_exception chain. Items yielded before the exception is raised should be processed normally. """ log2 = await self.crawl_log(GeneratorCallbackSpider, caplog) assert "Middleware: ImportError exception caught" in log2 assert "'item_scraped_count': 2" in log2 @coroutine_test async def test_async_generator_callback( self, caplog: pytest.LogCaptureFixture ) -> None: """ Same as test_generator_callback but with an async callback. """ log2 = await self.crawl_log(AsyncGeneratorCallbackSpider, caplog) assert "Middleware: ImportError exception caught" in log2 assert "'item_scraped_count': 2" in log2 @coroutine_test async def test_generator_callback_right_after_callback( self, caplog: pytest.LogCaptureFixture ) -> None: """ (2.1) Special case of (2): Exceptions should be caught even if the middleware is placed right after the spider """ log21 = await self.crawl_log( GeneratorCallbackSpiderMiddlewareRightAfterSpider, caplog ) assert "Middleware: ImportError exception caught" in log21 assert "'item_scraped_count': 2" in log21 @coroutine_test async def test_not_a_generator_callback( self, caplog: pytest.LogCaptureFixture ) -> None: """ (3) An exception from a spider callback (returning a list) should be caught by the process_spider_exception chain. No items should be processed. """ log3 = await self.crawl_log(NotGeneratorCallbackSpider, caplog) assert "Middleware: ZeroDivisionError exception caught" in log3 assert "item_scraped_count" not in log3 @coroutine_test async def test_not_a_generator_callback_right_after_callback( self, caplog: pytest.LogCaptureFixture ) -> None: """ (3.1) Special case of (3): Exceptions should be caught even if the middleware is placed right after the spider """ log31 = await self.crawl_log( NotGeneratorCallbackSpiderMiddlewareRightAfterSpider, caplog ) assert "Middleware: ZeroDivisionError exception caught" in log31 assert "item_scraped_count" not in log31 @coroutine_test async def test_generator_output_chain( self, caplog: pytest.LogCaptureFixture ) -> None: """ (4) An exception from a middleware's process_spider_output method should be sent to the process_spider_exception method from the next middleware in the chain. The result of the recovery by the process_spider_exception method should be handled by the process_spider_output method from the next middleware. The final item count should be 2 (one from the spider callback and one from the process_spider_exception chain) """ log4 = await self.crawl_log(GeneratorOutputChainSpider, caplog) assert "'item_scraped_count': 2" in log4 assert ( "GeneratorRecoverMiddleware.process_spider_exception: LookupError caught" in log4 ) assert ( "GeneratorDoNothingAfterFailureMiddleware.process_spider_exception: LookupError caught" in log4 ) assert ( "GeneratorFailMiddleware.process_spider_exception: LookupError caught" not in log4 ) assert ( "GeneratorDoNothingAfterRecoveryMiddleware.process_spider_exception: LookupError caught" not in log4 ) item_from_callback = { "processed": [ "parse-first-item", "GeneratorFailMiddleware.process_spider_output", "GeneratorDoNothingAfterFailureMiddleware.process_spider_output", "GeneratorRecoverMiddleware.process_spider_output", "GeneratorDoNothingAfterRecoveryMiddleware.process_spider_output", ] } item_recovered = { "processed": [ "GeneratorRecoverMiddleware.process_spider_exception", "GeneratorDoNothingAfterRecoveryMiddleware.process_spider_output", ] } assert str(item_from_callback) in log4 assert str(item_recovered) in log4 assert "parse-second-item" not in log4 @coroutine_test async def test_unhandled_exception(self, caplog: pytest.LogCaptureFixture) -> None: """ (5) An exception that no process_spider_exception method handles should be offered to each of them only once, and then reach the spider error log. """ log5 = await self.crawl_log(UnhandledExceptionSpider, caplog) for middleware in ( FirstUnhandledMiddleware, SecondUnhandledMiddleware, ThirdUnhandledMiddleware, ): method = f"{middleware.__name__}.process_spider_exception" assert log5.count(f"{method}: ImportError caught") == 1 assert "Spider error processing" in log5 assert "'item_scraped_count': 1" in log5 scrapy-scrapy-5569de3/tests/test_spidermiddleware_process_start.py000066400000000000000000000072461525051012400257360ustar00rootroot00000000000000from __future__ import annotations from asyncio import sleep from typing import TYPE_CHECKING, Any import pytest from scrapy import Spider, signals from scrapy.utils.defer import maybe_deferred_to_future from scrapy.utils.test import get_crawler from .utils import twisted_sleep from .utils.decorators import coroutine_test if TYPE_CHECKING: from collections.abc import AsyncIterator from scrapy.http import Response ITEM_A = {"id": "a"} ITEM_B = {"id": "b"} ITEM_C = {"id": "c"} SLEEP_SECONDS = 0.1 class AsyncioSleepSpiderMiddleware: async def process_start(self, start: AsyncIterator[Any]) -> AsyncIterator[Any]: await sleep(SLEEP_SECONDS) async for item_or_request in start: yield item_or_request class NoOpSpiderMiddleware: async def process_start(self, start: AsyncIterator[Any]) -> AsyncIterator[Any]: async for item_or_request in start: yield item_or_request class TwistedSleepSpiderMiddleware: async def process_start(self, start: AsyncIterator[Any]) -> AsyncIterator[Any]: await maybe_deferred_to_future(twisted_sleep(SLEEP_SECONDS)) async for item_or_request in start: yield item_or_request # Spiders and spider middlewares for TestMain._test_wrap class ModernWrapSpider(Spider): name = "test" async def start(self) -> AsyncIterator[Any]: yield ITEM_B class ModernWrapSpiderMiddleware: async def process_start(self, start: AsyncIterator[Any]) -> AsyncIterator[Any]: yield ITEM_A async for item_or_request in start: yield item_or_request yield ITEM_C async def _test( spider_middlewares: list[type], spider_cls: type[Spider], expected_items: list[Any], ) -> None: actual_items = [] def track_item(item: Any, response: Response, spider: Spider) -> None: actual_items.append(item) settings = { "SPIDER_MIDDLEWARES": {cls: n for n, cls in enumerate(spider_middlewares)}, } crawler = get_crawler(spider_cls, settings_dict=settings) crawler.signals.connect(track_item, signals.item_scraped) await crawler.crawl_async() assert crawler.stats.get_value("finish_reason") == "finished" assert actual_items == expected_items, f"{actual_items=} != {expected_items=}" async def _test_wrap( spider_middleware: type, spider_cls: type[Spider], expected_items: list[Any] | None = None, ) -> None: expected_items = expected_items or [ITEM_A, ITEM_B, ITEM_C] await _test([spider_middleware], spider_cls, expected_items) @coroutine_test async def test_modern_mw_modern_spider() -> None: await _test_wrap(ModernWrapSpiderMiddleware, ModernWrapSpider) async def _test_sleep(spider_middlewares: list[type]) -> None: class TestSpider(Spider): name = "test" async def start(self) -> AsyncIterator[Any]: yield ITEM_A await _test(spider_middlewares, TestSpider, [ITEM_A]) @pytest.mark.only_asyncio @coroutine_test async def test_asyncio_sleep_single() -> None: await _test_sleep([AsyncioSleepSpiderMiddleware]) @pytest.mark.only_asyncio @coroutine_test async def test_asyncio_sleep_multiple() -> None: await _test_sleep( [NoOpSpiderMiddleware, AsyncioSleepSpiderMiddleware, NoOpSpiderMiddleware] ) @pytest.mark.requires_reactor # needs a reactor for twisted_sleep() @coroutine_test async def test_twisted_sleep_single() -> None: await _test_sleep([TwistedSleepSpiderMiddleware]) @pytest.mark.requires_reactor # needs a reactor for twisted_sleep() @coroutine_test async def test_twisted_sleep_multiple() -> None: await _test_sleep( [NoOpSpiderMiddleware, TwistedSleepSpiderMiddleware, NoOpSpiderMiddleware] ) scrapy-scrapy-5569de3/tests/test_spidermiddleware_referer.py000066400000000000000000001147431525051012400244760ustar00rootroot00000000000000from __future__ import annotations import warnings from typing import TYPE_CHECKING, Any from urllib.parse import urlparse import pytest from scrapy.exceptions import NotConfigured, ScrapyDeprecationWarning from scrapy.http import Request, Response from scrapy.settings import Settings from scrapy.spidermiddlewares.referer import ( POLICY_NO_REFERRER, POLICY_NO_REFERRER_WHEN_DOWNGRADE, POLICY_ORIGIN, POLICY_ORIGIN_WHEN_CROSS_ORIGIN, POLICY_SAME_ORIGIN, POLICY_SCRAPY_DEFAULT, POLICY_STRICT_ORIGIN, POLICY_STRICT_ORIGIN_WHEN_CROSS_ORIGIN, POLICY_UNSAFE_URL, DefaultReferrerPolicy, NoReferrerPolicy, NoReferrerWhenDowngradePolicy, OriginPolicy, OriginWhenCrossOriginPolicy, RefererMiddleware, ReferrerPolicy, SameOriginPolicy, StrictOriginPolicy, StrictOriginWhenCrossOriginPolicy, UnsafeUrlPolicy, ) from scrapy.utils.misc import build_from_crawler from scrapy.utils.test import get_crawler from tests.utils.decorators import coroutine_test if TYPE_CHECKING: from collections.abc import AsyncIterator class TestRefererMiddleware: 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"), ] @pytest.fixture def mw(self) -> RefererMiddleware: settings = Settings(self.settings) return RefererMiddleware(settings) def get_request(self, target: str) -> Request: return Request(target, meta=self.req_meta) def get_response(self, origin: str) -> Response: return Response(origin, headers=self.resp_headers) def test(self, mw: RefererMiddleware) -> None: for origin, target, referrer in self.scenarii: response = self.get_response(origin) request = self.get_request(target) out = list(mw.process_spider_output(response, [request])) assert out[0].headers.get("Referer") == referrer class MixinDefault: """ Based on https://www.w3.org/TR/referrer-policy/#referrer-policy-no-referrer-when-downgrade with some additional filtering of s3:// """ 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/"), ("https://example.com/", "http://scrapy.org/", None), # no credentials leak ( "http://user:password@example.com/", "https://scrapy.org/", b"http://example.com/", ), # no referrer leak for local schemes ("file:///home/path/to/somefile.html", "https://scrapy.org/", None), ("file:///home/path/to/somefile.html", "http://scrapy.org/", None), # no referrer leak for s3 origins ("s3://mybucket/path/to/data.csv", "https://scrapy.org/", None), ("s3://mybucket/path/to/data.csv", "http://scrapy.org/", None), ] class MixinNoReferrer: 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), ("https://www.example.com/", "http://scrapy.org/", None), ("file:///home/path/to/somefile.html", "http://scrapy.org/", None), ] class MixinNoReferrerWhenDowngrade: scenarii: list[tuple[str, str, bytes | None]] = [ # TLS to TLS: send non-empty referrer ( "https://example.com/page.html", "https://not.example.com/", b"https://example.com/page.html", ), ( "https://example.com/page.html", "https://scrapy.org/", b"https://example.com/page.html", ), ( "https://example.com:443/page.html", "https://scrapy.org/", b"https://example.com/page.html", ), ( "https://example.com:444/page.html", "https://scrapy.org/", b"https://example.com:444/page.html", ), ( "ftps://example.com/urls.zip", "https://scrapy.org/", b"ftps://example.com/urls.zip", ), # TLS to non-TLS: do not send referrer ("https://example.com/page.html", "http://not.example.com/", None), ("https://example.com/page.html", "http://scrapy.org/", None), ("ftps://example.com/urls.zip", "http://scrapy.org/", None), # non-TLS to TLS or non-TLS: send referrer ( "http://example.com/page.html", "https://not.example.com/", b"http://example.com/page.html", ), ( "http://example.com/page.html", "https://scrapy.org/", b"http://example.com/page.html", ), ( "http://example.com:8080/page.html", "https://scrapy.org/", b"http://example.com:8080/page.html", ), ( "http://example.com:80/page.html", "http://not.example.com/", b"http://example.com/page.html", ), ( "http://example.com/page.html", "http://scrapy.org/", b"http://example.com/page.html", ), ( "http://example.com:443/page.html", "http://scrapy.org/", b"http://example.com:443/page.html", ), ( "ftp://example.com/urls.zip", "http://scrapy.org/", b"ftp://example.com/urls.zip", ), ( "ftp://example.com/urls.zip", "https://scrapy.org/", b"ftp://example.com/urls.zip", ), # test for user/password stripping ( "http://user:password@example.com/page.html", "https://not.example.com/", b"http://example.com/page.html", ), ] class MixinSameOrigin: scenarii: list[tuple[str, str, bytes | None]] = [ # Same origin (protocol, host, port): send referrer ( "https://example.com/page.html", "https://example.com/not-page.html", b"https://example.com/page.html", ), ( "http://example.com/page.html", "http://example.com/not-page.html", b"http://example.com/page.html", ), ( "https://example.com:443/page.html", "https://example.com/not-page.html", b"https://example.com/page.html", ), ( "http://example.com:80/page.html", "http://example.com/not-page.html", b"http://example.com/page.html", ), ( "http://example.com/page.html", "http://example.com:80/not-page.html", b"http://example.com/page.html", ), ( "http://example.com:8888/page.html", "http://example.com:8888/not-page.html", b"http://example.com:8888/page.html", ), # Different host: do NOT send referrer ( "https://example.com/page.html", "https://not.example.com/otherpage.html", None, ), ("http://example.com/page.html", "http://not.example.com/otherpage.html", None), ("http://example.com/page.html", "http://www.example.com/otherpage.html", None), # Different port: do NOT send referrer ( "https://example.com:444/page.html", "https://example.com/not-page.html", None, ), ("http://example.com:81/page.html", "http://example.com/not-page.html", None), ("http://example.com/page.html", "http://example.com:81/not-page.html", None), # Different protocols: do NOT send referrer ("https://example.com/page.html", "http://example.com/not-page.html", None), ("https://example.com/page.html", "http://not.example.com/", None), ("ftps://example.com/urls.zip", "https://example.com/not-page.html", None), ("ftp://example.com/urls.zip", "http://example.com/not-page.html", None), ("ftps://example.com/urls.zip", "http://example.com/not-page.html", None), # test for user/password stripping ( "https://user:password@example.com/page.html", "http://example.com/not-page.html", None, ), ( "https://user:password@example.com/page.html", "https://example.com/not-page.html", b"https://example.com/page.html", ), ] class MixinOrigin: 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", "https://example.com/not-page.html", b"https://example.com/", ), ( "https://example.com/page.html", "https://scrapy.org", b"https://example.com/", ), ("https://example.com/page.html", "http://scrapy.org", b"https://example.com/"), ("http://example.com/page.html", "http://scrapy.org", b"http://example.com/"), # test for user/password stripping ( "https://user:password@example.com/page.html", "http://scrapy.org", b"https://example.com/", ), ] class MixinStrictOrigin: 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", "https://example.com/not-page.html", b"https://example.com/", ), ( "https://example.com/page.html", "https://scrapy.org", b"https://example.com/", ), ("http://example.com/page.html", "http://scrapy.org", b"http://example.com/"), # downgrade: send nothing ("https://example.com/page.html", "http://scrapy.org", None), # upgrade: send origin ("http://example.com/page.html", "https://scrapy.org", b"http://example.com/"), # test for user/password stripping ( "https://user:password@example.com/page.html", "https://scrapy.org", b"https://example.com/", ), ("https://user:password@example.com/page.html", "http://scrapy.org", None), ] class MixinOriginWhenCrossOrigin: scenarii: list[tuple[str, str, bytes | None]] = [ # Same origin (protocol, host, port): send referrer ( "https://example.com/page.html", "https://example.com/not-page.html", b"https://example.com/page.html", ), ( "http://example.com/page.html", "http://example.com/not-page.html", b"http://example.com/page.html", ), ( "https://example.com:443/page.html", "https://example.com/not-page.html", b"https://example.com/page.html", ), ( "http://example.com:80/page.html", "http://example.com/not-page.html", b"http://example.com/page.html", ), ( "http://example.com/page.html", "http://example.com:80/not-page.html", b"http://example.com/page.html", ), ( "http://example.com:8888/page.html", "http://example.com:8888/not-page.html", b"http://example.com:8888/page.html", ), # Different host: send origin as referrer ( "https://example2.com/page.html", "https://scrapy.org/otherpage.html", b"https://example2.com/", ), ( "https://example2.com/page.html", "https://not.example2.com/otherpage.html", b"https://example2.com/", ), ( "http://example2.com/page.html", "http://not.example2.com/otherpage.html", b"http://example2.com/", ), # exact match required ( "http://example2.com/page.html", "http://www.example2.com/otherpage.html", b"http://example2.com/", ), # Different port: send origin as referrer ( "https://example3.com:444/page.html", "https://example3.com/not-page.html", b"https://example3.com:444/", ), ( "http://example3.com:81/page.html", "http://example3.com/not-page.html", b"http://example3.com:81/", ), # Different protocols: send origin as referrer ( "https://example4.com/page.html", "http://example4.com/not-page.html", b"https://example4.com/", ), ( "https://example4.com/page.html", "http://not.example4.com/", b"https://example4.com/", ), ( "ftps://example4.com/urls.zip", "https://example4.com/not-page.html", b"ftps://example4.com/", ), ( "ftp://example4.com/urls.zip", "http://example4.com/not-page.html", b"ftp://example4.com/", ), ( "ftps://example4.com/urls.zip", "http://example4.com/not-page.html", b"ftps://example4.com/", ), # test for user/password stripping ( "https://user:password@example5.com/page.html", "https://example5.com/not-page.html", b"https://example5.com/page.html", ), # TLS to non-TLS downgrade: send origin ( "https://user:password@example5.com/page.html", "http://example5.com/not-page.html", b"https://example5.com/", ), ] class MixinStrictOriginWhenCrossOrigin: scenarii: list[tuple[str, str, bytes | None]] = [ # Same origin (protocol, host, port): send referrer ( "https://example.com/page.html", "https://example.com/not-page.html", b"https://example.com/page.html", ), ( "http://example.com/page.html", "http://example.com/not-page.html", b"http://example.com/page.html", ), ( "https://example.com:443/page.html", "https://example.com/not-page.html", b"https://example.com/page.html", ), ( "http://example.com:80/page.html", "http://example.com/not-page.html", b"http://example.com/page.html", ), ( "http://example.com/page.html", "http://example.com:80/not-page.html", b"http://example.com/page.html", ), ( "http://example.com:8888/page.html", "http://example.com:8888/not-page.html", b"http://example.com:8888/page.html", ), # Different host: send origin as referrer ( "https://example2.com/page.html", "https://scrapy.org/otherpage.html", b"https://example2.com/", ), ( "https://example2.com/page.html", "https://not.example2.com/otherpage.html", b"https://example2.com/", ), ( "http://example2.com/page.html", "http://not.example2.com/otherpage.html", b"http://example2.com/", ), # exact match required ( "http://example2.com/page.html", "http://www.example2.com/otherpage.html", b"http://example2.com/", ), # Different port: send origin as referrer ( "https://example3.com:444/page.html", "https://example3.com/not-page.html", b"https://example3.com:444/", ), ( "http://example3.com:81/page.html", "http://example3.com/not-page.html", b"http://example3.com:81/", ), # downgrade ("https://example4.com/page.html", "http://example4.com/not-page.html", None), ("https://example4.com/page.html", "http://not.example4.com/", None), # non-TLS to non-TLS ( "ftp://example4.com/urls.zip", "http://example4.com/not-page.html", b"ftp://example4.com/", ), # upgrade ( "http://example4.com/page.html", "https://example4.com/not-page.html", b"http://example4.com/", ), ( "http://example4.com/page.html", "https://not.example4.com/", b"http://example4.com/", ), # Different protocols: send origin as referrer ( "ftps://example4.com/urls.zip", "https://example4.com/not-page.html", b"ftps://example4.com/", ), ( "ftp://example4.com/urls.zip", "http://example4.com/not-page.html", b"ftp://example4.com/", ), # test for user/password stripping ( "https://user:password@example5.com/page.html", "https://example5.com/not-page.html", b"https://example5.com/page.html", ), # TLS to non-TLS downgrade: send nothing ( "https://user:password@example5.com/page.html", "http://example5.com/not-page.html", None, ), ] class MixinUnsafeUrl: scenarii: list[tuple[str, str, bytes | None]] = [ # TLS to TLS: send referrer ( "https://example.com/sekrit.html", "http://not.example.com/", b"https://example.com/sekrit.html", ), ( "https://example1.com/page.html", "https://not.example1.com/", b"https://example1.com/page.html", ), ( "https://example1.com/page.html", "https://scrapy.org/", b"https://example1.com/page.html", ), ( "https://example1.com:443/page.html", "https://scrapy.org/", b"https://example1.com/page.html", ), ( "https://example1.com:444/page.html", "https://scrapy.org/", b"https://example1.com:444/page.html", ), ( "ftps://example1.com/urls.zip", "https://scrapy.org/", b"ftps://example1.com/urls.zip", ), # TLS to non-TLS: send referrer (yes, it's unsafe) ( "https://example2.com/page.html", "http://not.example2.com/", b"https://example2.com/page.html", ), ( "https://example2.com/page.html", "http://scrapy.org/", b"https://example2.com/page.html", ), ( "ftps://example2.com/urls.zip", "http://scrapy.org/", b"ftps://example2.com/urls.zip", ), # non-TLS to TLS or non-TLS: send referrer (yes, it's unsafe) ( "http://example3.com/page.html", "https://not.example3.com/", b"http://example3.com/page.html", ), ( "http://example3.com/page.html", "https://scrapy.org/", b"http://example3.com/page.html", ), ( "http://example3.com:8080/page.html", "https://scrapy.org/", b"http://example3.com:8080/page.html", ), ( "http://example3.com:80/page.html", "http://not.example3.com/", b"http://example3.com/page.html", ), ( "http://example3.com/page.html", "http://scrapy.org/", b"http://example3.com/page.html", ), ( "http://example3.com:443/page.html", "http://scrapy.org/", b"http://example3.com:443/page.html", ), ( "ftp://example3.com/urls.zip", "http://scrapy.org/", b"ftp://example3.com/urls.zip", ), ( "ftp://example3.com/urls.zip", "https://scrapy.org/", b"ftp://example3.com/urls.zip", ), # test for user/password stripping ( "http://user:password@example4.com/page.html", "https://not.example4.com/", b"http://example4.com/page.html", ), ( "https://user:password@example4.com/page.html", "http://scrapy.org/", b"https://example4.com/page.html", ), ] class TestRefererMiddlewareDefault(MixinDefault, TestRefererMiddleware): pass # --- Tests using settings to set policy using class path class TestSettingsNoReferrer(MixinNoReferrer, TestRefererMiddleware): settings = {"REFERRER_POLICY": "scrapy.spidermiddlewares.referer.NoReferrerPolicy"} class TestSettingsNoReferrerWhenDowngrade( MixinNoReferrerWhenDowngrade, TestRefererMiddleware ): settings = { "REFERRER_POLICY": "scrapy.spidermiddlewares.referer.NoReferrerWhenDowngradePolicy" } class TestSettingsSameOrigin(MixinSameOrigin, TestRefererMiddleware): settings = {"REFERRER_POLICY": "scrapy.spidermiddlewares.referer.SameOriginPolicy"} class TestSettingsOrigin(MixinOrigin, TestRefererMiddleware): settings = {"REFERRER_POLICY": "scrapy.spidermiddlewares.referer.OriginPolicy"} class TestSettingsStrictOrigin(MixinStrictOrigin, TestRefererMiddleware): settings = { "REFERRER_POLICY": "scrapy.spidermiddlewares.referer.StrictOriginPolicy" } class TestSettingsOriginWhenCrossOrigin( MixinOriginWhenCrossOrigin, TestRefererMiddleware ): settings = { "REFERRER_POLICY": "scrapy.spidermiddlewares.referer.OriginWhenCrossOriginPolicy" } class TestSettingsStrictOriginWhenCrossOrigin( MixinStrictOriginWhenCrossOrigin, TestRefererMiddleware ): settings = { "REFERRER_POLICY": "scrapy.spidermiddlewares.referer.StrictOriginWhenCrossOriginPolicy" } class TestSettingsUnsafeUrl(MixinUnsafeUrl, TestRefererMiddleware): settings = {"REFERRER_POLICY": "scrapy.spidermiddlewares.referer.UnsafeUrlPolicy"} class CustomPythonOrgPolicy(ReferrerPolicy): """ A dummy policy that returns referrer as http(s)://python.org depending on the scheme of the target URL. """ def referrer(self, response, request): scheme = urlparse(request).scheme if scheme == "https": return b"https://python.org/" if scheme == "http": return b"http://python.org/" return None class TestSettingsCustomPolicy(TestRefererMiddleware): settings = {"REFERRER_POLICY": CustomPythonOrgPolicy} scenarii = [ ("https://example.com/", "https://scrapy.org/", b"https://python.org/"), ("http://example.com/", "http://scrapy.org/", b"http://python.org/"), ("http://example.com/", "https://scrapy.org/", b"https://python.org/"), ("https://example.com/", "http://scrapy.org/", b"http://python.org/"), ( "file:///home/path/to/somefile.html", "https://scrapy.org/", b"https://python.org/", ), ( "file:///home/path/to/somefile.html", "http://scrapy.org/", b"http://python.org/", ), ] # --- Tests using Request meta dict to set policy class TestRequestMetaDefault(MixinDefault, TestRefererMiddleware): req_meta = {"referrer_policy": POLICY_SCRAPY_DEFAULT} class TestRequestMetaNoReferrer(MixinNoReferrer, TestRefererMiddleware): req_meta = {"referrer_policy": POLICY_NO_REFERRER} class TestRequestMetaNoReferrerWhenDowngrade( MixinNoReferrerWhenDowngrade, TestRefererMiddleware ): req_meta = {"referrer_policy": POLICY_NO_REFERRER_WHEN_DOWNGRADE} class TestRequestMetaSameOrigin(MixinSameOrigin, TestRefererMiddleware): req_meta = {"referrer_policy": POLICY_SAME_ORIGIN} class TestRequestMetaOrigin(MixinOrigin, TestRefererMiddleware): req_meta = {"referrer_policy": POLICY_ORIGIN} class TestRequestMetaSrictOrigin(MixinStrictOrigin, TestRefererMiddleware): req_meta = {"referrer_policy": POLICY_STRICT_ORIGIN} class TestRequestMetaOriginWhenCrossOrigin( MixinOriginWhenCrossOrigin, TestRefererMiddleware ): req_meta = {"referrer_policy": POLICY_ORIGIN_WHEN_CROSS_ORIGIN} class TestRequestMetaStrictOriginWhenCrossOrigin( MixinStrictOriginWhenCrossOrigin, TestRefererMiddleware ): req_meta = {"referrer_policy": POLICY_STRICT_ORIGIN_WHEN_CROSS_ORIGIN} class TestRequestMetaUnsafeUrl(MixinUnsafeUrl, TestRefererMiddleware): req_meta = {"referrer_policy": POLICY_UNSAFE_URL} class TestRequestMetaPrecedence001(MixinUnsafeUrl, TestRefererMiddleware): settings = {"REFERRER_POLICY": "scrapy.spidermiddlewares.referer.SameOriginPolicy"} req_meta = {"referrer_policy": POLICY_UNSAFE_URL} class TestRequestMetaPrecedence002(MixinNoReferrer, TestRefererMiddleware): settings = { "REFERRER_POLICY": "scrapy.spidermiddlewares.referer.NoReferrerWhenDowngradePolicy" } req_meta = {"referrer_policy": POLICY_NO_REFERRER} class TestRequestMetaPrecedence003(MixinUnsafeUrl, TestRefererMiddleware): settings = { "REFERRER_POLICY": "scrapy.spidermiddlewares.referer.OriginWhenCrossOriginPolicy" } req_meta = {"referrer_policy": POLICY_UNSAFE_URL} class TestRequestMetaSettingFallback: params = [ ( # When an unknown policy is referenced in Request.meta # (here, a typo error), # the policy defined in settings takes precedence { "REFERRER_POLICY": "scrapy.spidermiddlewares.referer.OriginWhenCrossOriginPolicy" }, {}, {"referrer_policy": "ssscrapy-default"}, OriginWhenCrossOriginPolicy, True, ), ( # same as above but with string value for settings policy {"REFERRER_POLICY": "origin-when-cross-origin"}, {}, {"referrer_policy": "ssscrapy-default"}, OriginWhenCrossOriginPolicy, True, ), ( # request meta references a wrong policy but it is set, # so the Referrer-Policy header in response is not used, # and the settings' policy is applied {"REFERRER_POLICY": "origin-when-cross-origin"}, {"Referrer-Policy": "unsafe-url"}, {"referrer_policy": "ssscrapy-default"}, OriginWhenCrossOriginPolicy, True, ), ( # here, request meta does not set the policy # so response headers take precedence {"REFERRER_POLICY": "origin-when-cross-origin"}, {"Referrer-Policy": "unsafe-url"}, {}, UnsafeUrlPolicy, False, ), ( # here, request meta does not set the policy, # but response headers also use an unknown policy, # so the settings' policy is used {"REFERRER_POLICY": "origin-when-cross-origin"}, {"Referrer-Policy": "unknown"}, {}, OriginWhenCrossOriginPolicy, True, ), ] def test(self): origin = "http://www.scrapy.org" target = "http://www.example.com" for ( settings, response_headers, request_meta, policy_class, check_warning, ) in self.params: mw = RefererMiddleware(Settings(settings)) response = Response(origin, headers=response_headers) request = Request(target, meta=request_meta) if check_warning: with pytest.warns( RuntimeWarning, match="Could not load referrer policy" ): policy = mw.policy(response, request) else: policy = mw.policy(response, request) assert isinstance(policy, policy_class) class TestSettingsPolicyByName: def test_valid_name(self): for s, p in [ (POLICY_SCRAPY_DEFAULT, DefaultReferrerPolicy), (POLICY_NO_REFERRER, NoReferrerPolicy), (POLICY_NO_REFERRER_WHEN_DOWNGRADE, NoReferrerWhenDowngradePolicy), (POLICY_SAME_ORIGIN, SameOriginPolicy), (POLICY_ORIGIN, OriginPolicy), (POLICY_STRICT_ORIGIN, StrictOriginPolicy), (POLICY_ORIGIN_WHEN_CROSS_ORIGIN, OriginWhenCrossOriginPolicy), (POLICY_STRICT_ORIGIN_WHEN_CROSS_ORIGIN, StrictOriginWhenCrossOriginPolicy), (POLICY_UNSAFE_URL, UnsafeUrlPolicy), ]: settings = Settings({"REFERRER_POLICY": s}) mw = RefererMiddleware(settings) assert mw.default_policy == p def test_valid_name_casevariants(self): for s, p in [ (POLICY_SCRAPY_DEFAULT, DefaultReferrerPolicy), (POLICY_NO_REFERRER, NoReferrerPolicy), (POLICY_NO_REFERRER_WHEN_DOWNGRADE, NoReferrerWhenDowngradePolicy), (POLICY_SAME_ORIGIN, SameOriginPolicy), (POLICY_ORIGIN, OriginPolicy), (POLICY_STRICT_ORIGIN, StrictOriginPolicy), (POLICY_ORIGIN_WHEN_CROSS_ORIGIN, OriginWhenCrossOriginPolicy), (POLICY_STRICT_ORIGIN_WHEN_CROSS_ORIGIN, StrictOriginWhenCrossOriginPolicy), (POLICY_UNSAFE_URL, UnsafeUrlPolicy), ]: settings = Settings({"REFERRER_POLICY": s.upper()}) mw = RefererMiddleware(settings) assert mw.default_policy == p def test_invalid_name(self): settings = Settings({"REFERRER_POLICY": "some-custom-unknown-policy"}) with pytest.raises(RuntimeError): RefererMiddleware(settings) def test_multiple_policy_tokens(self): # test parsing without space(s) after the comma settings1 = Settings( { "REFERRER_POLICY": ( f"some-custom-unknown-policy," f"{POLICY_SAME_ORIGIN}," f"{POLICY_STRICT_ORIGIN_WHEN_CROSS_ORIGIN}," f"another-custom-unknown-policy" ) } ) mw1 = RefererMiddleware(settings1) assert mw1.default_policy == StrictOriginWhenCrossOriginPolicy # test parsing with space(s) after the comma settings2 = Settings( { "REFERRER_POLICY": ( f"{POLICY_STRICT_ORIGIN_WHEN_CROSS_ORIGIN}," f" another-custom-unknown-policy," f" {POLICY_UNSAFE_URL}" ) } ) mw2 = RefererMiddleware(settings2) assert mw2.default_policy == UnsafeUrlPolicy def test_multiple_policy_tokens_all_invalid(self): settings = Settings( { "REFERRER_POLICY": ( "some-custom-unknown-policy," "another-custom-unknown-policy," "yet-another-custom-unknown-policy" ) } ) with pytest.raises(RuntimeError): RefererMiddleware(settings) class TestPolicyHeaderPrecedence001(MixinUnsafeUrl, TestRefererMiddleware): settings = {"REFERRER_POLICY": "scrapy.spidermiddlewares.referer.SameOriginPolicy"} resp_headers = {"Referrer-Policy": POLICY_UNSAFE_URL.upper()} class TestPolicyHeaderPrecedence002(MixinNoReferrer, TestRefererMiddleware): settings = { "REFERRER_POLICY": "scrapy.spidermiddlewares.referer.NoReferrerWhenDowngradePolicy" } resp_headers = {"Referrer-Policy": POLICY_NO_REFERRER.swapcase()} class TestPolicyHeaderPrecedence003( MixinNoReferrerWhenDowngrade, TestRefererMiddleware ): settings = { "REFERRER_POLICY": "scrapy.spidermiddlewares.referer.OriginWhenCrossOriginPolicy" } resp_headers = {"Referrer-Policy": POLICY_NO_REFERRER_WHEN_DOWNGRADE.title()} class TestPolicyHeaderPrecedence004( MixinNoReferrerWhenDowngrade, TestRefererMiddleware ): """ The empty string means "no-referrer-when-downgrade" """ settings = { "REFERRER_POLICY": "scrapy.spidermiddlewares.referer.OriginWhenCrossOriginPolicy" } resp_headers = {"Referrer-Policy": ""} class TestPolicyMethodResponseParamRename: def setup_method(self): self.crawler = get_crawler() self.mw = build_from_crawler(RefererMiddleware, self.crawler) self.request = Request("http://www.example.com") self.response = Response("http://www.example.com") def test_pos_string(self): with pytest.warns( ScrapyDeprecationWarning, match=r"Passing a response URL to RefererMiddleware\.policy\(\)", ): self.mw.policy("http://old.com", self.request) def test_pos_response(self): with warnings.catch_warnings(): warnings.filterwarnings( "error", category=ScrapyDeprecationWarning, message=r"Passing 'resp_or_url' is deprecated", ) self.mw.policy(self.response, self.request) def test_key_resp_or_url(self): with pytest.warns( ScrapyDeprecationWarning, match=r"Passing 'resp_or_url' is deprecated" ): self.mw.policy(resp_or_url=self.response, request=self.request) def test_key_response(self): with warnings.catch_warnings(): warnings.filterwarnings( "error", category=ScrapyDeprecationWarning, message=r"Passing 'resp_or_url' is deprecated", ) self.mw.policy(response=self.response, request=self.request) def test_key_response_string(self): with pytest.warns(ScrapyDeprecationWarning, match="Passing a response URL"): self.mw.policy(response="http://old.com", request=self.request) def test_both_resp_or_url_and_response(self): with pytest.raises( TypeError, match="Cannot pass both 'response' and 'resp_or_url'" ): self.mw.policy( response=self.response, resp_or_url=self.response, request=self.request ) def test_missing_response(self): with pytest.raises(TypeError, match="Missing required argument: 'response'"): self.mw.policy(request=self.request) def test_missing_request(self): with pytest.raises(TypeError, match="Missing required argument: 'request'"): self.mw.policy(response=self.response) @coroutine_test async def test_response_policy_only_supports_policy_names(): crawler = get_crawler(settings_dict={"REFERRER_POLICY": "no-referrer"}) mw = build_from_crawler(RefererMiddleware, crawler) async def input_result() -> AsyncIterator[Any]: yield Request("https://example.com/") response = Response( "https://example.com/", headers={ "Referrer-Policy": "scrapy.spidermiddlewares.referer.NoReferrerWhenDowngradePolicy" }, ) with pytest.warns( RuntimeWarning, match=r"Could not load referrer policy 'scrapy\.spidermiddlewares\.referer\.NoReferrerWhenDowngradePolicy' \(import paths from the response Referrer-Policy header are not allowed\)", ): output = [ request async for request in mw.process_spider_output_async( response, input_result() ) ] assert len(output) == 1 assert b"Referer" not in output[0].headers response = Response( "https://example.com/", headers={"Referrer-Policy": "no-referrer-when-downgrade"}, ) output = [ request async for request in mw.process_spider_output_async(response, input_result()) ] assert len(output) == 1 assert output[0].headers == {b"Referer": [b"https://example.com/"]} @coroutine_test async def test_referer_policies_setting(): crawler = get_crawler( settings_dict={ "REFERRER_POLICY": "no-referrer", "REFERRER_POLICIES": { "no-referrer-when-downgrade": None, "custom-policy": CustomPythonOrgPolicy, "": CustomPythonOrgPolicy, }, } ) mw = build_from_crawler(RefererMiddleware, crawler) async def input_result() -> AsyncIterator[Any]: yield Request("https://example.com/") # "no-referrer-when-downgrade": None, response = Response( "https://example.com/", headers={"Referrer-Policy": "no-referrer-when-downgrade"}, ) with pytest.warns( RuntimeWarning, match=r"Could not load referrer policy 'no-referrer-when-downgrade'", ): output = [ request async for request in mw.process_spider_output_async( response, input_result() ) ] assert len(output) == 1 assert b"Referer" not in output[0].headers # "custom-policy": CustomPythonOrgPolicy, response = Response( "https://example.com/", headers={"Referrer-Policy": "custom-policy"}, ) output = [ request async for request in mw.process_spider_output_async(response, input_result()) ] assert len(output) == 1 assert output[0].headers == {b"Referer": [b"https://python.org/"]} # "": CustomPythonOrgPolicy, response = Response( "https://example.com/", headers={"Referrer-Policy": ""}, ) output = [ request async for request in mw.process_spider_output_async(response, input_result()) ] assert len(output) == 1 assert output[0].headers == {b"Referer": [b"https://python.org/"]} class TestReferrerPolicyHelpers: def test_origin_referrer_local_scheme(self): # A local scheme yields no referrer. assert UnsafeUrlPolicy().origin_referrer("data:,foo") is None def test_strip_url_empty(self): assert UnsafeUrlPolicy().strip_url("") is None def test_potentially_trustworthy_data_scheme(self): assert UnsafeUrlPolicy().potentially_trustworthy("data:,foo") is False def test_default_policy(): crawler = get_crawler() mw = build_from_crawler(RefererMiddleware, crawler) assert mw.default_policy is DefaultReferrerPolicy def test_no_settings_constructor(): with pytest.warns( ScrapyDeprecationWarning, match="Instantiating RefererMiddleware without a 'settings' argument", ): mw = RefererMiddleware() assert mw.default_policy is DefaultReferrerPolicy def test_not_configured_when_disabled(): crawler = get_crawler(settings_dict={"REFERER_ENABLED": False}) with pytest.raises(NotConfigured): build_from_crawler(RefererMiddleware, crawler) scrapy-scrapy-5569de3/tests/test_spidermiddleware_start.py000066400000000000000000000026171525051012400241750ustar00rootroot00000000000000from __future__ import annotations from typing import TYPE_CHECKING from scrapy.http import Request, Response from scrapy.spidermiddlewares.start import StartSpiderMiddleware from scrapy.spiders import Spider from scrapy.utils.misc import build_from_crawler from scrapy.utils.test import get_crawler from tests.utils.decorators import coroutine_test if TYPE_CHECKING: from collections.abc import AsyncIterator @coroutine_test async def test_async() -> None: crawler = get_crawler(Spider) mw = build_from_crawler(StartSpiderMiddleware, crawler) async def start() -> AsyncIterator[Request]: yield Request("data:,1") yield Request("data:,2", meta={"is_start_request": True}) yield Request("data:,2", meta={"is_start_request": False}) yield Request("data:,2", meta={"is_start_request": "foo"}) result = [ request.meta["is_start_request"] async for request in mw.process_start(start()) ] assert result == [True, True, False, "foo"] def test_spider_output_not_marked() -> None: # Requests from a non-None response (spider output) are not flagged. crawler = get_crawler(Spider) mw = build_from_crawler(StartSpiderMiddleware, crawler) response = Response("data:,") request = Request("data:,1") out = list(mw.process_spider_output(response, [request])) assert out == [request] assert "is_start_request" not in request.meta scrapy-scrapy-5569de3/tests/test_spidermiddleware_urllength.py000066400000000000000000000034601525051012400250410ustar00rootroot00000000000000from __future__ import annotations from logging import INFO from typing import TYPE_CHECKING import pytest from scrapy.exceptions import NotConfigured from scrapy.http import Request, Response from scrapy.spidermiddlewares.urllength import UrlLengthMiddleware from scrapy.spiders import Spider from scrapy.utils.misc import build_from_crawler from scrapy.utils.test import get_crawler if TYPE_CHECKING: from scrapy.crawler import Crawler from scrapy.statscollectors import StatsCollector maxlength = 25 response = Response("http://scrapytest.org") short_url_req = Request("http://scrapytest.org/") long_url_req = Request("http://scrapytest.org/this_is_a_long_url") reqs: list[Request] = [short_url_req, long_url_req] @pytest.fixture def crawler() -> Crawler: return get_crawler(Spider, {"URLLENGTH_LIMIT": maxlength}) @pytest.fixture def stats(crawler: Crawler) -> StatsCollector: return crawler.stats @pytest.fixture def mw(crawler: Crawler) -> UrlLengthMiddleware: return build_from_crawler(UrlLengthMiddleware, crawler) def process_spider_output(mw: UrlLengthMiddleware) -> list[Request]: return list(mw.process_spider_output(response, reqs)) def test_middleware_works(mw: UrlLengthMiddleware) -> None: assert process_spider_output(mw) == [short_url_req] def test_not_configured_without_limit() -> None: crawler = get_crawler(Spider, {"URLLENGTH_LIMIT": 0}) with pytest.raises(NotConfigured): build_from_crawler(UrlLengthMiddleware, crawler) def test_logging( stats: StatsCollector, mw: UrlLengthMiddleware, caplog: pytest.LogCaptureFixture ) -> None: with caplog.at_level(INFO): process_spider_output(mw) ric = stats.get_value("urllength/request_ignored_count") assert ric == 1 assert f"Ignoring link (url length > {maxlength})" in caplog.text scrapy-scrapy-5569de3/tests/test_spiderstate.py000066400000000000000000000026161525051012400217620ustar00rootroot00000000000000from __future__ import annotations from datetime import datetime, timezone from typing import TYPE_CHECKING import pytest from scrapy.exceptions import NotConfigured from scrapy.extensions.spiderstate import SpiderState from scrapy.spiders import Spider from scrapy.utils.misc import build_from_crawler from scrapy.utils.test import get_crawler if TYPE_CHECKING: from pathlib import Path def test_store_load(tmp_path: Path) -> None: jobdir = str(tmp_path) spider = Spider(name="default") dt = datetime.now(tz=timezone.utc) ss = SpiderState(jobdir) ss.spider_opened(spider) assert hasattr(spider, "state") spider.state["one"] = 1 spider.state["dt"] = dt ss.spider_closed(spider) spider2 = Spider(name="default") ss2 = SpiderState(jobdir) ss2.spider_opened(spider2) assert hasattr(spider2, "state") assert spider2.state == {"one": 1, "dt": dt} ss2.spider_closed(spider2) def test_state_attribute() -> None: # state attribute must be present if jobdir is not set, to provide a # consistent interface spider = Spider(name="default") ss = SpiderState() ss.spider_opened(spider) assert hasattr(spider, "state") assert spider.state == {} ss.spider_closed(spider) def test_not_configured() -> None: crawler = get_crawler(Spider) with pytest.raises(NotConfigured): build_from_crawler(SpiderState, crawler) scrapy-scrapy-5569de3/tests/test_squeues.py000066400000000000000000000171201525051012400211210ustar00rootroot00000000000000import pickle import sys from typing import Any, cast import pytest from queuelib.tests import test_queue as t from scrapy.http import Request from scrapy.item import Field, Item from scrapy.loader import ItemLoader from scrapy.selector import Selector from scrapy.squeues import ( _MarshalFifoSerializationDiskQueue, _MarshalFifoSerializationSQLiteQueue, _MarshalLifoSerializationDiskQueue, _MarshalLifoSerializationSQLiteQueue, _PickleFifoSerializationDiskQueue, _PickleFifoSerializationSQLiteQueue, _PickleLifoSerializationDiskQueue, _PickleLifoSerializationSQLiteQueue, _scrapy_non_serialization_queue, _serializable_queue, ) # The disk and SQLite queue classes are built at run time, so they are untyped. _MarshalFifoQueue: Any = _MarshalFifoSerializationDiskQueue _MarshalLifoQueue: Any = _MarshalLifoSerializationDiskQueue _PickleFifoQueue: Any = _PickleFifoSerializationDiskQueue _PickleLifoQueue: Any = _PickleLifoSerializationDiskQueue _MarshalFifoSQLiteQueue: Any = _MarshalFifoSerializationSQLiteQueue _MarshalLifoSQLiteQueue: Any = _MarshalLifoSerializationSQLiteQueue _PickleFifoSQLiteQueue: Any = _PickleFifoSerializationSQLiteQueue _PickleLifoSQLiteQueue: Any = _PickleLifoSerializationSQLiteQueue class MyItem(Item): name = Field() class NoPeekQueue: """Queue class without the optional peek method.""" _no_peek_queue = cast("Any", NoPeekQueue) @pytest.mark.parametrize( "queue_class", [ _serializable_queue(_no_peek_queue, pickle.dumps, pickle.loads), _scrapy_non_serialization_queue(_no_peek_queue), ], ) def test_peek_unsupported(queue_class): with pytest.raises( NotImplementedError, match="The underlying queue class does not implement 'peek'", ): queue_class().peek() def _test_procesor(x): return x + x class MyLoader(ItemLoader): default_item_class = MyItem name_out = staticmethod(_test_procesor) def nonserializable_object_test(self): q = self.queue() with pytest.raises( ValueError, match=r"unmarshallable object|Can't (get|pickle) local object|Can't pickle .*: it's not found as", ): q.push(lambda x: x) # Selectors should fail (lxml.html.HtmlElement objects can't be pickled) sel = Selector(text="

    some text

    ") with pytest.raises( ValueError, match=r"unmarshallable object|can't pickle Selector objects" ): q.push(sel) q.close() class FifoQueueTestMixin: def queue(self) -> Any: raise NotImplementedError def test_serialize(self): q = self.queue() q.push("a") q.push(123) q.push({"a": "dict"}) assert q.pop() == "a" assert q.pop() == 123 assert q.pop() == {"a": "dict"} q.close() test_nonserializable_object = nonserializable_object_test class MarshalFifoDiskQueueTest(t.FifoDiskQueueTest, FifoQueueTestMixin): chunksize = 100000 def queue(self) -> Any: return _MarshalFifoQueue(self.qpath, chunksize=self.chunksize) class ChunkSize1MarshalFifoDiskQueueTest(MarshalFifoDiskQueueTest): chunksize = 1 class ChunkSize2MarshalFifoDiskQueueTest(MarshalFifoDiskQueueTest): chunksize = 2 class ChunkSize3MarshalFifoDiskQueueTest(MarshalFifoDiskQueueTest): chunksize = 3 class ChunkSize4MarshalFifoDiskQueueTest(MarshalFifoDiskQueueTest): chunksize = 4 class PickleFifoDiskQueueTest(t.FifoDiskQueueTest, FifoQueueTestMixin): chunksize = 100000 def queue(self) -> Any: return _PickleFifoQueue(self.qpath, chunksize=self.chunksize) def test_serialize_item(self): q = self.queue() i = MyItem(name="foo") q.push(i) i2 = q.pop() assert isinstance(i2, MyItem) assert i == i2 q.close() def test_serialize_loader(self): q = self.queue() loader = MyLoader() q.push(loader) loader2 = q.pop() assert isinstance(loader2, MyLoader) assert loader2.default_item_class is MyItem assert loader2.name_out("x") == "xx" q.close() def test_serialize_request_recursive(self): q = self.queue() r = Request("http://www.example.com") r.meta["request"] = r q.push(r) r2 = q.pop() assert isinstance(r2, Request) assert r.url == r2.url assert r2.meta["request"] is r2 q.close() def test_non_pickable_object(self): q = self.queue() with pytest.raises( ValueError, match=r"Can't (get|pickle) local object|Can't pickle .*: it's not found as", ) as exc_info: q.push(lambda x: x) if sys.version_info >= (3, 14) or hasattr(sys, "pypy_version_info"): assert isinstance(exc_info.value.__context__, pickle.PicklingError) else: assert isinstance(exc_info.value.__context__, AttributeError) sel = Selector(text="

    some text

    ") with pytest.raises( ValueError, match="can't pickle Selector objects" ) as exc_info: q.push(sel) assert isinstance(exc_info.value.__context__, TypeError) q.close() class ChunkSize1PickleFifoDiskQueueTest(PickleFifoDiskQueueTest): chunksize = 1 class ChunkSize2PickleFifoDiskQueueTest(PickleFifoDiskQueueTest): chunksize = 2 class ChunkSize3PickleFifoDiskQueueTest(PickleFifoDiskQueueTest): chunksize = 3 class ChunkSize4PickleFifoDiskQueueTest(PickleFifoDiskQueueTest): chunksize = 4 class LifoQueueTestMixin: def queue(self) -> Any: raise NotImplementedError def test_serialize(self): q = self.queue() q.push("a") q.push(123) q.push({"a": "dict"}) assert q.pop() == {"a": "dict"} assert q.pop() == 123 assert q.pop() == "a" q.close() test_nonserializable_object = nonserializable_object_test class MarshalLifoDiskQueueTest(t.LifoDiskQueueTest, LifoQueueTestMixin): def queue(self) -> Any: return _MarshalLifoQueue(self.qpath) class PickleLifoDiskQueueTest(t.LifoDiskQueueTest, LifoQueueTestMixin): def queue(self) -> Any: return _PickleLifoQueue(self.qpath) def test_serialize_item(self): q = self.queue() i = MyItem(name="foo") q.push(i) i2 = q.pop() assert isinstance(i2, MyItem) assert i == i2 q.close() def test_serialize_loader(self): q = self.queue() loader = MyLoader() q.push(loader) loader2 = q.pop() assert isinstance(loader2, MyLoader) assert loader2.default_item_class is MyItem assert loader2.name_out("x") == "xx" q.close() def test_serialize_request_recursive(self): q = self.queue() r = Request("http://www.example.com") r.meta["request"] = r q.push(r) r2 = q.pop() assert isinstance(r2, Request) assert r.url == r2.url assert r2.meta["request"] is r2 q.close() class MarshalFifoSQLiteQueueTest(t.FifoSQLiteQueueTest, FifoQueueTestMixin): def queue(self) -> Any: return _MarshalFifoSQLiteQueue(self.qpath) class PickleFifoSQLiteQueueTest(t.FifoSQLiteQueueTest, FifoQueueTestMixin): def queue(self) -> Any: return _PickleFifoSQLiteQueue(self.qpath) class MarshalLifoSQLiteQueueTest(t.LifoSQLiteQueueTest, LifoQueueTestMixin): def queue(self) -> Any: return _MarshalLifoSQLiteQueue(self.qpath) class PickleLifoSQLiteQueueTest(t.LifoSQLiteQueueTest, LifoQueueTestMixin): def queue(self) -> Any: return _PickleLifoSQLiteQueue(self.qpath) scrapy-scrapy-5569de3/tests/test_squeues_request.py000066400000000000000000000142641525051012400226770ustar00rootroot00000000000000""" Queues that handle requests """ from __future__ import annotations from abc import ABC, abstractmethod from typing import TYPE_CHECKING, Any import pytest from scrapy.http import Request from scrapy.spiders import Spider from scrapy.squeues import ( FifoMemoryQueue, LifoMemoryQueue, MarshalFifoDiskQueue, MarshalFifoSQLiteQueue, MarshalLifoDiskQueue, MarshalLifoSQLiteQueue, PickleFifoDiskQueue, PickleFifoSQLiteQueue, PickleLifoDiskQueue, PickleLifoSQLiteQueue, ) from scrapy.utils.misc import build_from_crawler from scrapy.utils.test import get_crawler if TYPE_CHECKING: from pathlib import Path import queuelib from scrapy.crawler import Crawler @pytest.fixture def crawler() -> Crawler: return get_crawler(Spider) class TestRequestQueueBase(ABC): @property @abstractmethod def is_fifo(self) -> bool: raise NotImplementedError def test_one_element(self, q: queuelib.queue.BaseQueue): assert len(q) == 0 assert q.peek() is None assert q.pop() is None req = Request("http://www.example.com") q.push(req) assert len(q) == 1 result = q.peek() assert result is not None assert result.url == req.url result = q.pop() assert result is not None assert result.url == req.url assert len(q) == 0 assert q.peek() is None assert q.pop() is None def test_order(self, q: queuelib.queue.BaseQueue): assert len(q) == 0 assert q.peek() is None assert q.pop() is None req1 = Request("http://www.example.com/1") req2 = Request("http://www.example.com/2") req3 = Request("http://www.example.com/3") q.push(req1) q.push(req2) q.push(req3) reqs = [req1, req2, req3] if self.is_fifo else [req3, req2, req1] for i, req in enumerate(reqs): assert len(q) == 3 - i result = q.peek() assert result is not None assert result.url == req.url result = q.pop() assert result is not None assert result.url == req.url assert len(q) == 0 assert q.peek() is None assert q.pop() is None class TestPickleFifoDiskQueueRequest(TestRequestQueueBase): is_fifo = True @pytest.fixture def q(self, crawler, tmp_path): queue = build_from_crawler( PickleFifoDiskQueue, crawler, key=str(tmp_path / "pickle" / "fifo") ) try: yield queue finally: queue.close() class TestPickleLifoDiskQueueRequest(TestRequestQueueBase): is_fifo = False @pytest.fixture def q(self, crawler, tmp_path): queue = build_from_crawler( PickleLifoDiskQueue, crawler, key=str(tmp_path / "pickle" / "lifo") ) try: yield queue finally: queue.close() class TestMarshalFifoDiskQueueRequest(TestRequestQueueBase): is_fifo = True @pytest.fixture def q(self, crawler, tmp_path): queue = build_from_crawler( MarshalFifoDiskQueue, crawler, key=str(tmp_path / "marshal" / "fifo") ) try: yield queue finally: queue.close() class TestMarshalLifoDiskQueueRequest(TestRequestQueueBase): is_fifo = False @pytest.fixture def q(self, crawler, tmp_path): queue = build_from_crawler( MarshalLifoDiskQueue, crawler, key=str(tmp_path / "marshal" / "lifo") ) try: yield queue finally: queue.close() class TestPickleFifoSQLiteQueueRequest(TestRequestQueueBase): is_fifo = True @pytest.fixture def q(self, crawler, tmp_path): queue = build_from_crawler( PickleFifoSQLiteQueue, crawler, key=str(tmp_path / "pickle" / "fifo") ) try: yield queue finally: queue.close() class TestPickleLifoSQLiteQueueRequest(TestRequestQueueBase): is_fifo = False @pytest.fixture def q(self, crawler, tmp_path): queue = build_from_crawler( PickleLifoSQLiteQueue, crawler, key=str(tmp_path / "pickle" / "lifo") ) try: yield queue finally: queue.close() class TestMarshalFifoSQLiteQueueRequest(TestRequestQueueBase): is_fifo = True @pytest.fixture def q(self, crawler, tmp_path): queue = build_from_crawler( MarshalFifoSQLiteQueue, crawler, key=str(tmp_path / "marshal" / "fifo") ) try: yield queue finally: queue.close() class TestMarshalLifoSQLiteQueueRequest(TestRequestQueueBase): is_fifo = False @pytest.fixture def q(self, crawler, tmp_path): queue = build_from_crawler( MarshalLifoSQLiteQueue, crawler, key=str(tmp_path / "marshal" / "lifo") ) try: yield queue finally: queue.close() @pytest.mark.parametrize( "queue_cls", [ PickleFifoSQLiteQueue, PickleLifoSQLiteQueue, MarshalFifoSQLiteQueue, MarshalLifoSQLiteQueue, ], ) def test_sqlite_queue_survives_unclean_shutdown( crawler: Crawler, tmp_path: Path, queue_cls: Any ) -> None: key = str(tmp_path / "queue") queue = queue_cls.from_crawler(crawler=crawler, key=key) queue.push(Request("https://toscrape.com")) # No close(), as if the process had been killed. The connection is closed # directly instead, since on Windows the file cannot be removed later while # it is still open. queue._db.close() queue = queue_cls.from_crawler(crawler=crawler, key=key) try: assert len(queue) == 1 request = queue.pop() assert request is not None assert request.url == "https://toscrape.com" finally: queue.close() class TestFifoMemoryQueueRequest(TestRequestQueueBase): is_fifo = True @pytest.fixture def q(self, crawler): return build_from_crawler(FifoMemoryQueue, crawler) class TestLifoMemoryQueueRequest(TestRequestQueueBase): is_fifo = False @pytest.fixture def q(self, crawler): return build_from_crawler(LifoMemoryQueue, crawler) scrapy-scrapy-5569de3/tests/test_stats.py000066400000000000000000000200231525051012400205610ustar00rootroot00000000000000from __future__ import annotations from datetime import datetime from typing import TYPE_CHECKING from unittest import mock import pytest from scrapy.exceptions import ScrapyDeprecationWarning from scrapy.extensions.corestats import CoreStats from scrapy.spiders import Spider from scrapy.statscollectors import DummyStatsCollector, StatsCollector from scrapy.utils.misc import build_from_crawler from scrapy.utils.test import get_crawler from tests.spiders import SimpleSpider from tests.utils.decorators import coroutine_test if TYPE_CHECKING: from scrapy.crawler import Crawler @pytest.fixture def crawler() -> Crawler: return get_crawler(Spider) @pytest.fixture def spider(crawler: Crawler) -> Spider: return crawler._create_spider("foo") class TestCoreStatsExtension: @mock.patch("scrapy.extensions.corestats.monotonic", return_value=0) @mock.patch("scrapy.extensions.corestats.datetime") def test_core_stats_default_stats_collector( self, mock_datetime: mock.Mock, mock_monotonic: mock.Mock, crawler: Crawler, spider: Spider, ) -> None: fixed_datetime = datetime(2019, 12, 1, 11, 38) mock_datetime.now = mock.Mock(return_value=fixed_datetime) crawler.stats = StatsCollector(crawler) ext = build_from_crawler(CoreStats, crawler) ext.spider_opened(spider) ext.item_scraped({}, spider) ext.response_received(spider) ext.item_dropped({}, spider, ZeroDivisionError()) ext.spider_closed(spider, "finished") assert ext.stats._stats == { "start_time": fixed_datetime, "finish_time": fixed_datetime, "item_scraped_count": 1, "response_received_count": 1, "item_dropped_count": 1, "item_dropped_reasons_count/ZeroDivisionError": 1, "finish_reason": "finished", "elapsed_time_seconds": 0.0, } def test_core_stats_dummy_stats_collector( self, crawler: Crawler, spider: Spider ) -> None: crawler.stats = DummyStatsCollector(crawler) ext = build_from_crawler(CoreStats, crawler) ext.spider_opened(spider) ext.item_scraped({}, spider) ext.response_received(spider) ext.item_dropped({}, spider, ZeroDivisionError()) ext.spider_closed(spider, "finished") assert ext.stats._stats == {} class TestStatsCollector: def test_collector(self, crawler: Crawler) -> None: stats = StatsCollector(crawler) assert stats.get_stats() == {} assert stats.get_value("anything") is None assert stats.get_value("anything", "default") == "default" stats.set_value("test", "value") assert stats.get_stats() == {"test": "value"} stats.set_value("test2", 23) assert stats.get_stats() == {"test": "value", "test2": 23} assert stats.get_value("test2") == 23 stats.inc_value("test2") assert stats.get_value("test2") == 24 stats.inc_value("test2", 6) assert stats.get_value("test2") == 30 stats.max_value("test2", 6) assert stats.get_value("test2") == 30 stats.max_value("test2", 40) assert stats.get_value("test2") == 40 stats.max_value("test3", 1) assert stats.get_value("test3") == 1 stats.min_value("test2", 60) assert stats.get_value("test2") == 40 stats.min_value("test2", 35) assert stats.get_value("test2") == 35 stats.min_value("test4", 7) assert stats.get_value("test4") == 7 stats.set_stats({"replaced": "stats"}) assert stats.get_stats() == {"replaced": "stats"} stats.clear_stats() assert stats.get_stats() == {} def test_dummy_collector(self) -> None: crawler = get_crawler(Spider, {"STATS_DUMP": False}) stats = DummyStatsCollector(crawler) assert stats.get_stats() == {} assert stats.get_value("anything") is None assert stats.get_value("anything", "default") == "default" stats.set_value("test", "value") stats.inc_value("v1") stats.max_value("v2", 100) stats.min_value("v3", 100) stats.set_stats({"key": "val"}) stats.open_spider() stats.set_value("test", "value") assert stats.get_stats() == {} stats.close_spider() def test_deprecated_spider_arg(self, crawler: Crawler, spider: Spider) -> None: stats = StatsCollector(crawler) with pytest.warns( ScrapyDeprecationWarning, match=r"Passing a 'spider' argument to StatsCollector\.set_value\(\) is deprecated", ): stats.set_value("test", "value", spider=spider) assert stats.get_stats() == {"test": "value"} with pytest.warns( ScrapyDeprecationWarning, match=r"Passing a 'spider' argument to StatsCollector\.get_stats\(\) is deprecated", ): assert stats.get_stats(spider) == {"test": "value"} @coroutine_test async def test_deprecated_spider_arg_custom_collector(self) -> None: # the class reimplements many methods because those are called during the test crawl class CustomStatsCollector: def __init__(self, crawler): self._stats = {} def open_spider(self, spider): pass def get_stats(self, spider=None): return self._stats def get_value(self, key, default=None, spider=None): return self._stats.get(key, default) def set_value(self, key, value, spider=None): self._stats[key] = value def inc_value(self, key, count=1, start=0, spider=None): d = self._stats d[key] = d.setdefault(key, start) + count def max_value(self, key, value, spider=None) -> None: self._stats[key] = max(self._stats.setdefault(key, value), value) def close_spider(self, spider, reason): pass crawler = get_crawler(SimpleSpider, {"STATS_CLASS": CustomStatsCollector}) with ( pytest.warns( ScrapyDeprecationWarning, match=r"The open_spider\(\) method of .*CustomStatsCollector requires a spider argument", ), pytest.warns( ScrapyDeprecationWarning, match=r"The close_spider\(\) method of .*CustomStatsCollector requires a spider argument", ), ): await crawler.crawl_async(url="data:,") @coroutine_test async def test_deprecated_spider_arg_custom_collector_subclass(self) -> None: class CustomStatsCollector(StatsCollector): def open_spider(self, spider): # type: ignore[override] # pylint: disable=signature-differs super().open_spider(spider) def inc_value(self, key, count=1, start=0, spider=None): # pylint: disable=useless-parent-delegation super().inc_value(key, count, start, spider) def close_spider(self, spider, reason): # type: ignore[override] # pylint: disable=signature-differs super().close_spider(spider, reason) crawler = get_crawler(SimpleSpider, {"STATS_CLASS": CustomStatsCollector}) with ( pytest.warns( ScrapyDeprecationWarning, match=r"The open_spider\(\) method of .*CustomStatsCollector requires a spider argument", ), pytest.warns( ScrapyDeprecationWarning, match=r"Passing a 'spider' argument to .*CustomStatsCollector\.open_spider\(\) is deprecated", ), pytest.warns( ScrapyDeprecationWarning, match=r"The close_spider\(\) method of .*CustomStatsCollector requires a spider argument", ), pytest.warns( ScrapyDeprecationWarning, match=r"Passing a 'spider' argument to .*CustomStatsCollector\.close_spider\(\) is deprecated", ), ): await crawler.crawl_async(url="data:,") scrapy-scrapy-5569de3/tests/test_toplevel.py000066400000000000000000000013451525051012400212630ustar00rootroot00000000000000import scrapy def test_version(): assert isinstance(scrapy.__version__, str) def test_version_info(): assert isinstance(scrapy.version_info, tuple) def test_request_shortcut(): from scrapy.http import FormRequest, Request # noqa: PLC0415 assert scrapy.Request is Request assert scrapy.FormRequest is FormRequest def test_spider_shortcut(): from scrapy.spiders import Spider # noqa: PLC0415 assert scrapy.Spider is Spider def test_selector_shortcut(): from scrapy.selector import Selector # noqa: PLC0415 assert scrapy.Selector is Selector def test_item_shortcut(): from scrapy.item import Field, Item # noqa: PLC0415 assert scrapy.Item is Item assert scrapy.Field is Field scrapy-scrapy-5569de3/tests/test_utils_asyncgen.py000066400000000000000000000007601525051012400224600ustar00rootroot00000000000000from __future__ import annotations from scrapy.utils.asyncgen import as_async_generator, collect_asyncgen from tests.utils.decorators import coroutine_test @coroutine_test async def test_as_async_generator(): ag = as_async_generator(range(42)) results = [i async for i in ag] assert results == list(range(42)) @coroutine_test async def test_collect_asyncgen(): ag = as_async_generator(range(42)) results = await collect_asyncgen(ag) assert results == list(range(42)) scrapy-scrapy-5569de3/tests/test_utils_asyncio.py000066400000000000000000000126031525051012400223150ustar00rootroot00000000000000from __future__ import annotations import asyncio import random from typing import TYPE_CHECKING from unittest import mock import pytest from twisted.internet.defer import Deferred from scrapy.utils.asyncgen import as_async_generator from scrapy.utils.asyncio import ( AsyncioLoopingCall, _parallel_asyncio, call_later, is_asyncio_available, sleep, ) from tests.utils.decorators import coroutine_test if TYPE_CHECKING: from collections.abc import AsyncGenerator @coroutine_test async def test_is_asyncio_available(reactor_pytest: str) -> None: # the result should depend only on the pytest --reactor argument assert is_asyncio_available() == (reactor_pytest != "default") @coroutine_test async def test_sleep() -> None: events: list[str] = [] call_later(0.05, events.append, "call_later") await sleep(0.1) events.append("sleep") assert events == ["call_later", "sleep"] @pytest.mark.only_asyncio class TestParallelAsyncio: """Test for scrapy.utils.asyncio.parallel_asyncio(), based on tests.test_utils_defer.TestParallelAsync.""" CONCURRENT_ITEMS = 50 @staticmethod async def callable(o: int, results: list[int]) -> None: if random.random() < 0.4: # simulate async processing await asyncio.sleep(random.random() / 8) # simulate trivial sync processing results.append(o) async def callable_wrapped( self, o: int, results: list[int], parallel_count: list[int], max_parallel_count: list[int], ) -> None: parallel_count[0] += 1 max_parallel_count[0] = max(max_parallel_count[0], parallel_count[0]) await self.callable(o, results) assert parallel_count[0] > 0, parallel_count[0] parallel_count[0] -= 1 @staticmethod def get_async_iterable(length: int) -> AsyncGenerator[int, None]: # simulate a simple callback without delays between results return as_async_generator(range(length)) @staticmethod async def get_async_iterable_with_delays(length: int) -> AsyncGenerator[int, None]: # simulate a callback with delays between some of the results for i in range(length): if random.random() < 0.1: await asyncio.sleep(random.random() / 20) yield i @coroutine_test async def test_simple(self): for length in [20, 50, 100]: parallel_count = [0] max_parallel_count = [0] results: list[int] = [] ait = self.get_async_iterable(length) await _parallel_asyncio( ait, self.CONCURRENT_ITEMS, self.callable_wrapped, results, parallel_count, max_parallel_count, ) assert list(range(length)) == sorted(results) assert parallel_count[0] == 0 assert max_parallel_count[0] <= self.CONCURRENT_ITEMS @coroutine_test async def test_delays(self): for length in [20, 50, 100]: parallel_count = [0] max_parallel_count = [0] results: list[int] = [] ait = self.get_async_iterable_with_delays(length) await _parallel_asyncio( ait, self.CONCURRENT_ITEMS, self.callable_wrapped, results, parallel_count, max_parallel_count, ) assert list(range(length)) == sorted(results) assert parallel_count[0] == 0 assert max_parallel_count[0] <= self.CONCURRENT_ITEMS @pytest.mark.only_asyncio class TestAsyncioLoopingCall: @coroutine_test async def test_looping_call(self): func = mock.MagicMock() looping_call = AsyncioLoopingCall(func) looping_call.start(1, now=False) assert looping_call.running looping_call.stop() assert not looping_call.running assert not func.called @coroutine_test async def test_looping_call_now(self): func = mock.MagicMock() looping_call = AsyncioLoopingCall(func) looping_call.start(1) looping_call.stop() assert func.called @coroutine_test async def test_looping_call_already_running(self): looping_call = AsyncioLoopingCall(lambda: None) looping_call.start(1) with pytest.raises(RuntimeError): looping_call.start(1) looping_call.stop() @coroutine_test async def test_looping_call_interval(self): looping_call = AsyncioLoopingCall(lambda: None) with pytest.raises(ValueError, match="Interval must be greater than 0"): looping_call.start(0) with pytest.raises(ValueError, match="Interval must be greater than 0"): looping_call.start(-1) assert not looping_call.running @coroutine_test async def test_looping_call_bad_function(self): looping_call = AsyncioLoopingCall(Deferred) with pytest.raises(TypeError): looping_call.start(0.1) assert not looping_call.running @coroutine_test async def test_looping_function_raises( self, caplog: pytest.LogCaptureFixture ) -> None: looping_call = AsyncioLoopingCall(lambda: 1 / 0) looping_call.start(0.1) assert not looping_call.running assert "Error calling the AsyncioLoopingCall function" in caplog.text scrapy-scrapy-5569de3/tests/test_utils_benchserver.py000066400000000000000000000024031525051012400231530ustar00rootroot00000000000000from __future__ import annotations import re from typing import TYPE_CHECKING, cast from scrapy.utils.benchserver import Root, _getarg if TYPE_CHECKING: from twisted.web.server import Request class _Request: def __init__(self, **args: bytes) -> None: self.args = {name.encode(): [value] for name, value in args.items()} self.written: list[bytes] = [] def write(self, data: bytes) -> None: self.written.append(data) def test_getarg() -> None: request = cast("Request", _Request(total=b"5")) assert _getarg(request, b"total", 100, int) == 5 assert _getarg(request, b"show", 100, int) == 100 assert _getarg(request, b"missing") is None def test_render() -> None: root = Root() # type: ignore[no-untyped-call] request = _Request(total=b"5", show=b"2") assert root.getChild("follow", cast("Request", request)) is root assert root.render(cast("Request", request)) == b"" body = b"".join(request.written).decode() assert body.startswith("") assert body.endswith("") numbers = re.findall( r"follow \1", body ) assert len(numbers) == 2 assert all(1 <= int(number) <= 5 for number in numbers) scrapy-scrapy-5569de3/tests/test_utils_conf.py000066400000000000000000000155171525051012400216040ustar00rootroot00000000000000from __future__ import annotations from typing import Any import pytest from scrapy.exceptions import UsageError from scrapy.settings import BaseSettings, Settings from scrapy.utils.conf import ( arglist_to_dict, build_component_list, closest_scrapy_cfg, feed_complete_default_values_from_settings, feed_process_params_from_cli, get_sources, ) class TestBuildComponentList: def test_build_dict(self): d = {"one": 1, "two": None, "three": 8, "four": 4} assert build_component_list(d, convert=lambda x: x) == ["one", "four", "three"] def test_duplicate_components_in_basesettings(self): # Higher priority takes precedence duplicate_bs = BaseSettings({"one": 1, "two": 2}, priority=0) duplicate_bs.set("ONE", 4, priority=10) assert build_component_list(duplicate_bs, convert=lambda x: x.lower()) == [ "two", "one", ] duplicate_bs.set("one", duplicate_bs["one"], priority=20) assert build_component_list(duplicate_bs, convert=lambda x: x.lower()) == [ "one", "two", ] # Same priority raises ValueError duplicate_bs.set("ONE", duplicate_bs["ONE"], priority=20) with pytest.raises( ValueError, match=r"Some paths in .* convert to the same object" ): build_component_list(duplicate_bs, convert=lambda x: x.lower()) def test_duplicate_components_in_dict(self): d = {"one": 1, "ONE": 2} with pytest.raises( ValueError, match=r"Some paths in .* convert to the same object" ): build_component_list(d, convert=lambda x: x.lower()) def test_invalid_value(self): d = {"one": "1"} with pytest.raises( ValueError, match=r"Invalid value 1 for component one, please provide" ): build_component_list(d, convert=lambda x: x) def test_valid_numbers(self): # work well with None and numeric values d = {"a": 10, "b": None, "c": 15, "d": 5.0} assert build_component_list(d, convert=lambda x: x) == ["d", "a", "c"] d = { "a": 33333333333333333333, "b": 11111111111111111111, "c": 22222222222222222222, } assert build_component_list(d, convert=lambda x: x) == ["b", "c", "a"] def test_get_sources(): assert get_sources() == [*get_sources(use_closest=False), closest_scrapy_cfg()] def test_arglist_to_dict(): assert arglist_to_dict(["arg1=val1", "arg2=val2"]) == { "arg1": "val1", "arg2": "val2", } class TestFeedExportConfig: def test_feed_export_config_invalid_format(self): settings = Settings() with pytest.raises(UsageError): feed_process_params_from_cli(settings, ["items.dat"]) def test_feed_export_config_mismatch(self): settings = Settings() with pytest.raises(UsageError): feed_process_params_from_cli(settings, ["items1.dat", "items2.dat"]) def test_feed_export_config_explicit_formats(self): settings = Settings() assert { "items_1.dat": {"format": "json"}, "items_2.dat": {"format": "xml"}, "items_3.dat": {"format": "csv"}, } == feed_process_params_from_cli( settings, ["items_1.dat:json", "items_2.dat:xml", "items_3.dat:csv"] ) def test_feed_export_config_implicit_formats(self): settings = Settings() assert { "items_1.json": {"format": "json"}, "items_2.xml": {"format": "xml"}, "items_3.csv": {"format": "csv"}, } == feed_process_params_from_cli( settings, ["items_1.json", "items_2.xml", "items_3.csv"] ) def test_feed_export_config_stdout(self): settings = Settings() assert {"stdout:": {"format": "pickle"}} == feed_process_params_from_cli( settings, ["-:pickle"] ) def test_feed_export_config_overwrite(self): settings = Settings() assert { "output.json": {"format": "json", "overwrite": True} } == feed_process_params_from_cli( settings, [], overwrite_output=["output.json"] ) def test_output_and_overwrite_output(self): with pytest.raises(UsageError): feed_process_params_from_cli( Settings(), ["output1.json"], overwrite_output=["output2.json"] ) def test_feed_complete_default_values_from_settings_empty(self): feed: dict[str, Any] = {} settings = Settings( { "FEED_EXPORT_ENCODING": "custom encoding", "FEED_EXPORT_FIELDS": ["f1", "f2", "f3"], "FEED_EXPORT_INDENT": 42, "FEED_STORE_EMPTY": True, "FEED_URI_PARAMS": (1, 2, 3, 4), "FEED_EXPORT_BATCH_ITEM_COUNT": 2, } ) new_feed = feed_complete_default_values_from_settings(feed, settings) assert new_feed == { "encoding": "custom encoding", "fields": ["f1", "f2", "f3"], "indent": 42, "store_empty": True, "uri_params": (1, 2, 3, 4), "batch_item_count": 2, "item_export_kwargs": {}, } def test_feed_complete_default_values_from_settings_non_empty(self): feed = { "encoding": "other encoding", "fields": None, } settings = Settings( { "FEED_EXPORT_ENCODING": "custom encoding", "FEED_EXPORT_FIELDS": ["f1", "f2", "f3"], "FEED_EXPORT_INDENT": 42, "FEED_STORE_EMPTY": True, "FEED_EXPORT_BATCH_ITEM_COUNT": 2, } ) new_feed = feed_complete_default_values_from_settings(feed, settings) assert new_feed == { "encoding": "other encoding", "fields": None, "indent": 42, "store_empty": True, "uri_params": None, "batch_item_count": 2, "item_export_kwargs": {}, } def test_feed_complete_default_values_from_settings_format_from_uri(self): feed: dict[str, Any] = {} new_feed = feed_complete_default_values_from_settings( feed, Settings(), "output.json" ) assert new_feed["format"] == "json" def test_feed_complete_default_values_from_settings_format_kept(self): feed = {"format": "csv"} new_feed = feed_complete_default_values_from_settings( feed, Settings(), "output.json" ) assert new_feed["format"] == "csv" def test_feed_complete_default_values_from_settings_format_not_inferable(self): feed: dict[str, Any] = {} new_feed = feed_complete_default_values_from_settings( feed, Settings(), "stdout:" ) assert new_feed["format"] == "" scrapy-scrapy-5569de3/tests/test_utils_console.py000066400000000000000000000151161525051012400223140ustar00rootroot00000000000000from __future__ import annotations import subprocess import sys from importlib.util import find_spec from io import BytesIO from types import ModuleType from typing import TYPE_CHECKING import pytest from pexpect import EOF from scrapy.utils.console import get_shell_embed_func, start_python_console from scrapy.utils.test import get_testenv if TYPE_CHECKING: from pathlib import Path CONSOLE = """ from scrapy.utils.console import start_python_console start_python_console(banner="SHELL-READY", shells=["ipython"]) """ CONSOLE_IN_RUNNING_LOOP = """ import asyncio from scrapy.utils.console import start_python_console async def main(): start_python_console(banner="SHELL-READY", shells=["ipython"]) asyncio.run(main()) """ def test_get_shell_embed_func(): shell = get_shell_embed_func(["invalid"]) assert shell is None shell = get_shell_embed_func(["invalid", "python"]) assert callable(shell) assert shell.__name__ == "_embed_standard_shell" def test_get_shell_embed_func_known_shells(): def embed(namespace: dict[str, object] | None = None, banner: str = "") -> None: pass known_shells = {"custom": lambda: embed} assert get_shell_embed_func(["custom"], known_shells) is embed assert get_shell_embed_func(["python"], known_shells) is None def test_get_shell_embed_func_python(): # the standard shell is always available shell = get_shell_embed_func(["python"]) assert callable(shell) assert shell.__name__ == "_embed_standard_shell" def test_get_shell_embed_func_bpython(): pytest.importorskip("bpython") shell = get_shell_embed_func(["bpython"]) assert callable(shell) assert shell.__name__ == "_embed_bpython_shell" def test_embed_bpython_shell(monkeypatch: pytest.MonkeyPatch) -> None: # bpython is never the default shell, so a stand-in module takes the place # of the interactive session the IPython tests below use. calls: list[tuple[dict[str, object], str]] = [] bpython = ModuleType("bpython") monkeypatch.setattr( bpython, "embed", lambda locals_, banner: calls.append((locals_, banner)), raising=False, ) monkeypatch.setitem(sys.modules, "bpython", bpython) shell = get_shell_embed_func(["bpython"]) assert shell is not None shell({"a": 1}, "SHELL-READY") assert calls == [({"a": 1}, "SHELL-READY")] def test_get_shell_embed_func_ipython(): pytest.importorskip("IPython") shell = get_shell_embed_func(["ipython"]) assert shell is not None assert shell.__name__ == "_embed_ipython_shell" def test_get_shell_embed_func_ptpython(): pytest.importorskip("ptpython") shell = get_shell_embed_func(["ptpython"]) assert shell is not None assert shell.__name__ == "_embed_ptpython_shell" def test_get_shell_embed_func_default(): # with no shells given, the first available shell in preference order # (ptpython, ipython, bpython, python) is returned; the standard shell # is always available, so the default is never None shell = get_shell_embed_func() assert shell is not None if find_spec("ptpython") is not None: expected = "_embed_ptpython_shell" elif find_spec("IPython") is not None: expected = "_embed_ipython_shell" elif find_spec("bpython") is not None: expected = "_embed_bpython_shell" else: expected = "_embed_standard_shell" assert shell.__name__ == expected @pytest.mark.skipif(find_spec("IPython") is None, reason="IPython is not installed") class TestIPythonShell: """Starting an IPython shell, with and without an asyncio event loop already running in the calling thread. The latter happens when inspect_response() is called from a spider callback while using the asyncio reactor.""" @staticmethod def _env(tmp_path: Path) -> dict[str, str]: env = get_testenv() # Keep IPython away from the profile and history of the user running the tests. env["IPYTHONDIR"] = str(tmp_path) return env def test_simple_prompt(self, tmp_path: Path) -> None: """IPython falls back to its simple prompt, which needs no event loop, when stdin is not a TTY.""" env = self._env(tmp_path) p = subprocess.run( [sys.executable, "-c", CONSOLE_IN_RUNNING_LOOP], check=False, capture_output=True, encoding="utf-8", timeout=60, env=env, stdin=subprocess.DEVNULL, ) output = p.stdout + p.stderr assert "SHELL-READY" in output assert p.returncode == 0, output @pytest.mark.skipif( sys.platform == "win32", reason="requires a POSIX pseudo-terminal" ) # The child of the pseudo-terminal fork execs right away, so the deadlocks # that Python warns about cannot happen. @pytest.mark.filterwarnings( "ignore:.*is multi-threaded, use of forkpty:DeprecationWarning" ) @pytest.mark.parametrize( "script", [CONSOLE, CONSOLE_IN_RUNNING_LOOP], ids=["no_running_loop", "running_loop"], ) def test_tty(self, tmp_path: Path, script: str) -> None: """IPython uses prompt_toolkit, which needs an event loop of its own, when stdin is a TTY.""" # pexpect only defines spawn, which needs a pseudo-terminal, on POSIX. from pexpect import spawn # noqa: PLC0415 env = self._env(tmp_path) env.pop("IPY_TEST_SIMPLE_PROMPT", None) env["TERM"] = "xterm" logfile = BytesIO() p = spawn( sys.executable, ["-c", script], env=env, timeout=60, ) p.logfile_read = logfile try: # Wait for the prompt, which prompt_toolkit draws once it is done # querying the terminal, before typing into it. p.expect(r"In \[") p.sendline("21*2") p.expect_exact("42") p.sendline("exit()") p.expect(EOF) finally: p.close() output = logfile.getvalue().decode() assert "Traceback" not in output assert p.exitstatus == 0, output def test_start_python_console_exit(monkeypatch: pytest.MonkeyPatch) -> None: def embed(namespace: dict[str, object], banner: str) -> None: raise SystemExit monkeypatch.setattr( "scrapy.utils.console.get_shell_embed_func", lambda shells: embed ) start_python_console() def test_start_python_console_no_shell(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr( "scrapy.utils.console.get_shell_embed_func", lambda shells: None ) start_python_console() scrapy-scrapy-5569de3/tests/test_utils_curl.py000066400000000000000000000301341525051012400216140ustar00rootroot00000000000000from __future__ import annotations import warnings from typing import Any import pytest from w3lib.http import basic_auth_header from scrapy import Request from scrapy.utils.curl import curl_to_request_kwargs class TestCurlToRequestKwargs: @staticmethod def _test_command(curl_command: str, expected_result: dict[str, Any]) -> None: result = curl_to_request_kwargs(curl_command) assert result == expected_result try: Request(**result) except TypeError as e: pytest.fail(f"Request kwargs are not correct {e}") def test_get(self): curl_command = "curl http://example.org/" expected_result = {"method": "GET", "url": "http://example.org/"} self._test_command(curl_command, expected_result) def test_get_without_scheme(self): curl_command = "curl www.example.org" expected_result = {"method": "GET", "url": "http://www.example.org"} self._test_command(curl_command, expected_result) def test_get_basic_auth(self): curl_command = 'curl "https://api.test.com/" -u "some_username:some_password"' expected_result = { "method": "GET", "url": "https://api.test.com/", "headers": [ ("Authorization", basic_auth_header("some_username", "some_password")) ], } self._test_command(curl_command, expected_result) def test_get_cookie_option(self): curl_command = 'curl "http://example.org/" -b "a=1; b=2"' expected_result = { "method": "GET", "url": "http://example.org/", "cookies": {"a": "1", "b": "2"}, } self._test_command(curl_command, expected_result) def test_get_cookie_file_option(self): # curl reads cookies from a file when the value is not a key-value # pair; Scrapy ignores it. curl_command = 'curl "http://example.org/" -b cookies.txt -b "a=1"' expected_result = { "method": "GET", "url": "http://example.org/", "cookies": {"a": "1"}, } self._test_command(curl_command, expected_result) def test_get_complex(self): curl_command = ( "curl 'http://httpbin.org/get' -H 'Accept-Encoding: gzip, deflate'" " -H 'Accept-Language: en-US,en;q=0.9,ru;q=0.8,es;q=0.7' -H 'Upgra" "de-Insecure-Requests: 1' -H 'User-Agent: Mozilla/5.0 (X11; Linux " "x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Ubuntu Chromium/62" ".0.3202.75 Chrome/62.0.3202.75 Safari/537.36' -H 'Accept: text/ht" "ml,application/xhtml+xml,application/xml;q=0.9,image/webp,image/a" "png,*/*;q=0.8' -H 'Referer: http://httpbin.org/' -H 'Cookie: _gau" "ges_unique_year=1; _gauges_unique=1; _gauges_unique_month=1; _gau" "ges_unique_hour=1' -H 'Connection: keep-alive' --compressed -b '_" "gauges_unique_day=1'" ) expected_result = { "method": "GET", "url": "http://httpbin.org/get", "headers": [ ("Accept-Encoding", "gzip, deflate"), ("Accept-Language", "en-US,en;q=0.9,ru;q=0.8,es;q=0.7"), ("Upgrade-Insecure-Requests", "1"), ( "User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML" ", like Gecko) Ubuntu Chromium/62.0.3202.75 Chrome/62.0.32" "02.75 Safari/537.36", ), ( "Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,ima" "ge/webp,image/apng,*/*;q=0.8", ), ("Referer", "http://httpbin.org/"), ("Connection", "keep-alive"), ], "cookies": { "_gauges_unique_year": "1", "_gauges_unique_hour": "1", "_gauges_unique_day": "1", "_gauges_unique": "1", "_gauges_unique_month": "1", }, } self._test_command(curl_command, expected_result) def test_post(self): curl_command = ( "curl 'http://httpbin.org/post' -X POST -H 'Cookie: _gauges_unique" "_year=1; _gauges_unique=1; _gauges_unique_month=1; _gauges_unique" "_hour=1; _gauges_unique_day=1' -H 'Origin: http://httpbin.org' -H" " 'Accept-Encoding: gzip, deflate' -H 'Accept-Language: en-US,en;q" "=0.9,ru;q=0.8,es;q=0.7' -H 'Upgrade-Insecure-Requests: 1' -H 'Use" "r-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTM" "L, like Gecko) Ubuntu Chromium/62.0.3202.75 Chrome/62.0.3202.75 S" "afari/537.36' -H 'Content-Type: application/x-www-form-urlencoded" "' -H 'Accept: text/html,application/xhtml+xml,application/xml;q=0" ".9,image/webp,image/apng,*/*;q=0.8' -H 'Cache-Control: max-age=0'" " -H 'Referer: http://httpbin.org/forms/post' -H 'Connection: keep" "-alive' --data 'custname=John+Smith&custtel=500&custemail=jsmith%" "40example.org&size=small&topping=cheese&topping=onion&delivery=12" "%3A15&comments=' --compressed" ) expected_result = { "method": "POST", "url": "http://httpbin.org/post", "body": "custname=John+Smith&custtel=500&custemail=jsmith%40exampl" "e.org&size=small&topping=cheese&topping=onion&delivery=12" "%3A15&comments=", "cookies": { "_gauges_unique_year": "1", "_gauges_unique_hour": "1", "_gauges_unique_day": "1", "_gauges_unique": "1", "_gauges_unique_month": "1", }, "headers": [ ("Origin", "http://httpbin.org"), ("Accept-Encoding", "gzip, deflate"), ("Accept-Language", "en-US,en;q=0.9,ru;q=0.8,es;q=0.7"), ("Upgrade-Insecure-Requests", "1"), ( "User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML" ", like Gecko) Ubuntu Chromium/62.0.3202.75 Chrome/62.0.32" "02.75 Safari/537.36", ), ("Content-Type", "application/x-www-form-urlencoded"), ( "Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,ima" "ge/webp,image/apng,*/*;q=0.8", ), ("Cache-Control", "max-age=0"), ("Referer", "http://httpbin.org/forms/post"), ("Connection", "keep-alive"), ], } self._test_command(curl_command, expected_result) def test_post_data_raw(self): curl_command = ( "curl 'https://www.example.org/' --data-raw 'excerptLength=200&ena" "bleDidYouMean=true&sortCriteria=ffirstz32xnamez32x201740686%20asc" "ending&queryFunctions=%5B%5D&rankingFunctions=%5B%5D'" ) expected_result = { "method": "POST", "url": "https://www.example.org/", "body": ( "excerptLength=200&enableDidYouMean=true&sortCriteria=ffirstz3" "2xnamez32x201740686%20ascending&queryFunctions=%5B%5D&ranking" "Functions=%5B%5D" ), } self._test_command(curl_command, expected_result) def test_post_data_raw_with_string_prefix(self): curl_command = "curl 'https://www.example.org/' --data-raw $'{\"$filters\":\"Filter\u0021\"}'" expected_result = { "method": "POST", "url": "https://www.example.org/", "body": '{"$filters":"Filter!"}', } self._test_command(curl_command, expected_result) def test_post_data_multiple(self): # curl merges repeated -d/--data/--data-raw options into a single body # joined with "&"; scrapy must do the same, not keep only the last one. curl_command = "curl 'https://www.example.org/' -d 'a=1' -d 'b=2' -d 'c=3'" expected_result = { "method": "POST", "url": "https://www.example.org/", "body": "a=1&b=2&c=3", } self._test_command(curl_command, expected_result) def test_post_data_multiple_mixed_flag_names(self): curl_command = ( "curl 'https://www.example.org/' --data 'a=1' --data-raw 'b=2' -d 'c=3'" ) expected_result = { "method": "POST", "url": "https://www.example.org/", "body": "a=1&b=2&c=3", } self._test_command(curl_command, expected_result) def test_post_data_multiple_with_string_prefix(self): # The leading "$" left by bash $'...' quoting is stripped per option, # before the values are merged. curl_command = "curl 'https://www.example.org/' -d $'a=1' -d $'b=2'" expected_result = { "method": "POST", "url": "https://www.example.org/", "body": "a=1&b=2", } self._test_command(curl_command, expected_result) def test_explicit_get_with_data(self): curl_command = "curl httpbin.org/anything -X GET --data asdf" expected_result = { "method": "GET", "url": "http://httpbin.org/anything", "body": "asdf", } self._test_command(curl_command, expected_result) def test_patch(self): curl_command = ( 'curl "https://example.com/api/fake" -u "username:password" -H "Ac' 'cept: application/vnd.go.cd.v4+json" -H "Content-Type: applicatio' 'n/json" -X PATCH -d \'{"hostname": "agent02.example.com", "agent' '_config_state": "Enabled", "resources": ["Java","Linux"], "enviro' 'nments": ["Dev"]}\'' ) expected_result = { "method": "PATCH", "url": "https://example.com/api/fake", "headers": [ ("Accept", "application/vnd.go.cd.v4+json"), ("Content-Type", "application/json"), ("Authorization", basic_auth_header("username", "password")), ], "body": '{"hostname": "agent02.example.com", "agent_config_state"' ': "Enabled", "resources": ["Java","Linux"], "environments' '": ["Dev"]}', } self._test_command(curl_command, expected_result) def test_delete(self): curl_command = 'curl -X "DELETE" https://www.url.com/page' expected_result = {"method": "DELETE", "url": "https://www.url.com/page"} self._test_command(curl_command, expected_result) def test_get_silent(self): curl_command = 'curl --silent "www.example.com"' expected_result = {"method": "GET", "url": "http://www.example.com"} assert curl_to_request_kwargs(curl_command) == expected_result def test_too_few_arguments_error(self): with pytest.raises( ValueError, match=r"too few arguments|the following arguments are required:\s*url", ): curl_to_request_kwargs("curl") def test_ignore_unknown_options(self): # case 1: ignore_unknown_options=True: curl_command = "curl --bar --baz http://www.example.com" expected_result = {"method": "GET", "url": "http://www.example.com"} with warnings.catch_warnings(): # avoid warning when executing tests warnings.filterwarnings( "ignore", category=UserWarning, message="Unrecognized options:" ) assert curl_to_request_kwargs(curl_command) == expected_result # case 2: ignore_unknown_options=False (raise exception): with pytest.raises(ValueError, match=r"Unrecognized options:.*--bar.*--baz"): curl_to_request_kwargs( "curl --bar --baz http://www.example.com", ignore_unknown_options=False ) def test_must_start_with_curl_error(self): with pytest.raises(ValueError, match="A curl command must start"): curl_to_request_kwargs("carl -X POST http://example.org") scrapy-scrapy-5569de3/tests/test_utils_datatypes.py000066400000000000000000000271331525051012400226520ustar00rootroot00000000000000from __future__ import annotations import copy from collections.abc import Iterator, Mapping, MutableMapping from typing import Any import pytest from scrapy.http import Request from scrapy.utils.datatypes import ( CaseInsensitiveDict, LocalCache, LocalWeakReferencedCache, SequenceExclude, ) from scrapy.utils.python import garbage_collect class TestCaseInsensitiveDict: dict_class = CaseInsensitiveDict def test_init_dict(self): seq = {"red": 1, "black": 3} d = self.dict_class(seq) assert d["red"] == 1 assert d["black"] == 3 def test_init_pair_sequence(self): seq = (("red", 1), ("black", 3)) d = self.dict_class(seq) assert d["red"] == 1 assert d["black"] == 3 def test_init_mapping(self): class MyMapping(Mapping[str, int]): def __init__(self, **kwargs: int) -> None: self._d = kwargs def __getitem__(self, key: str) -> int: return self._d[key] def __iter__(self) -> Iterator[str]: return iter(self._d) def __len__(self) -> int: return len(self._d) seq = MyMapping(red=1, black=3) d = self.dict_class(seq) assert d["red"] == 1 assert d["black"] == 3 def test_init_mutable_mapping(self): class MyMutableMapping(MutableMapping[str, int]): def __init__(self, **kwargs: int) -> None: self._d = kwargs def __getitem__(self, key: str) -> int: return self._d[key] def __setitem__(self, key: str, value: int) -> None: self._d[key] = value def __delitem__(self, key: str) -> None: del self._d[key] def __iter__(self) -> Iterator[str]: return iter(self._d) def __len__(self) -> int: return len(self._d) seq = MyMutableMapping(red=1, black=3) d = self.dict_class(seq) assert d["red"] == 1 assert d["black"] == 3 def test_caseless(self): d = self.dict_class() d["key_Lower"] = 1 assert d["KEy_loWer"] == 1 assert d.get("KEy_loWer") == 1 d["KEY_LOWER"] = 3 assert d["key_Lower"] == 3 assert d.get("key_Lower") == 3 def test_delete(self): d = self.dict_class({"key_lower": 1}) del d["key_LOWER"] with pytest.raises(KeyError): d["key_LOWER"] with pytest.raises(KeyError): d["key_lower"] def test_getdefault(self): d = self.dict_class() assert d.get("c", 5) == 5 d["c"] = 10 assert d.get("c", 5) == 10 def test_setdefault(self): d = self.dict_class({"a": 1, "b": 2}) r = d.setdefault("A", 5) assert r == 1 assert d["A"] == 1 r = d.setdefault("c", 5) assert r == 5 assert d["C"] == 5 def test_fromkeys(self): keys = ("a", "b") d = self.dict_class.fromkeys(keys) assert d["A"] is None assert d["B"] is None d = self.dict_class.fromkeys(keys, 1) assert d["A"] == 1 assert d["B"] == 1 instance = self.dict_class() d = instance.fromkeys(keys) assert d["A"] is None assert d["B"] is None d = instance.fromkeys(keys, 1) assert d["A"] == 1 assert d["B"] == 1 def test_contains(self): d = self.dict_class() d["a"] = 1 assert "A" in d def test_pop(self): d = self.dict_class() d["a"] = 1 assert d.pop("A") == 1 with pytest.raises(KeyError): d.pop("A") def test_normkey(self): class MyDict(self.dict_class): # type: ignore[misc,name-defined] def _normkey(self, key): return key.title() d = MyDict() d["key-one"] = 2 assert list(d.keys()) == ["Key-One"] def test_normvalue(self): class MyDict(self.dict_class): # type: ignore[misc,name-defined] def _normvalue(self, value): if value is not None: return value + 1 return None d = MyDict({"key": 1}) assert d["key"] == 2 assert d.get("key") == 2 d = MyDict() d["key"] = 1 assert d["key"] == 2 assert d.get("key") == 2 d = MyDict() d.setdefault("key", 1) assert d["key"] == 2 assert d.get("key") == 2 d = MyDict() d.update({"key": 1}) assert d["key"] == 2 assert d.get("key") == 2 d = MyDict.fromkeys(("key",), 1) assert d["key"] == 2 assert d.get("key") == 2 def test_copy(self): h1 = self.dict_class({"header1": "value"}) h2 = copy.copy(h1) assert isinstance(h2, self.dict_class) assert h1 == h2 assert h1.get("header1") == h2.get("header1") assert h1.get("header1") == h2.get("HEADER1") h3 = h1.copy() assert isinstance(h3, self.dict_class) assert h1 == h3 assert h1.get("header1") == h3.get("header1") assert h1.get("header1") == h3.get("HEADER1") def test_copy_is_independent(self): h1 = self.dict_class({"header1": "value1", "header2": "value2"}) for h2 in (copy.copy(h1), h1.copy()): del h2["header1"] h2["header3"] = "value3" assert "header1" in h1 assert "header3" not in h1 assert dict(h1) == {"header1": "value1", "header2": "value2"} def test_repr(self): d1 = self.dict_class({"foo": "bar"}) assert repr(d1) == "" d2 = self.dict_class({"AsDf": "QwErTy", "FoO": "bAr"}) assert repr(d2) == "" def test_iter(self): d = self.dict_class({"AsDf": "QwErTy", "FoO": "bAr"}) iterkeys = iter(d) assert isinstance(iterkeys, Iterator) assert list(iterkeys) == ["AsDf", "FoO"] def test_copy_keeps_values(self): class MyDict(self.dict_class): # type: ignore[misc,name-defined] def _normvalue(self, value): return value + 1 d = MyDict({"key": 1}) for copied in (copy.copy(d), d.copy()): assert copied["key"] == 2 def test_ior(self): d = self.dict_class({"header1": "value1"}) d |= {"HEADER1": "value2", "header2": "value3"} assert len(d) == 2 assert d["HeAdEr1"] == "value2" assert d["HeAdEr2"] == "value3" def test_ior_mapping(self): d = self.dict_class({"header1": "value1"}) d |= self.dict_class({"HEADER1": "value2"}) assert len(d) == 1 assert d["HeAdEr1"] == "value2" class TestSequenceExclude: def test_list(self): seq = [1, 2, 3] d = SequenceExclude(seq) assert 0 in d assert 4 in d assert 2 not in d def test_range(self): seq = range(10, 20) d = SequenceExclude(seq) assert 5 in d assert 20 in d assert 15 not in d def test_range_step(self): seq = range(10, 20, 3) d = SequenceExclude(seq) are_not_in = [v for v in range(10, 20, 3) if v in d] assert are_not_in == [] are_not_in = [v for v in range(10, 20) if v in d] assert are_not_in == [11, 12, 14, 15, 17, 18] def test_string_seq(self): seq = "cde" d = SequenceExclude(seq) chars = "".join(v for v in "abcdefg" if v in d) assert chars == "abfg" def test_stringset_seq(self): seq = set("cde") d = SequenceExclude(seq) chars = "".join(v for v in "abcdefg" if v in d) assert chars == "abfg" def test_set(self): """Anything that is not in the supplied sequence will evaluate as 'in' the container.""" seq = {-3, "test", 1.1} d = SequenceExclude(seq) assert 0 in d assert "foo" in d assert 3.14 in d assert set("bar") in d # supplied sequence is a set, so checking for list (non)inclusion fails with pytest.raises(TypeError): ["a", "b", "c"] in d # noqa: B015 for v in [-3, "test", 1.1]: assert v not in d class TestLocalCache: def test_cache_with_limit(self): cache: LocalCache[str, int] = LocalCache(limit=2) cache["a"] = 1 cache["b"] = 2 cache["c"] = 3 assert len(cache) == 2 assert "a" not in cache assert "b" in cache assert "c" in cache assert cache["b"] == 2 assert cache["c"] == 3 def test_cache_without_limit(self): maximum = 10**4 cache: LocalCache[str, int] = LocalCache() for x in range(maximum): cache[str(x)] = x assert len(cache) == maximum for x in range(maximum): assert str(x) in cache assert cache[str(x)] == x def test_cache_with_zero_limit(self): cache: LocalCache[str, int] = LocalCache(limit=0) cache["a"] = 1 cache["b"] = 2 cache["c"] = 3 assert len(cache) == 0 assert "a" not in cache assert "b" not in cache assert "c" not in cache def test_cache_update_existing_key_does_not_evict(self): # Updating an existing key must not evict another entry or drop the # cache below its limit (regression: it used to evict the oldest key). cache: LocalCache[str, int] = LocalCache(limit=2) cache["a"] = 1 cache["b"] = 2 cache["b"] = 20 assert len(cache) == 2 assert "a" in cache assert cache["a"] == 1 assert cache["b"] == 20 class TestLocalWeakReferencedCache: def test_cache_with_limit(self): cache: LocalWeakReferencedCache[Request, int] = LocalWeakReferencedCache( limit=2 ) r1 = Request("https://example.org") r2 = Request("https://example.com") r3 = Request("https://example.net") cache[r1] = 1 cache[r2] = 2 cache[r3] = 3 assert len(cache) == 2 assert r1 not in cache assert r2 in cache assert r3 in cache assert cache[r1] is None assert cache[r2] == 2 assert cache[r3] == 3 del r2 # PyPy takes longer to collect dead references garbage_collect() assert len(cache) == 1 def test_cache_non_weak_referenceable_objects(self): cache: LocalWeakReferencedCache[Any, int] = LocalWeakReferencedCache() k1 = None k2 = 1 k3 = [1, 2, 3] cache[k1] = 1 cache[k2] = 2 cache[k3] = 3 assert k1 not in cache assert k2 not in cache assert k3 not in cache assert len(cache) == 0 def test_cache_without_limit(self): maximum = 10**4 cache: LocalWeakReferencedCache[Request, int] = LocalWeakReferencedCache() refs = [] for x in range(maximum): refs.append(Request(f"https://example.org/{x}")) cache[refs[-1]] = x assert len(cache) == maximum for i, r in enumerate(refs): assert r in cache assert cache[r] == i 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(maximum // 2): refs.pop() # PyPy takes longer to collect dead references garbage_collect() assert len(cache) == maximum // 2 for i, r in enumerate(refs): assert r in cache assert cache[r] == i scrapy-scrapy-5569de3/tests/test_utils_decorators.py000066400000000000000000000120751525051012400230200ustar00rootroot00000000000000from __future__ import annotations import sys import warnings from typing import TYPE_CHECKING, Any import pytest from twisted.internet.defer import Deferred from scrapy.exceptions import ScrapyDeprecationWarning from scrapy.utils.decorators import _warn_spider_arg, deprecated, inthread from scrapy.utils.defer import maybe_deferred_to_future from tests.utils.decorators import coroutine_test if TYPE_CHECKING: from collections.abc import AsyncGenerator, Callable class TestDeprecated: def test_warns_and_still_calls(self): @deprecated() def add(a: int, b: int) -> int: return a + b with pytest.warns( ScrapyDeprecationWarning, match=r"Call to deprecated function add\." ): result = add(2, 3) assert result == 5 def test_use_instead_in_message(self): @deprecated(use_instead="other_function") def old() -> None: return None with pytest.warns( ScrapyDeprecationWarning, match=r"Call to deprecated function old\. Use other_function instead\.", ): old() def test_applied_without_parentheses(self): @deprecated def square(x: int) -> int: return x * x with pytest.warns( ScrapyDeprecationWarning, match=r"Call to deprecated function square\." ) as record: result = square(4) assert result == 16 # No "Use ... instead." part when applied directly to the function. assert "instead" not in str(record[0].message) class TestInthread: @coroutine_test async def test_returns_deferred_with_result(self): @inthread def multiply(a, b): return a * b deferred = multiply(6, 7) assert isinstance(deferred, Deferred) assert await maybe_deferred_to_future(deferred) == 42 class TestWarnSpiderArg: def test_sync_warns_with_spider_arg(self): @_warn_spider_arg def parse(response: str, spider: str | None = None) -> str: return response with pytest.warns( ScrapyDeprecationWarning, match=r"Passing a 'spider' argument" ): assert parse("response", spider="spider") == "response" @pytest.mark.skipif( sys.version_info < (3, 14), reason="annotations are only lazily evaluated since Python 3.14 (PEP 649)", ) def test_sync_warns_with_unresolvable_annotations(self): # dont_inherit=True, or the module's future import stringizes the annotations namespace: dict[str, Any] = {} exec( # pylint: disable=exec-used compile( "def parse(response: OnlyAtTypeCheckingTime," " spider: OnlyAtTypeCheckingTime | None = None): return response", "", "exec", dont_inherit=True, ), namespace, ) parse_func: Callable[..., str] = namespace["parse"] parse = _warn_spider_arg(parse_func) with pytest.warns( ScrapyDeprecationWarning, match=r"Passing a 'spider' argument" ): assert parse("response", spider="spider") == "response" def test_sync_warns_with_positional_spider_arg(self): @_warn_spider_arg def parse(response: str, spider: str | None = None) -> str: return response with pytest.warns( ScrapyDeprecationWarning, match=r"Passing a 'spider' argument" ): assert parse("response", "spider") == "response" def test_sync_warns_with_keyword_only_spider_arg(self): @_warn_spider_arg def parse(response: str, *, spider: str | None = None) -> str: return response with pytest.warns( ScrapyDeprecationWarning, match=r"Passing a 'spider' argument" ): assert parse("response", spider="spider") == "response" def test_sync_no_warning_without_spider_arg(self): @_warn_spider_arg def parse(response: str, spider: str | None = None) -> str: return response with warnings.catch_warnings(): warnings.simplefilter("error", category=ScrapyDeprecationWarning) assert parse("response") == "response" @coroutine_test async def test_async_warns_with_spider_arg(self): @_warn_spider_arg async def parse(response: str, spider: str | None = None) -> str: return response with pytest.warns( ScrapyDeprecationWarning, match=r"Passing a 'spider' argument" ): assert await parse("response", spider="spider") == "response" @coroutine_test async def test_asyncgen_warns_with_spider_arg(self): @_warn_spider_arg async def parse( response: str, spider: str | None = None ) -> AsyncGenerator[str]: yield response with pytest.warns( ScrapyDeprecationWarning, match=r"Passing a 'spider' argument" ): results = [item async for item in parse("response", spider="spider")] assert results == ["response"] scrapy-scrapy-5569de3/tests/test_utils_defer.py000066400000000000000000000372221525051012400217410ustar00rootroot00000000000000from __future__ import annotations import asyncio import random import socket import warnings from asyncio import Future from typing import TYPE_CHECKING, Any import pytest from twisted.internet.defer import Deferred, inlineCallbacks from twisted.internet.interfaces import IReadDescriptor from zope.interface import implementer from scrapy.utils.asyncgen import as_async_generator, collect_asyncgen from scrapy.utils.asyncio import is_asyncio_available from scrapy.utils.defer import ( _process_pending_io, aiter_errback, deferred_f_from_coro_f, deferred_from_coro, deferred_to_future, iter_errback, maybe_deferred_to_future, maybeDeferred_coro, mustbe_deferred, parallel_async, ) from tests.utils.decorators import coroutine_test, inline_callbacks_test if TYPE_CHECKING: from collections.abc import AsyncGenerator, Awaitable, Callable, Generator from twisted.python.failure import Failure from typing_extensions import Self @pytest.mark.requires_reactor # mustbe_deferred() requires a reactor @pytest.mark.filterwarnings("ignore::scrapy.exceptions.ScrapyDeprecationWarning") class TestMustbeDeferred: @inline_callbacks_test def test_success_function(self) -> Generator[Deferred[Any], Any, None]: steps: list[int] = [] def _append(v: int) -> list[int]: steps.append(v) return steps def _assert(v: list[int]) -> None: assert v == [1, 2] # it is [1] with maybeDeferred dfd = mustbe_deferred(_append, 1) dfd.addCallback(_assert) steps.append(2) # add another value, that should be caught by assertEqual yield dfd @inline_callbacks_test def test_unfired_deferred(self) -> Generator[Deferred[Any], Any, None]: steps: list[int] = [] def _append(v: int) -> Deferred[list[int]]: from twisted.internet import reactor steps.append(v) dfd: Deferred[list[int]] = Deferred() reactor.callLater(0, dfd.callback, steps) return dfd def _assert(v: list[int]) -> None: assert v == [1, 2] dfd = mustbe_deferred(_append, 1) dfd.addCallback(_assert) steps.append(2) # add another value, that should be caught by assertEqual yield dfd class TestIterErrback: def test_iter_errback_good(self): def itergood() -> Generator[int, None, None]: yield from range(10) errors: list[Failure] = [] out = list(iter_errback(itergood(), errors.append)) assert out == list(range(10)) assert not errors def test_iter_errback_bad(self): def iterbad() -> Generator[int, None, None]: for x in range(10): if x == 5: 1 / 0 yield x errors: list[Failure] = [] out = list(iter_errback(iterbad(), errors.append)) assert out == [0, 1, 2, 3, 4] assert len(errors) == 1 assert isinstance(errors[0].value, ZeroDivisionError) class TestAiterErrback: @coroutine_test async def test_aiter_errback_good(self): async def itergood() -> AsyncGenerator[int, None]: for x in range(10): yield x errors: list[Failure] = [] out = await collect_asyncgen(aiter_errback(itergood(), errors.append)) assert out == list(range(10)) assert not errors @coroutine_test async def test_iter_errback_bad(self): async def iterbad() -> AsyncGenerator[int, None]: for x in range(10): if x == 5: 1 / 0 yield x errors: list[Failure] = [] out = await collect_asyncgen(aiter_errback(iterbad(), errors.append)) assert out == [0, 1, 2, 3, 4] assert len(errors) == 1 assert isinstance(errors[0].value, ZeroDivisionError) class TestAsyncDefTestsuite: @coroutine_test async def test_coroutine_test(self): pass @pytest.mark.xfail(reason="Checks that the test is actually executed", strict=True) @coroutine_test async def test_coroutine_test_xfail(self): raise RuntimeError("This is expected to be raised") @implementer(IReadDescriptor) class _ReadTracker: """Socket that the event loop finds readable, counting how often it reads it. Entering the context manager registers the socket and makes it readable, from the next poll of the event loop on, so that a non-zero *reads* means that the loop has polled its file descriptors since then. *on_read*, if given, is called on every read. """ def __init__(self, on_read: Callable[[], None] | None = None) -> None: self._on_read = on_read self._rx, self._tx = socket.socketpair() self.reads = 0 def _read(self) -> None: self.reads += 1 self._rx.recv(1) if self._on_read is not None: self._on_read() def fileno(self) -> int: return self._rx.fileno() doRead = _read def connectionLost(self, reason: Failure) -> None: pass def logPrefix(self) -> str: return "read-tracker" def __enter__(self) -> Self: if is_asyncio_available(): asyncio.get_event_loop().add_reader(self._rx, self._read) else: from twisted.internet import reactor reactor.addReader(self) self._tx.send(b"x") return self def __exit__(self, *exc_info: object) -> None: if is_asyncio_available(): asyncio.get_event_loop().remove_reader(self._rx) else: from twisted.internet import reactor reactor.removeReader(self) self._rx.close() self._tx.close() @coroutine_test async def test_process_pending_io() -> None: # The wait runs from a read callback, which is where Scrapy's own waits # happen, since what precedes them is a response arriving over a socket. It # is also the strictest place to wait from: the event loop polled right # before calling us, so only another poll can read the socket that check() # registers. done: Deferred[int] = Deferred() async def check() -> None: with _ReadTracker() as tracker: await _process_pending_io() done.callback(tracker.reads) def start() -> None: deferred_from_coro(check()).addErrback(done.errback) with _ReadTracker(on_read=start): reads = await maybe_deferred_to_future(done) assert reads @pytest.mark.requires_reactor # parallel_async() requires a reactor class TestParallelAsync: """This tests _AsyncCooperatorAdapter by testing parallel_async which is its only usage. parallel_async is called with the results of a callback (so an iterable of items, requests and None, with arbitrary delays between values), and it uses Scraper._process_spidermw_output as the callable (so a callable that returns a Deferred for an item, which will fire after pipelines process it, and None for everything else). The concurrent task count is the CONCURRENT_ITEMS setting. We want to test different concurrency values compared to the iterable length. We also want to simulate the real usage, with arbitrary delays between getting the values from the iterable. We also want to simulate sync and async results from the callable. """ CONCURRENT_ITEMS = 50 @staticmethod def callable(o: int, results: list[int]) -> Deferred[None] | None: from twisted.internet import reactor if random.random() < 0.4: # simulate async processing dfd: Deferred[None] = Deferred() dfd.addCallback(lambda _: results.append(o)) delay = random.random() / 8 reactor.callLater(delay, dfd.callback, None) return dfd # simulate trivial sync processing results.append(o) return None def callable_wrapped( self, o: int, results: list[int], parallel_count: list[int], max_parallel_count: list[int], ) -> Deferred[None] | None: parallel_count[0] += 1 max_parallel_count[0] = max(max_parallel_count[0], parallel_count[0]) dfd = self.callable(o, results) def decrement(_: Any = None) -> None: assert parallel_count[0] > 0, parallel_count[0] parallel_count[0] -= 1 if dfd is not None: dfd.addBoth(decrement) else: decrement() return dfd @staticmethod def get_async_iterable(length: int) -> AsyncGenerator[int, None]: # simulate a simple callback without delays between results return as_async_generator(range(length)) @staticmethod async def get_async_iterable_with_delays(length: int) -> AsyncGenerator[int, None]: # simulate a callback with delays between some of the results from twisted.internet import reactor for i in range(length): if random.random() < 0.1: dfd: Deferred[None] = Deferred() delay = random.random() / 20 reactor.callLater(delay, dfd.callback, None) await maybe_deferred_to_future(dfd) yield i @inline_callbacks_test def test_simple(self): for length in [20, 50, 100]: parallel_count = [0] max_parallel_count = [0] results: list[int] = [] ait = self.get_async_iterable(length) dl = parallel_async( ait, self.CONCURRENT_ITEMS, self.callable_wrapped, results, parallel_count, max_parallel_count, ) yield dl assert list(range(length)) == sorted(results) assert parallel_count[0] == 0 assert max_parallel_count[0] <= self.CONCURRENT_ITEMS, max_parallel_count[0] @inline_callbacks_test def test_delays(self): for length in [20, 50, 100]: parallel_count = [0] max_parallel_count = [0] results: list[int] = [] ait = self.get_async_iterable_with_delays(length) dl = parallel_async( ait, self.CONCURRENT_ITEMS, self.callable_wrapped, results, parallel_count, max_parallel_count, ) yield dl assert list(range(length)) == sorted(results) assert parallel_count[0] == 0 assert max_parallel_count[0] <= self.CONCURRENT_ITEMS, max_parallel_count[0] class TestDeferredFromCoro: def test_deferred(self): d: Deferred[None] = Deferred() result = deferred_from_coro(d) assert isinstance(result, Deferred) assert result is d def test_object(self): result = deferred_from_coro(42) assert result == 42 @inline_callbacks_test def test_coroutine(self): async def coroutine() -> int: return 42 result = deferred_from_coro(coroutine()) assert isinstance(result, Deferred) coro_result = yield result assert coro_result == 42 @pytest.mark.only_asyncio @inline_callbacks_test def test_coroutine_asyncio(self): async def coroutine() -> int: await asyncio.sleep(0.01) return 42 result = deferred_from_coro(coroutine()) assert isinstance(result, Deferred) coro_result = yield result assert coro_result == 42 @pytest.mark.only_asyncio @inline_callbacks_test def test_future(self): future: Future[int] = Future() result = deferred_from_coro(future) assert isinstance(result, Deferred) future.set_result(42) future_result = yield result assert future_result == 42 class TestDeferredFFromCoroF: @inlineCallbacks def _assert_result( self, c_f: Callable[[], Awaitable[int]] ) -> Generator[Deferred[Any], Any, None]: d_f = deferred_f_from_coro_f(c_f) d = d_f() assert isinstance(d, Deferred) result = yield d assert result == 42 @inline_callbacks_test def test_coroutine(self): async def c_f() -> int: return 42 yield self._assert_result(c_f) @pytest.mark.only_asyncio @inline_callbacks_test def test_coroutine_asyncio(self): async def c_f() -> int: await asyncio.sleep(0.01) return 42 yield self._assert_result(c_f) @pytest.mark.only_asyncio @inline_callbacks_test def test_future(self): def c_f() -> Future[int]: f: Future[int] = Future() f.set_result(42) return f yield self._assert_result(c_f) @pytest.mark.only_asyncio class TestDeferredToFuture: @coroutine_test async def test_deferred(self): d: Deferred[int] = Deferred() result = deferred_to_future(d) assert isinstance(result, Future) d.callback(42) future_result = await result assert future_result == 42 @coroutine_test async def test_wrapped_coroutine(self): async def c_f() -> int: return 42 d = deferred_from_coro(c_f()) result = deferred_to_future(d) assert isinstance(result, Future) future_result = await result assert future_result == 42 @coroutine_test async def test_wrapped_coroutine_asyncio(self): async def c_f() -> int: await asyncio.sleep(0.01) return 42 d = deferred_from_coro(c_f()) result = deferred_to_future(d) assert isinstance(result, Future) future_result = await result assert future_result == 42 @pytest.mark.only_not_asyncio class TestDeferredToFutureNotAsyncio: def test_deferred(self): with pytest.raises( RuntimeError, match=r"deferred_to_future\(\) requires an installed asyncio" ): deferred_to_future(Deferred()) @pytest.mark.only_asyncio class TestMaybeDeferredToFutureAsyncio: @coroutine_test async def test_deferred(self): d: Deferred[int] = Deferred() result = maybe_deferred_to_future(d) assert isinstance(result, Future) d.callback(42) future_result = await result assert future_result == 42 @coroutine_test async def test_wrapped_coroutine(self): async def c_f() -> int: return 42 d = deferred_from_coro(c_f()) result = maybe_deferred_to_future(d) assert isinstance(result, Future) future_result = await result assert future_result == 42 @coroutine_test async def test_wrapped_coroutine_asyncio(self): async def c_f() -> int: await asyncio.sleep(0.01) return 42 d = deferred_from_coro(c_f()) result = maybe_deferred_to_future(d) assert isinstance(result, Future) future_result = await result assert future_result == 42 @pytest.mark.only_not_asyncio class TestMaybeDeferredToFutureNotAsyncio: @coroutine_test async def test_deferred(self): d: Deferred[int] = Deferred() result = maybe_deferred_to_future(d) assert isinstance(result, Deferred) assert result is d def test_maybe_deferred_coro_deferred() -> None: d: Deferred[int] = Deferred() with warnings.catch_warnings(record=True) as records: warnings.simplefilter("always") assert maybeDeferred_coro(lambda: d) is d # Only the deprecation of maybeDeferred_coro() itself is reported; callables # that return a Deferred are the reason it exists. assert [str(record.message) for record in records] == [ "maybeDeferred_coro() is deprecated and will be removed in a future" " Scrapy version." ] scrapy-scrapy-5569de3/tests/test_utils_deprecate.py000066400000000000000000000252351525051012400226110ustar00rootroot00000000000000from __future__ import annotations import inspect import warnings from unittest import mock import pytest from scrapy.exceptions import ScrapyDeprecationWarning from scrapy.utils.deprecate import attribute, create_deprecated_class, update_classpath class MyWarning(UserWarning): pass class SomeBaseClass: pass class NewName(SomeBaseClass): pass class TestWarnWhenSubclassed: def test_no_warning_on_definition(self): with warnings.catch_warnings(): warnings.simplefilter("error", category=ScrapyDeprecationWarning) create_deprecated_class("Deprecated", NewName) def test_subclassing_warning_message(self): msg = ( r"tests\.test_utils_deprecate\.UserClass inherits from " r"deprecated class tests\.test_utils_deprecate\.Deprecated, " r"please inherit from tests\.test_utils_deprecate\.NewName." r" \(warning only on first subclass, there may be others\)" ) Deprecated = create_deprecated_class( "Deprecated", NewName, warn_category=MyWarning ) with pytest.warns(MyWarning, match=msg) as w: class UserClass(Deprecated): # type: ignore[misc, valid-type] pass assert w[0].lineno == inspect.getsourcelines(UserClass)[1] def test_custom_class_paths(self): Deprecated = create_deprecated_class( "Deprecated", NewName, new_class_path="foo.NewClass", old_class_path="bar.OldClass", warn_category=MyWarning, ) with pytest.warns( MyWarning, match=r"UserClass inherits from deprecated class bar\.OldClass, please inherit from foo\.NewClass", ): class UserClass(Deprecated): # type: ignore[misc, valid-type] pass with pytest.warns( MyWarning, match=r"bar\.OldClass is deprecated, instantiate foo\.NewClass instead", ): _ = Deprecated() def test_subclassing_warns_only_on_direct_children(self): Deprecated = create_deprecated_class( "Deprecated", NewName, warn_once=False, warn_category=MyWarning ) with pytest.warns( MyWarning, match="UserClass inherits from deprecated class", ): class UserClass(Deprecated): # type: ignore[misc, valid-type] pass with warnings.catch_warnings(): warnings.simplefilter("error", MyWarning) class NoWarnOnMe(UserClass): pass def test_subclassing_warns_once_by_default(self): Deprecated = create_deprecated_class( "Deprecated", NewName, warn_category=MyWarning ) with pytest.warns( MyWarning, match="UserClass inherits from deprecated class", ): class UserClass(Deprecated): # type: ignore[misc, valid-type] pass with warnings.catch_warnings(): warnings.simplefilter("error", MyWarning) class FooClass(Deprecated): # type: ignore[misc, valid-type] pass class BarClass(Deprecated): # type: ignore[misc, valid-type] pass def test_warning_on_instance(self): Deprecated = create_deprecated_class( "Deprecated", NewName, warn_category=MyWarning ) with pytest.warns( MyWarning, match=r"tests\.test_utils_deprecate\.Deprecated is deprecated, " r"instantiate tests\.test_utils_deprecate\.NewName instead\.", ) as w: _, lineno = Deprecated(), inspect.getlineno(inspect.currentframe()) # type: ignore[arg-type] assert len(w) == 1 assert w[0].lineno == lineno # ignore subclassing warnings with warnings.catch_warnings(): warnings.simplefilter("ignore", MyWarning) class UserClass(Deprecated): # type: ignore[misc, valid-type] pass with warnings.catch_warnings(): warnings.simplefilter("error", MyWarning) UserClass() # subclass instances don't warn def test_warning_auto_message(self): Deprecated = create_deprecated_class("Deprecated", NewName) with pytest.warns( ScrapyDeprecationWarning, match=r"UserClass2 inherits from deprecated class tests\.test_utils_deprecate\.Deprecated, please inherit from tests\.test_utils_deprecate\.NewName", ): class UserClass2(Deprecated): # type: ignore[misc, valid-type] pass def test_issubclass(self): with warnings.catch_warnings(): warnings.simplefilter("ignore", ScrapyDeprecationWarning) DeprecatedName = create_deprecated_class("DeprecatedName", NewName) class UpdatedUserClass1(NewName): pass class UpdatedUserClass1a(NewName): pass class OutdatedUserClass1(DeprecatedName): # type: ignore[misc, valid-type] pass class OutdatedUserClass1a(DeprecatedName): # type: ignore[misc, valid-type] pass class UnrelatedClass: pass assert issubclass(UpdatedUserClass1, NewName) assert issubclass(UpdatedUserClass1a, NewName) assert issubclass(UpdatedUserClass1, DeprecatedName) assert issubclass(UpdatedUserClass1a, DeprecatedName) assert issubclass(OutdatedUserClass1, DeprecatedName) assert not issubclass(UnrelatedClass, DeprecatedName) assert not issubclass(OutdatedUserClass1, OutdatedUserClass1a) assert not issubclass(OutdatedUserClass1a, OutdatedUserClass1) with pytest.raises(TypeError): issubclass(object(), DeprecatedName) # type: ignore[arg-type] def test_isinstance(self): with warnings.catch_warnings(): warnings.simplefilter("ignore", ScrapyDeprecationWarning) DeprecatedName = create_deprecated_class("DeprecatedName", NewName) class UpdatedUserClass2(NewName): pass class UpdatedUserClass2a(NewName): pass class OutdatedUserClass2(DeprecatedName): # type: ignore[misc, valid-type] pass class OutdatedUserClass2a(DeprecatedName): # type: ignore[misc, valid-type] pass class UnrelatedClass: pass assert isinstance(UpdatedUserClass2(), NewName) assert isinstance(UpdatedUserClass2a(), NewName) assert isinstance(UpdatedUserClass2(), DeprecatedName) assert isinstance(UpdatedUserClass2a(), DeprecatedName) assert isinstance(OutdatedUserClass2(), DeprecatedName) assert isinstance(OutdatedUserClass2a(), DeprecatedName) assert not isinstance(OutdatedUserClass2a(), OutdatedUserClass2) assert not isinstance(OutdatedUserClass2(), OutdatedUserClass2a) assert not isinstance(UnrelatedClass(), DeprecatedName) def test_clsdict(self): with warnings.catch_warnings(): warnings.simplefilter("ignore", ScrapyDeprecationWarning) Deprecated = create_deprecated_class("Deprecated", NewName, {"foo": "bar"}) assert Deprecated.foo == "bar" # type: ignore[attr-defined] def test_deprecate_a_class_with_custom_metaclass(self): Meta1 = type("Meta1", (type,), {}) New = Meta1("New", (), {}) create_deprecated_class("Deprecated", New) def test_deprecate_subclass_of_deprecated_class(self): with warnings.catch_warnings(): warnings.simplefilter("error", MyWarning) Deprecated = create_deprecated_class( "Deprecated", NewName, warn_category=MyWarning ) AlsoDeprecated = create_deprecated_class( "AlsoDeprecated", Deprecated, new_class_path="foo.Bar", warn_category=MyWarning, ) with pytest.warns( MyWarning, match=r"AlsoDeprecated is deprecated, instantiate foo\.Bar instead", ): AlsoDeprecated() with pytest.warns( MyWarning, match=r"UserClass inherits from deprecated class tests\.test_utils_deprecate\.AlsoDeprecated, please inherit from foo\.Bar", ): class UserClass(AlsoDeprecated): # type: ignore[misc, valid-type] pass def test_inspect_stack(self): with ( mock.patch("inspect.stack", side_effect=IndexError), pytest.warns(UserWarning, match="Error detecting parent module"), ): create_deprecated_class("DeprecatedName", NewName) def test_unknown_parent_module(self): with mock.patch("inspect.getmodule", return_value=None): cls = create_deprecated_class("DeprecatedName", NewName) assert cls.__module__ == "scrapy.utils.deprecate" @mock.patch( "scrapy.utils.deprecate.DEPRECATION_RULES", [ ("scrapy.contrib.pipeline.", "scrapy.pipelines."), ("scrapy.contrib.", "scrapy.extensions."), ], ) class TestUpdateClassPath: def test_old_path_gets_fixed(self): with pytest.warns( ScrapyDeprecationWarning, match="`scrapy.contrib.debug.Debug` class is deprecated, use `scrapy.extensions.debug.Debug` instead", ): output = update_classpath("scrapy.contrib.debug.Debug") assert output == "scrapy.extensions.debug.Debug" def test_sorted_replacement(self): with warnings.catch_warnings(): warnings.simplefilter("ignore", ScrapyDeprecationWarning) output = update_classpath("scrapy.contrib.pipeline.Pipeline") assert output == "scrapy.pipelines.Pipeline" def test_unmatched_path_stays_the_same(self): with warnings.catch_warnings(): warnings.simplefilter("error", ScrapyDeprecationWarning) output = update_classpath("scrapy.unmatched.Path") assert output == "scrapy.unmatched.Path" def test_returns_nonstring(self): for notastring in [None, True, [1, 2, 3], object()]: assert update_classpath(notastring) == notastring class TestAttribute: class MyClass: pass def test_default_version(self): with pytest.warns( ScrapyDeprecationWarning, match=r"MyClass\.old attribute is deprecated and will be no longer " r"supported in Scrapy 0\.12, use MyClass\.new attribute instead", ): attribute(self.MyClass(), "old", "new") def test_custom_version(self): with pytest.warns(ScrapyDeprecationWarning, match="in Scrapy 3.0"): attribute(self.MyClass(), "old", "new", "3.0") scrapy-scrapy-5569de3/tests/test_utils_display.py000066400000000000000000000071221525051012400223150ustar00rootroot00000000000000from __future__ import annotations import builtins import sys from io import StringIO from typing import TYPE_CHECKING from unittest import mock import pytest from scrapy.utils.display import _enable_windows_terminal_processing, pformat, pprint if TYPE_CHECKING: from collections.abc import Mapping, Sequence from types import ModuleType value = {"a": 1} colorized_strings = { ( ( "{\x1b[33m'\x1b[39;49;00m\x1b[33ma\x1b[39;49;00m\x1b[33m'" "\x1b[39;49;00m: \x1b[34m1\x1b[39;49;00m}" ) + suffix ) for suffix in ( # https://github.com/pygments/pygments/issues/2313 "\n", # pygments ≤ 2.13 "\x1b[37m\x1b[39;49;00m\n", # pygments ≥ 2.14 ) } plain_string = "{'a': 1}" @mock.patch("sys.platform", "linux") @mock.patch("sys.stdout.isatty") def test_pformat(isatty: mock.Mock) -> None: isatty.return_value = True assert pformat(value) in colorized_strings @mock.patch("sys.stdout.isatty") def test_pformat_dont_colorize(isatty: mock.Mock) -> None: isatty.return_value = True assert pformat(value, colorize=False) == plain_string def test_pformat_not_tty(): assert pformat(value) == plain_string @mock.patch("sys.platform", "win32") @mock.patch("platform.version") @mock.patch("sys.stdout.isatty") def test_pformat_old_windows(isatty: mock.Mock, version: mock.Mock) -> None: isatty.return_value = True version.return_value = "10.0.14392" assert pformat(value) in colorized_strings @mock.patch("sys.platform", "win32") @mock.patch("scrapy.utils.display._enable_windows_terminal_processing") @mock.patch("platform.version") @mock.patch("sys.stdout.isatty") def test_pformat_windows_no_terminal_processing( isatty: mock.Mock, version: mock.Mock, terminal_processing: mock.Mock ) -> None: isatty.return_value = True version.return_value = "10.0.14393" terminal_processing.return_value = False assert pformat(value) == plain_string @mock.patch("sys.platform", "win32") @mock.patch("scrapy.utils.display._enable_windows_terminal_processing") @mock.patch("platform.version") @mock.patch("sys.stdout.isatty") def test_pformat_windows( isatty: mock.Mock, version: mock.Mock, terminal_processing: mock.Mock ) -> None: isatty.return_value = True version.return_value = "10.0.14393" terminal_processing.return_value = True assert pformat(value) in colorized_strings @pytest.mark.skipif(sys.platform != "win32", reason="Windows-only console API") def test_enable_windows_terminal_processing() -> None: # The tests above mock this out, so this is the only place the real # kernel32 calls run. Whether they succeed depends on whether stdout is a # console, which pytest does not guarantee. assert isinstance(_enable_windows_terminal_processing(), bool) @mock.patch("sys.platform", "linux") @mock.patch("sys.stdout.isatty") def test_pformat_no_pygments(isatty: mock.Mock) -> None: isatty.return_value = True real_import = builtins.__import__ def mock_import( name: str, globals_: Mapping[str, object] | None = None, locals_: Mapping[str, object] | None = None, fromlist: Sequence[str] | None = (), level: int = 0, ) -> ModuleType: if "pygments" in name: raise ImportError return real_import(name, globals_, locals_, fromlist, level) with mock.patch("builtins.__import__", mock_import): assert pformat(value) == plain_string def test_pprint(): with mock.patch("sys.stdout", new=StringIO()) as mock_out: pprint(value) assert mock_out.getvalue() == "{'a': 1}\n" scrapy-scrapy-5569de3/tests/test_utils_gz.py000066400000000000000000000032431525051012400212700ustar00rootroot00000000000000from __future__ import annotations from gzip import BadGzipFile from pathlib import Path import pytest from w3lib.encoding import html_to_unicode from scrapy.http import Response from scrapy.utils.gz import gunzip, gzip_magic_number from tests import tests_datadir SAMPLEDIR = Path(tests_datadir, "compressed") def test_gunzip_basic(): r1 = Response( "http://www.example.com", body=(SAMPLEDIR / "feed-sample1.xml.gz").read_bytes(), ) assert gzip_magic_number(r1) r2 = Response("http://www.example.com", body=gunzip(r1.body)) assert not gzip_magic_number(r2) assert len(r2.body) == 9950 def test_gunzip_truncated(): text = gunzip((SAMPLEDIR / "truncated-crc-error.gz").read_bytes()) assert text.endswith(b"") assert not gzip_magic_number(r2) def test_is_gzipped_empty(): r1 = Response("http://www.example.com") assert not gzip_magic_number(r1) def test_gunzip_illegal_eof(): text = html_to_unicode( "charset=cp1252", gunzip((SAMPLEDIR / "unexpected-eof.gz").read_bytes()) )[1] expected_text = (SAMPLEDIR / "unexpected-eof-output.txt").read_text( encoding="utf-8" ) assert len(text) == len(expected_text) assert text == expected_text scrapy-scrapy-5569de3/tests/test_utils_httpobj.py000066400000000000000000000010551525051012400223210ustar00rootroot00000000000000from __future__ import annotations from urllib.parse import urlparse from scrapy.http import Request from scrapy.utils.httpobj import urlparse_cached def test_urlparse_cached(): url = "http://www.example.com/index.html" request1 = Request(url) request2 = Request(url) req1a = urlparse_cached(request1) req1b = urlparse_cached(request1) req2 = urlparse_cached(request2) urlp = urlparse(url) assert req1a == req2 assert req1a == urlp assert req1a is req1b assert req1a is not req2 assert req1b is not req2 scrapy-scrapy-5569de3/tests/test_utils_iterators.py000066400000000000000000000477371525051012400227040ustar00rootroot00000000000000from __future__ import annotations import pytest from scrapy.http import Response, TextResponse, XmlResponse from scrapy.utils.iterators import _body_or_str, csviter, xmliter_lxml from tests import get_testdata class TestXmliter: def test_xmliter(self): body = b""" Type 1 Name 1 Type 2 Name 2 """ response = XmlResponse(url="http://example.com", body=body) attrs = [ ( x.attrib["id"], x.xpath("name/text()").getall(), x.xpath("./type/text()").getall(), ) for x in xmliter_lxml(response, "product") ] assert attrs == [ ("001", ["Name 1"], ["Type 1"]), ("002", ["Name 2"], ["Type 2"]), ] def test_xmliter_unusual_node(self): body = b""" """ response = XmlResponse(url="http://example.com", body=body) nodenames = [ e.xpath("name()").getall() for e in xmliter_lxml(response, "matchme...") ] assert nodenames == [["matchme..."]] def test_xmliter_unicode(self): # example taken from https://github.com/scrapy/scrapy/issues/1665 body = """ <þingflokkar> <þingflokkur id="26"> - 80 <þingflokkur id="21"> Alþýðubandalag Ab Alþb. 76 123 <þingflokkur id="27"> Alþýðuflokkur A Alþfl. 27 120 """ for r in ( # with bytes XmlResponse(url="http://example.com", body=body.encode("utf-8")), # Unicode body needs encoding information XmlResponse(url="http://example.com", body=body, encoding="utf-8"), ): attrs = [ ( x.attrib["id"], x.xpath("./skammstafanir/stuttskammstöfun/text()").getall(), x.xpath("./tímabil/fyrstaþing/text()").getall(), ) for x in xmliter_lxml(r, "þingflokkur") ] assert attrs == [ ("26", ["-"], ["80"]), ("21", ["Ab"], ["76"]), ("27", ["A"], ["27"]), ] def test_xmliter_text(self): body = ( '' "onetwo" ) assert [x.xpath("text()").getall() for x in xmliter_lxml(body, "product")] == [ ["one"], ["two"], ] def test_xmliter_namespaces(self): body = b""" My Dummy Company http://www.mydummycompany.com This is a dummy company. We do nothing. Item 1 This is item 1 http://www.mydummycompany.com/items/1 http://www.mydummycompany.com/images/item1.jpg ITEM_1 400 """ response = XmlResponse(url="http://mydummycompany.com", body=body) my_iter = xmliter_lxml(response, "item") node = next(my_iter) node.register_namespace("g", "http://base.google.com/ns/1.0") assert node.xpath("title/text()").getall() == ["Item 1"] assert node.xpath("description/text()").getall() == ["This is item 1"] assert node.xpath("link/text()").getall() == [ "http://www.mydummycompany.com/items/1" ] assert node.xpath("g:image_link/text()").getall() == [ "http://www.mydummycompany.com/images/item1.jpg" ] assert node.xpath("g:id/text()").getall() == ["ITEM_1"] assert node.xpath("g:price/text()").getall() == ["400"] assert node.xpath("image_link/text()").getall() == [] assert node.xpath("id/text()").getall() == [] assert node.xpath("price/text()").getall() == [] def test_xmliter_namespaced_nodename(self): body = b""" My Dummy Company http://www.mydummycompany.com This is a dummy company. We do nothing. Item 1 This is item 1 http://www.mydummycompany.com/items/1 http://www.mydummycompany.com/images/item1.jpg ITEM_1 400 """ response = XmlResponse(url="http://mydummycompany.com", body=body) my_iter = xmliter_lxml(response, "g:image_link") node = next(my_iter) node.register_namespace("g", "http://base.google.com/ns/1.0") assert node.xpath("text()").extract() == [ "http://www.mydummycompany.com/images/item1.jpg" ] def test_xmliter_namespaced_nodename_missing(self): body = b""" My Dummy Company http://www.mydummycompany.com This is a dummy company. We do nothing. Item 1 This is item 1 http://www.mydummycompany.com/items/1 http://www.mydummycompany.com/images/item1.jpg ITEM_1 400 """ response = XmlResponse(url="http://mydummycompany.com", body=body) my_iter = xmliter_lxml(response, "g:link_image") with pytest.raises(StopIteration): next(my_iter) def test_xmliter_namespaced_nodename_other_prefixes(self): body = b""" A_1 ITEM_1 """ response = XmlResponse(url="http://example.com", body=body) node = next(xmliter_lxml(response, "g:id")) assert node.xpath("text()").getall() == ["ITEM_1"] def test_xmliter_non_text_response(self): body = ( b'' b"one" ) response = Response(url="http://example.com", body=body) assert [ node.xpath("text()").get() for node in xmliter_lxml(response, "product") ] == ["one"] def test_xmliter_exception(self): body = ( '' "onetwo" ) my_iter = xmliter_lxml(body, "product") next(my_iter) next(my_iter) with pytest.raises(StopIteration): next(my_iter) def test_xmliter_objtype_exception(self): i = xmliter_lxml(42, "product") # type: ignore[arg-type] with pytest.raises(TypeError): next(i) def test_xmliter_encoding(self): body = ( b'\n' b"\n" b" Some Turkish Characters \xd6\xc7\xde\xdd\xd0\xdc \xfc\xf0\xfd\xfe\xe7\xf6\n" b"\n\n" ) response = XmlResponse("http://www.example.com", body=body) assert ( next(xmliter_lxml(response, "item")).get() == "Some Turkish Characters \xd6\xc7\u015e\u0130\u011e\xdc \xfc\u011f\u0131\u015f\xe7\xf6" ) def test_xmliter_iterate_namespace(self): body = b""" My Dummy Company http://www.mydummycompany.com This is a dummy company. We do nothing. Item 1 This is item 1 http://www.mydummycompany.com/items/1 http://www.mydummycompany.com/images/item1.jpg http://www.mydummycompany.com/images/item2.jpg """ response = XmlResponse(url="http://mydummycompany.com", body=body) no_namespace_iter = xmliter_lxml(response, "image_link") assert len(list(no_namespace_iter)) == 0 namespace_iter = xmliter_lxml( response, "image_link", "http://base.google.com/ns/1.0" ) node = next(namespace_iter) assert node.xpath("text()").getall() == [ "http://www.mydummycompany.com/images/item1.jpg" ] node = next(namespace_iter) assert node.xpath("text()").getall() == [ "http://www.mydummycompany.com/images/item2.jpg" ] def test_xmliter_namespaces_prefix(self): body = b""" Apples Bananas African Coffee Table 80 120 """ response = XmlResponse(url="http://mydummycompany.com", body=body) my_iter = xmliter_lxml(response, "table", "http://www.w3.org/TR/html4/", "h") node = next(my_iter) assert len(node.xpath("h:tr/h:td").getall()) == 2 assert node.xpath("h:tr/h:td[1]/text()").getall() == ["Apples"] assert node.xpath("h:tr/h:td[2]/text()").getall() == ["Bananas"] my_iter = xmliter_lxml( response, "table", "http://www.w3schools.com/furniture", "f" ) node = next(my_iter) assert node.xpath("f:name/text()").getall() == ["African Coffee Table"] class TestUtilsCsv: def test_csviter_defaults(self): body = get_testdata("feeds", "feed-sample3.csv") response = TextResponse(url="http://example.com/", body=body) csv = csviter(response) result = list(csv) assert result == [ {"id": "1", "name": "alpha", "value": "foobar"}, {"id": "2", "name": "unicode", "value": "\xfan\xedc\xf3d\xe9\u203d"}, {"id": "3", "name": "multi", "value": "foo\nbar"}, {"id": "4", "name": "empty", "value": ""}, ] # explicit type check cuz' we no like stinkin' autocasting! yarrr for result_row in result: assert all(isinstance(k, str) for k in result_row) assert all(isinstance(v, str) for v in result_row.values()) def test_csviter_delimiter(self): body = get_testdata("feeds", "feed-sample3.csv").replace(b",", b"\t") response = TextResponse(url="http://example.com/", body=body) csv = csviter(response, delimiter="\t") assert list(csv) == [ {"id": "1", "name": "alpha", "value": "foobar"}, {"id": "2", "name": "unicode", "value": "\xfan\xedc\xf3d\xe9\u203d"}, {"id": "3", "name": "multi", "value": "foo\nbar"}, {"id": "4", "name": "empty", "value": ""}, ] def test_csviter_quotechar(self): body1 = get_testdata("feeds", "feed-sample6.csv") body2 = get_testdata("feeds", "feed-sample6.csv").replace(b",", b"|") response1 = TextResponse(url="http://example.com/", body=body1) csv1 = csviter(response1, quotechar="'") assert list(csv1) == [ {"id": "1", "name": "alpha", "value": "foobar"}, {"id": "2", "name": "unicode", "value": "\xfan\xedc\xf3d\xe9\u203d"}, {"id": "3", "name": "multi", "value": "foo\nbar"}, {"id": "4", "name": "empty", "value": ""}, ] response2 = TextResponse(url="http://example.com/", body=body2) csv2 = csviter(response2, delimiter="|", quotechar="'") assert list(csv2) == [ {"id": "1", "name": "alpha", "value": "foobar"}, {"id": "2", "name": "unicode", "value": "\xfan\xedc\xf3d\xe9\u203d"}, {"id": "3", "name": "multi", "value": "foo\nbar"}, {"id": "4", "name": "empty", "value": ""}, ] def test_csviter_wrong_quotechar(self): body = get_testdata("feeds", "feed-sample6.csv") response = TextResponse(url="http://example.com/", body=body) csv = csviter(response) assert list(csv) == [ {"'id'": "1", "'name'": "'alpha'", "'value'": "'foobar'"}, { "'id'": "2", "'name'": "'unicode'", "'value'": "'\xfan\xedc\xf3d\xe9\u203d'", }, {"'id'": "'3'", "'name'": "'multi'", "'value'": "'foo"}, {"'id'": "4", "'name'": "'empty'", "'value'": ""}, ] def test_csviter_delimiter_binary_response_assume_utf8_encoding(self): body = get_testdata("feeds", "feed-sample3.csv").replace(b",", b"\t") response = Response(url="http://example.com/", body=body) csv = csviter(response, delimiter="\t") assert list(csv) == [ {"id": "1", "name": "alpha", "value": "foobar"}, {"id": "2", "name": "unicode", "value": "\xfan\xedc\xf3d\xe9\u203d"}, {"id": "3", "name": "multi", "value": "foo\nbar"}, {"id": "4", "name": "empty", "value": ""}, ] def test_csviter_headers(self): sample = get_testdata("feeds", "feed-sample3.csv").splitlines() headers, body = sample[0].split(b","), b"\n".join(sample[1:]) response = TextResponse(url="http://example.com/", body=body) csv = csviter(response, headers=[h.decode("utf-8") for h in headers]) assert list(csv) == [ {"id": "1", "name": "alpha", "value": "foobar"}, {"id": "2", "name": "unicode", "value": "\xfan\xedc\xf3d\xe9\u203d"}, {"id": "3", "name": "multi", "value": "foo\nbar"}, {"id": "4", "name": "empty", "value": ""}, ] def test_csviter_falserow(self): body = get_testdata("feeds", "feed-sample3.csv") body = b"\n".join((body, b"a,b", b"a,b,c,d")) response = TextResponse(url="http://example.com/", body=body) csv = csviter(response) assert list(csv) == [ {"id": "1", "name": "alpha", "value": "foobar"}, {"id": "2", "name": "unicode", "value": "\xfan\xedc\xf3d\xe9\u203d"}, {"id": "3", "name": "multi", "value": "foo\nbar"}, {"id": "4", "name": "empty", "value": ""}, ] def test_csviter_empty(self): assert not list(csviter("")) def test_csviter_exception(self): body = get_testdata("feeds", "feed-sample3.csv") response = TextResponse(url="http://example.com/", body=body) my_iter = csviter(response) next(my_iter) next(my_iter) next(my_iter) next(my_iter) with pytest.raises(StopIteration): next(my_iter) def test_csviter_encoding(self): body1 = get_testdata("feeds", "feed-sample4.csv") body2 = get_testdata("feeds", "feed-sample5.csv") response = TextResponse( url="http://example.com/", body=body1, encoding="latin1" ) csv = csviter(response) assert list(csv) == [ {"id": "1", "name": "latin1", "value": "test"}, {"id": "2", "name": "something", "value": "\xf1\xe1\xe9\xf3"}, ] response = TextResponse(url="http://example.com/", body=body2, encoding="cp852") csv = csviter(response) assert list(csv) == [ {"id": "1", "name": "cp852", "value": "test"}, { "id": "2", "name": "something", "value": "\u255a\u2569\u2569\u2569\u2550\u2550\u2557", }, ] class TestBodyOrStr: bbody = b"utf8-body" ubody = bbody.decode("utf8") @pytest.mark.parametrize( "obj", [ bbody, ubody, TextResponse(url="http://example.org/", body=bbody, encoding="utf-8"), Response(url="http://example.org/", body=bbody), ], ) def test_body_or_str(self, obj: Response | str | bytes) -> None: r1 = _body_or_str(obj) self._assert_type_and_value(r1, self.ubody, obj) r2 = _body_or_str(obj, unicode=True) self._assert_type_and_value(r2, self.ubody, obj) r3 = _body_or_str(obj, unicode=False) self._assert_type_and_value(r3, self.bbody, obj) assert type(r1) is type(r2) assert type(r1) is not type(r3) # type: ignore[comparison-overlap] def test_body_or_str_unsupported_type(self) -> None: with pytest.raises( TypeError, match="must be Response or str or bytes, not int" ): _body_or_str(42) # type: ignore[call-overload] @staticmethod def _assert_type_and_value( a: str | bytes, b: str | bytes, obj: Response | str | bytes ) -> None: assert type(a) is type(b), f"Got {type(a)}, expected {type(b)} for {obj!r}" assert a == b scrapy-scrapy-5569de3/tests/test_utils_job.py000066400000000000000000000011711525051012400214200ustar00rootroot00000000000000from __future__ import annotations from typing import TYPE_CHECKING from scrapy.settings import Settings from scrapy.utils.job import job_dir if TYPE_CHECKING: from pathlib import Path def test_no_jobdir() -> None: assert job_dir(Settings()) is None assert job_dir(Settings({"JOBDIR": ""})) is None def test_existing_jobdir(tmp_path: Path) -> None: assert job_dir(Settings({"JOBDIR": str(tmp_path)})) == str(tmp_path) def test_missing_jobdir(tmp_path: Path) -> None: jobdir = tmp_path / "missing" / "jobdir" assert job_dir(Settings({"JOBDIR": str(jobdir)})) == str(jobdir) assert jobdir.is_dir() scrapy-scrapy-5569de3/tests/test_utils_log.py000066400000000000000000000440171525051012400214350ustar00rootroot00000000000000from __future__ import annotations import json import logging import re import sys import warnings from io import StringIO from typing import TYPE_CHECKING, Any, cast import pytest from twisted.python import log as twisted_log from twisted.python.failure import Failure from scrapy.exceptions import ScrapyDeprecationWarning from scrapy.settings import Settings from scrapy.utils.log import ( LogCounterHandler, SpiderLoggerAdapter, StreamLogger, TopLevelFormatter, _get_formatter, _uninstall_scrapy_root_handler, configure_logging, failure_to_exc_info, get_scrapy_root_handler, install_scrapy_root_handler, logformatter_adapter, ) from scrapy.utils.test import get_crawler from tests.spiders import LogSpider if TYPE_CHECKING: from collections.abc import Generator, Mapping, MutableMapping from scrapy.crawler import Crawler from scrapy.logformatter import LogFormatterResult class TestFailureToExcInfo: def test_failure(self): try: 0 / 0 except ZeroDivisionError: exc_info = sys.exc_info() failure = Failure() assert exc_info == failure_to_exc_info(failure) def test_non_failure(self): assert failure_to_exc_info("test") is None # type: ignore[arg-type] class TestTopLevelFormatter: def test_top_level_logger(self, caplog: pytest.LogCaptureFixture) -> None: caplog.handler.addFilter(TopLevelFormatter(["test"])) logger = logging.getLogger("test") logger.warning("test log msg") assert ("test", logging.WARNING, "test log msg") in caplog.record_tuples def test_children_logger(self, caplog: pytest.LogCaptureFixture) -> None: caplog.handler.addFilter(TopLevelFormatter(["test"])) logger = logging.getLogger("test.test1") logger.warning("test log msg") assert ("test", logging.WARNING, "test log msg") in caplog.record_tuples def test_overlapping_name_logger(self, caplog: pytest.LogCaptureFixture) -> None: caplog.handler.addFilter(TopLevelFormatter(["test"])) logger = logging.getLogger("test2") logger.warning("test log msg") assert ("test2", logging.WARNING, "test log msg") in caplog.record_tuples def test_different_name_logger(self, caplog: pytest.LogCaptureFixture) -> None: caplog.handler.addFilter(TopLevelFormatter(["test"])) logger = logging.getLogger("different") logger.warning("test log msg") assert ("different", logging.WARNING, "test log msg") in caplog.record_tuples class TestLogCounterHandler: @pytest.fixture def crawler(self) -> Crawler: settings = {"LOG_LEVEL": "WARNING"} return get_crawler(settings_dict=settings) @pytest.fixture def logger(self, crawler: Crawler) -> Generator[logging.Logger]: logger = logging.getLogger("test") logger.setLevel(logging.DEBUG) logger.propagate = False handler = LogCounterHandler(crawler, level=crawler.settings.get("LOG_LEVEL")) logger.addHandler(handler) try: yield logger finally: logger.propagate = True logger.setLevel(logging.NOTSET) logger.removeHandler(handler) def test_init(self, crawler: Crawler, logger: logging.Logger) -> None: assert crawler.stats.get_value("log_count/DEBUG") is None assert crawler.stats.get_value("log_count/INFO") is None assert crawler.stats.get_value("log_count/WARNING") is None assert crawler.stats.get_value("log_count/ERROR") is None assert crawler.stats.get_value("log_count/CRITICAL") is None def test_accepted_level(self, crawler: Crawler, logger: logging.Logger) -> None: logger.error("test log msg") assert crawler.stats.get_value("log_count/ERROR") == 1 def test_filtered_out_level(self, crawler: Crawler, logger: logging.Logger) -> None: logger.debug("test log msg") assert crawler.stats.get_value("log_count/DEBUG") is None class TestStreamLogger: def test_redirect(self, caplog: pytest.LogCaptureFixture) -> None: logger = logging.getLogger("test") logger.setLevel(logging.WARNING) old_stdout = sys.stdout sys.stdout = StreamLogger(logger, logging.ERROR) caplog.clear() print("test log msg") assert caplog.record_tuples == [("test", logging.ERROR, "test log msg")] sys.stdout = old_stdout def test_flush(self) -> None: class FlushCountingHandler(logging.Handler): flushes = 0 def flush(self) -> None: self.flushes += 1 handler = FlushCountingHandler() logger = logging.getLogger("test_flush") logger.addHandler(handler) try: StreamLogger(logger).flush() finally: logger.removeHandler(handler) assert handler.flushes == 1 class _TTYStringIO(StringIO): def isatty(self) -> bool: return True class TestGetFormatter: @staticmethod def _settings(**overrides: Any) -> Settings: values = { "LOG_FORMAT": "%(levelname)s: %(message)s", "LOG_DATEFORMAT": "%Y-%m-%d", "LOG_COLOR": True, } values.update(overrides) return Settings(values) def test_colored_when_tty_and_enabled( self, monkeypatch: pytest.MonkeyPatch ) -> None: pytest.importorskip("colorlog") monkeypatch.setattr("scrapy.utils.log._tty_supports_color", lambda: True) stream = _TTYStringIO() handler = logging.StreamHandler(stream) handler.setFormatter(_get_formatter(handler, self._settings())) logger = logging.getLogger("test_get_formatter_colored") logger.addHandler(handler) logger.setLevel(logging.DEBUG) try: logger.error("boom") finally: logger.removeHandler(handler) output = stream.getvalue() assert "\x1b[" in output assert "boom" in output def test_plain_when_not_a_tty(self) -> None: handler = logging.StreamHandler(StringIO()) formatter = _get_formatter(handler, self._settings()) assert type(formatter) is logging.Formatter def test_plain_when_log_color_disabled( self, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.setattr("scrapy.utils.log._tty_supports_color", lambda: True) handler = logging.StreamHandler(_TTYStringIO()) formatter = _get_formatter(handler, self._settings(LOG_COLOR=False)) assert type(formatter) is logging.Formatter def test_plain_for_file_handler(self, tmp_path: Any) -> None: handler = logging.FileHandler(tmp_path / "log.txt") try: formatter = _get_formatter(handler, self._settings()) finally: handler.close() assert type(formatter) is logging.Formatter def test_plain_when_colorlog_not_installed( self, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.setattr("scrapy.utils.log._tty_supports_color", lambda: True) monkeypatch.setitem(sys.modules, "colorlog", None) handler = logging.StreamHandler(_TTYStringIO()) formatter = _get_formatter(handler, self._settings()) assert type(formatter) is logging.Formatter class TestConfigureLogging: @pytest.fixture(autouse=True) def restore_logging(self) -> Generator[None]: root_handlers = logging.root.handlers[:] # configure_logging() adds a Twisted log observer without giving any way # to remove it afterwards. observers = twisted_log.theLogPublisher.observers[:] old_stdout = sys.stdout old_showwarning = warnings.showwarning try: yield finally: warnings.showwarning = old_showwarning sys.stdout = old_stdout twisted_log.theLogPublisher.observers[:] = observers logging.root.handlers[:] = root_handlers _uninstall_scrapy_root_handler() @staticmethod def _warnings_are_captured() -> bool: return warnings.showwarning.__module__ == "logging" def test_log_stdout(self) -> None: configure_logging( settings={"LOG_STDOUT": True, "LOG_INSTALL_ROOT_HANDLER": False} ) assert isinstance(sys.stdout, StreamLogger) def test_captures_warnings(self, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(sys, "warnoptions", []) logging.captureWarnings(False) configure_logging(settings={"LOG_INSTALL_ROOT_HANDLER": False}) assert self._warnings_are_captured() def test_keeps_warnoptions(self, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(sys, "warnoptions", ["default"]) logging.captureWarnings(False) configure_logging(settings={"LOG_INSTALL_ROOT_HANDLER": False}) assert not self._warnings_are_captured() def test_install_root_handler_param_deprecated(self) -> None: with pytest.warns(ScrapyDeprecationWarning, match="install_root_handler"): configure_logging(install_root_handler=False) def test_reinstall_root_handler_removed_from_root(self) -> None: install_scrapy_root_handler(Settings()) handler = get_scrapy_root_handler() assert handler is not None # Something else removed the handler from the root logger. logging.root.removeHandler(handler) install_scrapy_root_handler(Settings()) assert get_scrapy_root_handler() is not handler @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[str, Any], expected_extra: dict[str, Any], ) -> None: 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 TestLogging: @pytest.fixture def log_stream(self) -> StringIO: return StringIO() @pytest.fixture def spider(self) -> LogSpider: return LogSpider() @pytest.fixture(autouse=True) def logger(self, log_stream: StringIO) -> Generator[logging.Logger]: handler = logging.StreamHandler(log_stream) logger = logging.getLogger("log_spider") logger.addHandler(handler) logger.setLevel(logging.DEBUG) yield logger logger.removeHandler(handler) def test_debug_logging(self, log_stream: StringIO, spider: LogSpider) -> None: log_message = "Foo message" spider.log_debug(log_message) log_contents = log_stream.getvalue() assert log_contents == f"{log_message}\n" def test_info_logging(self, log_stream: StringIO, spider: LogSpider) -> None: log_message = "Bar message" spider.log_info(log_message) log_contents = log_stream.getvalue() assert log_contents == f"{log_message}\n" def test_warning_logging(self, log_stream: StringIO, spider: LogSpider) -> None: log_message = "Baz message" spider.log_warning(log_message) log_contents = log_stream.getvalue() assert log_contents == f"{log_message}\n" def test_error_logging(self, log_stream: StringIO, spider: LogSpider) -> None: log_message = "Foo bar message" spider.log_error(log_message) log_contents = log_stream.getvalue() assert log_contents == f"{log_message}\n" def test_critical_logging(self, log_stream: StringIO, spider: LogSpider) -> None: log_message = "Foo bar baz message" spider.log_critical(log_message) log_contents = log_stream.getvalue() assert log_contents == f"{log_message}\n" class TestLoggingWithExtra: regex_pattern = re.compile(r"^]+>$") @pytest.fixture def log_stream(self) -> StringIO: return StringIO() @pytest.fixture def spider(self) -> LogSpider: return LogSpider() @pytest.fixture(autouse=True) def logger(self, log_stream: StringIO) -> Generator[logging.Logger]: handler = logging.StreamHandler(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) yield logger logger.removeHandler(handler) def test_debug_logging(self, log_stream: StringIO, spider: LogSpider) -> None: log_message = "Foo message" extra = {"important_info": "foo"} spider.log_debug(log_message, extra) log_contents_str = log_stream.getvalue() log_contents = json.loads(log_contents_str) 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_stream: StringIO, spider: LogSpider) -> None: log_message = "Bar message" extra = {"important_info": "bar"} spider.log_info(log_message, extra) log_contents_str = log_stream.getvalue() log_contents = json.loads(log_contents_str) 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_stream: StringIO, spider: LogSpider) -> None: log_message = "Baz message" extra = {"important_info": "baz"} spider.log_warning(log_message, extra) log_contents_str = log_stream.getvalue() log_contents = json.loads(log_contents_str) 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_stream: StringIO, spider: LogSpider) -> None: log_message = "Foo bar message" extra = {"important_info": "foo bar"} spider.log_error(log_message, extra) log_contents_str = log_stream.getvalue() log_contents = json.loads(log_contents_str) 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_stream: StringIO, spider: LogSpider) -> None: log_message = "Foo bar baz message" extra = {"important_info": "foo bar baz"} spider.log_critical(log_message, extra) log_contents_str = log_stream.getvalue() log_contents = json.loads(log_contents_str) 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_stream: StringIO, spider: LogSpider ) -> None: log_message = "Foo message" extra = {"important_info": "foo", "spider": "shouldn't change"} spider.log_error(log_message, extra) log_contents_str = log_stream.getvalue() log_contents = json.loads(log_contents_str) 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"] class TestLogformatterAdapter: @staticmethod def _log(caplog: pytest.LogCaptureFixture, logkws: LogFormatterResult) -> str: with caplog.at_level(logging.INFO): logging.getLogger(__name__).log(*logformatter_adapter(logkws)) return caplog.records[-1].getMessage() @pytest.mark.parametrize("args", [None, {}, ()]) def test_empty_args( self, caplog: pytest.LogCaptureFixture, args: dict[str, Any] | tuple[Any, ...] | None, ) -> None: logkws = cast( "LogFormatterResult", {"level": logging.INFO, "msg": "90% done", "args": args}, ) with warnings.catch_warnings(): warnings.simplefilter("error", ScrapyDeprecationWarning) assert self._log(caplog, logkws) == "90% done" @pytest.mark.parametrize( ("msg", "args"), [("%(pct)d%% done", {"pct": 90}), ("%d%% done", (90,))], ) def test_args( self, caplog: pytest.LogCaptureFixture, msg: str, args: dict[str, Any] | tuple[Any, ...], ) -> None: logkws: LogFormatterResult = {"level": logging.INFO, "msg": msg, "args": args} with warnings.catch_warnings(): warnings.simplefilter("error", ScrapyDeprecationWarning) assert self._log(caplog, logkws) == "90% done" def test_msg_mapping_placeholders_without_args( self, caplog: pytest.LogCaptureFixture ) -> None: logkws = cast( "LogFormatterResult", {"level": logging.INFO, "msg": "%(pct)d%% done", "pct": 90}, ) with pytest.warns(ScrapyDeprecationWarning, match="no args"): assert self._log(caplog, logkws) == "90% done" scrapy-scrapy-5569de3/tests/test_utils_misc/000077500000000000000000000000001525051012400212275ustar00rootroot00000000000000scrapy-scrapy-5569de3/tests/test_utils_misc/__init__.py000066400000000000000000000134151525051012400233440ustar00rootroot00000000000000from __future__ import annotations import os import sys from pathlib import Path from unittest import mock import pytest from scrapy.exceptions import ScrapyDeprecationWarning from scrapy.item import Field, Item from scrapy.utils.misc import ( arg_to_iter, build_from_crawler, load_object, rel_has_nofollow, set_environ, walk_modules, walk_modules_iter, ) def test_load_object_class() -> None: obj = load_object(Field) assert obj is Field obj = load_object("scrapy.item.Field") assert obj is Field def test_load_object_function() -> None: obj = load_object(load_object) assert obj is load_object obj = load_object("scrapy.utils.misc.load_object") assert obj is load_object def test_load_object_exceptions() -> None: with pytest.raises(ImportError): load_object("nomodule999.mod.function") with pytest.raises(NameError): load_object("scrapy.utils.misc.load_object999") with pytest.raises(TypeError): load_object({}) # type: ignore[arg-type] def test_walk_modules() -> None: mods = walk_modules_iter("tests.test_utils_misc.test_walk_modules") expected = [ "tests.test_utils_misc.test_walk_modules", "tests.test_utils_misc.test_walk_modules.mod", "tests.test_utils_misc.test_walk_modules.mod.mod0", "tests.test_utils_misc.test_walk_modules.mod1", ] assert {m.__name__ for m in mods} == set(expected) mods = walk_modules_iter("tests.test_utils_misc.test_walk_modules.mod") expected = [ "tests.test_utils_misc.test_walk_modules.mod", "tests.test_utils_misc.test_walk_modules.mod.mod0", ] assert {m.__name__ for m in mods} == set(expected) mods = walk_modules_iter("tests.test_utils_misc.test_walk_modules.mod1") expected = [ "tests.test_utils_misc.test_walk_modules.mod1", ] assert {m.__name__ for m in mods} == set(expected) with pytest.raises(ImportError): for _ in walk_modules_iter("nomodule999"): pass with ( pytest.raises(ImportError), pytest.warns( ScrapyDeprecationWarning, match="The scrapy.utils.misc.walk_modules function is deprecated and will be " "removed in a future version of Scrapy. " "Use scrapy.utils.misc.walk_modules_iter instead.", ), ): walk_modules("nomodule999") def test_walk_modules_egg() -> None: egg = str(Path(__file__).parent / "test.egg") sys.path.append(egg) try: mods = walk_modules_iter("testegg") expected = [ "testegg.spiders", "testegg.spiders.a", "testegg.spiders.b", "testegg", ] assert {m.__name__ for m in mods} == set(expected) finally: sys.path.remove(egg) def test_arg_to_iter() -> None: class TestItem(Item): name = Field() assert hasattr(arg_to_iter(None), "__iter__") assert hasattr(arg_to_iter(100), "__iter__") assert hasattr(arg_to_iter("lala"), "__iter__") assert hasattr(arg_to_iter([1, 2, 3]), "__iter__") assert hasattr(arg_to_iter(c for c in "abcd"), "__iter__") assert not list(arg_to_iter(None)) assert list(arg_to_iter("lala")) == ["lala"] assert list(arg_to_iter(100)) == [100] assert list(arg_to_iter(c for c in "abc")) == ["a", "b", "c"] assert list(arg_to_iter([1, 2, 3])) == [1, 2, 3] assert list(arg_to_iter({"a": 1})) == [{"a": 1}] assert list(arg_to_iter(TestItem(name="john"))) == [TestItem(name="john")] def test_build_from_crawler() -> None: crawler = mock.MagicMock(spec_set=["settings"]) args = (True, 100.0) kwargs = {"key": "val"} def _test_with_crawler(mock: mock.MagicMock, crawler: mock.MagicMock) -> None: build_from_crawler(mock, crawler, *args, **kwargs) if hasattr(mock, "from_crawler"): mock.from_crawler.assert_called_once_with(crawler, *args, **kwargs) assert mock.call_count == 0 else: mock.assert_called_once_with(*args, **kwargs) # Check usage of correct constructor using 2 mocks: # 1. with no alternative constructors # 2. with from_crawler() constructor spec_sets = ( ["__qualname__"], ["__qualname__", "from_crawler"], ) for specs in spec_sets: m = mock.MagicMock(spec_set=specs) _test_with_crawler(m, crawler) m.reset_mock() # Check adoption of crawler m = mock.MagicMock(spec_set=["__qualname__", "from_crawler"]) m.from_crawler.return_value = None with pytest.raises(TypeError): build_from_crawler(m, crawler, *args, **kwargs) def test_set_environ() -> None: assert os.environ.get("some_test_environ") is None with set_environ(some_test_environ="test_value"): assert os.environ.get("some_test_environ") == "test_value" assert os.environ.get("some_test_environ") is None os.environ["some_test_environ"] = "test" assert os.environ.get("some_test_environ") == "test" with set_environ(some_test_environ="test_value"): assert os.environ.get("some_test_environ") == "test_value" assert os.environ.get("some_test_environ") == "test" def test_rel_has_nofollow() -> None: assert rel_has_nofollow("ugc nofollow") is True assert rel_has_nofollow("ugc,nofollow") is True assert rel_has_nofollow("ugc") is False assert rel_has_nofollow("nofollow") is True assert rel_has_nofollow("nofollowfoo") is False assert rel_has_nofollow("foonofollow") is False assert rel_has_nofollow("ugc, , nofollow") is True # rel attribute values are ASCII case-insensitive per the HTML spec assert rel_has_nofollow("NoFollow") is True assert rel_has_nofollow("NOFOLLOW") is True assert rel_has_nofollow("UGC NoFollow") is True assert rel_has_nofollow("ugc,NoFollow") is True scrapy-scrapy-5569de3/tests/test_utils_misc/test.egg000066400000000000000000000042671525051012400227030ustar00rootroot00000000000000PKRþ<“×2EGG-INFO/zip-safeãPKRþ<�¸-lÞEGG-INFO/SOURCES.txt+N-)-Ð+¨ä*I-.IMO×��ÏÌË,‰�GÓbÝ̼´|ýow]O?7L™`ÿÐ g×`½’ŠLɔԂԼ”Ô¼äÊøœÌ¼ìbìªJò âsRËRs�¥õ‹ 2SR‹Š±¹ .—ˆM0 (PKRþ<ËÔ[ž EGG-INFO/top_level.txt+I-.IMOçPKRþ<“×2EGG-INFO/dependency_links.txtãPKRþ™É©yÅHÚ\R‹“‹2 JÀvÁrKÒò‹r"PKRþ<XÛGe‡testegg/__init__.pyc»ø‰—ë»|°O20±³‰†` �ˆŸH�(6I¥™9)úI)™Å%z9™y¥ºfñf&ú©ééú%©Å% :>>3/³$>^¯ ²„¨É&7?¥4'Õdt1È(PK×~þ<testegg/__init__.pyPKæ~þ<testegg/spiders/b.pyPKRþ Generator[int, None, int]: yield 1 yield 2 return 3 @pytest.fixture def mock_spider() -> Spider: class MockSettings: def __init__(self, settings_dict: dict[str, Any] | None = None): self.settings_dict = settings_dict or { "WARN_ON_GENERATOR_RETURN_VALUE": True } def getbool(self, name, default=False): return self.settings_dict.get(name, default) class MockSpider: def __init__(self) -> None: self.settings = MockSettings() return MockSpider() # type: ignore[return-value] def test_generators_return_something(mock_spider): def f1(): yield 1 return 2 def g1(): yield 1 return "asdf" def h1(): yield 1 def helper() -> int: return 0 yield helper() return 2 def i1(): """ docstring """ url = """ https://example.org """ yield url return 1 assert is_generator_with_return_value(top_level_return_something) assert is_generator_with_return_value(f1) assert is_generator_with_return_value(g1) assert is_generator_with_return_value(h1) assert is_generator_with_return_value(i1) with pytest.warns( UserWarning, match='The "MockSpider.top_level_return_something" method is a generator', ): warn_on_generator_with_return_value(mock_spider, top_level_return_something) with pytest.warns(UserWarning, match='The "MockSpider.f1" method is a generator'): warn_on_generator_with_return_value(mock_spider, f1) with pytest.warns(UserWarning, match='The "MockSpider.g1" method is a generator'): warn_on_generator_with_return_value(mock_spider, g1) with pytest.warns(UserWarning, match='The "MockSpider.h1" method is a generator'): warn_on_generator_with_return_value(mock_spider, h1) with pytest.warns(UserWarning, match='The "MockSpider.i1" method is a generator'): warn_on_generator_with_return_value(mock_spider, i1) def test_generators_return_none(mock_spider): def f2(): yield 1 def g2(): yield 1 def h2(): yield 1 def i2(): yield 1 yield from generator_that_returns_stuff() def j2(): yield 1 def helper() -> int: return 0 yield helper() def k2(): """ docstring """ url = """ https://example.org """ yield url def l2(): return assert not is_generator_with_return_value(top_level_return_none) assert not is_generator_with_return_value(f2) assert not is_generator_with_return_value(g2) assert not is_generator_with_return_value(h2) assert not is_generator_with_return_value(i2) assert not is_generator_with_return_value(j2) # not recursive assert not is_generator_with_return_value(k2) # not recursive assert not is_generator_with_return_value(l2) with warnings.catch_warnings(): warnings.simplefilter("error", UserWarning) warn_on_generator_with_return_value(mock_spider, top_level_return_none) warn_on_generator_with_return_value(mock_spider, f2) warn_on_generator_with_return_value(mock_spider, g2) warn_on_generator_with_return_value(mock_spider, h2) warn_on_generator_with_return_value(mock_spider, i2) warn_on_generator_with_return_value(mock_spider, j2) warn_on_generator_with_return_value(mock_spider, k2) warn_on_generator_with_return_value(mock_spider, l2) def test_generators_return_none_with_decorator(mock_spider): def decorator(func): def inner_func(): func() return inner_func @decorator def f3(): yield 1 @decorator def g3(): yield 1 @decorator def h3(): yield 1 @decorator def i3(): yield 1 yield from generator_that_returns_stuff() @decorator def j3(): yield 1 def helper() -> int: return 0 yield helper() @decorator def k3(): """ docstring """ url = """ https://example.org """ yield url @decorator def l3(): return assert not is_generator_with_return_value(top_level_return_none) assert not is_generator_with_return_value(f3) assert not is_generator_with_return_value(g3) assert not is_generator_with_return_value(h3) assert not is_generator_with_return_value(i3) assert not is_generator_with_return_value(j3) # not recursive assert not is_generator_with_return_value(k3) # not recursive assert not is_generator_with_return_value(l3) with warnings.catch_warnings(): warnings.simplefilter("error", UserWarning) warn_on_generator_with_return_value(mock_spider, top_level_return_none) warn_on_generator_with_return_value(mock_spider, f3) warn_on_generator_with_return_value(mock_spider, g3) warn_on_generator_with_return_value(mock_spider, h3) warn_on_generator_with_return_value(mock_spider, i3) warn_on_generator_with_return_value(mock_spider, j3) warn_on_generator_with_return_value(mock_spider, k3) warn_on_generator_with_return_value(mock_spider, l3) @mock.patch("scrapy.utils.misc.is_generator_with_return_value", new=_indentation_error) def test_indentation_error(mock_spider): with pytest.warns(UserWarning, match="Unable to determine"): warn_on_generator_with_return_value(mock_spider, top_level_return_none) def test_partial() -> None: def cb(arg1, arg2): yield {} partial_cb = partial(cb, arg1=42) assert not is_generator_with_return_value(partial_cb) def test_warn_on_generator_with_return_value_settings_disabled() -> None: class MockSettings: def __init__(self, settings_dict: dict[str, Any] | None = None): self.settings_dict = settings_dict or {} def getbool(self, name, default=False): return self.settings_dict.get(name, default) class MockSpider: def __init__(self) -> None: self.settings = MockSettings({"WARN_ON_GENERATOR_RETURN_VALUE": False}) spider = MockSpider() def gen_with_return(): yield 1 return "value" with warnings.catch_warnings(): warnings.simplefilter("error", UserWarning) warn_on_generator_with_return_value(spider, gen_with_return) # type: ignore[arg-type] spider.settings.settings_dict["WARN_ON_GENERATOR_RETURN_VALUE"] = True with pytest.warns(UserWarning, match="is a generator"): warn_on_generator_with_return_value(spider, gen_with_return) # type: ignore[arg-type] scrapy-scrapy-5569de3/tests/test_utils_misc/test_walk_modules/000077500000000000000000000000001525051012400247545ustar00rootroot00000000000000scrapy-scrapy-5569de3/tests/test_utils_misc/test_walk_modules/__init__.py000066400000000000000000000000001525051012400270530ustar00rootroot00000000000000scrapy-scrapy-5569de3/tests/test_utils_misc/test_walk_modules/mod/000077500000000000000000000000001525051012400255335ustar00rootroot00000000000000scrapy-scrapy-5569de3/tests/test_utils_misc/test_walk_modules/mod/__init__.py000066400000000000000000000000001525051012400276320ustar00rootroot00000000000000scrapy-scrapy-5569de3/tests/test_utils_misc/test_walk_modules/mod/mod0.py000066400000000000000000000000001525051012400267320ustar00rootroot00000000000000scrapy-scrapy-5569de3/tests/test_utils_misc/test_walk_modules/mod1.py000066400000000000000000000000001525051012400261540ustar00rootroot00000000000000scrapy-scrapy-5569de3/tests/test_utils_ossignal.py000066400000000000000000000026511525051012400224710ustar00rootroot00000000000000from __future__ import annotations import signal from typing import TYPE_CHECKING, Any import pytest from scrapy.utils.ossignal import install_shutdown_handlers if TYPE_CHECKING: from collections.abc import Generator from types import FrameType SHUTDOWN_SIGNALS = [ getattr(signal, name) for name in ("SIGTERM", "SIGINT", "SIGBREAK") if hasattr(signal, name) ] @pytest.fixture(autouse=True) def restore_handlers() -> Generator[None]: handlers = {sig: signal.getsignal(sig) for sig in SHUTDOWN_SIGNALS} try: yield finally: for sig, handler in handlers.items(): signal.signal(sig, handler) def shutdown(signum: int, frame: FrameType | None) -> Any: pass def test_install() -> None: install_shutdown_handlers(shutdown) for sig in SHUTDOWN_SIGNALS: assert signal.getsignal(sig) is shutdown def test_keeps_custom_sigint_handler() -> None: def custom(signum: int, frame: FrameType | None) -> Any: pass signal.signal(signal.SIGINT, custom) install_shutdown_handlers(shutdown, override_sigint=False) assert signal.getsignal(signal.SIGINT) is custom assert signal.getsignal(signal.SIGTERM) is shutdown def test_overrides_default_sigint_handler() -> None: signal.signal(signal.SIGINT, signal.default_int_handler) install_shutdown_handlers(shutdown, override_sigint=False) assert signal.getsignal(signal.SIGINT) is shutdown scrapy-scrapy-5569de3/tests/test_utils_project.py000066400000000000000000000141061525051012400223160ustar00rootroot00000000000000from __future__ import annotations import os import sys from pathlib import Path from typing import TYPE_CHECKING import pytest from scrapy.exceptions import NotConfigured from scrapy.utils.misc import set_environ from scrapy.utils.project import ( data_path, find_projects, get_project_settings, inside_project, project_data_dir, ) if TYPE_CHECKING: from collections.abc import Generator @pytest.fixture def proj_path(tmp_path: Path) -> Generator[Path]: prev_dir = Path.cwd() project_dir = tmp_path try: os.chdir(project_dir) Path("scrapy.cfg").touch() yield project_dir finally: os.chdir(prev_dir) @pytest.fixture def no_proj_path(tmp_path: Path) -> Generator[Path]: """A working directory without a scrapy.cfg file, also isolated from the user-wide and system-wide Scrapy configuration files.""" prev_dir = Path.cwd() try: os.chdir(tmp_path) with set_environ(HOME=str(tmp_path), XDG_CONFIG_HOME=str(tmp_path)): yield tmp_path finally: os.chdir(prev_dir) def test_data_path_outside_project() -> None: assert str(Path(".scrapy", "somepath")) == data_path("somepath") abspath = str(Path(os.path.sep, "absolute", "path")) assert abspath == data_path(abspath) def test_data_path_inside_project(proj_path: Path) -> None: expected = proj_path / ".scrapy" / "somepath" assert expected.resolve() == Path(data_path("somepath")).resolve() abspath = str(Path(os.path.sep, "absolute", "path").resolve()) assert abspath == data_path(abspath) def test_data_path_createdir(no_proj_path: Path) -> None: path = Path(data_path("somepath", createdir=True)) assert path.is_dir() # An existing directory is left alone. assert Path(data_path("somepath", createdir=True)) == path def test_inside_project_unimportable_settings_module(no_proj_path: Path) -> None: with ( set_environ(SCRAPY_SETTINGS_MODULE="tests.no_such_settings_module"), pytest.warns( UserWarning, match="Cannot import scrapy settings module tests.no_such" ), ): assert inside_project() is False def test_project_data_dir_outside_project(no_proj_path: Path) -> None: with pytest.raises(NotConfigured, match="Not inside a project"): project_data_dir() def test_project_data_dir_without_scrapy_cfg(no_proj_path: Path) -> None: with ( set_environ(SCRAPY_SETTINGS_MODULE="tests.test_cmdline.settings"), pytest.raises(NotConfigured, match=r"Unable to find scrapy\.cfg file"), ): project_data_dir() def test_project_data_dir_default(proj_path: Path) -> None: expected = (proj_path / ".scrapy").resolve() assert Path(project_data_dir()) == expected assert expected.is_dir() # A second call finds the directory already created. assert Path(project_data_dir()) == expected def test_project_data_dir_from_scrapy_cfg(proj_path: Path) -> None: datadir = proj_path / "custom-datadir" Path("scrapy.cfg").write_text(f"[datadir]\ndefault = {datadir}\n") assert Path(project_data_dir()) == datadir assert datadir.is_dir() class TestGetProjectSettings: def test_valid_envvar(self): value = "tests.test_cmdline.settings" envvars = { "SCRAPY_SETTINGS_MODULE": value, } with set_environ(**envvars): settings = get_project_settings() assert settings.get("SETTINGS_MODULE") == value def test_invalid_envvar(self): envvars = { "SCRAPY_FOO": "bar", } with set_environ(**envvars): settings = get_project_settings() assert settings.get("SCRAPY_FOO") is None def test_envvar_project_dir_in_sys_path( self, proj_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.setattr(sys, "path", sys.path.copy()) monkeypatch.setenv("SCRAPY_SETTINGS_MODULE", "tests.test_cmdline.settings") get_project_settings() assert str(proj_path) in sys.path def test_valid_and_invalid_envvars(self): value = "tests.test_cmdline.settings" envvars = { "SCRAPY_FOO": "bar", "SCRAPY_SETTINGS_MODULE": value, } with set_environ(**envvars): settings = get_project_settings() assert settings.get("SETTINGS_MODULE") == value assert settings.get("SCRAPY_FOO") is None def test_find_projects(tmp_path: Path) -> None: for relative_path in ( "a/scrapy.cfg", "a/nested/scrapy.cfg", "b/c/d/scrapy.cfg", ".hidden/scrapy.cfg", "no-project/setup.py", ): path = tmp_path / relative_path path.parent.mkdir(parents=True, exist_ok=True) path.touch() assert list(find_projects(tmp_path)) == [ tmp_path / "a", tmp_path / "b" / "c" / "d", ] def test_find_projects_skips_virtual_environments(tmp_path: Path) -> None: # An arbitrarily-named directory, to show detection does not rely on it # being called venv or .venv. venv = tmp_path / "my-env" (venv / "lib" / "project").mkdir(parents=True) (venv / "pyvenv.cfg").touch() (venv / "lib" / "project" / "scrapy.cfg").touch() assert list(find_projects(tmp_path)) == [] def test_find_projects_ignored_dirs(tmp_path: Path) -> None: for relative_path in ("a/scrapy.cfg", "b/scrapy.cfg"): path = tmp_path / relative_path path.parent.mkdir(parents=True) path.touch() assert list(find_projects(tmp_path, ignored_dirs=["a"])) == [tmp_path / "b"] def test_find_projects_root(tmp_path: Path) -> None: (tmp_path / "scrapy.cfg").touch() (tmp_path / "a").mkdir() (tmp_path / "a" / "scrapy.cfg").touch() assert list(find_projects(tmp_path)) == [tmp_path] def test_find_projects_max_depth(tmp_path: Path) -> None: for relative_path in ("a/scrapy.cfg", "b/c/scrapy.cfg"): path = tmp_path / relative_path path.parent.mkdir(parents=True) path.touch() assert list(find_projects(tmp_path, max_depth=0)) == [] assert list(find_projects(tmp_path, max_depth=1)) == [tmp_path / "a"] scrapy-scrapy-5569de3/tests/test_utils_python.py000066400000000000000000000173251525051012400221770ustar00rootroot00000000000000from __future__ import annotations import functools import operator import platform import sys from typing import TYPE_CHECKING, Any, TypeVar import pytest from scrapy.utils.asyncgen import as_async_generator, collect_asyncgen from scrapy.utils.defer import aiter_errback from scrapy.utils.python import ( MutableAsyncChain, _looks_like_import_path, binary_is_text, get_func_args, get_spec, memoizemethod_noargs, to_bytes, to_unicode, without_none_values, ) from tests.utils.decorators import coroutine_test if TYPE_CHECKING: from collections.abc import AsyncIterator, Iterable, Mapping _KT = TypeVar("_KT") _VT = TypeVar("_VT") class TestMutableAsyncChain: @staticmethod async def g1() -> AsyncIterator[int]: for i in range(3): yield i @staticmethod async def g2() -> AsyncIterator[int]: return yield @staticmethod async def g3() -> AsyncIterator[int]: for i in range(7, 10): yield i @staticmethod async def g4() -> AsyncIterator[int]: for i in range(3, 5): yield i 1 / 0 for i in range(5, 7): yield i @coroutine_test async def test_mutableasyncchain(self): m = MutableAsyncChain(self.g1(), as_async_generator(range(3, 7))) m.extend(self.g2()) m.extend(self.g3()) assert await anext(m) == 0 results = await collect_asyncgen(m) assert results == list(range(1, 10)) @coroutine_test async def test_mutableasyncchain_exc(self): m = MutableAsyncChain(self.g1()) m.extend(self.g4()) m.extend(self.g3()) results = await collect_asyncgen(aiter_errback(m, lambda _: None)) assert results == list(range(5)) class TestToUnicode: def test_converting_an_utf8_encoded_string_to_unicode(self): assert to_unicode(b"lel\xc3\xb1e") == "lel\xf1e" def test_converting_a_latin_1_encoded_string_to_unicode(self): assert to_unicode(b"lel\xf1e", "latin-1") == "lel\xf1e" def test_converting_a_unicode_to_unicode_should_return_the_same_object(self): assert to_unicode("\xf1e\xf1e\xf1e") == "\xf1e\xf1e\xf1e" def test_converting_a_strange_object_should_raise_type_error(self): with pytest.raises(TypeError): to_unicode(423) # type: ignore[arg-type] def test_errors_argument(self): assert to_unicode(b"a\xedb", "utf-8", errors="replace") == "a\ufffdb" class TestToBytes: def test_converting_a_unicode_object_to_an_utf_8_encoded_string(self): assert to_bytes("\xa3 49") == b"\xc2\xa3 49" def test_converting_a_unicode_object_to_a_latin_1_encoded_string(self): assert to_bytes("\xa3 49", "latin-1") == b"\xa3 49" def test_converting_a_regular_bytes_to_bytes_should_return_the_same_object(self): assert to_bytes(b"lel\xf1e") == b"lel\xf1e" def test_converting_a_strange_object_should_raise_type_error(self): with pytest.raises(TypeError): to_bytes(pytest) # type: ignore[arg-type] def test_errors_argument(self): assert to_bytes("a\ufffdb", "latin-1", errors="replace") == b"a?b" def test_memoizemethod_noargs(): class A: @memoizemethod_noargs def cached(self) -> object: return object() def noncached(self) -> object: return object() a = A() one = a.cached() two = a.cached() three = a.noncached() assert one is two assert one is not three @pytest.mark.parametrize( ("value", "expected"), [ (b"hello", True), ("hello".encode("utf-16"), True), (b"
    Price \xa3
    ", True), (b"\x02\xa3", False), ], ) def test_binaryistext(value: bytes, expected: bool) -> None: assert binary_is_text(value) is expected def test_binaryistext_not_bytes() -> None: with pytest.raises(TypeError, match="data must be bytes, got 'str'"): binary_is_text("hello") # type: ignore[arg-type] def test_get_func_args(): def f1(a, b, c): pass def f2(a, b=None, c=None): pass def f3(a, b=None, *, c=None): pass class A: def __init__(self, a: int, b: int, c: int): pass def method(self, a, b, c): pass class Callable: def __call__(self, a, b, c): pass a = A(1, 2, 3) cal = Callable() partial_f1 = functools.partial(f1, None) partial_f2 = functools.partial(f1, b=None) partial_f3 = functools.partial(partial_f2, None) # a positional value that happens to match the name of a free parameter partial_f4 = functools.partial(f1, "b") assert get_func_args(f1) == ["a", "b", "c"] assert get_func_args(f2) == ["a", "b", "c"] assert get_func_args(f3) == ["a", "b", "c"] assert get_func_args(A) == ["a", "b", "c"] assert get_func_args(a.method) == ["a", "b", "c"] assert get_func_args(partial_f1) == ["b", "c"] assert get_func_args(partial_f2) == ["a", "c"] assert get_func_args(partial_f3) == ["c"] assert get_func_args(partial_f4) == ["b", "c"] assert get_func_args(cal) == ["a", "b", "c"] assert get_func_args(object) == [] assert get_func_args(str.split, stripself=True) == ["sep", "maxsplit"] assert get_func_args(" ".join, stripself=True) == ["iterable"] if sys.version_info >= (3, 13) or platform.python_implementation() == "PyPy": # the correct and correctly extracted signature assert get_func_args(operator.itemgetter(2), stripself=True) == ["obj"] elif platform.python_implementation() == "CPython": # ["args", "kwargs"] is a correct result for the pre-3.13 incorrect function signature # [] is an incorrect result on even older CPython (https://github.com/python/cpython/issues/86951) assert get_func_args(operator.itemgetter(2), stripself=True) in [ [], ["args", "kwargs"], ] @pytest.mark.skipif( sys.version_info < (3, 14), reason="annotations are only lazily evaluated since Python 3.14 (PEP 649)", ) def test_get_func_args_unresolvable_annotations(): # dont_inherit=True, or the module's future import stringizes the annotations namespace: dict[str, Any] = {} exec( # pylint: disable=exec-used compile( "def f(a: OnlyAtTypeCheckingTime, b: int = 1) -> OnlyAtTypeCheckingTime: pass", "", "exec", dont_inherit=True, ), namespace, ) assert get_func_args(namespace["f"]) == ["a", "b"] def test_get_func_args_not_callable() -> None: with pytest.raises(TypeError, match="func must be callable, got 'int'"): get_func_args(1) # type: ignore[arg-type] def test_get_spec_not_callable() -> None: with pytest.raises(TypeError, match="is not callable"): get_spec(1) # type: ignore[arg-type] @pytest.mark.parametrize( ("value", "expected"), [ ("scrapy.Spider", True), ("scrapy", True), ("", False), ("scrapy Spider", False), ("scrapy.Spider\n", False), ("scrapy-Spider", False), (".scrapy", False), ("scrapy.", False), ("scrapy..Spider", False), ("scrapy.1Spider", False), ], ) def test_looks_like_import_path(value: str, expected: bool) -> None: assert _looks_like_import_path(value) is expected @pytest.mark.parametrize( ("value", "expected"), [ ([1, None, 3, 4], [1, 3, 4]), ((1, None, 3, 4), (1, 3, 4)), ( {"one": 1, "none": None, "three": 3, "four": 4}, {"one": 1, "three": 3, "four": 4}, ), ], ) def test_without_none_values( value: Mapping[_KT, _VT] | Iterable[_KT], expected: dict[_KT, _VT] | Iterable[_KT] ) -> None: assert without_none_values(value) == expected scrapy-scrapy-5569de3/tests/test_utils_reactor.py000066400000000000000000000066751525051012400223230ustar00rootroot00000000000000from __future__ import annotations import asyncio import sys from typing import TYPE_CHECKING import pytest from twisted.internet.error import CannotListenError from twisted.internet.protocol import ServerFactory from scrapy.utils.reactor import ( _asyncio_reactor_path, install_reactor, is_asyncio_reactor_installed, listen_tcp, set_asyncio_event_loop, verify_installed_asyncio_event_loop, verify_installed_reactor, ) from tests.utils.decorators import coroutine_test if TYPE_CHECKING: from collections.abc import Generator from twisted.internet.tcp import Port class TestAsyncio: @pytest.mark.requires_reactor # needs a reactor def test_is_asyncio_reactor_installed(self, reactor_pytest: str) -> None: # the result should depend only on the pytest --reactor argument assert is_asyncio_reactor_installed() == (reactor_pytest == "asyncio") @pytest.mark.requires_reactor # installs a reactor @pytest.mark.only_asyncio @coroutine_test async def test_set_asyncio_event_loop(self): install_reactor(_asyncio_reactor_path) assert set_asyncio_event_loop(None) is asyncio.get_running_loop() class TestVerifyWithoutReactor: @pytest.fixture(autouse=True) def no_reactor(self, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delitem(sys.modules, "twisted.internet.reactor", raising=False) def test_verify_installed_reactor(self) -> None: with pytest.raises( RuntimeError, match=r"verify_installed_reactor\(\) called without" ): verify_installed_reactor(_asyncio_reactor_path) def test_verify_installed_asyncio_event_loop(self) -> None: with pytest.raises( RuntimeError, match=r"verify_installed_asyncio_event_loop\(\) called without", ): verify_installed_asyncio_event_loop("asyncio.SelectorEventLoop") @pytest.mark.requires_reactor # needs a reactor class TestListenTcp: @pytest.fixture def ports(self) -> Generator[list[Port]]: opened: list[Port] = [] yield opened for port in opened: port.stopListening() @pytest.mark.parametrize("portrange", [[1, 2, 3], [8000, 7000]]) def test_invalid_portrange(self, portrange: list[int]) -> None: with pytest.raises(ValueError, match="invalid portrange"): listen_tcp(portrange, "127.0.0.1", ServerFactory()) def test_empty_portrange(self, ports: list[Port]) -> None: port = listen_tcp([], "127.0.0.1", ServerFactory()) ports.append(port) assert port.getHost().port > 0 def test_single_port(self, ports: list[Port]) -> None: port = listen_tcp([0], "127.0.0.1", ServerFactory()) ports.append(port) assert port.getHost().port > 0 def test_skips_used_ports(self, ports: list[Port]) -> None: used = listen_tcp([], "127.0.0.1", ServerFactory()) ports.append(used) used_number = used.getHost().port port = listen_tcp([used_number, used_number + 50], "127.0.0.1", ServerFactory()) ports.append(port) assert used_number < port.getHost().port <= used_number + 50 def test_no_free_port(self, ports: list[Port]) -> None: used = listen_tcp([], "127.0.0.1", ServerFactory()) ports.append(used) used_number = used.getHost().port with pytest.raises(CannotListenError): listen_tcp([used_number, used_number], "127.0.0.1", ServerFactory()) scrapy-scrapy-5569de3/tests/test_utils_reactorless.py000066400000000000000000000031261525051012400231760ustar00rootroot00000000000000from __future__ import annotations import sys import pytest from scrapy.utils.reactorless import ( ReactorImportHook, install_reactor_import_hook, uninstall_reactor_import_hook, ) class TestReactorImportHook: def test_install_and_uninstall(self) -> None: hook = install_reactor_import_hook() try: assert isinstance(hook, ReactorImportHook) assert sys.meta_path[0] is hook finally: uninstall_reactor_import_hook(hook) assert hook not in sys.meta_path def test_uninstall_twice(self) -> None: hook = install_reactor_import_hook() uninstall_reactor_import_hook(hook) uninstall_reactor_import_hook(hook) assert hook not in sys.meta_path def test_uninstall_not_installed(self) -> None: uninstall_reactor_import_hook(ReactorImportHook()) def test_uninstall_only_removes_the_given_hook(self) -> None: hook1 = install_reactor_import_hook() hook2 = install_reactor_import_hook() try: uninstall_reactor_import_hook(hook1) assert hook1 not in sys.meta_path assert hook2 in sys.meta_path finally: uninstall_reactor_import_hook(hook1) uninstall_reactor_import_hook(hook2) def test_find_spec(self) -> None: hook = ReactorImportHook() with pytest.raises( ImportError, match=r"Import of twisted\.internet\.reactor is forbidden" ): hook.find_spec("twisted.internet.reactor", None) assert hook.find_spec("twisted.internet.defer", None) is None scrapy-scrapy-5569de3/tests/test_utils_remote_control.py000066400000000000000000000072451525051012400237110ustar00rootroot00000000000000from __future__ import annotations import json import os import stat import sys from pathlib import Path import pytest from scrapy.settings import Settings from scrapy.utils._remote_control import ( JOB_FILE_VERSION, job_files_dir, new_job_file_name, write_job_file, ) def _write_job_file(path: Path) -> None: write_job_file( path, spider="dummy", project="testbot", scrapy_version="2.17.0", port=12345, token="secret", ) def test_write_job_file(tmp_path: Path) -> None: path = tmp_path / "jobs" / f"{os.getpid()}-abc.json" _write_job_file(path) if sys.platform != "win32": assert stat.S_IMODE(path.stat().st_mode) == 0o600 assert stat.S_IMODE(path.parent.stat().st_mode) == 0o700 record = json.loads(path.read_text(encoding="utf-8")) assert record["version"] == JOB_FILE_VERSION assert record["pid"] == os.getpid() assert record["port"] == 12345 assert record["token"] == "secret" assert record["spider"] == "dummy" assert record["project"] == "testbot" assert record["scrapy_version"] == "2.17.0" assert isinstance(record["start_time"], float) def test_write_job_file_leaves_no_temporary_file(tmp_path: Path) -> None: name = f"{os.getpid()}-abc.json" _write_job_file(tmp_path / name) assert [path.name for path in tmp_path.iterdir()] == [name] def test_new_job_file_name() -> None: name = new_job_file_name() assert name.endswith(".json") pid, _, rest = name.removeprefix(".").partition("-") assert rest assert pid.isdigit() assert int(pid) == os.getpid() def test_write_job_file_refuses_an_existing_temporary_file(tmp_path: Path) -> None: path = tmp_path / new_job_file_name() planted = path.with_name(f".{path.name}.tmp") planted.write_text("", encoding="utf-8") with pytest.raises(FileExistsError): _write_job_file(path) assert not path.exists() assert planted.read_text(encoding="utf-8") == "" # left untouched def test_write_job_file_removes_the_temporary_file_after_a_failure( tmp_path: Path, ) -> None: # A directory in the way makes the final rename fail, after the temporary # file has already been created. path = tmp_path / new_job_file_name() path.mkdir() # the specific exception differs between platforms with pytest.raises(OSError): # noqa: PT011 _write_job_file(path) assert list(tmp_path.iterdir()) == [path] def test_write_job_file_ignores_a_failure_to_remove_the_temporary_file( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: def raise_runtime_error(*args: object, **kwargs: object) -> None: raise RuntimeError("boom") def raise_os_error(*args: object, **kwargs: object) -> None: raise OSError("cannot remove") monkeypatch.setattr(json, "dump", raise_runtime_error) monkeypatch.setattr(Path, "unlink", raise_os_error) path = tmp_path / new_job_file_name() # the original error wins over the cleanup one with pytest.raises(RuntimeError, match="boom"): _write_job_file(path) assert not path.exists() assert path.with_name(f".{path.name}.tmp").exists() # could not be removed def test_jobs_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: assert ( job_files_dir(Settings({"REMOTE_CONTROL_JOBS_DIR": str(tmp_path)})) == tmp_path ) # platformdirs does not determine the user state folder from environment # variables on every platform, hence the patching. monkeypatch.setattr( "scrapy.utils._remote_control.user_state_dir", lambda *args, **kwargs: str(tmp_path), ) assert job_files_dir(Settings()) == tmp_path / "job_files" scrapy-scrapy-5569de3/tests/test_utils_request.py000066400000000000000000000417161525051012400223470ustar00rootroot00000000000000from __future__ import annotations import json from hashlib import sha1 from typing import TYPE_CHECKING, Any, Protocol from weakref import WeakKeyDictionary import pytest from scrapy.http import Request from scrapy.utils.python import to_bytes from scrapy.utils.request import ( _fingerprint_cache, fingerprint, request_httprepr, request_to_curl, ) from scrapy.utils.test import get_crawler if TYPE_CHECKING: from collections.abc import Iterable @pytest.mark.parametrize( ("r", "expected"), [ ( Request("http://www.example.com"), b"GET / HTTP/1.1\r\nHost: www.example.com\r\n\r\n", ), ( Request("http://www.example.com/some/page.html?arg=1"), b"GET /some/page.html?arg=1 HTTP/1.1\r\nHost: www.example.com\r\n\r\n", ), ( Request( "http://www.example.com", method="POST", headers={"Content-type": b"text/html"}, body=b"Some body", ), b"POST / HTTP/1.1\r\nHost: www.example.com\r\nContent-Type: text/html\r\n\r\nSome body", ), ], ) def test_request_httprepr(r: Request, expected: bytes) -> None: assert request_httprepr(r) == expected @pytest.mark.parametrize( "r", [ Request("file:///tmp/foo.txt"), Request("ftp://localhost/tmp/foo.txt"), ], ) def test_request_httprepr_for_non_http_request(r: Request) -> None: # the representation is not important but it must not fail. request_httprepr(r) class _FingerprintFunction(Protocol): def __call__( self, request: Request, *, include_headers: Iterable[bytes | str] | None = None, keep_fragments: bool = False, ) -> bytes: ... class TestFingerprint: function: _FingerprintFunction = staticmethod(fingerprint) cache: ( WeakKeyDictionary[ Request, dict[tuple[tuple[bytes, ...] | None, bool, bool], bytes] ] | WeakKeyDictionary[ Request, dict[tuple[tuple[bytes, ...] | None, bool, bool], str] ] ) = _fingerprint_cache default_cache_key = (None, False, False) known_hashes: tuple[tuple[Request, bytes | str, dict[str, Any]], ...] = ( ( Request("http://example.org"), b"xs\xd7\x0c3uj\x15\xfe\xd7d\x9b\xa9\t\xe0d\xbf\x9cXD", {}, ), ( Request("https://example.org"), b"\xc04\x85P,\xaa\x91\x06\xf8t\xb4\xbd*\xd9\xe9\x8a:m\xc3l", {}, ), ( Request("https://example.org?a"), b"G\xad\xb8Ck\x19\x1c\xed\x838,\x01\xc4\xde;\xee\xa5\x94a\x0c", {}, ), ( Request("https://example.org?a=b"), b"\x024MYb\x8a\xc2\x1e\xbc>\xd6\xac*\xda\x9cF\xc1r\x7f\x17", {}, ), ( Request("https://example.org?a=b&a"), b"t+\xe8*\xfb\x84\xe3v\x1a}\x88p\xc0\xccB\xd7\x9d\xfez\x96", {}, ), ( Request("https://example.org?a=b&a=c"), b"\xda\x1ec\xd0\x9c\x08s`\xb4\x9b\xe2\xb6R\xf8k\xef\xeaQG\xef", {}, ), ( Request("https://example.org", method="POST"), b"\x9d\xcdA\x0fT\x02:\xca\xa0}\x90\xda\x05B\xded\x8aN7\x1d", {}, ), ( Request("https://example.org", body=b"a"), b"\xc34z>\xd8\x99\x8b\xda7\x05r\x99I\xa8\xa0x;\xa41_", {}, ), ( Request("https://example.org", method="POST", body=b"a"), b"5`\xe2y4\xd0\x9d\xee\xe0\xbatw\x87Q\xe8O\xd78\xfc\xe7", {}, ), ( Request("https://example.org#a", headers={"A": b"B"}), b"\xc04\x85P,\xaa\x91\x06\xf8t\xb4\xbd*\xd9\xe9\x8a:m\xc3l", {}, ), ( Request("https://example.org#a", headers={"A": b"B"}), b"]\xc7\x1f\xf2\xafG2\xbc\xa4\xfa\x99\n33\xda\x18\x94\x81U.", {"include_headers": ["A"]}, ), ( Request("https://example.org#a", headers={"A": b"B"}), b"<\x1a\xeb\x85y\xdeW\xfb\xdcq\x88\xee\xaf\x17\xdd\x0c\xbfH\x18\x1f", {"keep_fragments": True}, ), ( Request("https://example.org#a", headers={"A": b"B"}), b"\xc1\xef~\x94\x9bS\xc1\x83\t\xdcz8\x9f\xdc{\x11\x16I.\x11", {"include_headers": ["A"], "keep_fragments": True}, ), ( Request("https://example.org/ab"), b"N\xe5l\xb8\x12@iw\xe2\xf3\x1bp\xea\xffp!u\xe2\x8a\xc6", {}, ), ( Request("https://example.org/a", body=b"b"), b"_NOv\xbco$6\xfcW\x9f\xb24g\x9f\xbb\xdd\xa82\xc5", {}, ), ) def test_query_string_key_order(self): r1 = Request("http://www.example.com/query?id=111&cat=222") r2 = Request("http://www.example.com/query?cat=222&id=111") assert self.function(r1) == self.function(r1) assert self.function(r1) == self.function(r2) def test_query_string_key_without_value(self): r1 = Request("http://www.example.com/hnnoticiaj1.aspx?78132,199") r2 = Request("http://www.example.com/hnnoticiaj1.aspx?78160,199") assert self.function(r1) != self.function(r2) def test_caching(self): r1 = Request("http://www.example.com/hnnoticiaj1.aspx?78160,199") assert self.function(r1) == self.cache[r1][self.default_cache_key] def test_header(self): r1 = Request("http://www.example.com/members/offers.html") r2 = Request("http://www.example.com/members/offers.html") r2.headers["SESSIONID"] = b"somehash" assert self.function(r1) == self.function(r2) def test_headers(self): r1 = Request("http://www.example.com/") r2 = Request("http://www.example.com/") r2.headers["Accept-Language"] = b"en" r3 = Request("http://www.example.com/") r3.headers["Accept-Language"] = b"en" r3.headers["SESSIONID"] = b"somehash" assert self.function(r1) == self.function(r2) == self.function(r3) assert self.function(r1) == self.function( r1, include_headers=["Accept-Language"] ) assert self.function(r1) != self.function( r2, include_headers=["Accept-Language"] ) assert self.function( r3, include_headers=["accept-language", "sessionid"] ) == self.function(r3, include_headers=["SESSIONID", "Accept-Language"]) def test_fragment(self): r1 = Request("http://www.example.com/test.html") r2 = Request("http://www.example.com/test.html#fragment") assert self.function(r1) == self.function(r2) assert self.function(r1) == self.function(r1, keep_fragments=True) assert self.function(r2) != self.function(r2, keep_fragments=True) assert self.function(r1) != self.function(r2, keep_fragments=True) def test_verbatim_url(self): # verbatim_url requests skip URL canonicalization r1 = Request( "http://www.example.com/query?a=1&b=2", meta={"verbatim_url": True} ) r2 = Request( "http://www.example.com/query?b=2&a=1", meta={"verbatim_url": True} ) assert self.function(r1) != self.function(r2) # without verbatim_url, canonicalization makes query-param order irrelevant r3 = Request("http://www.example.com/query?a=1&b=2") r4 = Request("http://www.example.com/query?b=2&a=1") assert self.function(r3) == self.function(r4) # with verbatim_url, the fragment is always kept in the fingerprint r5 = Request( "http://www.example.com/test#fragment", meta={"verbatim_url": True} ) r6 = Request("http://www.example.com/test", meta={"verbatim_url": True}) assert self.function(r5) != self.function(r6) # keep_fragments parameter is ignored for verbatim_url requests assert self.function(r5) == self.function(r5, keep_fragments=True) assert self.function(r5) == self.function(r5, keep_fragments=False) def test_method_and_body(self): r1 = Request("http://www.example.com") r2 = Request("http://www.example.com", method="POST") r3 = Request("http://www.example.com", method="POST", body=b"request body") assert self.function(r1) != self.function(r2) assert self.function(r2) != self.function(r3) def test_request_replace(self): # cached fingerprint must be cleared on request copy r1 = Request("http://www.example.com") fp1 = self.function(r1) r2 = r1.replace(url="http://www.example.com/other") fp2 = self.function(r2) assert fp1 != fp2 def test_part_separation(self): # An old implementation used to serialize request data in a way that # would put the body right after the URL. r1 = Request("http://www.example.com/foo") fp1 = self.function(r1) r2 = Request("http://www.example.com/f", body=b"oo") fp2 = self.function(r2) assert fp1 != fp2 def test_hashes(self): """Test hardcoded hashes, to make sure future changes to not introduce backward incompatibilities.""" actual = [ self.function(request, **kwargs) for request, _, kwargs in self.known_hashes ] expected = [_fingerprint for _, _fingerprint, _ in self.known_hashes] assert actual == expected class TestRequestFingerprinter: def test_fingerprint(self): crawler = get_crawler() request = Request("https://example.com") assert crawler.request_fingerprinter.fingerprint(request) == fingerprint( request ) class TestCustomRequestFingerprinter: def test_include_headers(self): class RequestFingerprinter: def fingerprint(self, request): return fingerprint(request, include_headers=["X-ID"]) settings = { "REQUEST_FINGERPRINTER_CLASS": RequestFingerprinter, } crawler = get_crawler(settings_dict=settings) r1 = Request("http://www.example.com", headers={"X-ID": "1"}) fp1 = crawler.request_fingerprinter.fingerprint(r1) r2 = Request("http://www.example.com", headers={"X-ID": "2"}) fp2 = crawler.request_fingerprinter.fingerprint(r2) assert fp1 != fp2 def test_dont_canonicalize(self): class RequestFingerprinter: cache: WeakKeyDictionary[Request, bytes] = WeakKeyDictionary() def fingerprint(self, request: Request) -> bytes: if request not in self.cache: fp = sha1() fp.update(to_bytes(request.url)) self.cache[request] = fp.digest() return self.cache[request] settings = { "REQUEST_FINGERPRINTER_CLASS": RequestFingerprinter, } crawler = get_crawler(settings_dict=settings) r1 = Request("http://www.example.com?a=1&a=2") fp1 = crawler.request_fingerprinter.fingerprint(r1) r2 = Request("http://www.example.com?a=2&a=1") fp2 = crawler.request_fingerprinter.fingerprint(r2) assert fp1 != fp2 def test_meta(self): class RequestFingerprinter: def fingerprint(self, request): if "fingerprint" in request.meta: return request.meta["fingerprint"] return fingerprint(request) settings = { "REQUEST_FINGERPRINTER_CLASS": RequestFingerprinter, } crawler = get_crawler(settings_dict=settings) r1 = Request("http://www.example.com") fp1 = crawler.request_fingerprinter.fingerprint(r1) r2 = Request("http://www.example.com", meta={"fingerprint": "a"}) fp2 = crawler.request_fingerprinter.fingerprint(r2) r3 = Request("http://www.example.com", meta={"fingerprint": "a"}) fp3 = crawler.request_fingerprinter.fingerprint(r3) r4 = Request("http://www.example.com", meta={"fingerprint": "b"}) fp4 = crawler.request_fingerprinter.fingerprint(r4) assert fp1 != fp2 assert fp1 != fp4 assert fp2 != fp4 assert fp2 == fp3 def test_from_crawler(self): class RequestFingerprinter: @classmethod def from_crawler(cls, crawler): return cls(crawler) def __init__(self, crawler): self._fingerprint = crawler.settings["FINGERPRINT"] def fingerprint(self, request): return self._fingerprint settings = { "REQUEST_FINGERPRINTER_CLASS": RequestFingerprinter, "FINGERPRINT": b"fingerprint", } crawler = get_crawler(settings_dict=settings) request = Request("http://www.example.com") fingerprint = crawler.request_fingerprinter.fingerprint(request) assert fingerprint == settings["FINGERPRINT"] class TestRequestToCurl: def _test_request( self, request_object: Request, expected_curl_command: str ) -> None: curl_command = request_to_curl(request_object) assert curl_command == expected_curl_command def test_get(self) -> None: request_object = Request("https://www.example.com") expected_curl_command = "curl -X GET https://www.example.com" self._test_request(request_object, expected_curl_command) def test_post(self) -> None: request_object = Request( "https://www.httpbin.org/post", method="POST", body=json.dumps({"foo": "bar"}), ) expected_curl_command = ( 'curl -X POST https://www.httpbin.org/post --data-raw \'{"foo": "bar"}\'' ) self._test_request(request_object, expected_curl_command) def test_headers(self) -> None: request_object = Request( "https://www.httpbin.org/post", method="POST", headers={"Content-Type": "application/json", "Accept": "application/json"}, body=json.dumps({"foo": "bar"}), ) expected_curl_command = ( "curl -X POST https://www.httpbin.org/post" ' --data-raw \'{"foo": "bar"}\'' " -H 'Content-Type: application/json' -H 'Accept: application/json'" ) self._test_request(request_object, expected_curl_command) def test_cookies_dict(self) -> None: request_object = Request( "https://www.httpbin.org/post", method="POST", cookies={"foo": "bar"}, body=json.dumps({"foo": "bar"}), ) expected_curl_command = ( "curl -X POST https://www.httpbin.org/post" " --data-raw '{\"foo\": \"bar\"}' --cookie 'foo=bar'" ) self._test_request(request_object, expected_curl_command) def test_cookies_dict_bytes(self) -> None: request_object = Request( "https://www.httpbin.org/post", method="POST", cookies={b"foo": b"bar"}, body=json.dumps({"foo": "bar"}), ) expected_curl_command = ( "curl -X POST https://www.httpbin.org/post" " --data-raw '{\"foo\": \"bar\"}' --cookie 'foo=bar'" ) self._test_request(request_object, expected_curl_command) def test_cookies_list_verbose(self) -> None: request_object = Request( "https://www.httpbin.org/post", method="POST", cookies=[ { "name": b"foo", "value": b"bar", "domain": "example.com", "path": "/", "secure": True, } ], body=json.dumps({"foo": "bar"}), ) expected_curl_command = ( "curl -X POST https://www.httpbin.org/post" " --data-raw '{\"foo\": \"bar\"}' --cookie 'foo=bar'" ) self._test_request(request_object, expected_curl_command) def test_cookies_list_verbose_non_string_value(self) -> None: request_object = Request( "https://www.httpbin.org/post", method="POST", cookies=[ { "name": "foo", "value": 1, "domain": "example.com", "path": "/", "secure": True, } ], body=json.dumps({"foo": "bar"}), ) expected_curl_command = ( "curl -X POST https://www.httpbin.org/post" " --data-raw '{\"foo\": \"bar\"}' --cookie 'foo=1'" ) self._test_request(request_object, expected_curl_command) def test_request_to_curl_method(self) -> None: request_object = Request( "https://www.httpbin.org/post", method="POST", body=json.dumps({"foo": "bar"}), ) expected_curl_command = ( 'curl -X POST https://www.httpbin.org/post --data-raw \'{"foo": "bar"}\'' ) assert request_object.to_curl() == expected_curl_command scrapy-scrapy-5569de3/tests/test_utils_response.py000066400000000000000000000257731525051012400225220ustar00rootroot00000000000000from __future__ import annotations 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 ( get_base_url, get_meta_refresh, open_in_browser, response_status_message, ) def _read_browser_output(burl: str) -> bytes: path = urlparse(burl).path if not path or not Path(path).exists(): path = burl.replace("file://", "") return Path(path).read_bytes() def test_open_in_browser(): url = "http://www.example.com/some/page.html" body = ( b" test page test body " ) def browser_open(burl: str) -> bool: bbody = _read_browser_output(burl) assert b'' in bbody return True response = HtmlResponse(url, body=body) assert open_in_browser(response, _openfunc=browser_open), "Browser not called" resp = Response(url, body=body) with pytest.raises(TypeError): open_in_browser(resp, _openfunc=browser_open) def test_get_meta_refresh(): r1 = HtmlResponse( "http://www.example.com", body=b""" Dummy blahablsdfsal& """, ) r2 = HtmlResponse( "http://www.example.com", body=b""" Dummy blahablsdfsal& """, ) r3 = HtmlResponse( "http://www.example.com", body=b"""