Lesson 351 · AWS Learning Path

AWS 351: Python and Boto3 fundamentals for AWS automation

· Published · 8 min read

Labelled process diagram for AWS 351: Versioned intent to Automated validation to Controlled AWS change to Observed result and retained evidence, with decision, proof and rejection evidence.

Why this lesson matters

Bash is excellent for composing commands, but Python becomes clearer when automation needs structured responses, reusable modules, tests, richer error handling, and multiple API pages. Boto3 is the AWS SDK for Python; it is a library, not the AWS CLI, and it has its own installed version and dependency set.

You will build a small package that reports caller identity and Regions without creating resources. The design separates configuration, AWS calls, formatting, and process exit so every layer can be tested without credentials.

Learning outcomes

You will be able to:

  • create an isolated virtual environment and record dependencies;
  • explain Boto3 session, client, resource, operation, request, response, and botocore model;
  • use the default credential provider chain without hardcoding keys;
  • choose an explicit Region and verify caller identity before work;
  • parse nested responses defensively and avoid leaking metadata;
  • use type hints, dataclasses, logging, and small injected functions;
  • unit-test SDK calls using botocore Stubber;
  • distinguish SDK success from business acceptance.

Safe setup

Use Python 3 supported by your operating system and the current Boto3 release. Do not install packages into the system interpreter as root.

mkdir -p "$PWD/aws351-python-lab"
cd "$PWD/aws351-python-lab"
python3 --version
python3 -m venv .venv
. .venv/bin/activate
python -m pip install --upgrade pip
python -m pip install boto3 pytest
python -m pip freeze > requirements-lock.txt
python -m pip show boto3 botocore

The lock file records the resolved lab environment; a production team should use its approved dependency and hash-verification process. Never commit .venv, caches, local credentials, or test output.

printf '%s\n' '.venv/' '__pycache__/' '.pytest_cache/' '*.pyc' '.env' > .gitignore
mkdir -p src/aws_inventory tests
touch src/aws_inventory/__init__.py

Boto3 mental model

your code -> boto3 Session -> botocore credential/config resolution
          -> low-level service client -> signed HTTPS API request
          -> AWS service authorization -> structured response or exception

A Session groups configuration and credential resolution. A low-level client maps closely to a service API and returns dictionaries. Resource interfaces offer higher-level object abstractions for selected services but are not available or equally current for every service. Prefer clients for predictable automation and complete API coverage unless a resource interface materially simplifies tested code.

Credentials answer “who signs the request”; IAM/resource policies answer “is this request allowed”; Region and endpoint answer “where is it sent.” A valid credential can still receive AccessDenied, and an allowed identity can query the wrong Region.

Create the package model

Create src/aws_inventory/model.py:

from dataclasses import dataclass


@dataclass(frozen=True)
class Caller:
    account: str
    arn: str
    user_id: str


@dataclass(frozen=True)
class Inventory:
    caller: Caller
    session_region: str
    enabled_regions: tuple[str, ...]

Frozen dataclasses make the result explicit and prevent accidental mutation after collection. They do not validate that account/ARN data is safe to publish; evidence still requires redaction.

Create src/aws_inventory/collector.py:

from typing import Any

import boto3
from botocore.config import Config

from .model import Caller, Inventory


def require_text(value: Any, field: str) -> str:
    if not isinstance(value, str) or not value:
        raise ValueError(f"missing or invalid response field: {field}")
    return value


def collect_inventory(*, profile: str | None, region: str) -> Inventory:
    session = boto3.Session(profile_name=profile, region_name=region)
    config = Config(connect_timeout=3, read_timeout=10)
    sts = session.client("sts", config=config)
    ec2 = session.client("ec2", config=config)

    identity = sts.get_caller_identity()
    caller = Caller(
        account=require_text(identity.get("Account"), "Account"),
        arn=require_text(identity.get("Arn"), "Arn"),
        user_id=require_text(identity.get("UserId"), "UserId"),
    )

    response = ec2.describe_regions(AllRegions=False)
    region_names = {
        require_text(item.get("RegionName"), "Regions[].RegionName")
        for item in response.get("Regions", [])
        if isinstance(item, dict)
    }
    return Inventory(caller, region, tuple(sorted(region_names)))

This call uses no literal credentials. The session resolves them at runtime. The function requires an explicit Region, applies finite connection/read timeouts, validates required fields, deduplicates Regions, and returns a stable sorted tuple.

Configuration and command-line boundary

Create src/aws_inventory/cli.py:

import argparse
import json
import logging
import sys

from botocore.exceptions import BotoCoreError, ClientError, NoCredentialsError

from .collector import collect_inventory


def parser() -> argparse.ArgumentParser:
    result = argparse.ArgumentParser(description="Read-only AWS identity and Region inventory")
    result.add_argument("--region", required=True)
    result.add_argument("--profile")
    result.add_argument("--show-sensitive-identity", action="store_true")
    return result


def main(argv: list[str] | None = None) -> int:
    args = parser().parse_args(argv)
    logging.basicConfig(level=logging.INFO, format="level=%(levelname)s event=%(message)s")
    try:
        inventory = collect_inventory(profile=args.profile, region=args.region)
    except NoCredentialsError:
        logging.error("credential_resolution_failed")
        return 2
    except ClientError as error:
        details = error.response.get("Error", {})
        logging.error(
            "aws_api_failed code=%s request_id=%s",
            details.get("Code", "Unknown"),
            error.response.get("ResponseMetadata", {}).get("RequestId", "unknown"),
        )
        return 3
    except (BotoCoreError, ValueError) as error:
        logging.error("inventory_failed type=%s", type(error).__name__)
        return 4

    account = inventory.caller.account if args.show_sensitive_identity else "REDACTED"
    output = {
        "account": account,
        "session_region": inventory.session_region,
        "enabled_regions": list(inventory.enabled_regions),
    }
    print(json.dumps(output, sort_keys=True))
    return 0


if __name__ == "__main__":
    sys.exit(main())

The default output redacts account identity. The log records AWS error code and request ID, not the full response or credential-bearing environment. ClientError represents a service response; broader botocore errors include client configuration, endpoint, and transport failures.

Run safely

Set module discovery for the current shell, then inspect help before any API call:

export PYTHONPATH="$PWD/src"
python -m aws_inventory.cli --help
python -m compileall -q src

For an approved account, use an existing IAM Identity Center/profile or workload role. Verify the CLI identity privately first, then run:

aws sts get-caller-identity --profile training --output json
python -m aws_inventory.cli --profile training --region ap-south-1

The profile name is not a security boundary. Confirm account and role, remove account/ARN from shared evidence, and stop if the identity is unexpected.

Unit-test without AWS credentials

Create tests/test_collector.py:

import boto3
from botocore.stub import Stubber

from aws_inventory.collector import require_text


def test_require_text_accepts_nonempty_string() -> None:
    assert require_text("ap-south-1", "region") == "ap-south-1"


def test_require_text_rejects_missing_value() -> None:
    try:
        require_text(None, "Account")
    except ValueError as error:
        assert "Account" in str(error)
    else:
        raise AssertionError("ValueError was not raised")


def test_stubber_validates_sts_response() -> None:
    client = boto3.client(
        "sts",
        region_name="ap-south-1",
        aws_access_key_id="testing",
        aws_secret_access_key="testing",
        aws_session_token="testing",
    )
    with Stubber(client) as stubber:
        stubber.add_response(
            "get_caller_identity",
            {"UserId": "TESTUSER", "Account": "123456789012", "Arn": "arn:aws:iam::123456789012:role/test"},
        )
        assert client.get_caller_identity()["Account"] == "123456789012"

Literal testing values are inert test doubles supplied to prevent real credential resolution; they are not AWS keys. Stubber validates operation order and modeled response shape, but it does not prove IAM, endpoints, network, quotas, or real service behavior.

PYTHONPATH=src pytest -q

Packaging and quality boundaries

Add formatter, linter, type checker, and security/dependency scanning according to the organization's supported toolchain. Pin tool versions in CI, separate runtime from development dependencies, and review transitive updates. Passing static tools does not prove correct AWS authorization or business behavior.

Keep functions small: parse/validate input, create dependencies, call one operation, normalize response, make a decision, and format output. Inject sessions/clients or adapters for larger systems so tests do not monkey-patch global state.

Failure diagnosis

SymptomLikely layerEvidence
ModuleNotFoundErrorWrong interpreter/environmentcommand -v python; python -m pip show boto3
NoCredentialsErrorProvider chain found nothingSession profile and approved runtime identity
AccessDeniedAuthorizationError code/request ID, caller identity, policy evaluation
Endpoint/timeoutRegion, DNS, proxy, TLS, routeExplicit Region, endpoint, network evidence
Empty RegionsResponse assumption or permissionsSanitized modeled response and operation docs
Works locally, fails in runnerDifferent credentials/config/version/networkCompare identity, Region, package lock, runtime

Never “fix” authorization by embedding access keys or attaching administrator access.

Independent challenge and acceptance

Extend the package with partition, caller type classification, and --format json|text. Inject clients behind a protocol or adapter. Add tests for missing response fields, API AccessDenied, no credentials, deterministic Region ordering, redaction default, and exit codes.

Submit repository tree, dependency lock, identity/Region boundary, source, tests, passing output, one injected service error, redacted live output if authorized, and cleanup evidence. Pass requires no secret material, explicit Region, default redaction, deterministic output, modeled tests without network, and a clear statement of what each test does not prove.

Cleanup

Deactivate the environment when finished. Retain the repository for the following lessons; it contains no AWS resources and incurs no AWS cost.

deactivate

Official sources

Advertisement