Lazy Imports for Optional Cloud Dependencies
Deferring cloud SDK imports to runtime lets the same codebase run with or without any given SDK installed, and enables testing without real dependencies.
Tags
Lazy Imports for Optional Cloud Dependencies
The Lesson
When a Python application supports multiple cloud providers as optional backends, move cloud SDK imports inside the class constructor rather than placing them at module level. This lets the application load without any cloud SDK installed, and lets tests mock the SDK by injecting into sys.modules before the class is instantiated.
Context
A RAG chatbot backend needed to support four LLM providers (Ollama, AWS Bedrock, Azure OpenAI, GCP Vertex AI) and four vector stores (ChromaDB, OpenSearch, Azure AI Search, Vertex Vector Search). Only one pair is active at runtime, determined by deployment target. The local development stack (Ollama + ChromaDB) requires zero cloud credentials. CI tests must validate all adapter code without installing any cloud SDK — the SDKs are large, have native dependencies, and some require platform-specific binaries.
What Happened
- Initial adapter implementations used standard top-of-file imports (
import boto3,from openai import AzureOpenAI). This meant importing the adapter module at all required the SDK to be installed. - The dependency injection layer (
_deps.py) imports all adapter modules to dispatch based on a deployment profile. With module-level imports, starting the backend withDEPLOYMENT_PROFILE=localwould fail ifboto3wasn't installed — even though Bedrock was never used. - Moved all cloud SDK imports inside each adapter's
__init__method. The module can now be imported freely; theImportErroronly fires if someone actually instantiates the cloud adapter without the SDK. - Tests initially used
@patch("app.adapters.llm.bedrock_adapter.boto3")to mock the SDK. This broke because with lazy imports,boto3is not a module-level attribute —patchcouldn't find it. - Switched tests to
sys.modules.setdefault("boto3", mock_boto3)before importing the adapter class. When the adapter's__init__runsimport boto3, Python finds the mock insys.modulesand returns it. No SDK installation needed. - Azure adapters required mocking every level of the namespace hierarchy separately (
azure,azure.core,azure.core.credentials,azure.search,azure.search.documents, etc.) — 9 entries for two Azure adapters.
Key Insights
Lazy imports turn hard dependencies into soft ones. A module-level
import boto3makes boto3 a hard dependency of the entire application. Moving it into__init__makes it a dependency of only the code path that actually uses Bedrock. The rest of the application is unaffected.sys.modules.setdefault()is the correct mock pattern for lazy imports.unittest.mock.patchtargets module-level attributes. When the import happens inside a function, there's no attribute to patch. Pre-populatingsys.modulesintercepts Python's import machinery at the right layer.Nested package namespaces must be mocked at every level.
from azure.search.documents import SearchClienttriggers imports ofazure,azure.search, andazure.search.documentsin sequence. Missing any level causesModuleNotFoundError. The AWS SDK (boto3) is a flat namespace and needs only one mock entry; the Azure SDK needed nine.Order matters: mock before import. The
sys.modules.setdefault()calls must execute before the adapter class is imported. In test files, this means the mock setup is at module scope (before thefrom app.adapters... importline), with# noqa: E402on the deferred import.No error handling needed in the adapter. If someone configures
DEPLOYMENT_PROFILE=awswithout installing boto3, theImportErrorfrom__init__is exactly the right error — clear, immediate, and actionable. Wrapping it in a try/except would obscure the problem.
Implementation Guide
Step 1: Identify which imports to defer
Search your codebase for module-level imports of optional packages. These are imports that should only be required when a specific code path is used:
# Find all imports of optional SDKs at module level
grep -rn "^import boto3\|^from azure\|^from google.cloud" src/
Any import at the top of a file that isn't needed by every deployment configuration is a candidate for deferral.
Step 2: Move imports into the constructor
For each optional import, move it from module scope into the __init__ method of the class that uses it:
# Before: module-level (hard dependency)
import boto3
class BedrockAdapter:
def __init__(self):
self._client = boto3.client("bedrock-runtime")
# After: lazy (soft dependency)
class BedrockAdapter:
def __init__(self):
import boto3
self._client = boto3.client("bedrock-runtime")
If the SDK is used in multiple methods, import it once in __init__ and store it as an instance attribute:
class BedrockAdapter:
def __init__(self):
import boto3
self._boto3 = boto3
self._client = boto3.client("bedrock-runtime")
def invoke(self, prompt):
# Use self._client, no re-import needed
response = self._client.invoke_model(...)
Step 3: Set up test mocking with sys.modules
Standard unittest.mock.patch won't work for lazy imports because there's no module-level attribute to patch. Instead, pre-populate sys.modules before importing the class under test:
import sys
from unittest.mock import MagicMock
# Create mock BEFORE importing the adapter
mock_boto3 = MagicMock()
mock_boto3.client.return_value = MagicMock() # mock the client factory
sys.modules.setdefault("boto3", mock_boto3)
# Now import the adapter — it will find the mock in sys.modules
from myapp.adapters.bedrock import BedrockAdapter # noqa: E402
def test_bedrock_adapter():
adapter = BedrockAdapter() # Uses mock_boto3, not real SDK
assert mock_boto3.client.called
For SDKs with nested namespaces (Azure, Google Cloud), mock every level:
mock_azure = MagicMock()
for mod in ["azure", "azure.core", "azure.core.credentials",
"azure.search", "azure.search.documents"]:
sys.modules.setdefault(mod, mock_azure)
Step 4: Declare optional dependencies in pyproject.toml
Group optional packages as extras so users install only what they need:
[project.optional-dependencies]
aws = ["boto3>=1.28"]
azure = ["azure-search-documents>=11.4", "openai>=1.0"]
gcp = ["google-cloud-aiplatform>=1.38"]
dev = ["pytest", "ruff"]
Users install with pip install myapp[aws] for AWS support, or pip install myapp[dev] for development without any cloud SDK.
Examples
Module-level import (breaks without SDK):
import boto3 # Fails here if boto3 not installed
class BedrockAdapter(LLMAdapter):
def __init__(self):
self._client = boto3.client("bedrock-runtime")
Lazy import (only fails when instantiated):
class BedrockAdapter(LLMAdapter):
def __init__(self):
import boto3 # Only fails if this class is actually used
self._client = boto3.client("bedrock-runtime")
Test mocking pattern:
import sys
from unittest.mock import MagicMock
mock_boto3 = MagicMock()
sys.modules.setdefault("boto3", mock_boto3)
from app.adapters.llm.bedrock_adapter import BedrockAdapter # noqa: E402
Applicability
This pattern works well when:
- An application supports multiple backends where only one is active at runtime
- Dependencies are large or platform-specific (cloud SDKs, ML frameworks, database drivers)
- CI environments shouldn't need every possible dependency installed
It does not apply when:
- The dependency is always required (just import it normally)
- The import is needed for type checking — use
TYPE_CHECKINGblocks instead - The dependency is small and has no side effects at import time
Related Lessons
- Adapter Pattern for Multi-Cloud Portability — the architectural pattern that makes lazy imports necessary