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

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