Metadata-Version: 2.4
Name: impose-cli
Version: 1.0.0
Summary: Command line tools for impose.
Requires-Python: >=3.12
Requires-Dist: docstring-parser>=0.18
Requires-Dist: pydantic>=2.0
Provides-Extra: api
Requires-Dist: fastapi>=0.100; extra == 'api'
Description-Content-Type: text/markdown

# 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.

```python
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:

```python
@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`:

```python
@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

```python
# 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]:
    ...
```

```python
# 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:

```sh
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:

```text
cloud_tools/
  __init__.py
  aws/
    __init__.py
    elbv2.py
    rds.py
    s3.py
  github/
    __init__.py
    repos.py
  cli.py
```

```python
# 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]:
    ...
```

```python
# cloud_tools/aws/s3.py
from impose import impose


@impose
def list_buckets(profile: str | None = None) -> list[str]:
    ...
```

```python
# cloud_tools/aws/rds.py
from impose import impose


@impose
def reboot_instance(identifier: str, force_failover: bool = False) -> None:
    ...
```

```python
# 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:

```python
# 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:

```sh
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:

```text
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:

```python
# 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:

```sh
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

```python
# 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:

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

A decorated function such as:

```python
@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:

```text
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:

```python
@impose(impose_api_method="GET")
def list_users(tier: str, limit: int = 100) -> list[str]:
    ...
```

```text
GET /list-users?tier=paid&limit=25
```

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

```python
@impose(impose_api_method="DELETE")
def delete_user(user_id: str, hard: bool) -> str:
    ...
```

```text
DELETE /delete-user/{user_id}/{hard}
```

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

```python
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:

```json
{
  "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:

```python
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:

```python
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:

```python
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:

```python
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:

```sh
uv run pytest unit
```

Run admin-tool integration tests without coverage enforcement:

```sh
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

```python
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:

```sh
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:

```sh
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

```python
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.

## 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

```python
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:

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

Or use interactive mode:

```sh
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:

```sh
direnv allow
```

Install the project environment:

```sh
uv sync
```

Run the CLI:

```sh
uv run impose --help
```
