Search code examples
pythonboto3python-typingmypypyright

How do I initialize empty variable for boto3 client


I'd like to do a simple check if a variable for boto3 client is empty. I tried following approach:

"""Example of boto3 client lazy initialization"""
from typing import Optional
import boto3
from botocore.client import BaseClient
from mypy_boto3_ec2 import EC2Client


class ClassA:
    """Abstract class which lazily initializes the boto3 client"""
    def __init__(self) -> None:
        self._client: Optional[BaseClient] = None
        self.client_type: str = ""

    @property
    def client(self):
        """Lazy boto3 client initialization"""
        if not self._client:
            self._client = boto3.client(self.client_type)
        return self._client    


class ClassB(ClassA):
    """One of many concrete child classes of the ClassA"""
    def __init__(self) -> None:
        super().__init__()
        self._client: Optional[EC2Client] = None
        self.client_type: str = "ec2"

    def some_method(self) -> dict:
        """Just a method to try random EC2Client functionality"""
        result = self.client.describe_instances()
        return result

But this code has many typing problems (a summary of issues found by mypy and pyright):

  1. I cannot override parent type BaseClient by concrete class EC2Client
  2. When I leave out the Optional for the client type, None value is not compatible with types BaseClient and EC2Client
  3. When I leave client optional, then NoneType has no attribute describe_instances.
  4. When I leave ClassA._client untyped and equal None, the child class client is again incompatible.

So how do I type an empty variable for a boto3 client? What should be the value so that I recognize the variable is empty? (How do I make it optional without the NoneType attribute problem?)

Questions I used as a sources:


Solution

  • Your class should be generic in the client type - that's the LSP-friendly way to say "attribute is some subtype of X, and classes may want to know which one".

    boto3.client is overloaded in stubs to accept literal service names, but there's no alias to all such names available, so str with ignore comment is probably your best bet.

    Here's what I can suggest keeping your original approach:

    from __future__ import annotations
    
    from typing import Generic, TypeVar
    
    import boto3
    from botocore.client import BaseClient
    from mypy_boto3_ec2 import EC2Client
    
    _C = TypeVar("_C", bound=BaseClient)
    
    
    class ClassA(Generic[_C]):
        """Abstract class which lazily initializes the boto3 client"""
        def __init__(self) -> None:
            self._client: _C | None = None
            self.client_type: str = ""
    
        @property
        def client(self) -> _C:
            """Lazy boto3 client initialization"""
            if not self._client:
                self._client = boto3.client(self.client_type)  # type: ignore[call-overload]
            assert self._client is not None
            return self._client    
    
    
    class ClassB(ClassA[EC2Client]):
        """One of many concrete child classes of the ClassA"""
        def __init__(self) -> None:
            super().__init__()
            self.client_type: str = "ec2"
    
        def some_method(self) -> dict:
            """Just a method to try random EC2Client functionality"""
            result = self.client.describe_instances()
            return result
    

    Now the only errors remaining are the true ones:

    $ mypy a.py --strict
    a.py:33: error: Missing type parameters for generic type "dict"  [type-arg]
    a.py:36: error: Incompatible return value type (got "DescribeInstancesResultTypeDef", expected "dict[Any, Any]")  [return-value]
    Found 2 errors in 1 file (checked 1 source file)
    

    But I'd probably simplify this to a cached_property and restrict the service names to literals:

    from __future__ import annotations
    
    from functools import cached_property
    from typing import Generic, Literal, TypeAlias, TypeVar
    
    import boto3
    from botocore.client import BaseClient
    from mypy_boto3_ec2 import EC2Client
    
    _C = TypeVar("_C", bound=BaseClient)
    
    ServiceName: TypeAlias = Literal["ec2", "s3"]  # List all clients you're interested in
    # To get all service names as of now,
    # grep service_name .venv/lib/python*/site-packages/boto3-stubs/__init__.pyi | sed -E 's/.*Literal\["(.+)"\].*/\1/'| sort -u
    
    class ClassA(Generic[_C]):
        """Abstract class which lazily initializes the boto3 client"""
        def __init__(self, client_type: ServiceName) -> None:
            self.client_type = client_type
    
        @cached_property
        def client(self) -> _C:
            """Lazy boto3 client initialization"""
            return boto3.client(self.client_type)  # type: ignore[return-value]
    
    
    class ClassB(ClassA[EC2Client]):
        """One of many concrete child classes of the ClassA"""
        def __init__(self) -> None:
            super().__init__("ec2")
    
        def some_method(self) -> dict:
            """Just a method to try random EC2Client functionality"""
            result = self.client.describe_instances()
            return result
    

    This still suffers from not checking that service name and service class are compatible. If that's a problem for you, I'd recommend making client abstract in parent and just implementing it in every child class, removing client_type dependency altogether. It's just one line instantiating a service class - there's nothing wrong with repeating such code IMO.