From 4565082ec7a081f40c52bc6ba9e6c70d1e4f6a72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20M=C3=B8ldrup?= <14809761+martinmoldrup@users.noreply.github.com> Date: Wed, 29 Apr 2026 18:27:24 +0200 Subject: [PATCH 1/9] Add tests and new invocation options Co-authored-by: Copilot --- CHANGELOG.md | 1 + pyproject.toml | 6 +-- tests/cli_test.py | 30 ++++++++++++ tests/create_tasks_json_test.py | 84 ++++++++++++++++++++++++++++++++- toolit/cli.py | 9 ++-- 5 files changed, 122 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 00a2ac6..5268370 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to this project will be documented in this file. ## [0.7.0] - 27-06-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. - 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/pyproject.toml b/pyproject.toml index bd87b80..0912d0f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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..cf091b5 100644 --- a/tests/cli_test.py +++ b/tests/cli_test.py @@ -1,4 +1,7 @@ import toolit.create_apps_and_register as create_apps_and_register +import toolit.__main__ as main_module +import toolit.cli as cli_module +import pytest from typer.testing import CliRunner @@ -31,3 +34,30 @@ 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_main_registers_tools_and_runs_app(monkeypatch: pytest.MonkeyPatch) -> None: + called: dict[str, bool] = {"register": False, "app": False} + + def fake_register() -> None: + called["register"] = True + + def fake_app() -> None: + called["app"] = True + + monkeypatch.setattr(cli_module, "register_all_tools_from_folder_and_plugin", fake_register) + monkeypatch.setattr(cli_module, "app", fake_app) + cli_module.main() + assert called["register"] + assert called["app"] + + diff --git a/tests/create_tasks_json_test.py b/tests/create_tasks_json_test.py index 48b64f8..39b695b 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,21 @@ 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, # noqa: ARG001 + environment: Environment, # noqa: ARG001 +) -> None: + """Tool with multiple enum parameters.""" + + 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 +118,49 @@ 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" diff --git a/toolit/cli.py b/toolit/cli.py index 12bb43e..2b609e8 100644 --- a/toolit/cli.py +++ b/toolit/cli.py @@ -2,8 +2,11 @@ from toolit.create_apps_and_register import app from toolit.register_all_tool_and_plugins import register_all_tools_from_folder_and_plugin -register_all_tools_from_folder_and_plugin() -if __name__ == "__main__": - # Run the typer app +def main() -> None: + """Register tools and run the Typer application.""" + register_all_tools_from_folder_and_plugin() app() + +if __name__ == "__main__": + main() From 1d4610e9b67e5bbf84e6e031c3b515a3c7c02a05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20M=C3=B8ldrup?= <14809761+martinmoldrup@users.noreply.github.com> Date: Wed, 29 Apr 2026 18:34:45 +0200 Subject: [PATCH 2/9] Fix issue with optional enums Co-authored-by: Copilot --- tests/create_tasks_json_test.py | 4 ++-- toolit/create_tasks_json.py | 42 +++++++++++++++++++++++++++++---- 2 files changed, 39 insertions(+), 7 deletions(-) diff --git a/tests/create_tasks_json_test.py b/tests/create_tasks_json_test.py index 39b695b..2eb7bf1 100644 --- a/tests/create_tasks_json_test.py +++ b/tests/create_tasks_json_test.py @@ -50,8 +50,8 @@ def _tool_with_enum_param_with_default(color: Color = Color.RED) -> None: # noq def _tool_with_multiple_enum_params( - color: Color, # noqa: ARG001 - environment: Environment, # noqa: ARG001 + color: Color = Color.RED, # noqa: ARG001 + environment: Environment | None = None, # noqa: ARG001 ) -> None: """Tool with multiple enum parameters.""" diff --git a/toolit/create_tasks_json.py b/toolit/create_tasks_json.py index fea99db..7d251e1 100644 --- a/toolit/create_tasks_json.py +++ b/toolit/create_tasks_json.py @@ -54,6 +54,31 @@ 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 _annotation_to_string(annotation: Any) -> str: # noqa: ANN401 """Convert Python type annotations to readable strings.""" result: str = "" @@ -129,15 +154,22 @@ def _create_args_for_tool(self, tool: FunctionType) -> list[str]: 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): + enum_type = _extract_enum_type(annotation) + if enum_type is not None: input_type = "pickString" - choices: list[str] = [e.value for e in annotation] # type: ignore[misc] + choices: list[str] = [e.value for e in enum_type] input_options["options"] = choices - default_value = choices[0] if param.default == inspect.Parameter.empty else param.default.value - elif _is_bool(annotation): + if param.default == inspect.Parameter.empty or param.default is None: + default_value = choices[0] + else: + default_value = param.default.value + elif _contains_bool(annotation): input_type = "pickString" input_options["options"] = ["True", "False"] - default_value = "False" if param.default == inspect.Parameter.empty else str(param.default) + if param.default == inspect.Parameter.empty or param.default is None: + default_value = "False" + else: + default_value = str(param.default) input_entry: dict[str, Any] = { "id": input_id, From 1db8d4f2efeb25ac155b42749d089cc7026bbda5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20M=C3=B8ldrup?= <14809761+martinmoldrup@users.noreply.github.com> Date: Wed, 29 Apr 2026 20:34:31 +0200 Subject: [PATCH 3/9] Fix regression issue with tool discovery Co-authored-by: Copilot --- tests/cli_test.py | 1 + toolit/cli.py | 17 ++++++++++++++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/tests/cli_test.py b/tests/cli_test.py index cf091b5..abd00e0 100644 --- a/tests/cli_test.py +++ b/tests/cli_test.py @@ -54,6 +54,7 @@ def fake_register() -> None: def fake_app() -> None: called["app"] = True + monkeypatch.setattr(cli_module, "_registration_done", False) monkeypatch.setattr(cli_module, "register_all_tools_from_folder_and_plugin", fake_register) monkeypatch.setattr(cli_module, "app", fake_app) cli_module.main() diff --git a/toolit/cli.py b/toolit/cli.py index 2b609e8..d377558 100644 --- a/toolit/cli.py +++ b/toolit/cli.py @@ -2,11 +2,26 @@ from toolit.create_apps_and_register import app from toolit.register_all_tool_and_plugins import register_all_tools_from_folder_and_plugin +_registration_done: bool = False + + +def _ensure_tools_registered() -> None: + """Register tools once for both app and module/script entrypoints.""" + global _registration_done + if _registration_done: + return + register_all_tools_from_folder_and_plugin() + _registration_done = True + def main() -> None: """Register tools and run the Typer application.""" - register_all_tools_from_folder_and_plugin() + _ensure_tools_registered() app() + +# Keep compatibility with existing installed console scripts pointing to `toolit.cli:app`. +_ensure_tools_registered() + if __name__ == "__main__": main() From 390f5a915e237971eea7e9a3f7ba83c940f59226 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20M=C3=B8ldrup?= <14809761+martinmoldrup@users.noreply.github.com> Date: Wed, 29 Apr 2026 20:42:44 +0200 Subject: [PATCH 4/9] Improve support for list parameters and handle it in vscode task creation Co-authored-by: Copilot --- CHANGELOG.md | 1 + README.md | 9 +++ pyproject.toml | 2 +- tests/cli_test.py | 69 +++++++++++++++++++ tests/create_tasks_json_test.py | 60 +++++++++++++++++ toolit/__main__.py | 6 ++ toolit/create_apps_and_register.py | 105 ++++++++++++++++++++++++++++- toolit/create_tasks_json.py | 102 ++++++++++++++++++++++------ 8 files changed, 330 insertions(+), 24 deletions(-) create mode 100644 toolit/__main__.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 5268370..3b0b3a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to this project will be documented in this file. ## [0.7.0] - 27-06-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. - 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). 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 0912d0f..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" diff --git a/tests/cli_test.py b/tests/cli_test.py index abd00e0..24d9988 100644 --- a/tests/cli_test.py +++ b/tests/cli_test.py @@ -1,6 +1,7 @@ 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 @@ -62,3 +63,71 @@ def fake_app() -> None: assert called["app"] +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="test-list-str-input-is-converted-from-comma-separated-tokens", + ) + + runner = CliRunner() + result = runner.invoke( + create_apps_and_register.app, + ["test-list-str-input-is-converted-from-comma-separated-tokens", "alpha, beta, gamma"], + ) + + assert result.exit_code == 0 + 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="test-list-int-input-is-converted-from-comma-separated-tokens", + ) + + runner = CliRunner() + result = runner.invoke( + create_apps_and_register.app, + ["test-list-int-input-is-converted-from-comma-separated-tokens", "1, 2, 3"], + ) + + assert result.exit_code == 0 + 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="test-list-enum-input-accepts-enum-names-and-values", + ) + + runner = CliRunner() + result = runner.invoke( + create_apps_and_register.app, + ["test-list-enum-input-accepts-enum-names-and-values", "LOW, high"], + ) + + assert result.exit_code == 0 + assert captured["levels"] == [TestLevel.LOW, TestLevel.HIGH] + + + diff --git a/tests/create_tasks_json_test.py b/tests/create_tasks_json_test.py index 2eb7bf1..762f942 100644 --- a/tests/create_tasks_json_test.py +++ b/tests/create_tasks_json_test.py @@ -56,6 +56,22 @@ def _tool_with_multiple_enum_params( """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() @@ -164,3 +180,47 @@ def test_create_args_for_tool_multiple_enum_params() -> None: 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/create_apps_and_register.py b/toolit/create_apps_and_register.py index 4f4c46b..67727c4 100644 --- a/toolit/create_apps_and_register.py +++ b/toolit/create_apps_and_register.py @@ -2,9 +2,12 @@ from __future__ import annotations +import enum import typer +import types +import inspect from collections.abc import Callable -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Union, get_args, get_origin if TYPE_CHECKING: from mcp.server.fastmcp import FastMCP @@ -29,6 +32,103 @@ def initialize() -> None: """Welcome to the Toolit CLI.""" +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_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 _coerce_enum_item(enum_type: type[enum.Enum], value: str) -> enum.Enum: + """Convert a token to an enum member by accepting either name or value.""" + if value in enum_type.__members__: + return enum_type[value] + for member in enum_type: + if str(member.value) == value: + return member + msg = ( + f"Invalid enum value '{value}' for {enum_type.__name__}. " + f"Use one of: {', '.join(enum_type.__members__.keys())} or matching enum values." + ) + raise typer.BadParameter(msg) + + +def _coerce_list_items(raw_value: str | None, item_type: Any) -> list[Any] | None: # noqa: ANN401 + """Convert a comma-separated string into a typed list for list-annotated parameters.""" + if raw_value is None: + return None + tokens = [token.strip() for token in raw_value.split(",") if token.strip()] + if item_type is int: + converted_ints: list[int] = [] + for token in tokens: + try: + converted_ints.append(int(token)) + except ValueError as exc: + msg = f"Invalid integer value '{token}' in list input '{raw_value}'." + raise typer.BadParameter(msg) from exc + return converted_ints + if isinstance(item_type, type) and issubclass(item_type, enum.Enum): + return [_coerce_enum_item(item_type, token) for token in tokens] + return tokens + + +def _build_cli_command(command_func: Callable[..., Any]) -> Callable[..., Any]: + """Build a CLI callback that accepts list params as comma-separated strings.""" + signature = inspect.signature(command_func) + list_item_types: dict[str, Any] = {} + cli_parameters: list[inspect.Parameter] = [] + + for parameter in signature.parameters.values(): + list_item_type = _extract_list_item_type(parameter.annotation) + if list_item_type is None: + cli_parameters.append(parameter) + continue + + list_item_types[parameter.name] = list_item_type + replacement_default = parameter.default + if isinstance(parameter.default, list): + replacement_default = ", ".join(str(item.value) if isinstance(item, enum.Enum) else str(item) for item in parameter.default) + + cli_parameters.append(parameter.replace(annotation=str, default=replacement_default)) + + if not list_item_types: + return command_func + + cli_signature = signature.replace(parameters=cli_parameters) + + def _command_wrapper(*args: Any, **kwargs: Any) -> Any: + bound = cli_signature.bind(*args, **kwargs) + converted_arguments: dict[str, Any] = dict(bound.arguments) + for name, item_type in list_item_types.items(): + raw_value = converted_arguments.get(name) + if raw_value is None: + continue + converted_arguments[name] = _coerce_list_items(str(raw_value), item_type) + return command_func(**converted_arguments) + + _command_wrapper.__name__ = command_func.__name__ + _command_wrapper.__doc__ = command_func.__doc__ + _command_wrapper.__signature__ = cli_signature # type: ignore[attr-defined] + return _command_wrapper + + def register_command( command_func: Callable[..., Any], name: str | None = None, @@ -38,6 +138,7 @@ def register_command( if not callable(command_func): msg = f"Command function {command_func} is not callable." raise TypeError(msg) - app.command(name=name, rich_help_panel=rich_help_panel)(command_func) + cli_command = _build_cli_command(command_func) + app.command(name=name, rich_help_panel=rich_help_panel)(cli_command) if mcp is not None: mcp.tool(name)(command_func) diff --git a/toolit/create_tasks_json.py b/toolit/create_tasks_json.py index 7d251e1..68e4cb5 100644 --- a/toolit/create_tasks_json.py +++ b/toolit/create_tasks_json.py @@ -79,6 +79,51 @@ def _contains_bool(annotation: Any) -> bool: # noqa: ANN401 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 Any + return None + + +def _serialize_list_default(default_value: Any) -> str | None: # noqa: ANN401 + """Serialize Python list defaults to comma-separated values for VS Code prompts.""" + 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 _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 = "" @@ -130,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) @@ -149,27 +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 - - 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 - elif _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) + input_type, input_options, description, default_value = self._build_input_metadata(param) input_entry: dict[str, Any] = { "id": input_id, From 907e18470778d2f4a1e7dd90ab121fbd9688de6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20M=C3=B8ldrup?= <14809761+martinmoldrup@users.noreply.github.com> Date: Wed, 29 Apr 2026 20:58:42 +0200 Subject: [PATCH 5/9] Add support for optional list parameters and serialization utility Co-authored-by: Copilot --- tests/cli_test.py | 42 ++++++++++++++++++++++++++++++ toolit/cli.py | 15 +---------- toolit/create_apps_and_register.py | 9 ++++--- toolit/create_tasks_json.py | 20 +++----------- toolit/list_serialization.py | 21 +++++++++++++++ 5 files changed, 72 insertions(+), 35 deletions(-) create mode 100644 toolit/list_serialization.py diff --git a/tests/cli_test.py b/tests/cli_test.py index 24d9988..8ef5d66 100644 --- a/tests/cli_test.py +++ b/tests/cli_test.py @@ -130,4 +130,46 @@ def list_enum_tool(levels: list[TestLevel]) -> None: 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 + assert captured["values"] is None + + +def test_cli_optional_list_comma_only_input_becomes_empty_list() -> 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-comma-only-input-becomes-empty-list", + ) + + runner = CliRunner() + result = runner.invoke( + create_apps_and_register.app, + ["test-optional-list-comma-only-input-becomes-empty-list", "--values", ","], + ) + + assert result.exit_code == 0 + assert captured["values"] == [] + + diff --git a/toolit/cli.py b/toolit/cli.py index d377558..86ddb23 100644 --- a/toolit/cli.py +++ b/toolit/cli.py @@ -2,26 +2,13 @@ from toolit.create_apps_and_register import app from toolit.register_all_tool_and_plugins import register_all_tools_from_folder_and_plugin -_registration_done: bool = False - - -def _ensure_tools_registered() -> None: - """Register tools once for both app and module/script entrypoints.""" - global _registration_done - if _registration_done: - return - register_all_tools_from_folder_and_plugin() - _registration_done = True +register_all_tools_from_folder_and_plugin() def main() -> None: """Register tools and run the Typer application.""" - _ensure_tools_registered() app() -# Keep compatibility with existing installed console scripts pointing to `toolit.cli:app`. -_ensure_tools_registered() - if __name__ == "__main__": main() diff --git a/toolit/create_apps_and_register.py b/toolit/create_apps_and_register.py index 67727c4..0331516 100644 --- a/toolit/create_apps_and_register.py +++ b/toolit/create_apps_and_register.py @@ -8,6 +8,7 @@ import inspect from collections.abc import Callable from typing import TYPE_CHECKING, Any, Union, get_args, get_origin +from toolit.list_serialization import serialize_list_default if TYPE_CHECKING: from mcp.server.fastmcp import FastMCP @@ -70,10 +71,8 @@ def _coerce_enum_item(enum_type: type[enum.Enum], value: str) -> enum.Enum: raise typer.BadParameter(msg) -def _coerce_list_items(raw_value: str | None, item_type: Any) -> list[Any] | None: # noqa: ANN401 +def _coerce_list_items(raw_value: str, item_type: Any) -> list[Any]: # noqa: ANN401 """Convert a comma-separated string into a typed list for list-annotated parameters.""" - if raw_value is None: - return None tokens = [token.strip() for token in raw_value.split(",") if token.strip()] if item_type is int: converted_ints: list[int] = [] @@ -104,7 +103,7 @@ def _build_cli_command(command_func: Callable[..., Any]) -> Callable[..., Any]: list_item_types[parameter.name] = list_item_type replacement_default = parameter.default if isinstance(parameter.default, list): - replacement_default = ", ".join(str(item.value) if isinstance(item, enum.Enum) else str(item) for item in parameter.default) + replacement_default = serialize_list_default(parameter.default) cli_parameters.append(parameter.replace(annotation=str, default=replacement_default)) @@ -119,6 +118,8 @@ def _command_wrapper(*args: Any, **kwargs: Any) -> Any: for name, item_type in list_item_types.items(): raw_value = converted_arguments.get(name) if raw_value is None: + # Preserve omitted optional list arguments as None. + converted_arguments[name] = None continue converted_arguments[name] = _coerce_list_items(str(raw_value), item_type) return command_func(**converted_arguments) diff --git a/toolit/create_tasks_json.py b/toolit/create_tasks_json.py index 68e4cb5..9eb59fb 100644 --- a/toolit/create_tasks_json.py +++ b/toolit/create_tasks_json.py @@ -15,6 +15,7 @@ ) from toolit.config import load_devtools_folder from toolit.constants import ToolitTypesEnum +from toolit.list_serialization import serialize_list_default from types import FunctionType from typing import Any, Union, get_args, get_origin @@ -89,25 +90,10 @@ def _extract_list_item_type(annotation: Any) -> Any | None: # noqa: ANN401 args = get_args(candidate) if args: return args[0] - return Any + return str return None -def _serialize_list_default(default_value: Any) -> str | None: # noqa: ANN401 - """Serialize Python list defaults to comma-separated values for VS Code prompts.""" - 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 _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: @@ -186,7 +172,7 @@ def _build_input_metadata(self, param: inspect.Parameter) -> tuple[str, dict[str 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) + 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) diff --git a/toolit/list_serialization.py b/toolit/list_serialization.py new file mode 100644 index 0000000..d393467 --- /dev/null +++ b/toolit/list_serialization.py @@ -0,0 +1,21 @@ +"""Utilities for serializing list defaults for CLI/task inputs.""" + +from __future__ import annotations + +import enum +from typing import Any + + +def serialize_list_default(default_value: Any) -> str: # noqa: ANN401 + """Serialize list defaults to comma-separated text using enum values when needed.""" + if default_value is None: + return "" + 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) \ No newline at end of file From de99ed5767371d39cea6ad125fef736e2ef0bf6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20M=C3=B8ldrup?= <14809761+martinmoldrup@users.noreply.github.com> Date: Wed, 29 Apr 2026 21:08:31 +0200 Subject: [PATCH 6/9] Fixes to tests and linting Co-authored-by: Copilot --- CHANGELOG.md | 2 +- tests/cli_test.py | 17 ----------------- tests/create_tasks_json_test.py | 2 +- toolit/create_apps_and_register.py | 28 +++++++++++++++++----------- toolit/list_serialization.py | 6 +++--- uv.lock | 2 +- 6 files changed, 23 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b0b3a1..38700b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,7 @@ 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. diff --git a/tests/cli_test.py b/tests/cli_test.py index 8ef5d66..de41933 100644 --- a/tests/cli_test.py +++ b/tests/cli_test.py @@ -46,23 +46,6 @@ def fake_main() -> None: assert called["value"] -def test_cli_main_registers_tools_and_runs_app(monkeypatch: pytest.MonkeyPatch) -> None: - called: dict[str, bool] = {"register": False, "app": False} - - def fake_register() -> None: - called["register"] = True - - def fake_app() -> None: - called["app"] = True - - monkeypatch.setattr(cli_module, "_registration_done", False) - monkeypatch.setattr(cli_module, "register_all_tools_from_folder_and_plugin", fake_register) - monkeypatch.setattr(cli_module, "app", fake_app) - cli_module.main() - assert called["register"] - assert called["app"] - - def test_cli_list_str_input_is_converted_from_comma_separated_tokens() -> None: captured: dict[str, list[str]] = {} diff --git a/tests/create_tasks_json_test.py b/tests/create_tasks_json_test.py index 762f942..cd67ab0 100644 --- a/tests/create_tasks_json_test.py +++ b/tests/create_tasks_json_test.py @@ -212,7 +212,7 @@ def test_create_args_for_tool_list_enum_serializes_default_values() -> None: assert ( builder.inputs[0]["description"] - == "Enter comma-separated enum values for colors. Accepted values: red, green, blue. You can also use enum member names." + == "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" diff --git a/toolit/create_apps_and_register.py b/toolit/create_apps_and_register.py index 0331516..a8257c8 100644 --- a/toolit/create_apps_and_register.py +++ b/toolit/create_apps_and_register.py @@ -7,16 +7,18 @@ import types import inspect from collections.abc import Callable -from typing import TYPE_CHECKING, Any, Union, get_args, get_origin from toolit.list_serialization import serialize_list_default +from typing import TYPE_CHECKING, Any, Union, get_args, get_origin if TYPE_CHECKING: - from mcp.server.fastmcp import FastMCP + from mcp.server.fastmcp import FastMCP # type: ignore[import] + _has_mcp: bool = True else: # Make MCP optional try: from mcp.server.fastmcp import FastMCP + _has_mcp = True except ImportError: FastMCP: Any = None # type: ignore[no-redef] @@ -75,14 +77,18 @@ def _coerce_list_items(raw_value: str, item_type: Any) -> list[Any]: # noqa: AN """Convert a comma-separated string into a typed list for list-annotated parameters.""" tokens = [token.strip() for token in raw_value.split(",") if token.strip()] if item_type is int: - converted_ints: list[int] = [] - for token in tokens: - try: - converted_ints.append(int(token)) - except ValueError as exc: - msg = f"Invalid integer value '{token}' in list input '{raw_value}'." - raise typer.BadParameter(msg) from exc - return converted_ints + invalid_token = next( + ( + token + for token in tokens + if not token or token in {"+", "-"} or not token.lstrip("+-").isdigit() + ), + None, + ) + if invalid_token is not None: + msg = f"Invalid integer value '{invalid_token}' in list input '{raw_value}'." + raise typer.BadParameter(msg) + return [int(token) for token in tokens] if isinstance(item_type, type) and issubclass(item_type, enum.Enum): return [_coerce_enum_item(item_type, token) for token in tokens] return tokens @@ -112,7 +118,7 @@ def _build_cli_command(command_func: Callable[..., Any]) -> Callable[..., Any]: cli_signature = signature.replace(parameters=cli_parameters) - def _command_wrapper(*args: Any, **kwargs: Any) -> Any: + def _command_wrapper(*args: Any, **kwargs: Any) -> Any: # noqa: ANN401 bound = cli_signature.bind(*args, **kwargs) converted_arguments: dict[str, Any] = dict(bound.arguments) for name, item_type in list_item_types.items(): diff --git a/toolit/list_serialization.py b/toolit/list_serialization.py index d393467..6dcf832 100644 --- a/toolit/list_serialization.py +++ b/toolit/list_serialization.py @@ -6,10 +6,10 @@ from typing import Any -def serialize_list_default(default_value: Any) -> str: # noqa: ANN401 +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 "" + return None if isinstance(default_value, list): rendered_items: list[str] = [] for item in default_value: @@ -18,4 +18,4 @@ def serialize_list_default(default_value: Any) -> str: # noqa: ANN401 else: rendered_items.append(str(item)) return ", ".join(rendered_items) - return str(default_value) \ No newline at end of file + return str(default_value) 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" }, From 237833bdb79a0fc8ea920e085db6586b7cce0285 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20M=C3=B8ldrup?= <14809761+martinmoldrup@users.noreply.github.com> Date: Wed, 29 Apr 2026 21:19:06 +0200 Subject: [PATCH 7/9] Small simplifaction --- toolit/create_apps_and_register.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/toolit/create_apps_and_register.py b/toolit/create_apps_and_register.py index a8257c8..36cab47 100644 --- a/toolit/create_apps_and_register.py +++ b/toolit/create_apps_and_register.py @@ -81,7 +81,7 @@ def _coerce_list_items(raw_value: str, item_type: Any) -> list[Any]: # noqa: AN ( token for token in tokens - if not token or token in {"+", "-"} or not token.lstrip("+-").isdigit() + if token in {"+", "-"} or not token.lstrip("+-").isdigit() ), None, ) From 07e24f9a011eb852ae7c163739c0122c6df5a2de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20M=C3=B8ldrup?= <14809761+martinmoldrup@users.noreply.github.com> Date: Wed, 29 Apr 2026 21:47:51 +0200 Subject: [PATCH 8/9] Revert code since multiple values and optional is already handled in typer cli Co-authored-by: Copilot --- tests/cli_test.py | 44 +++-------- toolit/create_apps_and_register.py | 114 +---------------------------- toolit/create_tasks_json.py | 15 +++- toolit/list_serialization.py | 21 ------ 4 files changed, 27 insertions(+), 167 deletions(-) delete mode 100644 toolit/list_serialization.py diff --git a/tests/cli_test.py b/tests/cli_test.py index de41933..a1c6200 100644 --- a/tests/cli_test.py +++ b/tests/cli_test.py @@ -54,16 +54,16 @@ def list_str_tool(items: list[str]) -> None: create_apps_and_register.register_command( list_str_tool, - name="test-list-str-input-is-converted-from-comma-separated-tokens", + name="dummycommand", ) runner = CliRunner() result = runner.invoke( create_apps_and_register.app, - ["test-list-str-input-is-converted-from-comma-separated-tokens", "alpha, beta, gamma"], + ["dummycommand", "alpha", "beta", "gamma"], ) - assert result.exit_code == 0 + assert result.exit_code == 0, f"CLI invocation failed with output: {result.output}, captured: {captured}" assert captured["items"] == ["alpha", "beta", "gamma"] @@ -75,16 +75,16 @@ def list_int_tool(numbers: list[int]) -> None: create_apps_and_register.register_command( list_int_tool, - name="test-list-int-input-is-converted-from-comma-separated-tokens", + name="dummycommand", ) runner = CliRunner() result = runner.invoke( create_apps_and_register.app, - ["test-list-int-input-is-converted-from-comma-separated-tokens", "1, 2, 3"], + ["dummycommand", "1", "2", "3"], ) - assert result.exit_code == 0 + assert result.exit_code == 0, f"CLI invocation failed with output: {result.output}, captured: {captured}" assert captured["numbers"] == [1, 2, 3] @@ -100,16 +100,16 @@ def list_enum_tool(levels: list[TestLevel]) -> None: create_apps_and_register.register_command( list_enum_tool, - name="test-list-enum-input-accepts-enum-names-and-values", + name="dummycommand", ) runner = CliRunner() result = runner.invoke( create_apps_and_register.app, - ["test-list-enum-input-accepts-enum-names-and-values", "LOW, high"], + ["dummycommand", "low", "high"], ) - assert result.exit_code == 0 + assert result.exit_code == 0, f"CLI invocation failed with output: {result.output}, captured: {captured}" assert captured["levels"] == [TestLevel.LOW, TestLevel.HIGH] @@ -130,29 +130,5 @@ def optional_list_tool(values: list[str] | None = None) -> None: ["test-optional-list-omitted-preserves-none-default"], ) - assert result.exit_code == 0 + assert result.exit_code == 0, f"CLI invocation failed with output: {result.output}, captured: {captured}" assert captured["values"] is None - - -def test_cli_optional_list_comma_only_input_becomes_empty_list() -> 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-comma-only-input-becomes-empty-list", - ) - - runner = CliRunner() - result = runner.invoke( - create_apps_and_register.app, - ["test-optional-list-comma-only-input-becomes-empty-list", "--values", ","], - ) - - assert result.exit_code == 0 - assert captured["values"] == [] - - - diff --git a/toolit/create_apps_and_register.py b/toolit/create_apps_and_register.py index 36cab47..4f4c46b 100644 --- a/toolit/create_apps_and_register.py +++ b/toolit/create_apps_and_register.py @@ -2,23 +2,17 @@ from __future__ import annotations -import enum import typer -import types -import inspect from collections.abc import Callable -from toolit.list_serialization import serialize_list_default -from typing import TYPE_CHECKING, Any, Union, get_args, get_origin +from typing import TYPE_CHECKING, Any if TYPE_CHECKING: - from mcp.server.fastmcp import FastMCP # type: ignore[import] - + from mcp.server.fastmcp import FastMCP _has_mcp: bool = True else: # Make MCP optional try: from mcp.server.fastmcp import FastMCP - _has_mcp = True except ImportError: FastMCP: Any = None # type: ignore[no-redef] @@ -35,107 +29,6 @@ def initialize() -> None: """Welcome to the Toolit CLI.""" -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_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 _coerce_enum_item(enum_type: type[enum.Enum], value: str) -> enum.Enum: - """Convert a token to an enum member by accepting either name or value.""" - if value in enum_type.__members__: - return enum_type[value] - for member in enum_type: - if str(member.value) == value: - return member - msg = ( - f"Invalid enum value '{value}' for {enum_type.__name__}. " - f"Use one of: {', '.join(enum_type.__members__.keys())} or matching enum values." - ) - raise typer.BadParameter(msg) - - -def _coerce_list_items(raw_value: str, item_type: Any) -> list[Any]: # noqa: ANN401 - """Convert a comma-separated string into a typed list for list-annotated parameters.""" - tokens = [token.strip() for token in raw_value.split(",") if token.strip()] - if item_type is int: - invalid_token = next( - ( - token - for token in tokens - if token in {"+", "-"} or not token.lstrip("+-").isdigit() - ), - None, - ) - if invalid_token is not None: - msg = f"Invalid integer value '{invalid_token}' in list input '{raw_value}'." - raise typer.BadParameter(msg) - return [int(token) for token in tokens] - if isinstance(item_type, type) and issubclass(item_type, enum.Enum): - return [_coerce_enum_item(item_type, token) for token in tokens] - return tokens - - -def _build_cli_command(command_func: Callable[..., Any]) -> Callable[..., Any]: - """Build a CLI callback that accepts list params as comma-separated strings.""" - signature = inspect.signature(command_func) - list_item_types: dict[str, Any] = {} - cli_parameters: list[inspect.Parameter] = [] - - for parameter in signature.parameters.values(): - list_item_type = _extract_list_item_type(parameter.annotation) - if list_item_type is None: - cli_parameters.append(parameter) - continue - - list_item_types[parameter.name] = list_item_type - replacement_default = parameter.default - if isinstance(parameter.default, list): - replacement_default = serialize_list_default(parameter.default) - - cli_parameters.append(parameter.replace(annotation=str, default=replacement_default)) - - if not list_item_types: - return command_func - - cli_signature = signature.replace(parameters=cli_parameters) - - def _command_wrapper(*args: Any, **kwargs: Any) -> Any: # noqa: ANN401 - bound = cli_signature.bind(*args, **kwargs) - converted_arguments: dict[str, Any] = dict(bound.arguments) - for name, item_type in list_item_types.items(): - raw_value = converted_arguments.get(name) - if raw_value is None: - # Preserve omitted optional list arguments as None. - converted_arguments[name] = None - continue - converted_arguments[name] = _coerce_list_items(str(raw_value), item_type) - return command_func(**converted_arguments) - - _command_wrapper.__name__ = command_func.__name__ - _command_wrapper.__doc__ = command_func.__doc__ - _command_wrapper.__signature__ = cli_signature # type: ignore[attr-defined] - return _command_wrapper - - def register_command( command_func: Callable[..., Any], name: str | None = None, @@ -145,7 +38,6 @@ def register_command( if not callable(command_func): msg = f"Command function {command_func} is not callable." raise TypeError(msg) - cli_command = _build_cli_command(command_func) - app.command(name=name, rich_help_panel=rich_help_panel)(cli_command) + app.command(name=name, rich_help_panel=rich_help_panel)(command_func) if mcp is not None: mcp.tool(name)(command_func) diff --git a/toolit/create_tasks_json.py b/toolit/create_tasks_json.py index 9eb59fb..a5e1c34 100644 --- a/toolit/create_tasks_json.py +++ b/toolit/create_tasks_json.py @@ -15,13 +15,26 @@ ) from toolit.config import load_devtools_folder from toolit.constants import ToolitTypesEnum -from toolit.list_serialization import serialize_list_default from types import FunctionType from typing import Any, Union, get_args, get_origin PATH: pathlib.Path = load_devtools_folder() 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.""" diff --git a/toolit/list_serialization.py b/toolit/list_serialization.py deleted file mode 100644 index 6dcf832..0000000 --- a/toolit/list_serialization.py +++ /dev/null @@ -1,21 +0,0 @@ -"""Utilities for serializing list defaults for CLI/task inputs.""" - -from __future__ import annotations - -import enum -from typing import Any - - -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) From 9f504eae659a657b79cb58f06e4481c0222e4817 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20M=C3=B8ldrup?= <14809761+martinmoldrup@users.noreply.github.com> Date: Wed, 29 Apr 2026 21:49:41 +0200 Subject: [PATCH 9/9] Formatting fix --- toolit/create_tasks_json.py | 1 + 1 file changed, 1 insertion(+) diff --git a/toolit/create_tasks_json.py b/toolit/create_tasks_json.py index a5e1c34..c1d3368 100644 --- a/toolit/create_tasks_json.py +++ b/toolit/create_tasks_json.py @@ -21,6 +21,7 @@ PATH: pathlib.Path = load_devtools_folder() 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: