diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e11fd3..496b419 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,10 @@ All notable changes to this project will be documented in this file. *NOTE:* Version 0.X.X might have breaking changes in bumps of the minor version number. This is because the project is still in early development and the API is not yet stable. It will still be marked clearly in the release notes. +## [0.8.0] - 29-06-2026 +- Fix bug with bool without default value being treated as a required parameter in the CLI. This would make it not match the CLI builder and cause an error in execution. +- Fix bug with CLI builder using the wrong type of quotes, and not parsing correctly in some cases. Now uses single quotes instead of double quotes. + ## [0.7.0] - 03-05-2026 - Added support for the Optional and Union type hints in the CLI arguments. This allows for more flexible command definitions and better type checking. - Improved support for list parameters in the CLI, and handled the vscode tasks generation for list parameters appropriately. diff --git a/README.md b/README.md index 98c6bf0..6d209f6 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,6 @@ If you want mcp server support, you can install the optional dependency: ```bash pip install toolit[mcp] ``` -Note: MCP support is not available on python 3.9, since it is not supported by the `mcp` package. ## Usage Add a folder called `devtools` to your project root. Create python modules, you decide the name, in this folder. Add the tool decorator to functions you want to expose as commands. diff --git a/tests/cli_command_builder_test.py b/tests/cli_command_builder_test.py index f44a11b..1e88aaa 100644 --- a/tests/cli_command_builder_test.py +++ b/tests/cli_command_builder_test.py @@ -1,14 +1,12 @@ """Tests for CLI command builder with full parameter analysis and metadata generation.""" import enum -import inspect -from types import FunctionType -from typing import Any, Optional, cast - import pytest - +import inspect from toolit.cli_command_builder import CliCommandBuilder from toolit.constants import OPTIONAL_STR_SENTINEL +from types import FunctionType +from typing import Any, Optional, cast def _as_function(func: Any) -> FunctionType: @@ -139,10 +137,7 @@ def test_create_args_for_tool_handles_pep604_optional() -> None: inputs = spec.get_input_entries() assert args == [ - '--input-dataset-name "' - f'{OPTIONAL_STR_SENTINEL}' - '${input:_tool_with_pep604_optional_input_dataset_name}' - '"' + f"--input-dataset-name '{OPTIONAL_STR_SENTINEL}${{input:_tool_with_pep604_optional_input_dataset_name}}'", ] assert inputs[0]["description"] == "Enter value for input_dataset_name (str | None)" assert inputs[0]["default"] is None @@ -157,10 +152,7 @@ def test_create_args_for_tool_handles_typing_optional() -> None: inputs = spec.get_input_entries() assert args == [ - '--input-dataset-name "' - f'{OPTIONAL_STR_SENTINEL}' - '${input:_tool_with_typing_optional_input_dataset_name}' - '"' + f"--input-dataset-name '{OPTIONAL_STR_SENTINEL}${{input:_tool_with_typing_optional_input_dataset_name}}'", ] assert inputs[0]["description"] == "Enter value for input_dataset_name (str | None)" assert inputs[0]["default"] is None @@ -174,7 +166,7 @@ def test_create_args_for_tool_raises_on_missing_type_hint() -> None: cmd_builder = CliCommandBuilder() with pytest.raises( - ValueError, match="Parameter 'to_print' in function '_tool_without_type_hint' is missing a type annotation" + ValueError, match="Parameter 'to_print' in function '_tool_without_type_hint' is missing a type annotation", ): cmd_builder.analyze_tool(_as_function(_tool_without_type_hint)) @@ -206,7 +198,7 @@ def test_create_args_for_tool_enum_creates_picklist_input() -> None: args = spec.get_argument_strings() inputs = spec.get_input_entries() - assert args == ['"${input:_tool_with_enum_param_color}"'] + assert args == ["'${input:_tool_with_enum_param_color}'"] assert inputs[0]["type"] == "pickString" assert inputs[0]["options"] == ["red", "green", "blue"] @@ -229,7 +221,7 @@ def test_create_args_for_tool_enum_respects_provided_default() -> None: args = spec.get_argument_strings() inputs = spec.get_input_entries() - assert args == ['--color "${input:_tool_with_enum_param_with_default_color}"'] + assert args == ["--color '${input:_tool_with_enum_param_with_default_color}'"] assert inputs[0]["default"] == Color.RED.value @@ -242,8 +234,8 @@ def test_create_args_for_tool_multiple_enum_params() -> None: inputs = spec.get_input_entries() assert args == [ - '--color "${input:_tool_with_multiple_enum_params_color}"', - '--environment "${input:_tool_with_multiple_enum_params_environment}"', + "--color '${input:_tool_with_multiple_enum_params_color}'", + "--environment '${input:_tool_with_multiple_enum_params_environment}'", ] assert len(inputs) == 2 # First input (color) @@ -267,7 +259,7 @@ def test_create_args_for_tool_list_str_uses_promptstring_and_guidance() -> None: args = spec.get_argument_strings() inputs = spec.get_input_entries() - assert args == ['"${input:_tool_with_list_str_param_items}"'] + assert args == ["'${input:_tool_with_list_str_param_items}'"] assert inputs[0]["type"] == "promptString" assert inputs[0]["description"] == "Enter comma-separated text values for items (e.g. alpha, beta, gamma)" assert inputs[0]["default"] == "" @@ -281,7 +273,7 @@ def test_create_args_for_tool_list_int_serializes_default_values() -> None: args = spec.get_argument_strings() inputs = spec.get_input_entries() - assert args == ['--numbers "${input:_tool_with_list_int_param_numbers}"'] + assert args == ["--numbers '${input:_tool_with_list_int_param_numbers}'"] assert inputs[0]["description"] == "Enter comma-separated integer values for numbers (e.g. 1, 2, 3)" assert inputs[0]["default"] == "1, 2, 3" @@ -294,7 +286,7 @@ def test_create_args_for_tool_list_enum_serializes_default_values() -> None: args = spec.get_argument_strings() inputs = spec.get_input_entries() - assert args == ['--colors "${input:_tool_with_list_enum_param_colors}"'] + assert args == ["--colors '${input:_tool_with_list_enum_param_colors}'"] assert ( inputs[0]["description"] == "Enter comma-separated enum values for colors. Accepted values: [red, green, blue]. You can also use enum member names." @@ -310,7 +302,7 @@ def test_create_args_for_tool_optional_list_keeps_none_default() -> None: args = spec.get_argument_strings() inputs = spec.get_input_entries() - assert args == ['--values "${input:_tool_with_optional_list_param_values}"'] + assert args == ["--values '${input:_tool_with_optional_list_param_values}'"] assert inputs[0]["default"] is None @@ -325,7 +317,7 @@ def test_create_args_for_tool_bool_creates_picklist_input() -> None: args = spec.get_argument_strings() inputs = spec.get_input_entries() - assert args == ['--is-enabled "${input:_tool_with_bool_param_is_enabled}"'] + assert args == ["--is-enabled '${input:_tool_with_bool_param_is_enabled}'"] assert inputs[0]["type"] == "pickString" assert inputs[0]["options"] == ["True", "False"] assert inputs[0]["default"] == "False" diff --git a/tests/cli_integration_test.py b/tests/cli_integration_test.py index 9a3012e..c44d45a 100644 --- a/tests/cli_integration_test.py +++ b/tests/cli_integration_test.py @@ -173,6 +173,18 @@ def with_bool_true(enabled: bool = True) -> None: # noqa: ARG001 inputs = spec.get_input_entries() assert inputs[0]["default"] == "True" + def test_bool_without_default(self) -> None: + """Ensure bool parameters with True default preserve it.""" + + def with_bool_no_defaults(enabled: bool) -> None: # noqa: ARG001 + """Boolean parameter with True default.""" + + cmd_builder = CliCommandBuilder() + spec = cmd_builder.analyze_tool(with_bool_no_defaults) + + inputs = spec.get_input_entries() + assert inputs[0]["default"] == "False" + def test_bool_command_with_true_string(self) -> None: """Ensure 'True' string is correctly converted to bool True.""" cmd = CliCommandBuilder().create_typer_option_name("enabled") diff --git a/tests/cli_test.py b/tests/cli_test.py index 67e8803..c8c6442 100644 --- a/tests/cli_test.py +++ b/tests/cli_test.py @@ -213,6 +213,22 @@ def bool_tool(is_enabled: bool = True) -> None: assert captured["is_enabled"] is False +def test_cli_bool_option_no_default_string_is_received_as_false() -> None: + """Ensure passing false, in a function with no default, for a bool option results in Python False.""" + captured: dict[str, bool] = {} + + def bool_tool(is_enabled: bool) -> None: + captured["is_enabled"] = is_enabled + + create_apps_and_register.register_command(bool_tool, name="test-bool-no-default") + runner = CliRunner() + result = runner.invoke(create_apps_and_register.app, ["test-bool-no-default", "False"]) + + assert result.exit_code == 0, f"CLI failed: {result.output}" + assert captured["is_enabled"] is False + + + def test_cli_list_str_comma_separated_single_arg_is_split() -> None: """Ensure a single comma-separated string is split into a list[str].""" captured: dict[str, list[str]] = {} diff --git a/tests/create_tasks_json_test.py b/tests/create_tasks_json_test.py index 6bf8fad..2fb1e5b 100644 --- a/tests/create_tasks_json_test.py +++ b/tests/create_tasks_json_test.py @@ -41,7 +41,7 @@ def run_script(name: str) -> str: assert task["label"] == "Run Script" assert task["type"] == "shell" - assert task["command"].endswith('toolit run-script "${input:run_script_name}"') + assert task["command"].endswith("toolit run-script '${input:run_script_name}'") assert task["detail"] == "Run a shell script." assert task["problemMatcher"] == [] @@ -50,7 +50,7 @@ def test_task_json_builder_omits_detail_when_no_docstring() -> None: """Ensure detail field is omitted when tool has no docstring.""" @clitool - def run_without_docstring(name: str) -> str: # type: ignore[no-untyped-def] + def run_without_docstring(name: str) -> str: # type: ignore[no-untyped-def,empty-body] pass cmd_builder = CliCommandBuilder() @@ -69,7 +69,7 @@ def test_task_json_builder_collects_input_entries() -> None: """Ensure all input entries are collected during processing.""" @clitool - def multi_param(name: str, count: int = 5) -> None: # noqa: ARG001 + def multi_param(name: str, count: int = 5) -> None: """Tool with multiple parameters.""" cmd_builder = CliCommandBuilder() @@ -88,7 +88,7 @@ def test_task_json_builder_create_tasks_json_returns_proper_structure() -> None: """Ensure create_tasks_json returns properly formatted output.""" @clitool - def simple_tool(text: str) -> None: # noqa: ARG001 + def simple_tool(text: str) -> None: """A simple tool.""" cmd_builder = CliCommandBuilder() diff --git a/toolit/cli_command_builder.py b/toolit/cli_command_builder.py index 81b687c..b5ee8da 100644 --- a/toolit/cli_command_builder.py +++ b/toolit/cli_command_builder.py @@ -46,9 +46,9 @@ class ParameterSpec: def get_argument_string(self) -> str: """Get this parameter's argument string for command building.""" if self.is_optional_string: - input_ref = f'"{OPTIONAL_STR_SENTINEL}${{input:{self.input_id}}}"' + input_ref = f"'{OPTIONAL_STR_SENTINEL}${{input:{self.input_id}}}'" else: - input_ref = f'"${{input:{self.input_id}}}"' + input_ref = f"'${{input:{self.input_id}}}'" if self.uses_option: return f"{self.option_name} {input_ref}" return input_ref diff --git a/toolit/create_apps_and_register.py b/toolit/create_apps_and_register.py index 62408c9..87e6526 100644 --- a/toolit/create_apps_and_register.py +++ b/toolit/create_apps_and_register.py @@ -3,7 +3,6 @@ from __future__ import annotations import os -import enum import shlex import typer import inspect @@ -12,13 +11,13 @@ from functools import wraps from toolit.constants import ( MARKER_TOOL, - OPTIONAL_STR_SENTINEL, ToolitTypesEnum, ) -from toolit.type_utils import unwrap_union_members -from typing import TYPE_CHECKING, cast, get_args, get_origin +from toolit.type_coersion_wrapper import create_type_coercion_wrapper +from typing import TYPE_CHECKING, ParamSpec, TypeVar -OPTIONAL_UNION_MEMBER_COUNT = 2 +P = ParamSpec("P") +R = TypeVar("R") if TYPE_CHECKING: from mcp.server.fastmcp import FastMCP # type: ignore[import-not-found] @@ -31,7 +30,7 @@ _has_mcp = True except ImportError: - FastMCP: object = None # type: ignore[no-redef] + FastMCP = None # type: ignore[assignment,no-redef] _has_mcp = False # Initialize the Typer app @@ -46,7 +45,7 @@ def initialize() -> None: def register_command( - command_func: Callable[..., object], + command_func: Callable[..., R], name: str | None = None, rich_help_panel: str | None = None, ) -> None: @@ -55,178 +54,23 @@ def register_command( msg = f"Command function {command_func} is not callable." raise TypeError(msg) - command_to_register = _create_type_coercion_wrapper(command_func) + command_to_register = create_type_coercion_wrapper(command_func) if getattr(command_func, MARKER_TOOL, None) == ToolitTypesEnum.CLITOOL: - command_to_register = _create_clitool_runtime_wrapper(command_to_register) - - app.command(name=name, rich_help_panel=rich_help_panel)(command_to_register) + app.command(name=name, rich_help_panel=rich_help_panel)( + create_clitool_runtime_wrapper(command_to_register), + ) + else: + app.command(name=name, rich_help_panel=rich_help_panel)(command_to_register) if mcp is not None and getattr(command_func, MARKER_TOOL, None) != ToolitTypesEnum.CLITOOL: mcp.tool(name)(command_func) -def _extract_list_item_type(annotation: object) -> object | None: - """Return the T for list[T] (including Optional[list[T]]), or None if not a list.""" - for candidate in unwrap_union_members(annotation): - if candidate is type(None): - continue - if get_origin(candidate) is list: - args = get_args(candidate) - return args[0] if args else str - return None - - -def _is_optional_list(annotation: object) -> bool: - """Return True when annotation allows None alongside a list type.""" - members = unwrap_union_members(annotation) - return type(None) in members and any(get_origin(m) is list for m in members) - - -def _is_optional_str(annotation: object) -> bool: - """Return True when annotation is exactly str | None.""" - members = unwrap_union_members(annotation) - return str in members and type(None) in members and len(members) == OPTIONAL_UNION_MEMBER_COUNT - - -def _is_required_str(annotation: object, default: object) -> bool: - """Return True when annotation is plain str with no default value.""" - return annotation is str and default is inspect.Parameter.empty - - -def _contains_bool(annotation: object) -> bool: - """Return True when annotation is or contains bool.""" - return any(m is bool for m in unwrap_union_members(annotation)) - - -def _coerce_list_value(value: list[object] | None, item_type: object) -> list[object] | None: - """ - Split a single comma-separated element if needed, then convert to item_type. - - Returns None unchanged (for optional list parameters with no value provided). - """ - if value is None: - return None - if len(value) == 1 and isinstance(value[0], str) and "," in value[0]: - raw_items: list[str] = [v.strip() for v in value[0].split(",") if v.strip()] - else: - raw_items = [str(v) for v in value] - - if item_type is str: - return cast("list[object]", raw_items) - if item_type is int: - return cast("list[object]", [int(v) for v in raw_items]) - if isinstance(item_type, type) and issubclass(item_type, enum.Enum): - return cast("list[object]", [item_type(v) for v in raw_items]) - return cast("list[object]", raw_items) - - -def _parameter_coercion_spec( - param: inspect.Parameter, -) -> tuple[inspect.Parameter, tuple[str, object | None] | None]: - """Return rewritten parameter and optional coercion metadata for runtime conversion.""" - ann = param.annotation - - list_item_type = _extract_list_item_type(ann) - if list_item_type is not None: - new_ann: object = (list[str] | None) if _is_optional_list(ann) else list[str] - return param.replace(annotation=new_ann), ("list", list_item_type) - - if _contains_bool(ann): - bool_default = "False" if param.default is inspect.Parameter.empty else str(param.default) - return param.replace(annotation=str, default=bool_default), ("bool", None) - - if _is_optional_str(ann): - return param, ("optional_str", None) - - if _is_required_str(ann, param.default): - return param, ("required_str", None) - - return param, None - - -def _normalize_list_input(value: object) -> list[object] | None: - """Normalize Typer list input into a list or None before list coercion.""" - if value is None: - return None - if isinstance(value, list): - return cast("list[object]", value) - return [value] - - -def _apply_single_coercion(param_name: str, coercion_type: str, extra: object | None, value: object) -> object: - """Apply a single coercion strategy to one parameter value.""" - if coercion_type == "list": - return _coerce_list_value(_normalize_list_input(value), extra) - - if coercion_type == "bool": - return str(value).lower() == "true" - - if coercion_type == "optional_str": - if isinstance(value, str) and value.startswith(OPTIONAL_STR_SENTINEL): - raw_value = value[len(OPTIONAL_STR_SENTINEL) :] - return raw_value or None - if isinstance(value, str) and not value: - return None - return value - - if coercion_type == "required_str" and isinstance(value, str) and not value: - typer.secho(f"Error: '{param_name}' cannot be empty.", fg=typer.colors.RED) - raise typer.Exit(code=1) - - return value - - -def _create_type_coercion_wrapper(func: Callable[..., object]) -> Callable[..., object]: - """ - Wrap a function to add CLI type coercions applied before the function is called. - - Handles: - - list[T]: splits single comma-separated arg; preserves native multi-arg behavior. - - bool: changes --flag/--no-flag to --flag VALUE accepting 'True'/'False' strings. - - str | None: converts empty string to None. - - required str: rejects empty string with a non-zero exit. - """ - sig = inspect.signature(func) - new_params: list[inspect.Parameter] = [] - coercions: dict[str, tuple[str, object | None]] = {} - - for param in sig.parameters.values(): - rewritten_param, coercion = _parameter_coercion_spec(param) - new_params.append(rewritten_param) - if coercion is not None: - coercions[param.name] = coercion - - if not coercions: - return func - - new_sig = sig.replace(parameters=new_params) - - @wraps(func) - def _wrapper(*args: object, **kwargs: object) -> object: - for param_name, (coercion_type, extra) in coercions.items(): - if param_name not in kwargs: - continue - kwargs[param_name] = _apply_single_coercion(param_name, coercion_type, extra, kwargs[param_name]) - - return func(*args, **kwargs) - - setattr(_wrapper, "__signature__", new_sig) # noqa: B010 - # Rebuild __annotations__ to match the new signature so Typer/get_type_hints - # sees the transformed types rather than the originals copied by @wraps. - new_annotations: dict[str, object] = { - p.name: p.annotation for p in new_sig.parameters.values() if p.annotation is not inspect.Parameter.empty - } - if sig.return_annotation is not inspect.Parameter.empty: - new_annotations["return"] = sig.return_annotation - _wrapper.__annotations__ = new_annotations - return _wrapper - - -def _create_clitool_runtime_wrapper(command_func: Callable[..., object]) -> Callable[..., None]: +def create_clitool_runtime_wrapper(command_func: Callable[P, R]) -> Callable[P, None]: """Wrap a clitool function so its returned command string runs in a shell.""" @wraps(command_func) - def _wrapped(*args: object, **kwargs: object) -> None: + def _wrapped(*args: P.args, **kwargs: P.kwargs) -> None: command = command_func(*args, **kwargs) if not isinstance(command, str): typer.secho( diff --git a/toolit/type_coersion_wrapper.py b/toolit/type_coersion_wrapper.py new file mode 100644 index 0000000..4f99d25 --- /dev/null +++ b/toolit/type_coersion_wrapper.py @@ -0,0 +1,205 @@ +"""Module provides a decorator to wrap a function and apply type coercions to its parameters before calling it.""" + +from __future__ import annotations + +import enum +import typer +import inspect +from collections.abc import Callable +from functools import wraps +from toolit.constants import ( + OPTIONAL_STR_SENTINEL, +) +from toolit.type_utils import unwrap_union_members +from typing import Literal, ParamSpec, TypeAlias, TypeVar, cast, get_args, get_origin + +OPTIONAL_UNION_MEMBER_COUNT = 2 + +P = ParamSpec("P") +R = TypeVar("R") +AnnotationT = TypeVar("AnnotationT") +DefaultT = TypeVar("DefaultT") +RawListItem: TypeAlias = str | int | bool | float | enum.Enum | None +RawCliValue: TypeAlias = RawListItem | list[RawListItem] +CoercedListValue: TypeAlias = list[str] | list[int] | list[enum.Enum] | None +CoercedCliValue: TypeAlias = RawCliValue | CoercedListValue | bool +ListItemType: TypeAlias = type[str] | type[int] | type[enum.Enum] +CoercionKind: TypeAlias = Literal["list", "bool", "optional_str", "required_str"] + + +def create_type_coercion_wrapper(func: Callable[P, R]) -> Callable[P, R]: + """ + Wrap a function to add CLI type coercions applied before the function is called. + + Handles: + - list[T]: splits single comma-separated arg; preserves native multi-arg behavior. + - bool: changes --flag/--no-flag to --flag VALUE accepting 'True'/'False' strings. + - str | None: converts empty string to None. + - required str: rejects empty string with a non-zero exit. + """ + sig = inspect.signature(func) + new_params: list[inspect.Parameter] = [] + coercions: dict[str, tuple[CoercionKind, ListItemType | None]] = {} + + for param in sig.parameters.values(): + rewritten_param, coercion = _parameter_coercion_spec(param) + new_params.append(rewritten_param) + if coercion is not None: + coercions[param.name] = coercion + + if not coercions: + return func + + new_sig = sig.replace(parameters=new_params) + + @wraps(func) + def _wrapper(*args: P.args, **kwargs: P.kwargs) -> R: + coerced_kwargs = cast("dict[str, object]", kwargs) + for param_name, (coercion_type, extra) in coercions.items(): + if param_name not in coerced_kwargs: + continue + coerced_kwargs[param_name] = _apply_single_coercion( + param_name, + coercion_type, + extra, + cast("RawCliValue", coerced_kwargs[param_name]), + ) + + return func(*args, **kwargs) + + setattr(_wrapper, "__signature__", new_sig) # noqa: B010 + # Rebuild __annotations__ to match the new signature so Typer/get_type_hints + # sees the transformed types rather than the originals copied by @wraps. + new_annotations = { + p.name: p.annotation for p in new_sig.parameters.values() if p.annotation is not inspect.Parameter.empty + } + if sig.return_annotation is not inspect.Parameter.empty: + new_annotations["return"] = sig.return_annotation + _wrapper.__annotations__ = new_annotations + return _wrapper + + +def _extract_list_item_type(annotation: AnnotationT) -> ListItemType | None: + """Return the T for list[T] (including Optional[list[T]]), or None if not a list.""" + for candidate in unwrap_union_members(annotation): + if candidate is type(None): + continue + if get_origin(candidate) is list: + args = get_args(candidate) + if not args: + return str + + list_item = args[0] + if list_item is str or list_item is int: + return list_item + if isinstance(list_item, type) and issubclass(list_item, enum.Enum): + return cast("type[enum.Enum]", list_item) + return str + return None + + +def _is_optional_list(annotation: AnnotationT) -> bool: + """Return True when annotation allows None alongside a list type.""" + members = unwrap_union_members(annotation) + return type(None) in members and any(get_origin(m) is list for m in members) + + +def _is_optional_str(annotation: AnnotationT) -> bool: + """Return True when annotation is exactly str | None.""" + members = unwrap_union_members(annotation) + return str in members and type(None) in members and len(members) == OPTIONAL_UNION_MEMBER_COUNT + + +def _is_required_str(annotation: AnnotationT, default: DefaultT) -> bool: + """Return True when annotation is plain str with no default value.""" + return annotation is str and default is inspect.Parameter.empty + + +def _contains_bool(annotation: AnnotationT) -> bool: + """Return True when annotation is or contains bool.""" + return any(m is bool for m in unwrap_union_members(annotation)) + + +def _parameter_coercion_spec( + param: inspect.Parameter, +) -> tuple[inspect.Parameter, tuple[CoercionKind, ListItemType | None] | None]: + """Return rewritten parameter and optional coercion metadata for runtime conversion.""" + ann = param.annotation + + list_item_type = _extract_list_item_type(ann) + if list_item_type is not None: + new_ann = (list[str] | None) if _is_optional_list(ann) else list[str] + return param.replace(annotation=new_ann), ("list", list_item_type) + + if _contains_bool(ann): + if param.default is inspect.Parameter.empty: + return param.replace(annotation=str), ("bool", None) + return param.replace(annotation=str, default=str(param.default)), ("bool", None) + + if _is_optional_str(ann): + return param, ("optional_str", None) + + if _is_required_str(ann, param.default): + return param, ("required_str", None) + + return param, None + + +def _coerce_list_value(value: list[RawListItem] | None, item_type: ListItemType) -> CoercedListValue: + """ + Split a single comma-separated element if needed, then convert to item_type. + + Returns None unchanged (for optional list parameters with no value provided). + """ + if value is None: + return None + if len(value) == 1 and isinstance(value[0], str) and "," in value[0]: + raw_items: list[str] = [v.strip() for v in value[0].split(",") if v.strip()] + else: + raw_items = [str(v) for v in value] + + if item_type is str: + return raw_items + if item_type is int: + return [int(v) for v in raw_items] + if isinstance(item_type, type) and issubclass(item_type, enum.Enum): + return [item_type(v) for v in raw_items] + return raw_items + + +def _apply_single_coercion( + param_name: str, + coercion_type: CoercionKind, + extra: ListItemType | None, + value: RawCliValue, +) -> CoercedCliValue: + """Apply a single coercion strategy to one parameter value.""" + if coercion_type == "list": + assert extra is not None + return _coerce_list_value(_normalize_list_input(value), extra) + + if coercion_type == "bool": + return str(value).lower() == "true" + + if coercion_type == "optional_str": + if isinstance(value, str) and value.startswith(OPTIONAL_STR_SENTINEL): + raw_value = value[len(OPTIONAL_STR_SENTINEL) :] + return cast("CoercedCliValue", raw_value or None) + if isinstance(value, str) and not value: + return None + return cast("CoercedCliValue", value) + + if coercion_type == "required_str" and isinstance(value, str) and not value: + typer.secho(f"Error: '{param_name}' cannot be empty.", fg=typer.colors.RED) + raise typer.Exit(code=1) + + return cast("CoercedCliValue", value) + + +def _normalize_list_input(value: RawCliValue) -> list[RawListItem] | None: + """Normalize Typer list input into a list or None before list coercion.""" + if value is None: + return None + if isinstance(value, list): + return value + return [value]