Skip to content
Open
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
32 changes: 31 additions & 1 deletion python/ctranslate2/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,5 +55,35 @@
else:
raise

from ctranslate2 import converters, models, specs
from ctranslate2 import models
from ctranslate2.version import __version__

# converters and specs import torch (and, for converters, transformers) at module level.
# Those dependencies are only needed to convert models, not to run inference, so import
# these submodules on first use to keep "import ctranslate2" free of them.
_LAZY_SUBMODULES = ("converters", "specs")


def __getattr__(name):
if name in _LAZY_SUBMODULES:
import importlib

module = importlib.import_module(f"{__name__}.{name}")
globals()[name] = module
return module

raise AttributeError(f"module {__name__!r} has no attribute {name!r}")


def __dir__():
return sorted(set(globals()) | set(_LAZY_SUBMODULES))


# A wildcard import resolves ``__all__`` when it is defined and the module globals
# otherwise, so without this the lazy submodules would silently drop out of
# ``from ctranslate2 import *``. Deriving the list keeps the wildcard surface identical
# to what it was before they became lazy; a wildcard import asks for everything, so
# resolving them here is expected.
__all__ = sorted(
[name for name in globals() if not name.startswith("_")] + list(_LAZY_SUBMODULES)
)
41 changes: 41 additions & 0 deletions python/tests/test_misc.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import subprocess
import sys

import pytest

@jordimas jordimas Jul 30, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for your work

Could we add a small regression test for from ctranslate2 import *?

Before this change, wildcard imports exposed converters and specs. Since wildcard import uses __all__ if defined, otherwise current module globals, it would be good to check that this behavior is still preserved with the lazy imports.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call — writing the test turned up an actual regression rather than confirming the behavior.

Wildcard imports read __all__ when it is defined and fall back to the module globals otherwise. converters and specs were previously plain globals, so from ctranslate2 import * bound them. Once they moved behind the module __getattr__ they were no longer in the globals, and CPython's import * does not consult __getattr__ or __dir__ — so they silently dropped out of the wildcard surface. __dir__ alone was not enough.

Confirmed on 3.14 with a reduced repro:

# module __getattr__, no __all__
>>> from pkg import *
>>> 'converters' in dir()
False

Fixed by deriving __all__ at the end of __init__.py:

__all__ = sorted(
    [name for name in globals() if not name.startswith("_")] + list(_LAZY_SUBMODULES)
)

Deriving it rather than hardcoding a list keeps two things intact: the wildcard surface stays byte-for-byte what it was before the submodules became lazy, and installs without the compiled extension still work (those names are simply absent from the globals, exactly as before). A wildcard import asks for everything, so resolving the lazy submodules at that point is the expected cost — plain import ctranslate2 stays free of torch/transformers, which the existing test still covers.

Added test_wildcard_import_still_exposes_lazy_submodules for this. It runs in a subprocess so the wildcard import does not leak into the session, and it is guarded with pytest.importorskip("transformers") since converters imports transformers and the test requirements only install it on Linux. I verified it fails ([]) without the __all__ change and passes (['converters', 'specs']) with it.

black and flake8 (max-line-length=100) are clean on both files.

from ctranslate2.extensions import _batch_iterator as batch_iterator
Expand All @@ -17,3 +20,41 @@ def test_batch_iterator(batch_size, batch_type, lengths, expected_batch_sizes):
batch_sizes = [len(batch[0]) for batch in batches]

assert batch_sizes == expected_batch_sizes


@pytest.mark.parametrize("module_name", ["torch", "transformers"])
def test_import_does_not_load_conversion_dependencies(module_name):
# The converters and specs submodules are only needed to convert models, so importing
# the package for inference should not pull their heavy dependencies into the process.
# Run in a subprocess because the test session itself imports them.
code = "import sys; import ctranslate2; print(%r in sys.modules)" % module_name
result = subprocess.run(
[sys.executable, "-c", code],
capture_output=True,
check=True,
text=True,
)

assert result.stdout.strip() == "False"


def test_wildcard_import_still_exposes_lazy_submodules():
# Wildcard imports read ``__all__`` when it is defined, so the lazy submodules must
# stay listed there to keep exposing the same names as before they became lazy.
# converters imports transformers, which is only installed on Linux.
pytest.importorskip("transformers")

# Run in a subprocess so the wildcard import does not leak into the test session.
code = (
"from ctranslate2 import *\n"
"names = set(dir())\n"
"print(sorted(n for n in ('converters', 'specs') if n in names))\n"
)
result = subprocess.run(
[sys.executable, "-c", code],
capture_output=True,
check=True,
text=True,
)

assert result.stdout.strip() == "['converters', 'specs']"
Loading