Skip to content

A typical project

cfn-typed does not need a project layout. At its smallest it is a function that returns a dict, and you write that dict out yourself:

import json
json.dump(build(), open("template.json", "w"), indent=2)

The generator, the CLI, the golden-file tests and the sumo patterns are all optional. Use as much or as little as is useful, and ignore this page if one script is enough.

This page is for the case where the infrastructure has grown into a few stacks and a team. It shows one layout that works, and it is checked on every change: it is examples/reference/cfn, a real project that CI syncs, generates the types for, checks, diffs, tests and type-checks the way you would, and every code block below is pulled from its files. Take the parts you want.

The idea is to keep everything about the infrastructure in one directory, cfn/, so the rest of the repository does not need to know that Python is involved in it.

my-service/
  src/                         # the application
  cfn/
    cfn-typegen.toml           # which resource types you use
    cfn_types/                 # generated by cfn-typegen (gitignored here)
    patterns/                  # your helpers: ejected from sumo, or written by you
      queues.py
    stacks/                    # one module per kind of stack
      orders.py                # one stack
      buckets.py               # many stacks from one function
    main.py                    # names the stacks and calls cfn_typed.cli.main
    tests/
      test_stacks.py           # golden-file checks
    rendered/                  # the templates, committed
      orders.yaml
      buckets/
        bucket-logs.yaml
        bucket-uploads.yaml
        bucket-exports.yaml
    pyproject.toml             # dependencies and the pytest setting
  pyproject.toml

The files

cfn-typegen.toml lists the types the stacks and patterns use. cfn-typegen --config cfn-typegen.toml writes cfn_types/ next to it, wherever you run it from.

# The resource types the stacks and patterns use. `cfn-typegen --config cfn-typegen.toml` writes cfn_types/ next to it.
out = "cfn_types"
docs = false
types = ["AWS::SQS::Queue", "AWS::S3::Bucket"]

patterns/ holds the helpers your stacks share, and most of them will be your own: the functions that say how your company names things, tags them and wires a service up. Some may start as a copy of a sumo pattern that you ejected: in the reference project, patterns/queues.py is queue_with_dlq copied from cfn_typed_sumo.patterns with one change, its types come from the project's own cfn_types. The project does not depend on cfn-typed-sumo at all. Either way a pattern is just a function in your repository.

stacks/ has a module per kind of stack. There is one template per stack and no prod or dev variant of it: the stage is a CloudFormation parameter (Stage here), and the same rendered file is deployed to every stage with different parameter values (aws cloudformation deploy --parameter-overrides Stage=prod). What would change the template's shape goes in a config object, as OrdersConfig does:

from dataclasses import dataclass
from pathlib import Path

import cfn_typed as cfn
from cfn_typed.cli import StackSpec
from patterns.queues import queue_with_dlq

HERE = Path(__file__).parent


@dataclass(frozen=True)
class OrdersConfig:
    visibility_timeout: int = 180


def build(config: OrdersConfig = OrdersConfig()) -> cfn.Template:
    params = cfn.Params()
    stage = params.string("Stage", default="dev", allowed_values=["dev", "test", "prod"])
    jobs = queue_with_dlq("Jobs", visibility_timeout=config.visibility_timeout, tags=cfn.tags(Stage=stage))
    return {
        "AWSTemplateFormatVersion": "2010-09-09",
        "Parameters": params.declarations(),
        "Resources": jobs.resources,
        "Outputs": {"JobsUrl": {"Value": jobs.queue_url}, "JobsDlqUrl": {"Value": jobs.dlq_url}},
    }


def stacks() -> dict[str, StackSpec]:
    return {"orders": StackSpec(build, out=HERE.parent / "rendered")}

main.py is the command line, as in the CLI page:

import sys

import cfn_types
from cfn_typed import cli
from stacks import buckets, orders


def main() -> int:
    return cli.main({**orders.stacks(), **buckets.stacks()}, schemas=cfn_types)


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

Do not call it stacks.py

Next to a stacks/ directory it would shadow the package, and the import fails.

rendered/ holds the output. It is committed, so every template change shows up in review next to the Python change that caused it, as in the testing page. Each stack's StackSpec says which directory it renders into, so emit and diff need no arguments.

If the rendered templates already live elsewhere (a deploy/ or infra/ directory that a pipeline reads), leave them there and point out at them from outside the cfn/ tree, or pass the directory on the command line (python main.py diff ../deploy/templates, emit -o ../deploy/templates). That keeps the diff in review to the files that are actually deployed, and avoids a second copy of every template. The files are named <stack>.yaml.

Working in it

Run everything from inside cfn/, so patterns, stacks and cfn_types are plain top-level imports with no package or build configuration:

cd cfn
uv sync
uv run cfn-typegen --config cfn-typegen.toml    # once, and when the type list changes
uv run python main.py check                     # every check, against the registry schemas
uv run python main.py emit                      # write the templates, each into its own directory
uv run python main.py diff                      # exit 1 if they differ from the committed ones
uv run pytest                                   # the golden-file tests
uv run ty check

The tests render every stack and compare it with its committed file:

import cfn_types
from cfn_typed import cli
from cfn_typed.testing import assert_matches_file
from main import main  # noqa: F401  (the stacks, as the command line sees them)
from stacks import buckets, orders


def test_orders() -> None:
    assert_matches_file(orders.build(), orders.HERE.parent / "rendered/orders.yaml")


def test_every_stack_matches_its_committed_file() -> None:
    stacks = {**orders.stacks(), **buckets.stacks()}
    assert cli.main(stacks, schemas=cfn_types, argv=["diff"]) == 0
    assert cli.main(stacks, schemas=cfn_types, argv=["check"]) == 0

cfn/pyproject.toml makes cfn/ a small project of its own: it declares the dependencies (so uv sync works in that directory, and in the workflow below) and lets pytest find the modules. It is not a package to build.

dependencies = ["cfn-typed"]

[dependency-groups]
dev = ["pytest", "ty"]

[tool.pytest.ini_options]
pythonpath = ["."]

(In this repository the file also points cfn-typed at the checkout, and has a [tool.ty] section so ty does not read the repository's own configuration. Your own project needs neither.)

Generated types: commit or generate

With a handful of types, commit cfn_types/: there is no extra step and the code is there to read. As the list grows it is tidier to put cfn_types/ in .gitignore and run cfn-typegen first in development and in CI, which is what the reference project does. Distributing patterns covers the build-time variant in more detail.

A workflow that runs only for cfn/

Give the infrastructure its own workflow, triggered only when something in cfn/ changes, so application-only pull requests do not wait for it:

# .github/workflows/cfn.yml
name: CloudFormation
on:
  push:
    branches: [main]
    paths:
      - "cfn/**"
      - ".github/workflows/cfn.yml"
  pull_request:
    paths:
      - "cfn/**"
      - ".github/workflows/cfn.yml"

jobs:
  templates:
    runs-on: ubuntu-latest
    defaults:
      run:
        working-directory: cfn
    steps:
      - uses: actions/checkout@v7
      - uses: astral-sh/setup-uv@v7
      - run: uv sync
      # only if cfn_types/ is gitignored rather than committed:
      - run: uv run cfn-typegen --config cfn-typegen.toml
      - run: uv run python main.py check
      - run: uv run python main.py diff
      - run: uv run pytest
      - run: uv run ty check

The steps are the ones this repository's own CI runs against the reference project (in its test.yml); the paths filter and the file itself are not exercised there, because GitHub reads workflows only from the root of a repository.

  • check and diff fail the build. check exits 1 on a problem in a template, diff exits 1 when the rendered files are not what the code produces, so a change to a stack must come with its regenerated rendered/ files in the same pull request.
  • Add the paths of anything outside cfn/ that the templates are rendered into, such as deploy/templates/**, so a hand edit of a rendered file is caught too.
  • If the workflow is a required check, a path filter means it does not run, and so never reports, on pull requests that touch nothing in cfn/; GitHub then leaves the required check pending. Either do not make it required, or run it always and skip the work with a dorny/paths-filter step.
  • Deploying is a separate job, or a separate workflow, that runs on main after this one passes and sends rendered/ to aws cloudformation deploy. The templates it deploys are the committed ones, which is the point of committing them.

Many stacks from one function

When the stacks are the same function with different inputs, such as one stack per bucket, give the function a config object and make one StackSpec per config. The input is a typed BucketConfig, not a growing list of keyword arguments, and adding a bucket is one line of data:

from dataclasses import dataclass
from functools import partial
from pathlib import Path

import cfn_typed as cfn
from cfn_types import s3
from cfn_typed.cli import StackSpec

HERE = Path(__file__).parent


@dataclass(frozen=True)
class BucketConfig:
    name: str                                # also the stack name: bucket-<name>
    data_class: str                          # a tag value: audit, customer-data, ...
    archive_after_days: int | None = None


def build(config: BucketConfig) -> cfn.Template:
    props: s3.BucketProps = {
        "PublicAccessBlockConfiguration": {
            "BlockPublicAcls": True, "BlockPublicPolicy": True, "IgnorePublicAcls": True, "RestrictPublicBuckets": True,
        },
        "BucketEncryption": {"ServerSideEncryptionConfiguration": [
            {"ServerSideEncryptionByDefault": {"SSEAlgorithm": "AES256"}},
        ]},
        "Tags": cfn.tags(DataClass=config.data_class),
    }
    if config.archive_after_days is not None:
        props["LifecycleConfiguration"] = {"Rules": [{
            "Id": "archive", "Status": "Enabled",
            "Transitions": [{"StorageClass": "GLACIER", "TransitionInDays": config.archive_after_days}],
        }]}
    return {
        "AWSTemplateFormatVersion": "2010-09-09",
        "Resources": {"Bucket": s3.bucket(deletion_policy="Retain", update_replace_policy="Retain", **props)},
        "Outputs": {"BucketName": {"Value": cfn.ref("Bucket")}},
    }


CONFIGS = [
    BucketConfig("logs", "audit", archive_after_days=90),
    BucketConfig("uploads", "customer-data"),
    BucketConfig("exports", "customer-data"),
]


def stacks() -> dict[str, StackSpec]:
    return {
        f"bucket-{c.name}": StackSpec(partial(build, c), out=HERE.parent / "rendered/buckets")
        for c in CONFIGS
    }

emit writes rendered/buckets/bucket-logs.yaml, bucket-uploads.yaml and bucket-exports.yaml, and diff and check cover all three. Each bucket is its own stack with its own file in review, and a bucket that later needs something the others do not (replication, say) gets a new field in BucketConfig. The properties that only some buckets have are added with if to a typed props dict, as in Properties that are set only sometimes. If the stacks will always be identical and differ only by a name or a tag, a single template with parameters, deployed once per bucket, is simpler.

Growing

  • Another stack: a module in stacks/ with a stacks() function, and one line in main.py.
  • Another pattern: a file in patterns/. A pattern you want to keep as sumo has it can keep coming from cfn_typed_sumo until you want to change it.
  • Several services in one repository: one cfn/ per service, each with its own cfn-typegen.toml, so a service generates only the types it uses.