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.
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