diff --git a/CHANGELOG.md b/CHANGELOG.md index 00a2ac6..38700b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,9 +3,11 @@ 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.7.0] - 27-06-2026 +## [0.7.0] - Unreleased - 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. - Raise an error if a proper type hint is not provided for a parameter in a tool function. +- Make it possible to invoke the CLI using `python -m toolit` in addition to the `toolit` command, which is useful for environments where the command might not be available (or when there is a specific venv or python version you want to use). ## [0.6.0] - 20-12-2025 - Abandon python 3.9 which is deprecated. Now only support python 3.10 and higher. diff --git a/README.md b/README.md index d4ded0e..99093f4 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,15 @@ toolit create-vscode-tasks-json ``` NOTE: THIS WILL OVERWRITE YOUR EXISTING `.vscode/tasks.json` FILE IF IT EXISTS! +### List parameter inputs in VS Code tasks + +VS Code task inputs do not support native list pickers. For tool parameters typed as `list[str]`, `list[int]`, or `list[Enum]`, Toolit generates a `promptString` input and expects comma-separated values. + +Examples: +- `list[str]`: `alice, bob, charlie` +- `list[int]`: `1, 2, 3` +- `list[MyEnum]`: accepts both enum names and enum values, for example `OPTION_A, Option b` + ## Chaining Commands You can chain multiple using the `@sequential_group_of_tools` and `@parallel_group_of_tools` decorators to create more complex workflows. Functions decorated with these decorators should always return a list of callable functions. diff --git a/pyproject.toml b/pyproject.toml index bd87b80..8067f29 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "toolit" -version = "0.6.0" +version = "0.7.0" description = "MCP Server, Typer CLI and vscode tasks in one, provides an easy way to configure your own DevTools and python scripts in a project." readme = "README.md" requires-python = ">=3.10" @@ -51,12 +51,12 @@ dev = [ mcp = ["mcp[cli]"] [project.scripts] -toolit = "toolit.cli:app" - +toolit = "toolit.cli:main" + [build-system] requires = ["setuptools>=61.0"] build-backend = "setuptools.build_meta" - + [tool.setuptools] packages = ["toolit"] diff --git a/tests/cli_test.py b/tests/cli_test.py index af70d6b..a1c6200 100644 --- a/tests/cli_test.py +++ b/tests/cli_test.py @@ -1,4 +1,8 @@ import toolit.create_apps_and_register as create_apps_and_register +import toolit.__main__ as main_module +import toolit.cli as cli_module +import enum +import pytest from typer.testing import CliRunner @@ -31,3 +35,100 @@ def test_cli_command_is_registered() -> None: assert "a-new-command-registered" in result.stdout +def test_module_main_invokes_app(monkeypatch: pytest.MonkeyPatch) -> None: + called: dict[str, bool] = {"value": False} + + def fake_main() -> None: + called["value"] = True + + monkeypatch.setattr(main_module, "main", fake_main) + main_module.main() + assert called["value"] + + +def test_cli_list_str_input_is_converted_from_comma_separated_tokens() -> None: + captured: dict[str, list[str]] = {} + + def list_str_tool(items: list[str]) -> None: + captured["items"] = items + + create_apps_and_register.register_command( + list_str_tool, + name="dummycommand", + ) + + runner = CliRunner() + result = runner.invoke( + create_apps_and_register.app, + ["dummycommand", "alpha", "beta", "gamma"], + ) + + assert result.exit_code == 0, f"CLI invocation failed with output: {result.output}, captured: {captured}" + assert captured["items"] == ["alpha", "beta", "gamma"] + + +def test_cli_list_int_input_is_converted_from_comma_separated_tokens() -> None: + captured: dict[str, list[int]] = {} + + def list_int_tool(numbers: list[int]) -> None: + captured["numbers"] = numbers + + create_apps_and_register.register_command( + list_int_tool, + name="dummycommand", + ) + + runner = CliRunner() + result = runner.invoke( + create_apps_and_register.app, + ["dummycommand", "1", "2", "3"], + ) + + assert result.exit_code == 0, f"CLI invocation failed with output: {result.output}, captured: {captured}" + assert captured["numbers"] == [1, 2, 3] + + +def test_cli_list_enum_input_accepts_enum_names_and_values() -> None: + captured: dict[str, list[object]] = {} + + class TestLevel(enum.Enum): + LOW = "low" + HIGH = "high" + + def list_enum_tool(levels: list[TestLevel]) -> None: + captured["levels"] = levels + + create_apps_and_register.register_command( + list_enum_tool, + name="dummycommand", + ) + + runner = CliRunner() + result = runner.invoke( + create_apps_and_register.app, + ["dummycommand", "low", "high"], + ) + + assert result.exit_code == 0, f"CLI invocation failed with output: {result.output}, captured: {captured}" + assert captured["levels"] == [TestLevel.LOW, TestLevel.HIGH] + + +def test_cli_optional_list_omitted_preserves_none_default() -> None: + captured: dict[str, list[str] | None] = {} + + def optional_list_tool(values: list[str] | None = None) -> None: + captured["values"] = values + + create_apps_and_register.register_command( + optional_list_tool, + name="test-optional-list-omitted-preserves-none-default", + ) + + runner = CliRunner() + result = runner.invoke( + create_apps_and_register.app, + ["test-optional-list-omitted-preserves-none-default"], + ) + + assert result.exit_code == 0, f"CLI invocation failed with output: {result.output}, captured: {captured}" + assert captured["values"] is None diff --git a/tests/create_tasks_json_test.py b/tests/create_tasks_json_test.py index 48b64f8..cd67ab0 100644 --- a/tests/create_tasks_json_test.py +++ b/tests/create_tasks_json_test.py @@ -1,10 +1,29 @@ """Tests for create_tasks_json type annotation handling.""" -import pytest +import enum import inspect -from toolit.create_tasks_json import TaskJsonBuilder, _annotation_to_string # noqa: PLC2701 from typing import Any, Optional +import pytest + +from toolit.create_tasks_json import TaskJsonBuilder, _annotation_to_string # noqa: PLC2701 + + +class Color(enum.Enum): + """Test enum for colors.""" + + RED = "red" + GREEN = "green" + BLUE = "blue" + + +class Environment(str, enum.Enum): + """Test enum for environments.""" + + DEV = "development" + STAGING = "staging" + PROD = "production" + def _tool_with_pep604_optional(input_dataset_name: str | None = None) -> None: """Tool with a PEP 604 optional argument.""" @@ -22,6 +41,37 @@ def _tool_with_multiple_params_missing_hint(name, value: str) -> None: # type: """Tool where only the first parameter is missing a type hint.""" +def _tool_with_enum_param(color: Color) -> None: # noqa: ARG001 + """Tool with an enum parameter.""" + + +def _tool_with_enum_param_with_default(color: Color = Color.RED) -> None: # noqa: ARG001 + """Tool with an enum parameter that has a default value.""" + + +def _tool_with_multiple_enum_params( + color: Color = Color.RED, # noqa: ARG001 + environment: Environment | None = None, # noqa: ARG001 +) -> None: + """Tool with multiple enum parameters.""" + + +def _tool_with_list_str_param(items: list[str]) -> None: # noqa: ARG001 + """Tool with a list[str] parameter.""" + + +def _tool_with_list_int_param(numbers: list[int] = [1, 2, 3]) -> None: # noqa: B006, ARG001 + """Tool with a list[int] parameter and list default.""" + + +def _tool_with_list_enum_param(colors: list[Color] = [Color.RED, Color.GREEN]) -> None: # noqa: B006, ARG001 + """Tool with a list[Enum] parameter and list default.""" + + +def _tool_with_optional_list_param(values: list[str] | None = None) -> None: # noqa: ARG001 + """Tool with an optional list parameter.""" + + def test_create_args_for_tool_handles_pep604_optional() -> None: """Ensure str | None annotations do not crash and are rendered in descriptions.""" builder = TaskJsonBuilder() @@ -84,3 +134,93 @@ def test_create_args_for_tool_raises_on_first_missing_hint_in_mixed_params() -> with pytest.raises(ValueError, match="Parameter 'name' in function '_tool_with_multiple_params_missing_hint'"): builder._create_args_for_tool(_tool_with_multiple_params_missing_hint) + + +def test_create_args_for_tool_enum_creates_picklist_input() -> None: + """Ensure enum parameters create pickString input type in tasks.json.""" + builder = TaskJsonBuilder() + + args = builder._create_args_for_tool(_tool_with_enum_param) # noqa: SLF001 + + assert args == ['"${input:_tool_with_enum_param_color}"'] + assert builder.inputs[0]["type"] == "pickString" + assert builder.inputs[0]["options"] == ["red", "green", "blue"] + + +def test_create_args_for_tool_enum_sets_default_to_first_choice() -> None: + """Ensure enum parameters default to the first enum value when no default provided.""" + builder = TaskJsonBuilder() + + args = builder._create_args_for_tool(_tool_with_enum_param) # noqa: SLF001 + + assert builder.inputs[0]["default"] == "red" + + +def test_create_args_for_tool_enum_respects_provided_default() -> None: + """Ensure enum parameters use the provided default value if specified.""" + builder = TaskJsonBuilder() + + args = builder._create_args_for_tool(_tool_with_enum_param_with_default) # noqa: SLF001 + + assert builder.inputs[0]["default"] == Color.RED.value + + +def test_create_args_for_tool_multiple_enum_params() -> None: + """Ensure multiple enum parameters are all correctly handled in tasks.json.""" + builder = TaskJsonBuilder() + + args = builder._create_args_for_tool(_tool_with_multiple_enum_params) # noqa: SLF001 + + assert len(builder.inputs) == 2 + # First input (color) + assert builder.inputs[0]["type"] == "pickString" + assert builder.inputs[0]["options"] == ["red", "green", "blue"] + assert builder.inputs[0]["default"] == "red" + # Second input (environment) + assert builder.inputs[1]["type"] == "pickString" + assert builder.inputs[1]["options"] == ["development", "staging", "production"] + assert builder.inputs[1]["default"] == "development" + + +def test_create_args_for_tool_list_str_uses_promptstring_and_guidance() -> None: + """Ensure list[str] parameters use promptString with comma-separated guidance.""" + builder = TaskJsonBuilder() + + args = builder._create_args_for_tool(_tool_with_list_str_param) # noqa: SLF001 + + assert args == ['"${input:_tool_with_list_str_param_items}"'] + assert builder.inputs[0]["type"] == "promptString" + assert builder.inputs[0]["description"] == "Enter comma-separated text values for items (e.g. alpha, beta, gamma)" + assert builder.inputs[0]["default"] == "" + + +def test_create_args_for_tool_list_int_serializes_default_values() -> None: + """Ensure list[int] defaults are serialized to comma-separated text.""" + builder = TaskJsonBuilder() + + builder._create_args_for_tool(_tool_with_list_int_param) # noqa: SLF001 + + assert builder.inputs[0]["description"] == "Enter comma-separated integer values for numbers (e.g. 1, 2, 3)" + assert builder.inputs[0]["default"] == "1, 2, 3" + + +def test_create_args_for_tool_list_enum_serializes_default_values() -> None: + """Ensure list[Enum] defaults are serialized using enum values.""" + builder = TaskJsonBuilder() + + builder._create_args_for_tool(_tool_with_list_enum_param) # noqa: SLF001 + + assert ( + builder.inputs[0]["description"] + == "Enter comma-separated enum values for colors. Accepted values: [red, green, blue]. You can also use enum member names." + ) + assert builder.inputs[0]["default"] == "red, green" + + +def test_create_args_for_tool_optional_list_keeps_none_default() -> None: + """Ensure optional list parameters preserve None as default.""" + builder = TaskJsonBuilder() + + builder._create_args_for_tool(_tool_with_optional_list_param) # noqa: SLF001 + + assert builder.inputs[0]["default"] is None diff --git a/toolit/__main__.py b/toolit/__main__.py new file mode 100644 index 0000000..3de209b --- /dev/null +++ b/toolit/__main__.py @@ -0,0 +1,6 @@ +"""Module entry point to support `python -m toolit`.""" + +from toolit.cli import main + +if __name__ == "__main__": + main() diff --git a/toolit/cli.py b/toolit/cli.py index 12bb43e..86ddb23 100644 --- a/toolit/cli.py +++ b/toolit/cli.py @@ -4,6 +4,11 @@ register_all_tools_from_folder_and_plugin() -if __name__ == "__main__": - # Run the typer app + +def main() -> None: + """Register tools and run the Typer application.""" app() + + +if __name__ == "__main__": + main() diff --git a/toolit/create_tasks_json.py b/toolit/create_tasks_json.py index fea99db..c1d3368 100644 --- a/toolit/create_tasks_json.py +++ b/toolit/create_tasks_json.py @@ -22,6 +22,21 @@ output_file_path: pathlib.Path = pathlib.Path() / ".vscode" / "tasks.json" +def serialize_list_default(default_value: Any) -> str | None: # noqa: ANN401 + """Serialize list defaults to comma-separated text using enum values when needed.""" + if default_value is None: + return None + if isinstance(default_value, list): + rendered_items: list[str] = [] + for item in default_value: + if isinstance(item, enum.Enum): + rendered_items.append(str(item.value)) + else: + rendered_items.append(str(item)) + return ", ".join(rendered_items) + return str(default_value) + + def create_vscode_tasks_json() -> None: """Create a tasks.json file based on the tools discovered in the project.""" typer.echo(f"Creating tasks.json at {output_file_path}") @@ -54,6 +69,61 @@ def _is_bool(annotation: Any) -> bool: # noqa: ANN401 return annotation is bool +def _unwrap_union_annotations(annotation: Any) -> list[Any]: # noqa: ANN401 + """Return union members for `X | Y` / `Union[X, Y]`, or the annotation itself.""" + origin = get_origin(annotation) + args = get_args(annotation) + union_type = getattr(types, "UnionType", None) + if origin is Union or (union_type is not None and origin is union_type): + return list(args) + return [annotation] + + +def _extract_enum_type(annotation: Any) -> type[enum.Enum] | None: # noqa: ANN401 + """Extract enum type from an annotation, including optional/union wrappers.""" + for candidate in _unwrap_union_annotations(annotation): + if candidate in {None, type(None)}: + continue + if _is_enum(candidate): + return candidate + return None + + +def _contains_bool(annotation: Any) -> bool: # noqa: ANN401 + """Check whether an annotation contains bool directly or via union/optional.""" + return any(_is_bool(candidate) for candidate in _unwrap_union_annotations(annotation)) + + +def _extract_list_item_type(annotation: Any) -> Any | None: # noqa: ANN401 + """Extract list item type from an annotation, including optional/union wrappers.""" + for candidate in _unwrap_union_annotations(annotation): + if candidate in {None, type(None)}: + continue + origin = get_origin(candidate) + if origin is list: + args = get_args(candidate) + if args: + return args[0] + return str + return None + + +def _build_list_description(param_name: str, list_item_type: Any) -> str: # noqa: ANN401 + """Build a type-specific description for list prompt inputs.""" + if list_item_type is str: + return f"Enter comma-separated text values for {param_name} (e.g. alpha, beta, gamma)" + if list_item_type is int: + return f"Enter comma-separated integer values for {param_name} (e.g. 1, 2, 3)" + if _is_enum(list_item_type): + accepted_values = ", ".join(str(member.value) for member in list_item_type) + return ( + f"Enter comma-separated enum values for {param_name}. " + f"Accepted values: [{accepted_values}]. You can also use enum member names." + ) + item_type_name = _annotation_to_string(list_item_type) + return f"Enter comma-separated values for {param_name} ({item_type_name})" + + def _annotation_to_string(annotation: Any) -> str: # noqa: ANN401 """Convert Python type annotations to readable strings.""" result: str = "" @@ -105,6 +175,41 @@ def __init__(self) -> None: self.input_id_map: dict[tuple[str, str], str] = {} self.tasks: list[dict[str, Any]] = [] + def _build_input_metadata(self, param: inspect.Parameter) -> tuple[str, dict[str, Any], str, Any]: + """Build VS Code input metadata for a function parameter.""" + annotation = param.annotation + input_type: str = "promptString" + input_options: dict[str, Any] = {} + description: str = f"Enter value for {param.name} ({_annotation_to_string(annotation)})" + default_value: Any = "" if param.default == inspect.Parameter.empty else param.default + + list_item_type = _extract_list_item_type(annotation) + if list_item_type is not None: + description = _build_list_description(param.name, list_item_type) + default_value = "" if param.default == inspect.Parameter.empty else serialize_list_default(param.default) + return input_type, input_options, description, default_value + + enum_type = _extract_enum_type(annotation) + if enum_type is not None: + input_type = "pickString" + choices: list[str] = [e.value for e in enum_type] + input_options["options"] = choices + if param.default == inspect.Parameter.empty or param.default is None: + default_value = choices[0] + else: + default_value = param.default.value + return input_type, input_options, description, default_value + + if _contains_bool(annotation): + input_type = "pickString" + input_options["options"] = ["True", "False"] + if param.default == inspect.Parameter.empty or param.default is None: + default_value = "False" + else: + default_value = str(param.default) + + return input_type, input_options, description, default_value + def _create_args_for_tool(self, tool: FunctionType) -> list[str]: """Create argument list and input entries for a given tool.""" sig = inspect.signature(tool) @@ -124,20 +229,7 @@ def _create_args_for_tool(self, tool: FunctionType) -> list[str]: raise ValueError( msg, ) - input_type: str = "promptString" - input_options: dict[str, Any] = {} - description: str = f"Enter value for {param.name} ({_annotation_to_string(annotation)})" - default_value: Any = "" if param.default == inspect.Parameter.empty else param.default - - if _is_enum(annotation): - input_type = "pickString" - choices: list[str] = [e.value for e in annotation] # type: ignore[misc] - input_options["options"] = choices - default_value = choices[0] if param.default == inspect.Parameter.empty else param.default.value - elif _is_bool(annotation): - input_type = "pickString" - input_options["options"] = ["True", "False"] - default_value = "False" if param.default == inspect.Parameter.empty else str(param.default) + input_type, input_options, description, default_value = self._build_input_metadata(param) input_entry: dict[str, Any] = { "id": input_id, diff --git a/uv.lock b/uv.lock index f674a09..f016acc 100644 --- a/uv.lock +++ b/uv.lock @@ -634,7 +634,7 @@ wheels = [ [[package]] name = "toolit" -version = "0.6.0" +version = "0.7.0" source = { editable = "." } dependencies = [ { name = "toml" },