Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions src/apify/_try_import.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
from __future__ import annotations

import sys
from contextlib import contextmanager
from dataclasses import dataclass
from types import ModuleType
from typing import TYPE_CHECKING

if TYPE_CHECKING:
from collections.abc import Iterator
from typing import Any

_DISTRIBUTION_NAME = 'apify'


@contextmanager
def try_import(module_name: str, *symbol_names: str, extra_name: str | list[str]) -> Iterator[None]:
"""Context manager to attempt importing symbols into a module.

If an `ImportError` is raised during the import, the symbols are replaced with `FailedImport` objects. When the
error is a `ModuleNotFoundError`, the message also names the optional extra (or one of several) that installs the
missing dependency. Other import errors, including those raised by a nested guard, keep their message as is.
"""
try:
yield
except ImportError as e:
message = e.args[0]
if isinstance(e, ModuleNotFoundError):
message = f'{message}. {_get_install_hint(extra_name)}'
for symbol_name in symbol_names:
setattr(sys.modules[module_name], symbol_name, FailedImport(message))


def _get_install_hint(extra_name: str | list[str]) -> str:
"""Build the sentence telling the user which extra installs the missing optional dependency."""
if isinstance(extra_name, str):
return f"Install the optional '{extra_name}' extra to use it: pip install '{_DISTRIBUTION_NAME}[{extra_name}]'"

extras = ', '.join(f"'{name}'" for name in extra_name)
return (
f'Install one of the optional extras {extras} to use it, e.g. '
f"pip install '{_DISTRIBUTION_NAME}[{extra_name[0]}]'"
)


def install_import_hook(module_name: str) -> None:
"""Install an import hook for a specified module."""
sys.modules[module_name].__class__ = ImportWrapper


@dataclass
class FailedImport:
"""Represent a placeholder for a failed import."""

message: str
"""The error message associated with the failed import."""


class ImportWrapper(ModuleType):
"""A wrapper class for modules to handle attribute access for failed imports."""

def __getattribute__(self, name: str) -> Any:
result = super().__getattribute__(name)

if isinstance(result, FailedImport):
raise ImportError(result.message) # noqa: TRY004

return result
14 changes: 7 additions & 7 deletions src/apify/scrapy/__init__.py
Original file line number Diff line number Diff line change
@@ -1,23 +1,23 @@
from crawlee._utils.try_import import install_import_hook as _install_import_hook
from crawlee._utils.try_import import try_import as _try_import
from apify._try_import import install_import_hook as _install_import_hook
from apify._try_import import try_import as _try_import

_install_import_hook(__name__)

# The following imports use try_import to handle optional dependencies, as they may not always be available.

with _try_import(__name__, 'run_scrapy_actor'):
with _try_import(__name__, 'run_scrapy_actor', extra_name='scrapy'):
from ._actor_runner import run_scrapy_actor

with _try_import(__name__, 'initialize_logging'):
with _try_import(__name__, 'initialize_logging', extra_name='scrapy'):
from ._logging_config import initialize_logging

with _try_import(__name__, 'to_apify_request', 'to_scrapy_request'):
with _try_import(__name__, 'to_apify_request', 'to_scrapy_request', extra_name='scrapy'):
from .requests import to_apify_request, to_scrapy_request

with _try_import(__name__, 'ApifyScheduler'):
with _try_import(__name__, 'ApifyScheduler', extra_name='scrapy'):
from .scheduler import ApifyScheduler

with _try_import(__name__, 'apply_apify_settings', 'get_basic_auth_header'):
with _try_import(__name__, 'apply_apify_settings', 'get_basic_auth_header', extra_name='scrapy'):
from .utils import apply_apify_settings, get_basic_auth_header


Expand Down
70 changes: 70 additions & 0 deletions tests/unit/test_try_import.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
from __future__ import annotations

import sys
from types import ModuleType
from typing import TYPE_CHECKING, Any

import pytest

from apify._try_import import FailedImport, install_import_hook, try_import

if TYPE_CHECKING:
from collections.abc import Iterator


@pytest.fixture
def module() -> Iterator[Any]:
"""Register a throwaway module that the import guards can write their placeholders into."""
mod = ModuleType('apify_test_try_import_target')
sys.modules[mod.__name__] = mod
install_import_hook(mod.__name__)
yield mod
del sys.modules[mod.__name__]


def test_successful_import_is_left_alone(module: Any) -> None:
with try_import(module.__name__, 'symbol', extra_name='scrapy'):
module.symbol = 'value'

assert module.symbol == 'value'


def test_missing_module_names_the_apify_extra(module: Any) -> None:
with try_import(module.__name__, 'symbol', extra_name='scrapy'):
raise ModuleNotFoundError("No module named 'scrapy'")

with pytest.raises(ImportError) as exc_info:
_ = module.symbol

assert str(exc_info.value) == (
"No module named 'scrapy'. Install the optional 'scrapy' extra to use it: pip install 'apify[scrapy]'"
)


def test_missing_module_names_one_of_several_extras(module: Any) -> None:
with try_import(module.__name__, 'symbol', extra_name=['scrapy', 'other']):
raise ModuleNotFoundError("No module named 'scrapy'")

with pytest.raises(ImportError) as exc_info:
_ = module.symbol

assert str(exc_info.value) == (
"No module named 'scrapy'. Install one of the optional extras 'scrapy', 'other' to use it, "
"e.g. pip install 'apify[scrapy]'"
)


def test_other_import_errors_keep_their_message(module: Any) -> None:
with try_import(module.__name__, 'symbol', extra_name='scrapy'):
raise ImportError('cannot import name X')

with pytest.raises(ImportError, match=r'^cannot import name X$'):
_ = module.symbol


def test_all_guarded_symbols_are_replaced(module: Any) -> None:
with try_import(module.__name__, 'first', 'second', extra_name='scrapy'):
raise ModuleNotFoundError("No module named 'scrapy'")

for name in ('first', 'second'):
assert isinstance(object.__getattribute__(module, name), FailedImport)
8 changes: 4 additions & 4 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading