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
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
29faffacf230314be4d924982bc1cdda99c860ba275ed0ec5bd3445985b437d8
|
|
| MD5 |
3f35db980f86ff61aad9f287a541a40a
|
|
| BLAKE2b-256 |
19920c6687f7af2c5dba1ffd28d00cbb0974f709e138b071455a87b060c14cfb
|
Provenance
The following attestation bundles were made for impose_cli-1.0.1.tar.gz:
Publisher:
version-and-release.yaml on kassett/impose-cli
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
impose_cli-1.0.1.tar.gz -
Subject digest:
29faffacf230314be4d924982bc1cdda99c860ba275ed0ec5bd3445985b437d8 - Sigstore transparency entry: 2291089985
- Sigstore integration time:
-
Permalink:
kassett/impose-cli@0fa9515914d444d06529183834b2b6ca649b51e6 -
Branch / Tag:
refs/heads/main - Owner: https://github.com/kassett
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
version-and-release.yaml@0fa9515914d444d06529183834b2b6ca649b51e6 -
Trigger Event:
pull_request
-
Statement type:
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
6974a87e7e2e0e451a0bce9c2fec4680b3f31959160643405010862b869503f7
|
|
| MD5 |
5fc8f718f11c6855534b9b296d5363fd
|
|
| BLAKE2b-256 |
7a42c64986333f98563368d9911c53a20a9e6b42be20f68fc1ab59097334c243
|
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
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
impose_cli-1.0.1-py3-none-any.whl -
Subject digest:
6974a87e7e2e0e451a0bce9c2fec4680b3f31959160643405010862b869503f7 - Sigstore transparency entry: 2291090099
- Sigstore integration time:
-
Permalink:
kassett/impose-cli@0fa9515914d444d06529183834b2b6ca649b51e6 -
Branch / Tag:
refs/heads/main - Owner: https://github.com/kassett
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
version-and-release.yaml@0fa9515914d444d06529183834b2b6ca649b51e6 -
Trigger Event:
pull_request
-
Statement type: