diff --git a/.changeset/clean-snails-arrive.md b/.changeset/clean-snails-arrive.md new file mode 100644 index 0000000..5d85b7b --- /dev/null +++ b/.changeset/clean-snails-arrive.md @@ -0,0 +1,5 @@ +--- +"impose-cli": patch +--- + +Adding support for short options diff --git a/README.md b/README.md index d1b4e51..3acf87d 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/src/impose/__init__.py b/src/impose/__init__.py index 4367066..24f1acd 100644 --- a/src/impose/__init__.py +++ b/src/impose/__init__.py @@ -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" diff --git a/src/impose/api.py b/src/impose/api.py index 9ac9eb8..02f5522 100644 --- a/src/impose/api.py +++ b/src/impose/api.py @@ -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"} @@ -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 " @@ -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): @@ -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: diff --git a/src/impose/cli/arguments.py b/src/impose/cli/arguments.py index fa34d91..00f2cc3 100644 --- a/src/impose/cli/arguments.py +++ b/src/impose/cli/arguments.py @@ -12,6 +12,8 @@ from impose.values import ( annotation_name, choices_for_annotation, + inner_annotation, + option_shortcut, parse_value, ) @@ -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") @@ -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), } @@ -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( @@ -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) diff --git a/src/impose/values.py b/src/impose/values.py index 13f81a8..97d06c1 100644 --- a/src/impose/values.py +++ b/src/impose/values.py @@ -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) @@ -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: @@ -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: @@ -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): @@ -97,7 +127,7 @@ 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: @@ -105,7 +135,7 @@ def coerce_kwargs(function: Callable[..., Any], values: dict[str, Any]) -> dict[ 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): diff --git a/src/tests/test_api.py b/src/tests/test_api.py index cf10272..b58e2fb 100644 --- a/src/tests/test_api.py +++ b/src/tests/test_api.py @@ -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 @@ -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]: diff --git a/src/tests/test_cli_arguments.py b/src/tests/test_cli_arguments.py index 960d4dd..01faeed 100644 --- a/src/tests/test_cli_arguments.py +++ b/src/tests/test_cli_arguments.py @@ -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 @@ -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: diff --git a/src/tests/test_values.py b/src/tests/test_values.py index d45e382..a631c8a 100644 --- a/src/tests/test_values.py +++ b/src/tests/test_values.py @@ -1,15 +1,17 @@ from __future__ import annotations -from typing import Literal +from typing import Annotated, Literal import pytest from helpers import Color, Payload +from impose import ImposeOption from impose.values import ( annotation_accepts_none, annotation_name, choices_for_annotation, coerce_kwargs, + option_shortcut, parse_value, serialize_result, ) @@ -47,6 +49,25 @@ def test_value_helpers_cover_supported_annotations() -> None: assert annotation_name(object) == "value" +def test_value_helpers_unwrap_impose_option_annotations() -> None: + assert annotation_accepts_none(Annotated[str | None, ImposeOption("n")]) is True + assert parse_value("2", Annotated[int, ImposeOption("c")]) == 2 + assert parse_value("yes", Annotated[bool, ImposeOption("e")]) is True + assert parse_value("red", Annotated[Color, ImposeOption("C")]) is Color.red + assert parse_value('{"name":"sam"}', Annotated[Payload, ImposeOption("p")]) == Payload( + name="sam" + ) + assert parse_value("x", Annotated[Literal["x", "y"], ImposeOption("m")]) == "x" + assert choices_for_annotation(Annotated[Color, ImposeOption("C")]) == ["red", "blue"] + assert choices_for_annotation(Annotated[Literal["x", "y"], ImposeOption("m")]) == ["x", "y"] + assert annotation_name(Annotated[Payload, ImposeOption("p")]) == "json" + + +def test_option_shortcut_checks_nested_annotated_metadata() -> None: + assert option_shortcut(Annotated[Annotated[str, ImposeOption("s")], "metadata"]) == "s" + assert option_shortcut(Annotated[str, "metadata"]) is None + + def test_coerce_kwargs_handles_models_enums_defaults_and_errors() -> None: def command(payload: Payload, color: Color, count: int = 1) -> tuple[Payload, Color, int]: return payload, color, count @@ -58,3 +79,16 @@ def command(payload: Payload, color: Color, count: int = 1) -> tuple[Payload, Co with pytest.raises(ValueError, match="Missing required argument"): coerce_kwargs(command, {"payload": {"name": "sam"}}) + + +def test_coerce_kwargs_unwraps_impose_option_annotations() -> None: + def command( + payload: Annotated[Payload, ImposeOption("p")], + color: Annotated[Color, ImposeOption("c")], + ) -> tuple[Payload, Color]: + return payload, color + + assert coerce_kwargs( + command, + {"payload": {"name": "sam"}, "color": "red"}, + ) == {"payload": Payload(name="sam"), "color": Color.red}