diff --git a/docs/changelog/+missing-read-write-module.bugfix.rst b/docs/changelog/+missing-read-write-module.bugfix.rst new file mode 100644 index 00000000..97701c08 --- /dev/null +++ b/docs/changelog/+missing-read-write-module.bugfix.rst @@ -0,0 +1,3 @@ +Resolving :class:`~filelock.ReadWriteLock` or :class:`~filelock.AsyncReadWriteLock` raises the :exc:`ImportError` when a +module other than Python's :mod:`sqlite3` is missing, as in a frozen application that left out a filelock submodule. The +names are ``None`` only without SQLite. diff --git a/src/filelock/__init__.py b/src/filelock/__init__.py index 15a6a55e..45332e7c 100644 --- a/src/filelock/__init__.py +++ b/src/filelock/__init__.py @@ -112,10 +112,13 @@ def __getattr__(name: str) -> object: raise AttributeError(msg) try: module = import_module(module_name, __name__) - except ImportError: # pragma: lacks sqlite3 - if module_name not in {"._read_write", "._async_read_write"}: + except ImportError as error: + # Only a missing sqlite3 module (the package, CPython's _sqlite3, PyPy's _sqlite3_cffi) leaves the read-write + # locks None; a frozen application that left out a filelock module gets the error that names it. + sqlite_missing = (error.name or "").startswith(("sqlite3", "_sqlite3")) + if module_name not in {"._read_write", "._async_read_write"} or not sqlite_missing: raise - value = None + value = None # pragma: lacks sqlite3 else: if name == "asyncio": value = module diff --git a/tests/test_lazy_import.py b/tests/test_lazy_import.py index c597ec1a..2988109d 100644 --- a/tests/test_lazy_import.py +++ b/tests/test_lazy_import.py @@ -65,6 +65,49 @@ def test_lazy_import_resolves_asyncio_submodule() -> None: assert _run("from filelock import asyncio; print(asyncio.__name__)") == "filelock.asyncio\n" +def _hidden_module_script(blocked: str, missing: str, attribute: str) -> str: + return f""" + import sys + class Hide: + def find_spec(self, name, path=None, target=None): + if name == {blocked!r}: + raise ModuleNotFoundError(name={missing!r}) + sys.meta_path.insert(0, Hide()) + import filelock + try: + value = getattr(filelock, {attribute!r}) + except ImportError as error: + print(f"error:{{error.name}}") + else: + print("none" if value is None else "resolved") + """ + + +@pytest.mark.parametrize( + ("blocked", "missing", "attribute"), + [ + pytest.param("sqlite3", "sqlite3", "ReadWriteLock", id="package"), + pytest.param("_sqlite3", "_sqlite3", "AsyncReadWriteLock", id="cpython-binding"), + pytest.param("_sqlite3", "_sqlite3_cffi", "ReadWriteLock", id="pypy-binding"), + ], +) +def test_lazy_import_missing_sqlite3_leaves_read_write_lock_none(blocked: str, missing: str, attribute: str) -> None: + # coverage loads sqlite3 before filelock, so only an unmeasured child can miss it + assert _run(_hidden_module_script(blocked, missing, attribute), measured=False) == "none\n" + + +@pytest.mark.parametrize( + ("module", "attribute"), + [ + pytest.param("filelock._read_write", "ReadWriteLock", id="sync-read-write"), + pytest.param("filelock._async_read_write", "AsyncReadWriteLock", id="async-read-write"), + pytest.param("filelock._soft", "SoftFileLock", id="other-lazy-module"), + ], +) +def test_lazy_import_missing_filelock_module_raises(module: str, attribute: str) -> None: + assert _run(_hidden_module_script(module, module, attribute)) == f"error:{module}\n" + + def test_lazy_import_async_file_lock_subclasses_file_lock() -> None: assert {filelock.FileLock, filelock.BaseAsyncFileLock} <= set(filelock.AsyncFileLock.__mro__)