Skip to content

A command line for your stacks

cfn_typed.cli is an optional command line that you call from your own main(). It is not a cfn-typed command: your script says which stacks exist, and the library gives them four subcommands, emit, check, diff and ls (check can also ask AWS about a stack that is already deployed; zip needs no stack, and the standalone cfn-typed command adds import). Nothing else in the package imports it, and it needs nothing beyond the standard library and ruamel.yaml.

# stacks.py
import sys

import cfn_types  # generated by cfn-typegen
from cfn_typed import cli
from billing import build as billing
from orders import build as orders


def main() -> int:
    return cli.main(
        {"orders": orders, "billing": billing},
        schemas=cfn_types,  # optional: turns on the checks against the registry schemas
        # rules=[my_rule],  # optional: your own checks, see "Company rules"
    )


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

The first argument is one builder (a function that returns the template) or a mapping from stack name to builder. cli.main returns the exit status instead of exiting, so your main() decides what to do with it. Every subcommand takes stack names and uses all of them when you give none.

Without any Python: cfn-typed

The same subcommands work on template files, so the command line is useful for a template that cfn-typed did not write. Install the package (uv tool install cfn-typed, or uvx --from cfn-typed cfn-typed ..., or python -m cfn_typed.cli) and give it files, JSON or YAML with the short-form intrinsics (!Ref, !GetAtt A.Arn, !Sub, ...):

$ cfn-typed check template.yaml other.json
template: ok
other: ok
$ cfn-typed ls template.yaml -t lambda
$ cfn-typed emit template.yaml -f json        # convert YAML to JSON, or the other way round
$ cfn-typed diff rendered/ template.yaml      # compare with rendered/template.yaml

A stack is named after its file without the suffix, and two files with the same name are an error. YAML needs ruamel.yaml, which is installed with the package; JSON needs nothing. Without generated types the schema checks are off, and --schemas cfn_types (for check) turns them on from a package that cfn-typegen generated, imported from the current directory. To add your own rules, write the main() above instead.

emit: print or write templates

$ python stacks.py emit billing
AWSTemplateFormatVersion: '2010-09-09'
Resources:
  Jobs:
    Type: AWS::SQS::Queue
    ...

-f json|yaml picks the format (YAML by default, or the suffix of -o). With several stacks you have to name one, or write them all: -o rendered/ creates rendered/orders.yaml, rendered/billing.yaml, and -o template.json writes one stack to a file.

check: run every check

$ python stacks.py check
orders: ok
billing: ok

This is find_all_problems for each stack: references, unused parameters, quotas, values, IAM names and Lambda runtimes, and with schemas= the constraints, Fn::GetAtt names and property combinations too. A problem is printed as [check] message, and the exit status is 1 if any stack has one, so it works as a CI step. --inline applies the 51,200-byte inline limit, --no-unused and --no-defaults leave those checks out.

diff: compare with the committed files

$ python stacks.py diff rendered/
orders: matches rendered/orders.yaml
--- rendered/billing.yaml
+++ rendered
@@ -10,4 +10,3 @@
 Outputs:
   JobsUrl:
     Value: !Ref Jobs
-x

It compares each stack with DIR/<name>.yaml (-f json for JSON), prints a unified diff and exits 1 on a difference or a missing file. --update rewrites the files instead. This is the golden-file workflow without pytest, for a repository that wants a CI step and not a test file.

StackSpec: where each stack renders

By default you give emit -o and diff one directory per run. A StackSpec is a builder plus the directory its template renders into. It also suits the case where one function makes many stacks, here one per bucket, each with its own BucketConfig:

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
    }

The file is part of the reference project, which CI checks. Run from cfn/, with each stack's out set as above:

$ python main.py emit                    # every stack, each into its own out; stacks without one are an error
$ python main.py diff                    # compare each with <out>/<name>.yaml
$ python main.py diff bucket-logs        # only that stack
$ python main.py emit bucket-logs -o -   # print one stack instead of writing it
$ python main.py emit -o /tmp/all        # an explicit directory overrides every out
  • out is used as given, so a relative path is relative to where the script runs. Build it from Path(__file__) as above to make it independent of that.
  • StackSpec is accepted wherever a builder is, including a single builder, and plain builders keep working (and keep printing when you name one stack).
  • With diff, if the first word is a stack name there is no directory argument. To give a directory that has the same name as a stack, write it as ./bucket-logs.
  • ls shows each stack's out after its name.

Settings: a dataclass whose fields are flags

A builder often needs a value that is not in the code: the stage, the region, a size. Give main a dataclass and its fields become flags on emit, check, diff and ls. Each builder that takes an argument is called with an instance:

from dataclasses import dataclass, field
from typing import Literal


@dataclass
class Settings:
    stage: str = field(metadata={"help": "which environment", "env": "STAGE"})   # no default: required
    memory: int = 1024
    debug: bool = False                                      # --debug / --no-debug
    region: Literal["eu-west-1", "us-east-1"] = "eu-west-1"


def orders(s: Settings) -> cfn.Template: ...


cli.main({"orders": orders, "billing": billing}, settings=Settings)
$ python stacks.py emit orders --stage prod --memory 2048
$ STAGE=prod python stacks.py check
$ python stacks.py emit --help          # the flags, with their help and defaults
  • A field with no default is required: the flag, or the environment variable named in metadata={"env": ...}. X | None is not optional by itself, write X | None = None.
  • The types are str, int, float, bool (a --flag/--no-flag pair), Path, an Enum (by member name), a Literal, a list of those (repeat the flag, or comma-separated in the environment variable), and X | None. Another type is an error when main starts.
  • A value that does not convert, an unknown choice, or a ValueError from your __post_init__ is a usage error (exit status 2) with the message, so the dataclass is where to reject a combination such as stage="prod" with debug.
  • A builder that takes no argument is called as before, so plain builders and builders with settings can share one mapping.
  • A field cannot be named like an option of the command (format, out, type, long, update, inline, ...). That is an error when main starts, and the fix is a different name.
  • With diff, pass the same flags you rendered the committed files with, or it reports the difference. If the committed files should not depend on a flag, give the field a default that matches them.

Settings for one stack

StackSpec(build, settings=Other) uses Other for that stack, in place of the one given to main. A flag is only required by the stacks you select, so a stack that needs --stage does not stop emit billing from running:

cli.main(
    {
        "orders": orders,                                          # Settings
        "reports": StackSpec(reports, settings=ReportSettings),    # its own flags
    },
    settings=Settings,
)

All the flags are on the command, whichever stack uses them, and a name that two classes both declare has to have the same type in both. A stack with its own settings does not get the shared ones.

import: start from an existing template

$ cfn-typed import template.json -o stack.py --types cfn_types

Only the standalone cfn-typed command has this one. It writes a template file as a Python module with build() -> cfn.Template, using the generated factories where there is one and plain dicts where there is not (the module lists what it kept as a dict, and why). Generate the types first with cfn-typegen --template template.json. By default the module renders back to exactly the original. --sls is for a template that sls package wrote: wherever the Serverless Framework's resources for a function, an event, an API or the role are exactly what a call of cfn_typed_sumo.sls renders, the module calls it, and everything else stays as the plain resources. Each call is tried against the template while importing and written only when it renders the same resources, so the result still renders to the original. --asl writes each state machine's DefinitionString as a Definition object that cfn_typed.asl types and checks; the template then differs from the original in that one property. For a service built with the Serverless Framework, see Ejecting from the Serverless Framework.

check --live: ask AWS about a stack that is already deployed

check is offline and fast by default. Three opt-in flags add the checks that need AWS, and --live turns on all three. None of them changes anything: they read, and the one change set is created, read and deleted. They use your credentials (--aws-profile, --aws-region), and the stack name is the name here, unless the stack has StackSpec(build, stack_name="shop-site-dev") (a service that set its own stackName) or you pass --stack-name.

$ python stacks.py check --live site --bucket-name ServerlessDeploymentBucket=shop-dev-deployment-abc
site: 3 problem(s)
  [deployed] Resources.Fn.Properties.Runtime: rendered "python3.12", deployed "python3.11"
  [plan] Dist (AWS::CloudFront::Distribution) would be replaced (Properties.Origins (always))
  [plan] Key (AWS::KMS::Key) may be replaced
  [code] Resources.Fn.Properties.Code: s3://shop-dev-deployment-abc/api-2.zip does not exist
  changes to shop-site-dev:
    Fn    AWS::Lambda::Function          Modify  Properties.MemorySize
    Dist  AWS::CloudFront::Distribution  Modify (replacement)  Properties.Origins (always)
    Key   AWS::KMS::Key                  Modify (maybe replacement)
Flag Finds
--deployed where the rendered template differs from the running stack's, by path in the template (get-template; --template-stage Processed is after the transforms have run, the one to take for a SAM stack). It is the live version of the test that compares build() with original.json, and the first thing to run on one real stack before ejecting any others
--plan a stateful resource that a change set would replace or remove: a table, bucket, queue, key, distribution, log group or database (the list is cfn_typed.changes.STATEFUL; --strict stops on any replacement or removal). The change set is never executed, and --keep-change-set leaves it in CloudFormation to read there. An empty one prints no changes to ..., which is what an ejection should give. The template goes inline, so it must be under 51,200 bytes
--code an S3 object that a function or layer reads and that is not there (aws s3api head-object). A bucket that is a Ref needs its real name from --bucket-name LOGICAL=NAME

An aws command that fails (no such stack, no permission) exits 2: the check could not run, which says nothing about the template. check --retention is the offline companion of --plan: it requires DeletionPolicy and UpdateReplacePolicy Retain on those same resources.

zip: what is inside a package

An empty change set cannot see inside a zip.

$ cfn-typed zip build/api.zip --wsgi --app api.app --invoke event.json

zip reads a package, with no stack: --require NAME files, a --handler module.function that the zip defines, and --wsgi for what the Serverless WSGI plugin packages (see ejecting). --invoke EVENT.json calls the handler once in a fresh Python and prints the result; that runs the code in the zip, so use it on a zip you built. The same checks are in Python: cfn_typed.packaging, and assert_zip_ok, assert_wsgi_package_ok and assert_code_exists in cfn_typed.testing.

ls: list the resources

$ python stacks.py ls
orders: 1 resource(s)
  Jobs  queue

Each line has the logical id, the type (its alias when it has one, ManagedPolicy for an AWS::IAM:: type without one, and the full name otherwise; -l shows the full AWS::SQS::Queue) and the resource's own name when it is a literal (QueueName, FunctionName, and Name for the types that call it that). -t keeps only some types: an alias (queue, lambda, role, lb, sg, table, ...), a glob such as 'AWS::SQS::*', or the last part of a type name, so -t function finds every ...::Function.

The name and alias tables are short lists in cfn_typed/cli.py (_NAME_PROPERTY and _ALIASES). A type with a blank name is a missing entry, and a pull request that adds it is welcome.

Exit status

Status Meaning
0 everything is fine
1 check found problems, or diff found a difference or a missing file, or check --live (or one of its flags) found a problem, or zip found one
2 bad usage, such as emit with several stacks and no -o, or an aws command that failed