import dataclasses import json import marshal import pickle import re import tempfile import unittest from datetime import datetime from io import BytesIO from warnings import catch_warnings, filterwarnings import lxml.etree from itemadapter import ItemAdapter from scrapy.exceptions import ScrapyDeprecationWarning 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 TestItem(Item): name = Field() age = Field() class CustomFieldItem(Item): name = Field() age = Field(serializer=custom_serializer) @dataclasses.dataclass class TestDataClass: name: str age: int @dataclasses.dataclass class CustomFieldDataclass: name: str age: int = dataclasses.field(metadata={"serializer": custom_serializer}) class BaseItemExporterTest(unittest.TestCase): item_class = TestItem custom_field_item_class = CustomFieldItem def setUp(self): self.i = self.item_class(name="John\xa3", age="22") self.output = BytesIO() self.ie = self._get_exporter() def _get_exporter(self, **kwargs): return BaseItemExporter(**kwargs) def _check_output(self): pass def _assert_expected_item(self, exported_dict): for k, v in exported_dict.items(): exported_dict[k] = to_unicode(v) self.assertEqual(self.i, self.item_class(**exported_dict)) def _get_nonstring_types_item(self): return { "boolean": False, "number": 22, "time": datetime(2015, 1, 1, 1, 1, 1), "float": 3.14, } def assertItemExportWorks(self, item): self.ie.start_exporting() try: self.ie.export_item(item) except NotImplementedError: if self.ie.__class__ is not BaseItemExporter: raise self.ie.finish_exporting() self._check_output() def test_export_item(self): 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"]) self.assertEqual(res, "John\xa3") res = self.ie.serialize_field(a.get_field_meta("age"), "age", a["age"]) self.assertEqual(res, "22") def test_fields_to_export(self): ie = self._get_exporter(fields_to_export=["name"]) self.assertEqual( list(ie._get_serialized_fields(self.i)), [("name", "John\xa3")] ) ie = self._get_exporter(fields_to_export=["name"], encoding="latin-1") _, name = list(ie._get_serialized_fields(self.i))[0] assert isinstance(name, str) self.assertEqual(name, "John\xa3") ie = self._get_exporter(fields_to_export={"name": "名稱"}) self.assertEqual(list(ie._get_serialized_fields(self.i)), [("名稱", "John\xa3")]) def test_field_custom_serializer(self): i = self.custom_field_item_class(name="John\xa3", age="22") a = ItemAdapter(i) ie = self._get_exporter() self.assertEqual( ie.serialize_field(a.get_field_meta("name"), "name", a["name"]), "John\xa3" ) self.assertEqual( ie.serialize_field(a.get_field_meta("age"), "age", a["age"]), "24" ) class BaseItemExporterDataclassTest(BaseItemExporterTest): item_class = TestDataClass custom_field_item_class = CustomFieldDataclass class PythonItemExporterTest(BaseItemExporterTest): def _get_exporter(self, **kwargs): return PythonItemExporter(binary=False, **kwargs) def test_invalid_option(self): with self.assertRaisesRegex(TypeError, "Unexpected options: invalid_option"): PythonItemExporter(invalid_option="something") def test_nested_item(self): i1 = self.item_class(name="Joseph", age="22") i2 = dict(name="Maria", age=i1) i3 = self.item_class(name="Jesus", age=i2) ie = self._get_exporter() exported = ie.export_item(i3) self.assertEqual(type(exported), dict) self.assertEqual( exported, { "age": {"age": {"age": "22", "name": "Joseph"}, "name": "Maria"}, "name": "Jesus", }, ) self.assertEqual(type(exported["age"]), dict) self.assertEqual(type(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) self.assertEqual( exported, { "age": [{"age": [{"age": "22", "name": "Joseph"}], "name": "Maria"}], "name": "Jesus", }, ) self.assertEqual(type(exported["age"][0]), dict) self.assertEqual(type(exported["age"][0]["age"][0]), dict) def test_export_item_dict_list(self): i1 = self.item_class(name="Joseph", age="22") i2 = dict(name="Maria", age=[i1]) i3 = self.item_class(name="Jesus", age=[i2]) ie = self._get_exporter() exported = ie.export_item(i3) self.assertEqual( exported, { "age": [{"age": [{"age": "22", "name": "Joseph"}], "name": "Maria"}], "name": "Jesus", }, ) self.assertEqual(type(exported["age"][0]), dict) self.assertEqual(type(exported["age"][0]["age"][0]), dict) def test_export_binary(self): with catch_warnings(): filterwarnings("ignore", category=ScrapyDeprecationWarning) exporter = PythonItemExporter(binary=True) value = self.item_class(name="John\xa3", age="22") expected = {b"name": b"John\xc2\xa3", b"age": b"22"} self.assertEqual(expected, exporter.export_item(value)) def test_nonstring_types_item(self): item = self._get_nonstring_types_item() ie = self._get_exporter() exported = ie.export_item(item) self.assertEqual(exported, item) class PythonItemExporterDataclassTest(PythonItemExporterTest): item_class = TestDataClass custom_field_item_class = CustomFieldDataclass class PprintItemExporterTest(BaseItemExporterTest): def _get_exporter(self, **kwargs): return PprintItemExporter(self.output, **kwargs) def _check_output(self): self._assert_expected_item(eval(self.output.getvalue())) class PprintItemExporterDataclassTest(PprintItemExporterTest): item_class = TestDataClass custom_field_item_class = CustomFieldDataclass class PickleItemExporterTest(BaseItemExporterTest): def _get_exporter(self, **kwargs): return PickleItemExporter(self.output, **kwargs) def _check_output(self): 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() f.seek(0) self.assertEqual(self.item_class(**pickle.load(f)), i1) self.assertEqual(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() self.assertEqual(pickle.loads(fp.getvalue()), item) class PickleItemExporterDataclassTest(PickleItemExporterTest): item_class = TestDataClass custom_field_item_class = CustomFieldDataclass class MarshalItemExporterTest(BaseItemExporterTest): def _get_exporter(self, **kwargs): self.output = tempfile.TemporaryFile() return MarshalItemExporter(self.output, **kwargs) def _check_output(self): 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 = tempfile.TemporaryFile() ie = MarshalItemExporter(fp) ie.start_exporting() ie.export_item(item) ie.finish_exporting() fp.seek(0) self.assertEqual(marshal.load(fp), item) class MarshalItemExporterDataclassTest(MarshalItemExporterTest): item_class = TestDataClass custom_field_item_class = CustomFieldDataclass class CsvItemExporterTest(BaseItemExporterTest): def _get_exporter(self, **kwargs): self.output = tempfile.TemporaryFile() return CsvItemExporter(self.output, **kwargs) def assertCsvEqual(self, first, second, msg=None): def split_csv(csv): return [ sorted(re.split(r"(,|\s+)", line)) for line in to_unicode(csv).splitlines(True) ] return self.assertEqual(split_csv(first), split_csv(second), msg=msg) def _check_output(self): self.output.seek(0) self.assertCsvEqual( to_unicode(self.output.read()), "age,name\r\n22,John\xa3\r\n" ) def assertExportResult(self, item, expected, **kwargs): fp = BytesIO() ie = CsvItemExporter(fp, **kwargs) ie.start_exporting() ie.export_item(item) ie.finish_exporting() 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() 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=dict(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 self.assertRaises(UnicodeEncodeError): self.assertExportResult( item=dict(text="W\u0275\u200Brd"), expected=None, encoding="windows-1251", ) def test_errors_xmlcharrefreplace(self): self.assertExportResult( item=dict(text="W\u0275\u200Brd"), include_headers_line=False, expected="Wɵ​rd\r\n", encoding="windows-1251", errors="xmlcharrefreplace", ) class CsvItemExporterDataclassTest(CsvItemExporterTest): item_class = TestDataClass custom_field_item_class = CustomFieldDataclass class XmlItemExporterTest(BaseItemExporterTest): def _get_exporter(self, **kwargs): return XmlItemExporter(self.output, **kwargs) def assertXmlEquivalent(self, first, second, msg=None): def xmltuple(elem): children = list(elem.iterchildren()) if children: return [(child.tag, sorted(xmltuple(child))) for child in children] return [(elem.tag, [(elem.text, ())])] def xmlsplit(xmlcontent): doc = lxml.etree.fromstring(xmlcontent) return xmltuple(doc) return self.assertEqual(xmlsplit(first), xmlsplit(second), msg) def assertExportResult(self, item, expected_value): fp = BytesIO() ie = XmlItemExporter(fp) ie.start_exporting() ie.export_item(item) ie.finish_exporting() self.assertXmlEquivalent(fp.getvalue(), expected_value) def _check_output(self): 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 = dict(name="foo\xa3hoo", age="22") i2 = dict(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 = dict(name="foo") i2 = dict(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 XmlItemExporterDataclassTest(XmlItemExporterTest): item_class = TestDataClass custom_field_item_class = CustomFieldDataclass class JsonLinesItemExporterTest(BaseItemExporterTest): _expected_nested = { "name": "Jesus", "age": {"name": "Maria", "age": {"name": "Joseph", "age": "22"}}, } def _get_exporter(self, **kwargs): return JsonLinesItemExporter(self.output, **kwargs) def _check_output(self): exported = json.loads(to_unicode(self.output.getvalue().strip())) self.assertEqual(exported, ItemAdapter(self.i).asdict()) def test_nested_item(self): i1 = self.item_class(name="Joseph", age="22") i2 = dict(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() exported = json.loads(to_unicode(self.output.getvalue())) self.assertEqual(exported, self._expected_nested) def test_extra_keywords(self): self.ie = self._get_exporter(sort_keys=True) self.test_export_item() self._check_output() self.assertRaises(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() exported = json.loads(to_unicode(self.output.getvalue())) item["time"] = str(item["time"]) self.assertEqual(exported, item) class JsonLinesItemExporterDataclassTest(JsonLinesItemExporterTest): item_class = TestDataClass custom_field_item_class = CustomFieldDataclass class JsonItemExporterTest(JsonLinesItemExporterTest): _expected_nested = [JsonLinesItemExporterTest._expected_nested] def _get_exporter(self, **kwargs): return JsonItemExporter(self.output, **kwargs) def _check_output(self): exported = json.loads(to_unicode(self.output.getvalue().strip())) self.assertEqual(exported, [ItemAdapter(self.i).asdict()]) def assertTwoItemsExported(self, item): self.ie.start_exporting() self.ie.export_item(item) self.ie.export_item(item) self.ie.finish_exporting() exported = json.loads(to_unicode(self.output.getvalue())) self.assertEqual( 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_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() exported = json.loads(to_unicode(self.output.getvalue())) expected = { "name": "Jesus", "age": {"name": "Maria", "age": ItemAdapter(i1).asdict()}, } self.assertEqual(exported, [expected]) def test_nested_dict_item(self): i1 = dict(name="Joseph\xa3", age="22") i2 = self.item_class(name="Maria", age=i1) i3 = dict(name="Jesus", age=i2) self.ie.start_exporting() self.ie.export_item(i3) self.ie.finish_exporting() exported = json.loads(to_unicode(self.output.getvalue())) expected = {"name": "Jesus", "age": {"name": "Maria", "age": i1}} self.assertEqual(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() exported = json.loads(to_unicode(self.output.getvalue())) item["time"] = str(item["time"]) self.assertEqual(exported, [item]) class JsonItemExporterDataclassTest(JsonItemExporterTest): item_class = TestDataClass custom_field_item_class = CustomFieldDataclass class CustomExporterItemTest(unittest.TestCase): item_class = TestItem def setUp(self): if self.item_class is None: raise unittest.SkipTest("item class is None") def test_exporter_custom_serializer(self): class CustomItemExporter(BaseItemExporter): def serialize_field(self, field, name, value): if name == "age": return str(int(value) + 1) return super().serialize_field(field, name, value) i = self.item_class(name="John", age="22") a = ItemAdapter(i) ie = CustomItemExporter() self.assertEqual( ie.serialize_field(a.get_field_meta("name"), "name", a["name"]), "John" ) self.assertEqual( ie.serialize_field(a.get_field_meta("age"), "age", a["age"]), "23" ) i2 = {"name": "John", "age": "22"} self.assertEqual(ie.serialize_field({}, "name", i2["name"]), "John") self.assertEqual(ie.serialize_field({}, "age", i2["age"]), "23") class CustomExporterDataclassTest(CustomExporterItemTest): item_class = TestDataClass if __name__ == "__main__": unittest.main()