Repository: scrapy/itemadapter

Issue:
Implement ItemClassAdapter
This would help implementing delayed item object creation in `itemloaders`: https://github.com/scrapy/itemloaders/pull/20#issuecomment-681867612

It should only accept item classes, not objects.

It should implement `get_field_meta` just as `ItemAdapter`.

Comments:

Patch:
diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index dc8bdac..1d26f9a 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -39,7 +39,7 @@ jobs:
     runs-on: ubuntu-latest
     strategy:
       matrix:
-        python-version: [3.5, 3.6, 3.7, 3.8]
+        python-version: [3.6, 3.7, 3.8]
 
     steps:
     - uses: actions/checkout@v2
diff --git a/README.md b/README.md
index ecac8be..da7a53e 100644
--- a/README.md
+++ b/README.md
@@ -19,7 +19,7 @@ Currently supported types are:
 
 ## Requirements
 
-* Python 3.5+
+* Python 3.6+
 * [`scrapy`](https://scrapy.org/): optional, needed to interact with `scrapy` items
 * `dataclasses` ([stdlib](https://docs.python.org/3/library/dataclasses.html) in Python 3.7+,
   or its [backport](https://pypi.org/project/dataclasses/) in Python 3.6): optional, needed
@@ -163,13 +163,23 @@ _`itemadapter.utils.is_item(obj: Any) -> bool`_
 Return `True` if the given object belongs to one of the supported types,
 `False` otherwise.
 
+### `get_field_meta_from_class` function
+
+_`itemadapter.utils.get_field_meta_from_class(item_class: type, field_name: str) -> MappingProxyType`_
+
+Given an item class and a field name, return a
+[`MappingProxyType`](https://docs.python.org/3/library/types.html#types.MappingProxyType)
+object, which is a read-only mapping with metadata about the given field. If the item class does not
+support field metadata, or there is no metadata for the given field, an empty object is returned.
+
 
 ## Metadata support
 
 `scrapy.item.Item`, `dataclass` and `attrs` objects allow the inclusion of
-arbitrary field metadata, which can be retrieved with the
-`ItemAdapter.get_field_meta` method. The definition procedure depends on the
-underlying type.
+arbitrary field metadata. This can be retrieved from an item instance with the
+`itemadapter.adapter.ItemAdapter.get_field_meta` method, or from an item class
+with the `itemadapter.utils.get_field_meta_from_class` function.
+The definition procedure depends on the underlying type.
 
 #### `scrapy.item.Item` objects
 
diff --git a/itemadapter/__init__.py b/itemadapter/__init__.py
index 61b5cdd..a066e2d 100644
--- a/itemadapter/__init__.py
+++ b/itemadapter/__init__.py
@@ -1,5 +1,5 @@
 from .adapter import ItemAdapter  # noqa: F401
-from .utils import is_item  # noqa: F401
+from .utils import get_field_meta_from_class, is_item  # noqa: F401
 
 
 __version__ = "0.1.0"
diff --git a/itemadapter/adapter.py b/itemadapter/adapter.py
index 4312f2e..50e9bfa 100644
--- a/itemadapter/adapter.py
+++ b/itemadapter/adapter.py
@@ -2,7 +2,13 @@ from collections.abc import KeysView, MutableMapping
 from types import MappingProxyType
 from typing import Any, Iterator
 
-from .utils import is_item, is_attrs_instance, is_dataclass_instance, is_scrapy_item
+from .utils import (
+    get_field_meta_from_class,
+    is_attrs_instance,
+    is_dataclass_instance,
+    is_item,
+    is_scrapy_item,
+)
 
 
 class ItemAdapter(MutableMapping):
@@ -86,28 +92,7 @@ class ItemAdapter(MutableMapping):
         The returned value is an instance of types.MappingProxyType, i.e. a dynamic read-only view
         of the original mapping, which gets automatically updated if the original mapping changes.
         """
-        if is_scrapy_item(self.item):
-            return MappingProxyType(self.item.fields[field_name])
-        elif is_dataclass_instance(self.item):
-            from dataclasses import fields
-
-            for field in fields(self.item):
-                if field.name == field_name:
-                    return field.metadata  # type: ignore
-            raise KeyError(
-                "%s does not support field: %s" % (self.item.__class__.__name__, field_name)
-            )
-        elif is_attrs_instance(self.item):
-            from attr import fields_dict
-
-            try:
-                return fields_dict(self.item.__class__)[field_name].metadata  # type: ignore
-            except KeyError:
-                raise KeyError(
-                    "%s does not support field: %s" % (self.item.__class__.__name__, field_name)
-                )
-        else:
-            return MappingProxyType({})
+        return get_field_meta_from_class(self.item.__class__, field_name)
 
     def field_names(self) -> KeysView:
         """
diff --git a/itemadapter/utils.py b/itemadapter/utils.py
index 5c22dd3..6ddd52f 100644
--- a/itemadapter/utils.py
+++ b/itemadapter/utils.py
@@ -1,31 +1,52 @@
+from types import MappingProxyType
 from typing import Any
 
 
+def _get_scrapy_item_classes() -> tuple:
+    try:
+        import scrapy
+    except ImportError:
+        return ()
+    else:
+        try:
+            _base_item_cls = getattr(scrapy.item, "_BaseItem", scrapy.item.BaseItem)  # deprecated
+            return (scrapy.item.Item, _base_item_cls)
+        except AttributeError:
+            return (scrapy.item.Item,)
+
+
+def _is_dataclass(obj: Any) -> bool:
+    try:
+        import dataclasses
+    except ImportError:
+        return False
+    return dataclasses.is_dataclass(obj)
+
+
+def _is_attrs_class(obj: Any) -> bool:
+    try:
+        import attr
+    except ImportError:
+        return False
+    return attr.has(obj)
+
+
 def is_dataclass_instance(obj: Any) -> bool:
     """
     Return True if the given object is a dataclass object, False otherwise.
 
-    This function always returns False in py35. In py36, it returns False
-    if the "dataclasses" backport is not available.
+    In py36, this function returns False if the "dataclasses" backport is not available.
 
     Taken from https://docs.python.org/3/library/dataclasses.html#dataclasses.is_dataclass.
     """
-    try:
-        import dataclasses
-    except ImportError:
-        return False
-    return dataclasses.is_dataclass(obj) and not isinstance(obj, type)
+    return _is_dataclass(obj) and not isinstance(obj, type)
 
 
 def is_attrs_instance(obj: Any) -> bool:
     """
     Return True if the given object is a attrs-based object, False otherwise.
     """
-    try:
-        import attr
-    except ImportError:
-        return False
-    return attr.has(obj) and not isinstance(obj, type)
+    return _is_attrs_class(obj) and not isinstance(obj, type)
 
 
 def is_scrapy_item(obj: Any) -> bool:
@@ -56,3 +77,39 @@ def is_item(obj: Any) -> bool:
         or is_dataclass_instance(obj)
         or is_attrs_instance(obj)
     )
+
+
+def get_field_meta_from_class(item_class: type, field_name: str) -> MappingProxyType:
+    """
+    Return a read-only mapping with metadata for the given field name, within the given item class.
+    If there is no metadata for the field, or the item class does not support field metadata,
+    an empty object is returned.
+
+    Field metadata is taken from different sources, depending on the item type:
+    * scrapy.item.Item: corresponding scrapy.item.Field object
+    * dataclass items: "metadata" attribute for the corresponding field
+    * attrs items: "metadata" attribute for the corresponding field
+
+    The returned value is an instance of types.MappingProxyType, i.e. a dynamic read-only view
+    of the original mapping, which gets automatically updated if the original mapping changes.
+    """
+    if issubclass(item_class, _get_scrapy_item_classes()):
+        return MappingProxyType(item_class.fields[field_name])  # type: ignore
+    elif _is_dataclass(item_class):
+        from dataclasses import fields
+
+        for field in fields(item_class):
+            if field.name == field_name:
+                return field.metadata  # type: ignore
+        raise KeyError("%s does not support field: %s" % (item_class.__name__, field_name))
+    elif _is_attrs_class(item_class):
+        from attr import fields_dict
+
+        try:
+            return fields_dict(item_class)[field_name].metadata  # type: ignore
+        except KeyError:
+            raise KeyError("%s does not support field: %s" % (item_class.__name__, field_name))
+    elif issubclass(item_class, dict):
+        return MappingProxyType({})
+    else:
+        raise TypeError("%s is not a valid item class" % (item_class,))
diff --git a/setup.py b/setup.py
index 587fe63..76fc982 100644
--- a/setup.py
+++ b/setup.py
@@ -16,11 +16,11 @@ setuptools.setup(
     author_email="eugenio.lacuesta@gmail.com",
     url="https://github.com/scrapy/itemadapter",
     packages=["itemadapter"],
+    python_requires=">=3.6",
     classifiers=[
         "Development Status :: 3 - Alpha",
         "License :: OSI Approved :: BSD License",
         "Programming Language :: Python",
-        "Programming Language :: Python :: 3.5",
         "Programming Language :: Python :: 3.6",
         "Programming Language :: Python :: 3.7",
         "Programming Language :: Python :: 3.8",
diff --git a/tox.ini b/tox.ini
index b806947..bef3467 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,5 +1,5 @@
 [tox]
-envlist = flake8,typing,black,py35,py36,py37,py38
+envlist = flake8,typing,black,py36,py37,py38
 
 [testenv]
 deps =
@@ -7,9 +7,6 @@ deps =
 commands =
     pytest --verbose --cov=itemadapter --cov-report=term-missing --cov-report=html --cov-report=xml {posargs: itemadapter tests}
 
-[testenv:py35]
-basepython = python3.5
-
 [testenv:py36]
 basepython = python3.6
 


Test patch:
diff --git a/tests/test_adapter.py b/tests/test_adapter.py
index 6589975..adba8f8 100644
--- a/tests/test_adapter.py
+++ b/tests/test_adapter.py
@@ -18,20 +18,14 @@ class ItemAdapterReprTestCase(unittest.TestCase):
     def test_repr_dict(self):
         item = dict(name="asdf", value=1234)
         adapter = ItemAdapter(item)
-        # dicts are not guarantied to be sorted in py35
-        self.assertTrue(
-            repr(adapter) == "<ItemAdapter for dict(name='asdf', value=1234)>"
-            or repr(adapter) == "<ItemAdapter for dict(value=1234, name='asdf')>",
-        )
+        self.assertEqual(repr(adapter), "<ItemAdapter for dict(name='asdf', value=1234)>")
 
     @unittest.skipIf(not ScrapySubclassedItem, "scrapy module is not available")
     def test_repr_scrapy_item(self):
         item = ScrapySubclassedItem(name="asdf", value=1234)
         adapter = ItemAdapter(item)
-        # Scrapy fields are stored in a dict, which is not guarantied to be sorted in py35
-        self.assertTrue(
-            repr(adapter) == "<ItemAdapter for ScrapySubclassedItem(name='asdf', value=1234)>"
-            or repr(adapter) == "<ItemAdapter for ScrapySubclassedItem(value=1234, name='asdf')>",
+        self.assertEqual(
+            repr(adapter), "<ItemAdapter for ScrapySubclassedItem(name='asdf', value=1234)>"
         )
 
     @unittest.skipIf(not DataClassItem, "dataclasses module is not available")
diff --git a/tests/test_utils.py b/tests/test_utils.py
index 8c4194a..162e680 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -1,7 +1,14 @@
 import unittest
 from unittest import mock
+from types import MappingProxyType
 
-from itemadapter.utils import is_item, is_attrs_instance, is_dataclass_instance, is_scrapy_item
+from itemadapter.utils import (
+    get_field_meta_from_class,
+    is_attrs_instance,
+    is_dataclass_instance,
+    is_item,
+    is_scrapy_item,
+)
 
 from tests import AttrsItem, DataClassItem, ScrapyItem, ScrapySubclassedItem
 
@@ -10,6 +17,14 @@ def mocked_import(name, *args, **kwargs):
     raise ImportError(name)
 
 
+class InvalidItemClassTestCase(unittest.TestCase):
+    def test_invalid_item_class(self):
+        with self.assertRaises(TypeError, msg="1 is not a valid item class"):
+            get_field_meta_from_class(1, "field")
+        with self.assertRaises(TypeError, msg="list is not a valid item class"):
+            get_field_meta_from_class(list, "field")
+
+
 class ItemLikeTestCase(unittest.TestCase):
     def test_false(self):
         self.assertFalse(is_item(int))
@@ -64,11 +79,20 @@ class AttrsTestCase(unittest.TestCase):
     @mock.patch("builtins.__import__", mocked_import)
     def test_module_not_available(self):
         self.assertFalse(is_attrs_instance(AttrsItem(name="asdf", value=1234)))
+        with self.assertRaises(TypeError, msg="AttrsItem is not a valid item class"):
+            get_field_meta_from_class(AttrsItem, "name")
 
     @unittest.skipIf(not AttrsItem, "attrs module is not available")
     def test_true(self):
         self.assertTrue(is_attrs_instance(AttrsItem()))
         self.assertTrue(is_attrs_instance(AttrsItem(name="asdf", value=1234)))
+        # field metadata
+        self.assertEqual(
+            get_field_meta_from_class(AttrsItem, "name"), MappingProxyType({"serializer": str})
+        )
+        self.assertEqual(
+            get_field_meta_from_class(AttrsItem, "value"), MappingProxyType({"serializer": int})
+        )
 
 
 class DataclassTestCase(unittest.TestCase):
@@ -92,11 +116,21 @@ class DataclassTestCase(unittest.TestCase):
     @mock.patch("builtins.__import__", mocked_import)
     def test_module_not_available(self):
         self.assertFalse(is_dataclass_instance(DataClassItem(name="asdf", value=1234)))
+        with self.assertRaises(TypeError, msg="DataClassItem is not a valid item class"):
+            get_field_meta_from_class(DataClassItem, "name")
 
     @unittest.skipIf(not DataClassItem, "dataclasses module is not available")
     def test_true(self):
         self.assertTrue(is_dataclass_instance(DataClassItem()))
         self.assertTrue(is_dataclass_instance(DataClassItem(name="asdf", value=1234)))
+        # field metadata
+        self.assertEqual(
+            get_field_meta_from_class(DataClassItem, "name"), MappingProxyType({"serializer": str})
+        )
+        self.assertEqual(
+            get_field_meta_from_class(DataClassItem, "value"),
+            MappingProxyType({"serializer": int}),
+        )
 
 
 class ScrapyItemTestCase(unittest.TestCase):
@@ -118,12 +152,23 @@ class ScrapyItemTestCase(unittest.TestCase):
     @mock.patch("builtins.__import__", mocked_import)
     def test_module_not_available(self):
         self.assertFalse(is_scrapy_item(ScrapySubclassedItem(name="asdf", value=1234)))
+        with self.assertRaises(TypeError, msg="ScrapySubclassedItem is not a valid item class"):
+            get_field_meta_from_class(ScrapySubclassedItem, "name")
 
     @unittest.skipIf(not ScrapySubclassedItem, "scrapy module is not available")
     def test_true(self):
         self.assertTrue(is_scrapy_item(ScrapyItem()))
         self.assertTrue(is_scrapy_item(ScrapySubclassedItem()))
         self.assertTrue(is_scrapy_item(ScrapySubclassedItem(name="asdf", value=1234)))
+        # field metadata
+        self.assertEqual(
+            get_field_meta_from_class(ScrapySubclassedItem, "name"),
+            MappingProxyType({"serializer": str}),
+        )
+        self.assertEqual(
+            get_field_meta_from_class(ScrapySubclassedItem, "value"),
+            MappingProxyType({"serializer": int}),
+        )
 
 
 try:
@@ -161,8 +206,20 @@ class ScrapyDeprecatedBaseItemTestCase(unittest.TestCase):
 
     @unittest.skipIf(scrapy is None, "scrapy module is not available")
     def test_removed_baseitem(self):
+        """
+        Mock the scrapy.item module so it does not contain the deprecated _BaseItem class
+        """
+
         class MockItemModule:
             Item = ScrapyItem
 
         with mock.patch("scrapy.item", MockItemModule):
             self.assertFalse(is_scrapy_item(dict()))
+            self.assertEqual(
+                get_field_meta_from_class(ScrapySubclassedItem, "name"),
+                MappingProxyType({"serializer": str}),
+            )
+            self.assertEqual(
+                get_field_meta_from_class(ScrapySubclassedItem, "value"),
+                MappingProxyType({"serializer": int}),
+            )