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

  1. 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.
  2. The dependency injection layer (_deps.py) imports all adapter modules to dispatch based on a deployment profile. With module-level imports, starting the backend with DEPLOYMENT_PROFILE=local would fail if boto3 wasn't installed — even though Bedrock was never used.
  3. Moved all cloud SDK imports inside each adapter's __init__ method. The module can now be imported freely; the ImportError only fires if someone actually instantiates the cloud adapter without the SDK.
  4. Tests initially used @patch("app.adapters.llm.bedrock_adapter.boto3") to mock the SDK. This broke because with lazy imports, boto3 is not a module-level attribute — patch couldn't find it.
  5. Switched tests to sys.modules.setdefault("boto3", mock_boto3) before importing the adapter class. When the adapter's __init__ runs import boto3, Python finds the mock in sys.modules and returns it. No SDK installation needed.
  6. 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

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:

It does not apply when:

Related Lessons

Related Lessons