Skip to content

Commit

Permalink
Avoid imports inside functions (#60)
Browse files Browse the repository at this point in the history
  • Loading branch information
elacuesta authored Mar 18, 2022
1 parent 817054d commit 6d937d7
Show file tree
Hide file tree
Showing 10 changed files with 212 additions and 76 deletions.
48 changes: 31 additions & 17 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,31 +25,46 @@ jobs:
run: tox -e py

- name: Upload coverage report
run: bash <(curl -s https://codecov.io/bash)
run: |
curl -Os https://uploader.codecov.io/latest/linux/codecov
chmod +x codecov
./codecov
tests-other:
name: "Test: py3, ${{ matrix.os }}"
name: "Test: py38-scrapy22, Ubuntu"
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v2

- name: Set up Python
uses: actions/setup-python@v2
with:
python-version: 3.8

- name: Install tox
run: pip install tox

- name: Run tests
run: tox -e py38-scrapy22

- name: Upload coverage report
run: |
curl -Os https://uploader.codecov.io/latest/linux/codecov
chmod +x codecov
./codecov
tests-other-os:
name: "Test: py38, ${{ matrix.os }}"
runs-on: "${{ matrix.os }}"
strategy:
matrix:
include:
- python-version: 3
os: ubuntu-latest
env:
TOXENV: py38-scrapy22
- python-version: 3
os: macos-latest
env:
TOXENV: py
- python-version: 3
os: windows-latest
env:
TOXENV: py
os: [macos-latest, windows-latest]

steps:
- uses: actions/checkout@v2

- name: Set up Python 3.8
- name: Set up Python
uses: actions/setup-python@v2
with:
python-version: 3.8
Expand All @@ -58,5 +73,4 @@ jobs:
run: pip install tox

- name: Run tests
env: ${{ matrix.env }}
run: tox -e py
33 changes: 33 additions & 0 deletions itemadapter/_imports.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# attempt the following imports only once,
# to be imported from itemadapter's submodules

_scrapy_item_classes: tuple

try:
import scrapy # pylint: disable=W0611 (unused-import)
except ImportError:
scrapy = None # type: ignore [assignment]
_scrapy_item_classes = ()
else:
try:
# handle deprecated base classes
_base_item_cls = getattr(scrapy.item, "_BaseItem", scrapy.item.BaseItem)
except AttributeError:
_scrapy_item_classes = (scrapy.item.Item,)
else:
_scrapy_item_classes = (scrapy.item.Item, _base_item_cls)

try:
import dataclasses # pylint: disable=W0611 (unused-import)
except ImportError:
dataclasses = None # type: ignore [assignment]

try:
import attr # pylint: disable=W0611 (unused-import)
except ImportError:
attr = None # type: ignore [assignment]

try:
import pydantic # pylint: disable=W0611 (unused-import)
except ImportError:
pydantic = None # type: ignore [assignment]
33 changes: 21 additions & 12 deletions itemadapter/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,13 @@

from itemadapter.utils import (
_get_pydantic_model_metadata,
_get_scrapy_item_classes,
_is_attrs_class,
_is_dataclass,
_is_pydantic_model,
)

from itemadapter._imports import attr, dataclasses, _scrapy_item_classes


__all__ = [
"AdapterInterface",
Expand Down Expand Up @@ -100,8 +101,8 @@ def __len__(self) -> int:
class AttrsAdapter(_MixinAttrsDataclassAdapter, AdapterInterface):
def __init__(self, item: Any) -> None:
super().__init__(item)
import attr

if attr is None:
raise RuntimeError("attr module is not available")
# store a reference to the item's fields to avoid O(n) lookups and O(n^2) traversals
self._fields_dict = attr.fields_dict(self.item.__class__)

Expand All @@ -115,19 +116,19 @@ def is_item_class(cls, item_class: type) -> bool:

@classmethod
def get_field_meta_from_class(cls, item_class: type, field_name: str) -> MappingProxyType:
from attr import fields_dict

if attr is None:
raise RuntimeError("attr module is not available")
try:
return fields_dict(item_class)[field_name].metadata # type: ignore
return attr.fields_dict(item_class)[field_name].metadata # type: ignore
except KeyError:
raise KeyError(f"{item_class.__name__} does not support field: {field_name}")


class DataclassAdapter(_MixinAttrsDataclassAdapter, AdapterInterface):
def __init__(self, item: Any) -> None:
super().__init__(item)
import dataclasses

if dataclasses is None:
raise RuntimeError("dataclasses module is not available")
# store a reference to the item's fields to avoid O(n) lookups and O(n^2) traversals
self._fields_dict = {field.name: field for field in dataclasses.fields(self.item)}

Expand All @@ -141,9 +142,9 @@ def is_item_class(cls, item_class: type) -> bool:

@classmethod
def get_field_meta_from_class(cls, item_class: type, field_name: str) -> MappingProxyType:
from dataclasses import fields

for field in fields(item_class):
if dataclasses is None:
raise RuntimeError("dataclasses module is not available")
for field in dataclasses.fields(item_class):
if field.name == field_name:
return field.metadata # type: ignore
raise KeyError(f"{item_class.__name__} does not support field: {field_name}")
Expand Down Expand Up @@ -216,6 +217,10 @@ def __len__(self) -> int:


class DictAdapter(_MixinDictScrapyItemAdapter, AdapterInterface):
@classmethod
def is_item(cls, item: Any) -> bool:
return isinstance(item, dict)

@classmethod
def is_item_class(cls, item_class: type) -> bool:
return issubclass(item_class, dict)
Expand All @@ -225,9 +230,13 @@ def field_names(self) -> KeysView:


class ScrapyItemAdapter(_MixinDictScrapyItemAdapter, AdapterInterface):
@classmethod
def is_item(cls, item: Any) -> bool:
return isinstance(item, _scrapy_item_classes)

@classmethod
def is_item_class(cls, item_class: type) -> bool:
return issubclass(item_class, _get_scrapy_item_classes())
return issubclass(item_class, _scrapy_item_classes)

@classmethod
def get_field_meta_from_class(cls, item_class: type, field_name: str) -> MappingProxyType:
Expand Down
37 changes: 9 additions & 28 deletions itemadapter/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,55 +3,36 @@
from types import MappingProxyType
from typing import Any


__all__ = ["is_item", "get_field_meta_from_class"]
from itemadapter._imports import attr, dataclasses, pydantic


def _get_scrapy_item_classes() -> tuple:
try:
import scrapy
except ImportError:
return ()
else:
try:
# handle deprecated base classes
_base_item_cls = getattr(scrapy.item, "_BaseItem", scrapy.item.BaseItem)
except AttributeError:
return (scrapy.item.Item,)
else:
return (scrapy.item.Item, _base_item_cls)
__all__ = ["is_item", "get_field_meta_from_class"]


def _is_dataclass(obj: Any) -> bool:
"""In py36, this returns False if the "dataclasses" backport module is not installed."""
try:
import dataclasses
except ImportError:
if dataclasses is None:
return False
return dataclasses.is_dataclass(obj)


def _is_attrs_class(obj: Any) -> bool:
try:
import attr
except ImportError:
if attr is None:
return False
return attr.has(obj)


def _is_pydantic_model(obj: Any) -> bool:
try:
from pydantic import BaseModel
except ImportError:
if pydantic is None:
return False
return issubclass(obj, BaseModel)
return issubclass(obj, pydantic.BaseModel)


def _get_pydantic_model_metadata(item_model: Any, field_name: str) -> MappingProxyType:
metadata = {}
field = item_model.__fields__[field_name].field_info

for attr in [
for attribute in [
"alias",
"title",
"description",
Expand All @@ -67,9 +48,9 @@ def _get_pydantic_model_metadata(item_model: Any, field_name: str) -> MappingPro
"max_length",
"regex",
]:
value = getattr(field, attr)
value = getattr(field, attribute)
if value is not None:
metadata[attr] = value
metadata[attribute] = value
if not field.allow_mutation:
metadata["allow_mutation"] = field.allow_mutation
metadata.update(field.extra)
Expand Down
27 changes: 22 additions & 5 deletions tests/__init__.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,31 @@
import importlib
from typing import Optional
import sys
from contextlib import contextmanager
from typing import Callable, Optional

from itemadapter import ItemAdapter


def mocked_import(name, *args, **kwargs):
"""Allow only internal itemadapter imports."""
if name.split(".")[0] == "itemadapter":
def make_mock_import(block_name: str) -> Callable:
def mock_import(name: str, *args, **kwargs):
"""Prevent importing a specific module, let everything else pass."""
if name.split(".")[0] == block_name:
raise ImportError(name)
return importlib.__import__(name, *args, **kwargs)
raise ImportError(name)

return mock_import


@contextmanager
def clear_itemadapter_imports() -> None:
backup = {}
for key in sys.modules.copy().keys():
if key.startswith("itemadapter"):
backup[key] = sys.modules.pop(key)
try:
yield
finally:
sys.modules.update(backup)


try:
Expand Down
26 changes: 23 additions & 3 deletions tests/test_adapter_attrs.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
from types import MappingProxyType
from unittest import mock

from itemadapter.adapter import AttrsAdapter
from itemadapter.utils import get_field_meta_from_class

from tests import (
Expand All @@ -12,12 +11,15 @@
PydanticModel,
ScrapyItem,
ScrapySubclassedItem,
mocked_import,
make_mock_import,
clear_itemadapter_imports,
)


class AttrsTestCase(unittest.TestCase):
def test_false(self):
from itemadapter.adapter import AttrsAdapter

self.assertFalse(AttrsAdapter.is_item(int))
self.assertFalse(AttrsAdapter.is_item(sum))
self.assertFalse(AttrsAdapter.is_item(1234))
Expand All @@ -35,14 +37,32 @@ def test_false(self):
self.assertFalse(AttrsAdapter.is_item(AttrsItem))

@unittest.skipIf(not AttrsItem, "attrs module is not available")
@mock.patch("builtins.__import__", mocked_import)
@mock.patch("builtins.__import__", make_mock_import("attr"))
def test_module_import_error(self):
with clear_itemadapter_imports():
from itemadapter.adapter import AttrsAdapter

self.assertFalse(AttrsAdapter.is_item(AttrsItem(name="asdf", value=1234)))
with self.assertRaises(RuntimeError, msg="attr module is not available"):
AttrsAdapter(AttrsItem(name="asdf", value=1234))
with self.assertRaises(RuntimeError, msg="attr module is not available"):
AttrsAdapter.get_field_meta_from_class(AttrsItem, "name")
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")
@mock.patch("itemadapter.utils.attr", None)
def test_module_not_available(self):
from itemadapter.adapter import AttrsAdapter

self.assertFalse(AttrsAdapter.is_item(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):
from itemadapter.adapter import AttrsAdapter

self.assertTrue(AttrsAdapter.is_item(AttrsItem()))
self.assertTrue(AttrsAdapter.is_item(AttrsItem(name="asdf", value=1234)))
# field metadata
Expand Down
Loading

0 comments on commit 6d937d7

Please sign in to comment.