Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/clean-snails-arrive.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"impose-cli": patch
---

Adding support for short options
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -472,6 +472,32 @@ 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:

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

```sh
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.
Expand Down
3 changes: 2 additions & 1 deletion src/impose/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@

from impose.application import ImposeApplication
from impose.decorator import ImposeMetadata, impose
from impose.values import ImposeOption

__all__ = ["ImposeApplication", "ImposeMetadata", "__version__", "impose"]
__all__ = ["ImposeApplication", "ImposeMetadata", "ImposeOption", "__version__", "impose"]

__version__ = "0.1.0"
9 changes: 6 additions & 3 deletions src/impose/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

from impose.commands import CommandSpec
from impose.middleware import HttpMiddleware, apply_http_middleware, middleware_for_endpoint
from impose.values import coerce_kwargs, parse_value
from impose.values import coerce_kwargs, inner_annotation, parse_value

SUPPORTED_API_METHODS = {"GET", "PUT", "POST", "PATCH", "DELETE"}
BODY_API_METHODS = {"PUT", "POST", "PATCH"}
Expand Down Expand Up @@ -90,7 +90,9 @@ def validate_api_command(command: CommandSpec, method: str) -> None:
"""Validate that a command can be exposed for the configured HTTP method."""
if method in {"GET", "DELETE"}:
for parameter in api_parameters(command):
annotation = get_type_hints(command.function).get(parameter.name, parameter.annotation)
annotation = get_type_hints(command.function, include_extras=True).get(
parameter.name, parameter.annotation
)
if not is_primitive_api_annotation(annotation):
raise TypeError(
f"{command.function.__name__}.{parameter.name} cannot be used with "
Expand All @@ -105,6 +107,7 @@ def api_parameters(command: CommandSpec) -> tuple[inspect.Parameter, ...]:

def is_primitive_api_annotation(annotation: Any) -> bool:
"""Return whether an annotation can be represented as a primitive API value."""
annotation = inner_annotation(annotation)
if annotation is inspect.Signature.empty:
return True
if annotation in (str, int, float, bool):
Expand Down Expand Up @@ -159,7 +162,7 @@ async def request_kwargs(command: CommandSpec, request: Any, method: str) -> dic
def primitive_kwargs(command: CommandSpec, values: Any) -> dict[str, Any]:
"""Parse primitive query or path values into command keyword arguments."""
kwargs: dict[str, Any] = {}
type_hints = get_type_hints(command.function)
type_hints = get_type_hints(command.function, include_extras=True)
for parameter in api_parameters(command):
annotation = type_hints.get(parameter.name, parameter.annotation)
if parameter.name not in values:
Expand Down
31 changes: 25 additions & 6 deletions src/impose/cli/arguments.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
from impose.values import (
annotation_name,
choices_for_annotation,
inner_annotation,
option_shortcut,
parse_value,
)

Expand All @@ -34,7 +36,7 @@ def add_function_arguments(
) -> None:
"""Add argparse arguments for a command function signature."""
signature = inspect.signature(command.function)
type_hints = get_type_hints(command.function)
type_hints = get_type_hints(command.function, include_extras=True)
descriptions = command_parameter_descriptions(command)
parser._positionals.title = "required arguments"
optional_group = parser.add_argument_group("optional arguments")
Expand Down Expand Up @@ -68,6 +70,8 @@ def add_positional_argument(
) -> None:
"""Add one required function parameter as a positional argument."""
annotation = type_hints.get(parameter.name, parameter.annotation)
if option_shortcut(annotation) is not None:
raise TypeError(f"{parameter.name} cannot use ImposeOption without a default value.")
kwargs: dict[str, Any] = {
"help": parameter_help(parameter, annotation, descriptions),
}
Expand All @@ -89,20 +93,35 @@ def add_optional_argument(
) -> None:
"""Add one defaulted function parameter as an optional argument."""
annotation = type_hints.get(parameter.name, parameter.annotation)
inner = inner_annotation(annotation)
shortcut = option_shortcut(annotation)
kwargs: dict[str, Any] = {
"dest": parameter.name,
"help": parameter_help(parameter, annotation, descriptions),
}
if annotation is bool or isinstance(parameter.default, bool):
if inner is bool or isinstance(parameter.default, bool):
kwargs["action"] = argparse.BooleanOptionalAction
kwargs["default"] = None
else:
kwargs["type"] = parse_callback(annotation)
choices = choices_for_annotation(annotation)
kwargs["type"] = parse_callback(inner)
choices = choices_for_annotation(inner)
if choices is not None:
kwargs["choices"] = choices
kwargs["default"] = parameter.default
parser.add_argument(f"--{command_name(parameter.name)}", **kwargs)
option_strings = [f"--{command_name(parameter.name)}"]
if shortcut is not None:
validate_shortcut(parameter, shortcut)
option_strings.insert(0, f"-{shortcut}")
parser.add_argument(*option_strings, **kwargs)


def validate_shortcut(parameter: inspect.Parameter, shortcut: str) -> None:
"""Validate one short option marker."""
if len(shortcut) != 1 or shortcut.startswith("-"):
raise TypeError(
f"{parameter.name} uses invalid ImposeOption shortcut {shortcut!r}; "
"shortcuts must be exactly one character without a leading dash."
)


def parameter_help(
Expand Down Expand Up @@ -155,7 +174,7 @@ def namespace_kwargs(
) -> dict[str, Any]:
"""Convert an argparse namespace into keyword arguments for a command."""
kwargs: dict[str, Any] = {}
type_hints = get_type_hints(command.function)
type_hints = get_type_hints(command.function, include_extras=True)
for parameter in inspect.signature(command.function).parameters.values():
annotation = type_hints.get(parameter.name, parameter.annotation)
value = getattr(namespace, parameter.name)
Expand Down
38 changes: 34 additions & 4 deletions src/impose/values.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,41 @@
import inspect
import json
from collections.abc import Callable
from dataclasses import dataclass
from enum import Enum
from types import UnionType
from typing import Any, Literal, Union, cast, get_args, get_origin, get_type_hints
from typing import Annotated, Any, Literal, Union, cast, get_args, get_origin, get_type_hints

from pydantic import BaseModel


@dataclass(frozen=True)
class ImposeOption:
"""CLI option metadata for an imposed function parameter."""

shortcut: str


def inner_annotation(annotation: Any) -> Any:
"""Return the value annotation with Impose metadata removed."""
if get_origin(annotation) is Annotated:
return inner_annotation(get_args(annotation)[0])
return annotation


def option_shortcut(annotation: Any) -> str | None:
"""Return the configured short option for an annotation, if any."""
if get_origin(annotation) is not Annotated:
return None
for metadata in get_args(annotation)[1:]:
if isinstance(metadata, ImposeOption):
return metadata.shortcut
return option_shortcut(get_args(annotation)[0])


def annotation_accepts_none(annotation: Any) -> bool:
"""Return whether an annotation accepts None."""
annotation = inner_annotation(annotation)
if annotation is inspect.Signature.empty:
return False
origin = get_origin(annotation)
Expand All @@ -24,16 +50,18 @@ def annotation_accepts_none(annotation: Any) -> bool:

def is_pydantic_model(annotation: Any) -> bool:
"""Return whether an annotation is a Pydantic model class."""
annotation = inner_annotation(annotation)
return inspect.isclass(annotation) and issubclass(annotation, BaseModel)


def pydantic_model(annotation: Any) -> type[BaseModel]:
"""Cast an annotation to a Pydantic model class."""
return cast(type[BaseModel], annotation)
return cast(type[BaseModel], inner_annotation(annotation))


def parse_value(value: str, annotation: Any) -> Any:
"""Parse a string value according to a function annotation."""
annotation = inner_annotation(annotation)
if annotation is inspect.Signature.empty or annotation is str:
return value
if annotation is int:
Expand Down Expand Up @@ -72,6 +100,7 @@ def serialize_result(result: Any) -> str:

def choices_for_annotation(annotation: Any) -> list[str] | None:
"""Return CLI choices for enum and literal annotations."""
annotation = inner_annotation(annotation)
if inspect.isclass(annotation) and issubclass(annotation, Enum):
return [str(item.value) for item in annotation]
if get_origin(annotation) is Literal:
Expand All @@ -81,6 +110,7 @@ def choices_for_annotation(annotation: Any) -> list[str] | None:

def annotation_name(annotation: Any) -> str:
"""Return a short display name for an annotation."""
annotation = inner_annotation(annotation)
if annotation is bool:
return "boolean"
if annotation in (str, int, float):
Expand All @@ -97,15 +127,15 @@ def annotation_name(annotation: Any) -> str:
def coerce_kwargs(function: Callable[..., Any], values: dict[str, Any]) -> dict[str, Any]:
"""Coerce API payload values into keyword arguments for a function."""
kwargs: dict[str, Any] = {}
type_hints = get_type_hints(function)
type_hints = get_type_hints(function, include_extras=True)
for parameter in inspect.signature(function).parameters.values():
if parameter.name not in values:
if parameter.default is inspect.Parameter.empty:
raise ValueError(f"Missing required argument {parameter.name!r}.")
kwargs[parameter.name] = parameter.default
continue
value = values[parameter.name]
annotation = type_hints.get(parameter.name, parameter.annotation)
annotation = inner_annotation(type_hints.get(parameter.name, parameter.annotation))
if is_pydantic_model(annotation):
kwargs[parameter.name] = pydantic_model(annotation).model_validate(value)
elif inspect.isclass(annotation) and issubclass(annotation, Enum):
Expand Down
25 changes: 23 additions & 2 deletions src/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@

import asyncio
from enum import StrEnum
from typing import Literal
from typing import Annotated, Literal

import pytest
from helpers import module_for
from pydantic import BaseModel

from impose import ImposeApplication, impose
from impose import ImposeApplication, ImposeOption, impose
from impose.commands import CommandSpec
from impose.decorator import get_impose_metadata
from impose.middleware import middleware_for_endpoint
Expand Down Expand Up @@ -362,6 +362,27 @@ def choose(mode: Literal["fast", "safe"], count: int | None = None) -> dict[str,
}


def test_get_method_unwraps_impose_option_annotations() -> None:
@impose(impose_api_method="GET")
def choose(
mode: Annotated[Literal["fast", "safe"], ImposeOption("m")],
count: Annotated[int | None, ImposeOption("c")] = None,
) -> dict[str, object]:
return {"mode": mode, "count": count}

endpoint = (
ImposeApplication(modules=[module_for(choose, "annotated_get_commands")])
.api_router()
.routes[0]
.endpoint
)

assert asyncio.run(endpoint(Request(query_params={"mode": "fast", "count": "3"}))) == {
"mode": "fast",
"count": 3,
}


def test_get_method_handles_unannotated_required_and_default_query_parameters() -> None:
@impose(impose_api_method="GET")
def search(term, page: int = 1) -> dict[str, object]:
Expand Down
48 changes: 46 additions & 2 deletions src/tests/test_cli_arguments.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
from __future__ import annotations

import argparse
from typing import Literal
from typing import Annotated, Literal

import pytest
from helpers import Color, module_for

from impose import ImposeApplication, impose
from impose import ImposeApplication, ImposeOption, impose
from impose.cli.arguments import namespace_kwargs, parameter_help, prompt_for_parameter
from impose.commands import CommandSpec
from impose.decorator import get_impose_metadata
Expand Down Expand Up @@ -57,6 +57,50 @@ def configure(
)


def test_optional_argument_supports_shortcut_metadata() -> None:
@impose
def filter_items(condition: Annotated[str, ImposeOption("c")] = "ready") -> str:
return condition

app = ImposeApplication(modules=[module_for(filter_items, "shortcut_commands")])

assert app.run(["filter-items", "-c", "failed"]) == "failed"
assert app.run(["filter-items", "--condition", "ready"]) == "ready"


def test_optional_bool_argument_supports_shortcut_metadata() -> None:
@impose
def delete_user(force: Annotated[bool, ImposeOption("f")] = False) -> bool:
return force

app = ImposeApplication(modules=[module_for(delete_user, "bool_shortcut_commands")])

assert app.run(["delete-user", "-f"]) is True
assert app.run(["delete-user", "--no-force"]) is False


def test_invalid_shortcut_metadata_fails_loudly() -> None:
@impose
def command(condition: Annotated[str, ImposeOption("condition")] = "ready") -> str:
return condition

app = ImposeApplication(modules=[module_for(command, "invalid_shortcut_commands")])

with pytest.raises(TypeError, match="invalid ImposeOption shortcut"):
app.cli()


def test_required_positional_argument_cannot_use_shortcut_metadata() -> None:
@impose
def command(condition: Annotated[str, ImposeOption("c")]) -> str:
return condition

app = ImposeApplication(modules=[module_for(command, "positional_shortcut_commands")])

with pytest.raises(TypeError, match="without a default value"):
app.cli()


def test_positional_argument_with_underscore_uses_python_parameter_name() -> None:
@impose
def delete_user(user_id: str) -> str:
Expand Down
Loading
Loading