from __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.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.""" 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): 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): 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 = [] 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 = [] 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 = {"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(scrapy.extensions.feedexport.ItemFilter): def accepts(self, item): return "foo" in item.fields class CustomFilter3(scrapy.extensions.feedexport.ItemFilter): def accepts(self, item): return ( isinstance(item, tuple(self.item_classes)) and item["foo"] == "bar1" ) 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 = { "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 = [ # 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 = [ # 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: 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: 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 = FeedExporter.from_crawler(crawler) spider = scrapy.Spider("default") spider.crawler = crawler 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 = { "FEEDS": { "unsupported://uri": {}, }, } crawler = get_crawler(settings_dict=settings) with pytest.raises(NotConfigured): FeedExporter.from_crawler(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): FeedExporter.from_crawler(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): FeedExporter.from_crawler(crawler) 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 = FeedExporter.from_crawler(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 = FeedExporter.from_crawler(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