Skip to main content

impose-cli

impose-cli is the fastest and least disruptive way to turn your project, or a subset of your project, into a CLI or API.

Create an ImposeApplication() and point it at a module. Impose will iterate through that module and its submodules, then create a CLI and optionally a FastAPI application that can be mounted into another FastAPI application.

Every function decorated with @impose becomes a command while remaining a normal Python function. Add impose_api_method when the function should also become an API endpoint.

from impose import ImposeApplication, impose


@impose(impose_cs="elbv2")
def list_load_balancers(region: str) -> list[str]:
    ...


app = ImposeApplication()

Disruption Free

The primary goal of Impose is to be disruption free. Adding @impose to a function does not change how the function is called from Python code, does not require the function to inherit from a framework type, and does not prevent the function from being imported and reused normally.

The decorator is a passthrough:

@impose(impose_cs="elbv2")
def list_load_balancers(region: str) -> list[str]:
    ...


load_balancers = list_load_balancers("us-east-1")

Impose-specific decorator options are namespaced with the impose_ prefix, such as impose_cs. This keeps Impose configuration separate from the function's own parameters and limits the chance of keyword argument interference as the function evolves.

Command Sets

@impose accepts an impose_cs keyword argument, short for impose_command_set:

@impose(impose_cs="elbv2")
def describe_target_groups() -> list[str]:
    ...

All functions under the same command set automatically become subcommands under the same CLI group. Functions that also set impose_api_method become endpoints under the same API router path.

Command descriptions and per-argument help come from the function docstring, keeping the decorator focused on command registration.

Example: Explicit Command Sets

# cloud/elbv2.py
from impose import impose


@impose(impose_cs="elbv2")
def list_load_balancers(region: str) -> list[str]:
    ...


@impose(impose_cs="elbv2")
def describe_target_groups(load_balancer_arn: str) -> list[str]:
    ...
# cli.py
import cloud.elbv2
from impose import ImposeApplication


application = ImposeApplication(modules=[cloud.elbv2])
cli = application.cli()

The functions above become subcommands under the same elbv2 group:

impose elbv2 list-load-balancers us-east-1
impose elbv2 describe-target-groups arn:aws:...

Project Structure

Impose can optionally use the actual project structure to dynamically create commands and subcommands. For example, functions under an elbv2 folder can become subcommands of the elbv2 command.

Example: Dynamic Project Structure

Given a project like this:

cloud_tools/
  __init__.py
  aws/
    __init__.py
    elbv2.py
    rds.py
    s3.py
  github/
    __init__.py
    repos.py
  cli.py
# cloud_tools/aws/elbv2.py
from impose import impose


@impose
def list_load_balancers(region: str) -> list[str]:
    ...


@impose
def describe_target_group(target_group_arn: str) -> dict[str, str]:
    ...
# cloud_tools/aws/s3.py
from impose import impose


@impose
def list_buckets(profile: str | None = None) -> list[str]:
    ...
# cloud_tools/aws/rds.py
from impose import impose


@impose
def reboot_instance(identifier: str, force_failover: bool = False) -> None:
    ...
# cloud_tools/github/repos.py
from impose import impose


@impose
def archive_repo(owner: str, repo: str) -> None:
    ...

Configure Impose to use the Python package structure as the command structure:

# cloud_tools/cli.py
import cloud_tools
from impose import ImposeApplication


application = ImposeApplication(
    root_module=cloud_tools,
    use_project_structure=True,
    modules_as_subcommands=True,
)
cli = application.cli()

modules_as_subcommands is a global setting that defaults to True. With the default behavior, Impose reads the modules below cloud_tools, finds functions decorated with @impose, and creates commands shaped like the package tree, including module filenames:

impose aws elbv2 list-load-balancers us-east-1
impose aws elbv2 describe-target-group arn:aws:...
impose aws rds reboot-instance prod-db-1 --force-failover
impose aws s3 list-buckets --profile prod
impose github repos archive-repo example old-service

The generated command hierarchy mirrors the source layout:

cloud_tools/aws/elbv2.py:list_load_balancers
  -> impose aws elbv2 list-load-balancers

cloud_tools/aws/elbv2.py:describe_target_group
  -> impose aws elbv2 describe-target-group

cloud_tools/aws/rds.py:reboot_instance
  -> impose aws rds reboot-instance

cloud_tools/aws/s3.py:list_buckets
  -> impose aws s3 list-buckets

cloud_tools/github/repos.py:archive_repo
  -> impose github repos archive-repo

If you only want folders/packages to create command groups, disable module-generated subcommands:

# cloud_tools/cli.py
import cloud_tools
from impose import ImposeApplication


application = ImposeApplication(
    root_module=cloud_tools,
    use_project_structure=True,
    modules_as_subcommands=False,
)
cli = application.cli()

With modules_as_subcommands=False, the folder still creates the aws group, but rds.py, s3.py, and elbv2.py do not add another command level:

impose aws list-load-balancers us-east-1
impose aws describe-target-group arn:aws:...
impose aws reboot-instance prod-db-1 --force-failover
impose aws list-buckets --profile prod
impose github archive-repo example old-service

FastAPI

Impose can create a FastAPI application or router from decorated functions that explicitly opt into API exposure. The generated API can be mounted into another FastAPI application, which lets you expose project functionality without building a separate API layer by hand.

Example: Mounting into FastAPI

# api.py
import cloud_tools
from fastapi import FastAPI
from impose import ImposeApplication


service = FastAPI()

impose_application = ImposeApplication(
    root_module=cloud_tools,
    use_project_structure=True,
)

service.include_router(
    impose_application.api_router(),
    prefix="/internal/tools",
)

Or create a standalone FastAPI app directly:

app = impose_application.api_app(title="Internal Tools")

A decorated function such as:

@impose(impose_cs="elbv2", impose_api_method="GET")
def list_load_balancers(region: str) -> list[str]:
    ...

can be exposed as an endpoint under the generated router:

GET /internal/tools/elbv2/list-load-balancers?region=us-east-1

If a function is decorated with @impose but does not set impose_api_method, it remains available to the CLI but is skipped when api_router() or api_app() is built. Impose emits a warning for each skipped function so accidental API omissions are visible during app startup.

Supported API methods are GET, PUT, POST, PATCH, and DELETE. Any other impose_api_method value fails loudly when the API app or router is created.

API Parameter Rules

GET endpoints read function parameters from query parameters:

@impose(impose_api_method="GET")
def list_users(tier: str, limit: int = 100) -> list[str]:
    ...
GET /list-users?tier=paid&limit=25

DELETE endpoints read function parameters from path parameters. Impose appends the function parameters to the route path:

@impose(impose_api_method="DELETE")
def delete_user(user_id: str, hard: bool) -> str:
    ...
DELETE /delete-user/{user_id}/{hard}

PUT, POST, and PATCH endpoints read every function parameter from the JSON request body:

from pydantic import BaseModel


class Region(BaseModel):
    partition: str
    name: str


@impose(impose_api_method="POST")
def list_instances(region: Region, owner: str) -> list[str]:
    ...

The body nests Pydantic models under their parameter names:

{
  "region": {
    "partition": "aws",
    "name": "us-east-1"
  },
  "owner": "platform"
}

GET and DELETE only support primitive-like arguments: str, int, float, bool, enums, literals of primitive values, and optional unions of those types. Pydantic models and other structured types are rejected for GET and DELETE; Impose raises an error during API app/router creation instead of creating an invalid route.

Middleware

Impose supports FastAPI-style HTTP middleware on the whole generated API, on command sets, and on individual endpoints. Middleware runs for generated API routes only; CLI calls execute the Python function directly.

Middleware functions receive a request and call_next, matching FastAPI's @app.middleware("http") style:

from fastapi import HTTPException, Request, Response

from impose import impose


async def require_admin_scope(request: Request, call_next) -> Response:
    scopes = set(request.headers.get("x-auth-user-scopes", "").split())
    if "admin" not in scopes:
        raise HTTPException(status_code=403, detail="Missing admin scope.")
    return await call_next(request)


@impose(
    impose_cs="deploy",
    impose_api_method="POST",
    impose_middleware=[require_admin_scope],
)
def restart_service(name: str, environment: str) -> str:
    ...

Add middleware to every generated endpoint:

from impose import ImposeApplication


application = ImposeApplication()
application.add_middleware(require_admin_scope)

This works whether you call application.api_app() or include application.api_router() in another FastAPI app. In router mode, app-wide middleware means every generated Impose endpoint in that router; it does not become global middleware for unrelated routes in the parent app.

Add middleware to one command set:

application.add_command_set_middleware(
    "admin",
    require_admin_scope,
)

Every generated API endpoint in the admin command set will require the admin scope.

Confirmation Prompts

Commands that need an explicit safety check can opt into confirmation:

from impose import impose


@impose(impose_require_confirmation=True)
def delete_user(username: str, reason: str) -> None:
    ...

After all CLI arguments have been parsed, including any values collected through -i interactive mode, Impose prints an alarming confirmation message with a table of the exact function arguments. Long values are truncated instead of wrapped. The command only runs when the user types yes; typing no aborts it.

Tests

Run unit tests with 100% coverage enforcement:

uv run pytest unit

Run admin-tool integration tests without coverage enforcement:

uv run pytest integration

Serialization

Custom serializers can be attached to types expected by imposed functions using Pydantic. This lets your CLI and API share the same typed interface while still accepting and returning rich project-specific objects.

Example: Custom Serialization with Pydantic

from pydantic import BaseModel, field_serializer

from impose import impose


class Region(BaseModel):
    partition: str
    name: str

    @field_serializer("name")
    def serialize_name(self, value: str) -> str:
        return value.lower()


@impose(impose_cs="ec2")
def list_instances(region: Region) -> list[str]:
    ...

Invoke it from the CLI by passing the Pydantic model as structured input:

impose ec2 list-instances '{"partition":"aws","name":"US-EAST-1"}'

The field_serializer normalizes the region name when the value is serialized, so downstream CLI output or API responses can return us-east-1.

If the same function is exposed through FastAPI, the generated endpoint can accept the same shape in the request body:

curl -X POST http://localhost:8000/ec2/list-instances \
  -H 'content-type: application/json' \
  -d '{"region":{"partition":"aws","name":"US-EAST-1"}}'

The same type can be used by the CLI parser and the generated API schema, so project-specific values do not need separate CLI and HTTP representations.

Example: Rich Return Types

from pydantic import BaseModel

from impose import impose


class LoadBalancer(BaseModel):
    name: str
    arn: str
    scheme: str


@impose(impose_cs="elbv2")
def get_load_balancer(name: str) -> LoadBalancer:
    ...

When exposed through the API, the Pydantic model becomes the response schema. When used from the CLI, Impose can serialize the result into a stable command output format.

Short Options

Defaulted parameters become optional CLI flags. Add typing.Annotated metadata with ImposeOption when a flag should also have a one-character short option:

from typing import Annotated

from impose import ImposeOption, impose


@impose(impose_cs="deploy")
def deploy_service(
    service: str,
    condition: Annotated[str, ImposeOption("c")] = "healthy",
    force: Annotated[bool, ImposeOption("f")] = False,
) -> None:
    ...

The generated CLI accepts both long and short forms:

impose deploy deploy-service billing --condition ready --force
impose deploy deploy-service billing -c ready -f

Interactive Mode

Impose supports a global -i mode that turns your impose command into an interactive command. Enums, Literals, and Booleans become interactive options that can be selected instead of typed manually.

Example: Interactive Options

from enum import Enum
from typing import Literal

from impose import impose


class Environment(str, Enum):
    dev = "dev"
    staging = "staging"
    prod = "prod"


@impose(impose_cs="deploy")
def deploy_service(
    service: str,
    environment: Environment,
    strategy: Literal["rolling", "blue-green"],
    dry_run: bool = True,
) -> None:
    ...

Run the command normally:

impose deploy deploy-service \
  billing \
  prod \
  rolling \
  --dry-run

Or use interactive mode:

impose -i deploy deploy-service

In interactive mode, Impose can prompt for environment, strategy, and dry_run using selectable choices.

Development

Enter the development environment:

direnv allow

Install the project environment:

uv sync

Run the CLI:

uv run impose --help

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

impose_cli-1.0.1.tar.gz (28.7 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

impose_cli-1.0.1-py3-none-any.whl (22.4 kB view details)

Uploaded Python 3

File details

Details for the file impose_cli-1.0.1.tar.gz.

File metadata

  • Download URL: impose_cli-1.0.1.tar.gz
  • Upload date:
  • Size: 28.7 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for impose_cli-1.0.1.tar.gz
Algorithm Hash digest
SHA256 29faffacf230314be4d924982bc1cdda99c860ba275ed0ec5bd3445985b437d8
MD5 3f35db980f86ff61aad9f287a541a40a
BLAKE2b-256 19920c6687f7af2c5dba1ffd28d00cbb0974f709e138b071455a87b060c14cfb

See more details on using hashes here.

Provenance

The following attestation bundles were made for impose_cli-1.0.1.tar.gz:

Publisher: version-and-release.yaml on kassett/impose-cli

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file impose_cli-1.0.1-py3-none-any.whl.

File metadata

  • Download URL: impose_cli-1.0.1-py3-none-any.whl
  • Upload date:
  • Size: 22.4 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for impose_cli-1.0.1-py3-none-any.whl
Algorithm Hash digest
SHA256 6974a87e7e2e0e451a0bce9c2fec4680b3f31959160643405010862b869503f7
MD5 5fc8f718f11c6855534b9b296d5363fd
BLAKE2b-256 7a42c64986333f98563368d9911c53a20a9e6b42be20f68fc1ab59097334c243

See more details on using hashes here.

Provenance

The following attestation bundles were made for impose_cli-1.0.1-py3-none-any.whl:

Publisher: version-and-release.yaml on kassett/impose-cli

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page