From e380b46acdf812f4126d16f9e5b3a3258001c7bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20M=C3=B8ldrup?= <14809761+martinmoldrup@users.noreply.github.com> Date: Thu, 30 Apr 2026 19:15:26 +0200 Subject: [PATCH 1/9] Add cli tool option to allow for cli excecution Co-authored-by: Copilot --- README.md | 14 ++++ tests/cli_test.py | 74 ++++++++++++++++++++ tests/create_tasks_json_test.py | 17 +++++ tests/test_auto_loader.py | 107 +++++++++++++++++++++++++++++ toolit/__init__.py | 3 +- toolit/auto_loader.py | 10 ++- toolit/constants.py | 1 + toolit/create_apps_and_register.py | 44 +++++++++++- toolit/create_tasks_json.py | 14 +++- toolit/decorators.py | 6 ++ 10 files changed, 284 insertions(+), 6 deletions(-) create mode 100644 tests/test_auto_loader.py diff --git a/README.md b/README.md index 99093f4..98c6bf0 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,20 @@ toolit --help # To see available commands toolit my-command --to_print "Hello, Toolit!" # To run your command ``` +You add a command line command with parameter as a devtool, and it will be available as a command in the CLI and in the generated `tasks.json` file for Visual Studio Code. You do this by: + +```python +# devtools/my_cli_commands.py +from toolit import clitool +@clitool +def execute_cli_command(to_print: str = "Hello, World!") -> str: + """This is a command that can be run from the CLI.""" + return "python -c 'print(\"" + to_print + "\")'" +``` + +When this command runs from CLI or a generated VS Code task, Toolit executes the returned string in your shell. + + ### Customizing the DevTools Folder By default, Toolit looks for a folder named `devtools` in the project root. You can customize this by creating a `toolit.ini` or use your `pyproject.toml` file in your project root with the following content: diff --git a/tests/cli_test.py b/tests/cli_test.py index a1c6200..52192ac 100644 --- a/tests/cli_test.py +++ b/tests/cli_test.py @@ -4,6 +4,7 @@ import enum import pytest from typer.testing import CliRunner +from toolit import clitool def test_cli_run_with_no_tools() -> None: @@ -132,3 +133,76 @@ def optional_list_tool(values: list[str] | None = None) -> None: assert result.exit_code == 0, f"CLI invocation failed with output: {result.output}, captured: {captured}" assert captured["values"] is None + + +def test_clitool_runtime_executes_returned_shell_command(monkeypatch: pytest.MonkeyPatch) -> None: + captured: dict[str, str] = {} + + class DummyCompletedProcess: + """Completed-process stand-in with return code for subprocess mocks.""" + + def __init__(self, returncode: int) -> None: + self.returncode = returncode + + def fake_run(command: str, shell: bool, check: bool) -> DummyCompletedProcess: # noqa: FBT001 + captured["command"] = command + captured["shell"] = str(shell) + captured["check"] = str(check) + return DummyCompletedProcess(returncode=0) + + monkeypatch.setattr(create_apps_and_register.subprocess, "run", fake_run) + + @clitool + def run_echo(target: str) -> str: + return f"echo {target}" + + create_apps_and_register.register_command(run_echo, name="test-clitool-runtime-executes-returned-shell-command") + runner = CliRunner() + result = runner.invoke( + create_apps_and_register.app, + ["test-clitool-runtime-executes-returned-shell-command", "hello"], + ) + + assert result.exit_code == 0, result.output + assert captured["command"] == "echo hello" + assert captured["shell"] == "True" + assert captured["check"] == "False" + + +def test_clitool_runtime_requires_string_return_type() -> None: + @clitool + def returns_wrong_type() -> int: # type: ignore[return-value] + return 42 + + create_apps_and_register.register_command(returns_wrong_type, name="test-clitool-runtime-requires-string-return-type") + runner = CliRunner() + result = runner.invoke(create_apps_and_register.app, ["test-clitool-runtime-requires-string-return-type"]) + + assert result.exit_code == 1 + assert "must return a string command" in result.output + + +def test_clitool_runtime_propagates_subprocess_exit_code(monkeypatch: pytest.MonkeyPatch) -> None: + class DummyCompletedProcess: + """Completed-process stand-in with return code for subprocess mocks.""" + + def __init__(self, returncode: int) -> None: + self.returncode = returncode + + def fake_run(command: str, shell: bool, check: bool) -> DummyCompletedProcess: # noqa: ARG001, FBT001 + return DummyCompletedProcess(returncode=9) + + monkeypatch.setattr(create_apps_and_register.subprocess, "run", fake_run) + + @clitool + def returns_failing_command() -> str: + return "exit 9" + + create_apps_and_register.register_command( + returns_failing_command, + name="test-clitool-runtime-propagates-subprocess-exit-code", + ) + runner = CliRunner() + result = runner.invoke(create_apps_and_register.app, ["test-clitool-runtime-propagates-subprocess-exit-code"]) + + assert result.exit_code == 9 diff --git a/tests/create_tasks_json_test.py b/tests/create_tasks_json_test.py index cd67ab0..d9e6863 100644 --- a/tests/create_tasks_json_test.py +++ b/tests/create_tasks_json_test.py @@ -6,6 +6,7 @@ import pytest +from toolit import clitool from toolit.create_tasks_json import TaskJsonBuilder, _annotation_to_string # noqa: PLC2701 @@ -224,3 +225,19 @@ def test_create_args_for_tool_optional_list_keeps_none_default() -> None: builder._create_args_for_tool(_tool_with_optional_list_param) # noqa: SLF001 assert builder.inputs[0]["default"] is None + + +def test_process_tool_clitool_creates_task_and_inputs() -> None: + """Ensure clitool functions are processed into task and input entries.""" + + @clitool + def run_script(name: str) -> str: + return f"echo {name}" + + builder = TaskJsonBuilder() + builder.process_tool(run_script) + + assert len(builder.tasks) == 1 + assert len(builder.inputs) == 1 + assert builder.tasks[0]["command"].endswith('toolit run-script "${input:run_script_name}"') + assert builder.inputs[0]["id"] == "run_script_name" diff --git a/tests/test_auto_loader.py b/tests/test_auto_loader.py new file mode 100644 index 0000000..2752240 --- /dev/null +++ b/tests/test_auto_loader.py @@ -0,0 +1,107 @@ +"""Tests for load_tools_from_folder in auto_loader.""" + +import os +import pathlib +import types + +import pytest + +from toolit import auto_loader +from toolit.auto_loader import load_tools_from_folder + + +class DummyToolitTypesEnum: + """Dummy enum for tool types.""" + + TOOL = "TOOL" + CLITOOL = "CLITOOL" + SEQUENTIAL_GROUP = "SEQUENTIAL_GROUP" + PARALLEL_GROUP = "PARALLEL_GROUP" + + +@pytest.fixture +def temp_tools_folder(tmp_path: pathlib.Path) -> pathlib.Path: + """Fixture to create a temporary folder with tool files.""" + folder: pathlib.Path = tmp_path / "tools" + folder.mkdir() + + tool_file: pathlib.Path = folder / "my_tool.py" + tool_file.write_text( + "def dummy():\n" + " pass\n" + "dummy.toolit_type = 'TOOL'\n" + "\n" + "def group():\n" + " pass\n" + "group.toolit_type = 'SEQUENTIAL_GROUP'\n" + "\n" + "def cli_command():\n" + " pass\n" + "cli_command.toolit_type = 'CLITOOL'\n" + ) + + nontool_file: pathlib.Path = folder / "not_a_tool.py" + nontool_file.write_text( + "def not_a_tool():\n" + " pass\n" + ) + return folder + + +def patch_auto_loader(monkeypatch: pytest.MonkeyPatch) -> None: + """Patch auto_loader internals for isolated testing.""" + monkeypatch.setattr(auto_loader, "ToolitTypesEnum", DummyToolitTypesEnum) + monkeypatch.setattr(auto_loader, "MARKER_TOOL", "toolit_type") + monkeypatch.setattr(auto_loader, "register_command", lambda *args, **kwargs: None) + + def import_module(file: pathlib.Path) -> types.ModuleType: + module = types.ModuleType(file.stem) + code = file.read_text() + exec(code, module.__dict__) + return module + + monkeypatch.setattr(auto_loader, "import_module", import_module) + + +@pytest.mark.usefixtures("temp_tools_folder") +def test_load_tools_from_folder_loads_tools_and_groups( + temp_tools_folder: pathlib.Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Test load_tools_from_folder loads tools and tool groups.""" + patch_auto_loader(monkeypatch) + loaded = load_tools_from_folder(temp_tools_folder) + loaded_names: set[str] = {f.__name__ for f in loaded} + assert "dummy" in loaded_names + assert "group" in loaded_names + assert "cli_command" in loaded_names + assert "not_a_tool" not in loaded_names + + +def test_load_tools_from_folder_empty_folder(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Test load_tools_from_folder returns empty list for empty folder.""" + patch_auto_loader(monkeypatch) + empty_folder: pathlib.Path = tmp_path / "empty" + empty_folder.mkdir() + loaded = load_tools_from_folder(empty_folder) + assert loaded == [] + + +def test_load_tools_from_folder_relative_path(monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path) -> None: + """Test load_tools_from_folder works with relative paths.""" + patch_auto_loader(monkeypatch) + folder: pathlib.Path = tmp_path / "reltools" + folder.mkdir() + tool_file: pathlib.Path = folder / "toolx.py" + tool_file.write_text( + "def toolx():\n" + " pass\n" + "toolx.toolit_type = 'TOOL'\n" + ) + cwd = pathlib.Path.cwd() + try: + os.chdir(tmp_path) + loaded = load_tools_from_folder(pathlib.Path("reltools")) + loaded_names: set[str] = {f.__name__ for f in loaded} + assert "toolx" in loaded_names + finally: + os.chdir(cwd) \ No newline at end of file diff --git a/toolit/__init__.py b/toolit/__init__.py index 94ba30f..01a93b2 100644 --- a/toolit/__init__.py +++ b/toolit/__init__.py @@ -1,9 +1,10 @@ """Public API for the package.""" from toolit.config import get_config_value -from toolit.decorators import tool +from toolit.decorators import clitool, tool __all__ = [ "get_config_value", + "clitool", "tool", ] diff --git a/toolit/auto_loader.py b/toolit/auto_loader.py index 75de33f..6e09a96 100644 --- a/toolit/auto_loader.py +++ b/toolit/auto_loader.py @@ -46,6 +46,11 @@ def tool_strategy(module: ModuleType) -> list[FunctionType]: return load_tools_from_file(module, ToolitTypesEnum.TOOL) +def clitool_strategy(module: ModuleType) -> list[FunctionType]: + """Strategy to get CLI tools from a module.""" + return load_tools_from_file(module, ToolitTypesEnum.CLITOOL) + + def tool_group_strategy(module: ModuleType) -> list[FunctionType]: """Strategy to get tool groups from a module.""" groups: list[FunctionType] = [] @@ -82,11 +87,14 @@ def load_tools_from_folder(folder_path: pathlib.Path) -> list[FunctionType]: folder_path = pathlib.Path.cwd() / folder_path tools: list[FunctionType] = get_items_from_folder(folder_path, tool_strategy) + clitools: list[FunctionType] = get_items_from_folder(folder_path, clitool_strategy) tool_groups: list[FunctionType] = get_items_from_folder(folder_path, tool_group_strategy) # Register each tool as a command for tool in tools: register_command(tool, rich_help_panel=RichHelpPanelNames.PROJECT_COMMANDS_PANEL) - return tools + tool_groups + for clitool in clitools: + register_command(clitool, rich_help_panel=RichHelpPanelNames.PROJECT_COMMANDS_PANEL) + return tools + clitools + tool_groups def get_toolit_type(tool: FunctionType) -> ToolitTypesEnum | None: diff --git a/toolit/constants.py b/toolit/constants.py index 6275efb..48351b8 100644 --- a/toolit/constants.py +++ b/toolit/constants.py @@ -23,5 +23,6 @@ class ToolitTypesEnum(enum.Enum): """Enum for the different types of toolit tools.""" TOOL = "tool" + CLITOOL = "clitool" SEQUENTIAL_GROUP = "sequential_group" PARALLEL_GROUP = "parallel_group" diff --git a/toolit/create_apps_and_register.py b/toolit/create_apps_and_register.py index 4f4c46b..75ecda0 100644 --- a/toolit/create_apps_and_register.py +++ b/toolit/create_apps_and_register.py @@ -2,10 +2,15 @@ from __future__ import annotations +import inspect +import subprocess +from functools import wraps import typer from collections.abc import Callable from typing import TYPE_CHECKING, Any +from toolit.constants import MARKER_TOOL, ToolitTypesEnum + if TYPE_CHECKING: from mcp.server.fastmcp import FastMCP _has_mcp: bool = True @@ -38,6 +43,41 @@ 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) - if mcp is not None: + + command_to_register = command_func + if getattr(command_func, MARKER_TOOL, None) == ToolitTypesEnum.CLITOOL: + command_to_register = _create_clitool_runtime_wrapper(command_func) + + 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 _create_clitool_runtime_wrapper(command_func: Callable[..., Any]) -> Callable[..., None]: + """Wrap a clitool function so its returned command string runs in a shell.""" + + @wraps(command_func) + def _wrapped(*args: Any, **kwargs: Any) -> None: + command = command_func(*args, **kwargs) + if not isinstance(command, str): + typer.secho( + f"Error: clitool '{command_func.__name__}' must return a string command, got {type(command).__name__}.", + fg=typer.colors.RED, + ) + raise typer.Exit(code=1) + + command_to_run: str = command.strip() + if not command_to_run: + typer.secho( + f"Error: clitool '{command_func.__name__}' returned an empty command.", + fg=typer.colors.RED, + ) + raise typer.Exit(code=1) + + result = subprocess.run(command_to_run, shell=True, check=False) # noqa: S602 + if result.returncode != 0: + raise typer.Exit(code=result.returncode) + + _wrapped.__signature__ = inspect.signature(command_func) + return _wrapped diff --git a/toolit/create_tasks_json.py b/toolit/create_tasks_json.py index c1d3368..a109f4b 100644 --- a/toolit/create_tasks_json.py +++ b/toolit/create_tasks_json.py @@ -2,11 +2,13 @@ import enum import json +import shutil import typer import types import inspect import pathlib from toolit.auto_loader import ( + clitool_strategy, get_items_from_folder, get_plugin_tools, get_toolit_type, @@ -42,7 +44,9 @@ def create_vscode_tasks_json() -> None: typer.echo(f"Creating tasks.json at {output_file_path}") if PATH.exists() and PATH.is_dir(): tools: list[FunctionType] = get_items_from_folder(PATH, tool_strategy) + clitools: list[FunctionType] = get_items_from_folder(PATH, clitool_strategy) tool_groups: list[FunctionType] = get_items_from_folder(PATH, tool_group_strategy) + tools.extend(clitools) tools.extend(tool_groups) else: typer.echo(f"The devtools folder does not exist or is not a directory: {PATH.absolute().as_posix()}") @@ -175,6 +179,11 @@ def __init__(self) -> None: self.input_id_map: dict[tuple[str, str], str] = {} self.tasks: list[dict[str, Any]] = [] + @staticmethod + def _build_command_prefix() -> str: + """Build command prefix for task commands based on uv availability.""" + return "uv run --no-sync " if shutil.which("uv") else "" + 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 @@ -246,10 +255,11 @@ def _create_task_entry(self, tool: FunctionType, args: list[str]) -> None: """Create a task entry for a given tool.""" name_as_typer_command: str = _create_typer_command_name(tool) display_name: str = _create_display_name(tool) + command_prefix: str = self._build_command_prefix() task: dict[str, Any] = { "label": display_name, "type": "shell", - "command": f"toolit {name_as_typer_command}" + (f" {' '.join(args)}" if args else ""), + "command": f"{command_prefix}toolit {name_as_typer_command}" + (f" {' '.join(args)}" if args else ""), "problemMatcher": [], } if tool.__doc__: @@ -274,7 +284,7 @@ def _create_task_group_entry(self, tool: FunctionType, tool_type: ToolitTypesEnu def process_tool(self, tool: FunctionType) -> None: """Process a single tool to create its task entry and inputs.""" tool_type = get_toolit_type(tool) - if tool_type == ToolitTypesEnum.TOOL: + if tool_type in {ToolitTypesEnum.TOOL, ToolitTypesEnum.CLITOOL}: args = self._create_args_for_tool(tool) self._create_task_entry(tool, args) elif tool_type in {ToolitTypesEnum.SEQUENTIAL_GROUP, ToolitTypesEnum.PARALLEL_GROUP}: diff --git a/toolit/decorators.py b/toolit/decorators.py index 38955c7..2e541fd 100644 --- a/toolit/decorators.py +++ b/toolit/decorators.py @@ -13,6 +13,12 @@ def tool(func: T) -> T: return func +def clitool(func: T) -> T: + """Decorate function as a CLI tool that returns a shell command string.""" + setattr(func, MARKER_TOOL, ToolitTypesEnum.CLITOOL) + return func + + def sequential_group_of_tools(func: T) -> T: """Decorate a function that returns a list of callable tools.""" setattr(func, MARKER_TOOL, ToolitTypesEnum.SEQUENTIAL_GROUP) From f89b2a1f6834c6730e9b67c840d22f27c46a4951 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20M=C3=B8ldrup?= <14809761+martinmoldrup@users.noreply.github.com> Date: Fri, 1 May 2026 09:44:04 +0200 Subject: [PATCH 2/9] Fix on windows environment the shell output Co-authored-by: Copilot --- tests/cli_test.py | 13 ++++++------- toolit/create_apps_and_register.py | 7 ++++++- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/tests/cli_test.py b/tests/cli_test.py index 52192ac..b72f63d 100644 --- a/tests/cli_test.py +++ b/tests/cli_test.py @@ -144,10 +144,10 @@ class DummyCompletedProcess: def __init__(self, returncode: int) -> None: self.returncode = returncode - def fake_run(command: str, shell: bool, check: bool) -> DummyCompletedProcess: # noqa: FBT001 - captured["command"] = command - captured["shell"] = str(shell) - captured["check"] = str(check) + def fake_run(command: object, *args: object, **kwargs: object) -> DummyCompletedProcess: + captured["command"] = str(command) + captured["shell"] = str(kwargs.get("shell")) + captured["check"] = str(kwargs.get("check")) return DummyCompletedProcess(returncode=0) monkeypatch.setattr(create_apps_and_register.subprocess, "run", fake_run) @@ -164,8 +164,7 @@ def run_echo(target: str) -> str: ) assert result.exit_code == 0, result.output - assert captured["command"] == "echo hello" - assert captured["shell"] == "True" + assert "echo hello" in captured["command"] assert captured["check"] == "False" @@ -189,7 +188,7 @@ class DummyCompletedProcess: def __init__(self, returncode: int) -> None: self.returncode = returncode - def fake_run(command: str, shell: bool, check: bool) -> DummyCompletedProcess: # noqa: ARG001, FBT001 + def fake_run(command: object, *args: object, **kwargs: object) -> DummyCompletedProcess: # noqa: ARG001 return DummyCompletedProcess(returncode=9) monkeypatch.setattr(create_apps_and_register.subprocess, "run", fake_run) diff --git a/toolit/create_apps_and_register.py b/toolit/create_apps_and_register.py index 75ecda0..87f13a1 100644 --- a/toolit/create_apps_and_register.py +++ b/toolit/create_apps_and_register.py @@ -3,6 +3,7 @@ from __future__ import annotations import inspect +import os import subprocess from functools import wraps import typer @@ -75,7 +76,11 @@ def _wrapped(*args: Any, **kwargs: Any) -> None: ) raise typer.Exit(code=1) - result = subprocess.run(command_to_run, shell=True, check=False) # noqa: S602 + if os.name == "nt": + # Use PowerShell on Windows so command quoting matches interactive pwsh usage. + result = subprocess.run(["pwsh", "-NoProfile", "-Command", command_to_run], check=False) + else: + result = subprocess.run(command_to_run, shell=True, check=False) # noqa: S602 if result.returncode != 0: raise typer.Exit(code=result.returncode) From aa6accf3657ba90c857c5442ae03234693cab4bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20M=C3=B8ldrup?= <14809761+martinmoldrup@users.noreply.github.com> Date: Fri, 1 May 2026 10:36:22 +0200 Subject: [PATCH 3/9] Enhance CLI functionality with new @clitool decorator and improved option name handling Co-authored-by: Copilot --- CHANGELOG.md | 2 ++ tests/create_tasks_json_test.py | 25 +++++++++++++++++++------ toolit/create_tasks_json.py | 11 ++++++++++- 3 files changed, 31 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 38700b0..5af482e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ All notable changes to this project will be documented in this file. - 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). +- Add the @clitool decorator for ability to easily run CLI commands +- Better support for environment activation by using `uv run --no-sync` to fix problem of python interpreter not being correctly detected in vscode when using uv as the environment manager. ## [0.6.0] - 20-12-2025 - Abandon python 3.9 which is deprecated. Now only support python 3.10 and higher. diff --git a/tests/create_tasks_json_test.py b/tests/create_tasks_json_test.py index d9e6863..640ae03 100644 --- a/tests/create_tasks_json_test.py +++ b/tests/create_tasks_json_test.py @@ -7,7 +7,7 @@ import pytest from toolit import clitool -from toolit.create_tasks_json import TaskJsonBuilder, _annotation_to_string # noqa: PLC2701 +from toolit.create_tasks_json import TaskJsonBuilder, _annotation_to_string, _create_typer_option_name # noqa: PLC2701 class Color(enum.Enum): @@ -79,7 +79,7 @@ def test_create_args_for_tool_handles_pep604_optional() -> None: args = builder._create_args_for_tool(_tool_with_pep604_optional) # noqa: SLF001 - assert args == ['"${input:_tool_with_pep604_optional_input_dataset_name}"'] + assert args == ['--input-dataset-name "${input:_tool_with_pep604_optional_input_dataset_name}"'] assert builder.inputs[0]["description"] == "Enter value for input_dataset_name (str | None)" assert builder.inputs[0]["default"] is None @@ -90,7 +90,7 @@ def test_create_args_for_tool_handles_typing_optional() -> None: args = builder._create_args_for_tool(_tool_with_typing_optional) # noqa: SLF001 - assert args == ['"${input:_tool_with_typing_optional_input_dataset_name}"'] + assert args == ['--input-dataset-name "${input:_tool_with_typing_optional_input_dataset_name}"'] assert builder.inputs[0]["description"] == "Enter value for input_dataset_name (str | None)" assert builder.inputs[0]["default"] is None @@ -163,6 +163,7 @@ def test_create_args_for_tool_enum_respects_provided_default() -> None: args = builder._create_args_for_tool(_tool_with_enum_param_with_default) # noqa: SLF001 + assert args == ['--color "${input:_tool_with_enum_param_with_default_color}"'] assert builder.inputs[0]["default"] == Color.RED.value @@ -172,6 +173,10 @@ def test_create_args_for_tool_multiple_enum_params() -> None: args = builder._create_args_for_tool(_tool_with_multiple_enum_params) # noqa: SLF001 + assert args == [ + '--color "${input:_tool_with_multiple_enum_params_color}"', + '--environment "${input:_tool_with_multiple_enum_params_environment}"', + ] assert len(builder.inputs) == 2 # First input (color) assert builder.inputs[0]["type"] == "pickString" @@ -199,8 +204,9 @@ 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 + args = builder._create_args_for_tool(_tool_with_list_int_param) # noqa: SLF001 + assert args == ['--numbers "${input:_tool_with_list_int_param_numbers}"'] 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" @@ -209,8 +215,9 @@ 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 + args = builder._create_args_for_tool(_tool_with_list_enum_param) # noqa: SLF001 + assert args == ['--colors "${input:_tool_with_list_enum_param_colors}"'] assert ( builder.inputs[0]["description"] == "Enter comma-separated enum values for colors. Accepted values: [red, green, blue]. You can also use enum member names." @@ -222,11 +229,17 @@ 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 + args = builder._create_args_for_tool(_tool_with_optional_list_param) # noqa: SLF001 + assert args == ['--values "${input:_tool_with_optional_list_param_values}"'] assert builder.inputs[0]["default"] is None +def test_create_typer_option_name_replaces_underscores() -> None: + """Ensure option names follow Typer's kebab-case convention.""" + assert _create_typer_option_name("string_input") == "--string-input" + + def test_process_tool_clitool_creates_task_and_inputs() -> None: """Ensure clitool functions are processed into task and input entries.""" diff --git a/toolit/create_tasks_json.py b/toolit/create_tasks_json.py index a109f4b..c026fd1 100644 --- a/toolit/create_tasks_json.py +++ b/toolit/create_tasks_json.py @@ -170,6 +170,11 @@ def _create_display_name(tool: FunctionType) -> str: return tool.__name__.replace("_", " ").title() +def _create_typer_option_name(param_name: str) -> str: + """Create a Typer option name from a function parameter name.""" + return f"--{param_name.replace('_', '-')}" + + class TaskJsonBuilder: """Class to build tasks.json inputs and argument mappings.""" @@ -248,7 +253,11 @@ def _create_args_for_tool(self, tool: FunctionType) -> list[str]: } input_entry.update(input_options) self.inputs.append(input_entry) - args.append(f'"${{input:{input_id}}}"') + input_value_ref = f'"${{input:{input_id}}}"' + if param.default is inspect.Parameter.empty: + args.append(input_value_ref) + else: + args.append(f"{_create_typer_option_name(param.name)} {input_value_ref}") return args def _create_task_entry(self, tool: FunctionType, args: list[str]) -> None: From d7636830f38ed4d5acad533dc0b75ac0177c63d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20M=C3=B8ldrup?= <14809761+martinmoldrup@users.noreply.github.com> Date: Fri, 1 May 2026 12:56:05 +0200 Subject: [PATCH 4/9] Refactor the create_tasks_json file to have a seperate command builder module instead, add tests and fix issues in integration tests Co-authored-by: Copilot --- CHANGELOG.md | 2 +- tests/cli_command_builder_test.py | 517 +++++++++++++++++++++++++++++ tests/cli_integration_test.py | 517 +++++++++++++++++++++++++++++ tests/cli_test.py | 107 ++++++ tests/create_tasks_json_test.py | 306 +++++------------ toolit/cli_command_builder.py | 341 +++++++++++++++++++ toolit/create_apps_and_register.py | 152 ++++++++- toolit/create_tasks_json.py | 263 ++------------- 8 files changed, 1752 insertions(+), 453 deletions(-) create mode 100644 tests/cli_command_builder_test.py create mode 100644 tests/cli_integration_test.py create mode 100644 toolit/cli_command_builder.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 5af482e..09dba90 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] - Unreleased +## [0.7.0] - 01-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. - Raise an error if a proper type hint is not provided for a parameter in a tool function. diff --git a/tests/cli_command_builder_test.py b/tests/cli_command_builder_test.py new file mode 100644 index 0000000..a326939 --- /dev/null +++ b/tests/cli_command_builder_test.py @@ -0,0 +1,517 @@ +"""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 + +from toolit.cli_command_builder import CliCommandBuilder + + +def _as_function(func: Any) -> FunctionType: + """Cast a Python function to FunctionType for strict type checks.""" + return cast(FunctionType, func) + + +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.""" + + +def _tool_with_typing_optional(input_dataset_name: str | None = None) -> None: + """Tool with a typing.Optional argument.""" + + +def _tool_without_type_hint(to_print) -> None: # type: ignore[no-untyped-def] # noqa: ANN001 + """Tool without a type hint on a parameter.""" + + +def _tool_with_multiple_params_missing_hint(name, value: str) -> None: # type: ignore[no-untyped-def] # noqa: ANN001 + """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 _tool_with_bool_param(is_enabled: bool = False) -> None: # noqa: ARG001 + """Tool with a bool parameter.""" + + +def _tool_with_multiple_bool_params( + verbose: bool = False, # noqa: ARG001 + dry_run: bool = True, # noqa: ARG001 +) -> None: + """Tool with multiple bool parameters.""" + + +def _tool_with_mixed_params( + name: str, + count: int = 5, # noqa: ARG001 + color: Color = Color.RED, # noqa: ARG001 + is_active: bool = False, # noqa: ARG001 +) -> None: + """Tool with mixed parameter types.""" + + +def _tool_with_single_param(name: str) -> None: # noqa: ARG001 + """Tool with a single required parameter.""" + + +def _tool_with_no_params() -> None: + """Tool with no parameters.""" + + +# ============ Tests for annotation string conversion ============ + + +@pytest.mark.parametrize( + ("annotation", "expected"), + [ + (inspect.Parameter.empty, "str"), + (Any, "Any"), + (list[str], "list[str]"), + (dict[str, int], "dict[str, int]"), + (str | None, "str | None"), + (Optional[int], "int | None"), + ], +) +def test_annotation_to_string_formats_common_and_complex_types(annotation: Any, expected: str) -> None: + """Ensure annotation string conversion supports unions, optionals, and generics.""" + builder = CliCommandBuilder() + assert builder._annotation_to_string(annotation) == expected + + +# ============ Tests for optional parameters ============ + + +def test_create_args_for_tool_handles_pep604_optional() -> None: + """Ensure str | None annotations do not crash and are rendered in descriptions.""" + cmd_builder = CliCommandBuilder() + spec = cmd_builder.analyze_tool(_as_function(_tool_with_pep604_optional)) + + args = spec.get_argument_strings() + inputs = spec.get_input_entries() + + assert args == ['--input-dataset-name "${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 + + +def test_create_args_for_tool_handles_typing_optional() -> None: + """Ensure typing.Optional[str] annotations do not crash and are rendered in descriptions.""" + cmd_builder = CliCommandBuilder() + spec = cmd_builder.analyze_tool(_as_function(_tool_with_typing_optional)) + + args = spec.get_argument_strings() + inputs = spec.get_input_entries() + + assert args == ['--input-dataset-name "${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 + + +# ============ Tests for error handling ============ + + +def test_create_args_for_tool_raises_on_missing_type_hint() -> None: + """Ensure a missing type hint raises ValueError with an instructive message.""" + cmd_builder = CliCommandBuilder() + + with pytest.raises( + 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)) + + +def test_create_args_for_tool_error_message_includes_fix_hint() -> None: + """Ensure the error message tells the user how to fix the missing annotation.""" + cmd_builder = CliCommandBuilder() + + with pytest.raises(ValueError, match=r"def _tool_without_type_hint\(to_print: str\) -> None"): + cmd_builder.analyze_tool(_as_function(_tool_without_type_hint)) + + +def test_create_args_for_tool_raises_on_first_missing_hint_in_mixed_params() -> None: + """Ensure the error reports the specific parameter that is missing the annotation.""" + cmd_builder = CliCommandBuilder() + + with pytest.raises(ValueError, match="Parameter 'name' in function '_tool_with_multiple_params_missing_hint'"): + cmd_builder.analyze_tool(_as_function(_tool_with_multiple_params_missing_hint)) + + +# ============ Tests for enum parameters ============ + + +def test_create_args_for_tool_enum_creates_picklist_input() -> None: + """Ensure enum parameters create pickString input type in tasks.json.""" + cmd_builder = CliCommandBuilder() + spec = cmd_builder.analyze_tool(_as_function(_tool_with_enum_param)) + + args = spec.get_argument_strings() + inputs = spec.get_input_entries() + + assert args == ['"${input:_tool_with_enum_param_color}"'] + assert inputs[0]["type"] == "pickString" + assert 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.""" + cmd_builder = CliCommandBuilder() + spec = cmd_builder.analyze_tool(_as_function(_tool_with_enum_param)) + + inputs = spec.get_input_entries() + + assert 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.""" + cmd_builder = CliCommandBuilder() + spec = cmd_builder.analyze_tool(_as_function(_tool_with_enum_param_with_default)) + + args = spec.get_argument_strings() + inputs = spec.get_input_entries() + + assert args == ['--color "${input:_tool_with_enum_param_with_default_color}"'] + assert 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.""" + cmd_builder = CliCommandBuilder() + spec = cmd_builder.analyze_tool(_as_function(_tool_with_multiple_enum_params)) + + args = spec.get_argument_strings() + inputs = spec.get_input_entries() + + assert args == [ + '--color "${input:_tool_with_multiple_enum_params_color}"', + '--environment "${input:_tool_with_multiple_enum_params_environment}"', + ] + assert len(inputs) == 2 + # First input (color) + assert inputs[0]["type"] == "pickString" + assert inputs[0]["options"] == ["red", "green", "blue"] + assert inputs[0]["default"] == "red" + # Second input (environment) + assert inputs[1]["type"] == "pickString" + assert inputs[1]["options"] == ["development", "staging", "production"] + assert inputs[1]["default"] == "development" + + +# ============ Tests for list parameters ============ + + +def test_create_args_for_tool_list_str_uses_promptstring_and_guidance() -> None: + """Ensure list[str] parameters use promptString with comma-separated guidance.""" + cmd_builder = CliCommandBuilder() + spec = cmd_builder.analyze_tool(_as_function(_tool_with_list_str_param)) + + args = spec.get_argument_strings() + inputs = spec.get_input_entries() + + 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"] == "" + + +def test_create_args_for_tool_list_int_serializes_default_values() -> None: + """Ensure list[int] defaults are serialized to comma-separated text.""" + cmd_builder = CliCommandBuilder() + spec = cmd_builder.analyze_tool(_as_function(_tool_with_list_int_param)) + + args = spec.get_argument_strings() + inputs = spec.get_input_entries() + + 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" + + +def test_create_args_for_tool_list_enum_serializes_default_values() -> None: + """Ensure list[Enum] defaults are serialized using enum values.""" + cmd_builder = CliCommandBuilder() + spec = cmd_builder.analyze_tool(_as_function(_tool_with_list_enum_param)) + + args = spec.get_argument_strings() + inputs = spec.get_input_entries() + + 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." + ) + assert 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.""" + cmd_builder = CliCommandBuilder() + spec = cmd_builder.analyze_tool(_as_function(_tool_with_optional_list_param)) + + args = spec.get_argument_strings() + inputs = spec.get_input_entries() + + assert args == ['--values "${input:_tool_with_optional_list_param_values}"'] + assert inputs[0]["default"] is None + + +# ============ Tests for bool parameters ============ + + +def test_create_args_for_tool_bool_creates_picklist_input() -> None: + """Ensure bool parameters create pickString input type with True/False options.""" + cmd_builder = CliCommandBuilder() + spec = cmd_builder.analyze_tool(_as_function(_tool_with_bool_param)) + + args = spec.get_argument_strings() + inputs = spec.get_input_entries() + + 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" + + +def test_create_args_for_tool_bool_respects_true_default() -> None: + """Ensure bool parameters with True default preserve that value.""" + cmd_builder = CliCommandBuilder() + spec = cmd_builder.analyze_tool(_as_function(_tool_with_multiple_bool_params)) + + inputs = spec.get_input_entries() + + # dry_run has True default + assert inputs[1]["default"] == "True" + + +def test_create_args_for_tool_multiple_bool_params() -> None: + """Ensure multiple bool parameters are all correctly handled.""" + cmd_builder = CliCommandBuilder() + spec = cmd_builder.analyze_tool(_as_function(_tool_with_multiple_bool_params)) + + args = spec.get_argument_strings() + inputs = spec.get_input_entries() + + assert len(inputs) == 2 + assert all(inp["type"] == "pickString" for inp in inputs) + assert all(inp["options"] == ["True", "False"] for inp in inputs) + + +# ============ Tests for mixed parameter types ============ + + +def test_create_args_for_tool_mixed_params_preserves_order() -> None: + """Ensure mixed parameter types are processed in definition order.""" + cmd_builder = CliCommandBuilder() + spec = cmd_builder.analyze_tool(_as_function(_tool_with_mixed_params)) + + args = spec.get_argument_strings() + inputs = spec.get_input_entries() + + # Verify parameter order + assert len(args) == 4 + assert "name" in args[0] + assert "count" in args[1] + assert "color" in args[2] + assert "is-active" in args[3] + + +# ============ Tests for CLI option names ============ + + +def test_cli_command_builder_create_typer_option_name_replaces_underscores() -> None: + """Ensure option names follow Typer's kebab-case convention.""" + assert CliCommandBuilder.create_typer_option_name("string_input") == "--string-input" + + +def test_cli_command_builder_create_typer_option_name_handles_multiple_underscores() -> None: + """Ensure multiple underscores are all replaced with dashes.""" + assert CliCommandBuilder.create_typer_option_name("this_is_a_long_name") == "--this-is-a-long-name" + + +# ============ Tests for command name formatting ============ + + +def test_cli_command_builder_create_typer_command_name_converts_underscores() -> None: + """Ensure command names use kebab-case.""" + assert CliCommandBuilder.create_typer_command_name(lambda: None) is not None # Just verify the method exists + + +def test_cli_command_builder_create_display_name_formats_function_name() -> None: + """Ensure display names are title-cased with spaces.""" + cmd_builder = CliCommandBuilder() + # Note: Leading underscores in function names create leading spaces in display names + assert cmd_builder.create_display_name(_tool_with_single_param) == " Tool With Single Param" + + +def test_cli_command_builder_create_group_name_adds_group_prefix() -> None: + """Ensure group names are prefixed with 'Group: '.""" + cmd_builder = CliCommandBuilder() + group_name = cmd_builder.create_group_name(_tool_with_single_param) + assert group_name.startswith("Group: ") + + +# ============ Tests for command building ============ + + +def test_cli_command_builder_build_command_with_no_params() -> None: + """Ensure command building works for functions with no parameters.""" + cmd_builder = CliCommandBuilder() + spec = cmd_builder.analyze_tool(_as_function(_tool_with_no_params)) + + command = spec.build_command() + + assert "no-params" in command + assert command.endswith("no-params") + + +def test_cli_command_builder_build_command_with_single_param() -> None: + """Ensure command building includes parameter placeholders.""" + cmd_builder = CliCommandBuilder() + spec = cmd_builder.analyze_tool(_as_function(_tool_with_single_param)) + + command = spec.build_command() + + assert "single-param" in command + assert "${input:" in command + + +def test_cli_command_builder_build_command_respects_custom_program_name() -> None: + """Ensure custom program name is used when provided.""" + cmd_builder = CliCommandBuilder(program_name="custom-tool") + spec = cmd_builder.analyze_tool(_as_function(_tool_with_single_param)) + + command = spec.build_command(program_name="custom-tool") + + assert "custom-tool" in command + + +def test_cli_command_builder_build_command_respects_custom_prefix() -> None: + """Ensure custom command prefix is used when provided.""" + cmd_builder = CliCommandBuilder(command_prefix="python -m ") + spec = cmd_builder.analyze_tool(_as_function(_tool_with_single_param)) + + command = spec.build_command(command_prefix="python -m ") + + assert command.startswith("python -m ") + + +def test_cli_command_builder_build_command_detects_uv_when_available() -> None: + """Ensure uv prefix is detected and used automatically when available.""" + cmd_builder = CliCommandBuilder() + spec = cmd_builder.analyze_tool(_as_function(_tool_with_single_param)) + + command = spec.build_command() + + # The command should have some prefix (either 'uv run --no-sync' or empty) + # We just verify it constructs properly + assert "single-param" in command + + +# ============ Tests for tool specification analysis ============ + + +def test_cli_command_builder_analyze_tool_returns_complete_spec() -> None: + """Ensure analyze_tool returns a ToolCommandSpec with all required fields.""" + cmd_builder = CliCommandBuilder() + spec = cmd_builder.analyze_tool(_as_function(_tool_with_mixed_params)) + + assert spec.tool == _tool_with_mixed_params + assert spec.tool_name == "_tool_with_mixed_params" + assert spec.display_name == " Tool With Mixed Params" # Leading underscore creates leading space + assert spec.docstring == "Tool with mixed parameter types." + assert len(spec.parameters) == 4 + assert spec.command_name == "-tool-with-mixed-params" # Note: first underscore creates leading dash + + +def test_cli_command_builder_parameter_spec_contains_metadata() -> None: + """Ensure ParameterSpec contains all required metadata.""" + cmd_builder = CliCommandBuilder() + spec = cmd_builder.analyze_tool(_as_function(_tool_with_single_param)) + + param_spec = spec.parameters["name"] + + assert param_spec.name == "name" + assert param_spec.annotation == str + assert param_spec.input_id == "_tool_with_single_param_name" + assert param_spec.option_name == "--name" + assert param_spec.uses_option is False # Required parameter + assert param_spec.input_type == "promptString" + + +# ============ Tests for edge cases ============ + + +def test_cli_command_builder_handles_empty_string_default() -> None: + """Ensure empty string defaults are preserved in parameter specs.""" + + def _tool_with_empty_default(text: str = "") -> None: # noqa: ARG001 + """Tool with empty string default.""" + + cmd_builder = CliCommandBuilder() + spec = cmd_builder.analyze_tool(_as_function(_tool_with_empty_default)) + + inputs = spec.get_input_entries() + + assert inputs[0]["default"] == "" + + +def test_cli_command_builder_handles_zero_as_default() -> None: + """Ensure zero integer defaults are preserved and not treated as falsy.""" + + def _tool_with_zero_default(count: int = 0) -> None: # noqa: ARG001 + """Tool with zero default.""" + + cmd_builder = CliCommandBuilder() + spec = cmd_builder.analyze_tool(_as_function(_tool_with_zero_default)) + + inputs = spec.get_input_entries() + + assert inputs[0]["default"] == 0 diff --git a/tests/cli_integration_test.py b/tests/cli_integration_test.py new file mode 100644 index 0000000..20c9e36 --- /dev/null +++ b/tests/cli_integration_test.py @@ -0,0 +1,517 @@ +"""Integration tests for CLI command builder with actual typer command execution. + +This module tests that commands built by CliCommandBuilder can actually be +executed through a real Typer CLI application. It validates parameter parsing, +type conversions, and handling of edge cases like empty strings and multiple values. +""" + +import enum +import subprocess +import sys +from typing import Any + +import pytest +import typer + +from toolit.cli_command_builder import CliCommandBuilder + + +# ============ Test enums and types ============ + + +class Priority(str, enum.Enum): + """Test priority enum.""" + + LOW = "low" + MEDIUM = "medium" + HIGH = "high" + + +class Mode(enum.Enum): + """Test mode enum.""" + + DEVELOPMENT = "dev" + PRODUCTION = "prod" + + +# ============ Test CLI app setup ============ + + +def _create_test_cli_app() -> tuple[typer.Typer, dict[str, Any]]: + """Create a test CLI app with various tool functions. + + Returns: + Tuple of (app, results_dict) where results_dict stores command outputs + for assertion in tests. + """ + app = typer.Typer() + results: dict[str, Any] = {} + + @app.command() + def simple_string(text: str) -> None: + """Simple string parameter.""" + results["simple_string"] = text + + @app.command() + def with_default(name: str = "default") -> None: + """Parameter with default value.""" + results["with_default"] = name + + @app.command() + def with_integer(count: int) -> None: + """Integer parameter.""" + results["with_integer"] = count + + @app.command() + def with_bool(enabled: bool = False) -> None: + """Boolean parameter.""" + results["with_bool"] = enabled + + @app.command() + def with_priority(priority: Priority = Priority.MEDIUM) -> None: + """Enum parameter.""" + results["with_priority"] = priority.value + + @app.command() + def with_list_str(items: list[str]) -> None: + """List of strings parameter.""" + results["with_list_str"] = items + + @app.command() + def with_list_int(numbers: list[int]) -> None: + """List of integers parameter.""" + results["with_list_int"] = numbers + + @app.command() + def multiple_params(name: str, count: int = 1, verbose: bool = False) -> None: + """Multiple parameters of different types.""" + results["multiple_params"] = {"name": name, "count": count, "verbose": verbose} + + return app, results + + +# ============ Tests for basic parameter types ============ + + +class TestBasicParameterExecution: + """Tests for basic parameter types through CLI execution.""" + + def test_simple_string_parameter_passes_correctly(self) -> None: + """Ensure simple string parameters are passed and received correctly.""" + cmd_builder = CliCommandBuilder() + + def simple_string(text: str) -> None: # noqa: ARG001 + """Simple string parameter.""" + + spec = cmd_builder.analyze_tool(simple_string) + + # Verify the spec is correctly generated for a simple string parameter + assert "text" in spec.parameters + assert spec.parameters["text"].annotation == str + assert spec.parameters["text"].uses_option is False # Required parameter + + def test_parameter_with_default_value(self) -> None: + """Ensure parameters with defaults work correctly.""" + + def with_default(name: str = "default") -> None: # noqa: ARG001 + """Parameter with default.""" + + cmd_builder = CliCommandBuilder() + spec = cmd_builder.analyze_tool(with_default) + + param = spec.parameters["name"] + assert param.uses_option is True + assert param.option_name == "--name" + inputs = spec.get_input_entries() + assert inputs[0]["default"] == "default" + + def test_integer_parameter_conversion(self) -> None: + """Ensure integer parameters are handled correctly.""" + + def with_integer(count: int) -> None: # noqa: ARG001 + """Integer parameter.""" + + cmd_builder = CliCommandBuilder() + spec = cmd_builder.analyze_tool(with_integer) + + param = spec.parameters["count"] + assert param.annotation == int + assert param.uses_option is False + + +# ============ Tests for boolean parameters ============ + + +class TestBooleanParameterExecution: + """Tests for boolean parameter handling through CLI.""" + + def test_bool_parameter_defaults_to_false(self) -> None: + """Ensure bool parameters default to False when not specified.""" + + def with_bool(enabled: bool = False) -> None: # noqa: ARG001 + """Boolean parameter.""" + + cmd_builder = CliCommandBuilder() + spec = cmd_builder.analyze_tool(with_bool) + + inputs = spec.get_input_entries() + assert inputs[0]["type"] == "pickString" + assert inputs[0]["options"] == ["True", "False"] + assert inputs[0]["default"] == "False" + + def test_bool_parameter_with_true_default(self) -> None: + """Ensure bool parameters with True default preserve it.""" + + def with_bool_true(enabled: bool = True) -> None: # noqa: ARG001 + """Boolean parameter with True default.""" + + cmd_builder = CliCommandBuilder() + spec = cmd_builder.analyze_tool(with_bool_true) + + inputs = spec.get_input_entries() + assert inputs[0]["default"] == "True" + + 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") + assert cmd == "--enabled" + + +# ============ Tests for enum parameters ============ + + +class TestEnumParameterExecution: + """Tests for enum parameter handling through CLI.""" + + def test_enum_parameter_creates_picklist(self) -> None: + """Ensure enum parameters create pickString input.""" + + def with_priority(priority: Priority = Priority.MEDIUM) -> None: # noqa: ARG001 + """Enum parameter.""" + + cmd_builder = CliCommandBuilder() + spec = cmd_builder.analyze_tool(with_priority) + + inputs = spec.get_input_entries() + assert inputs[0]["type"] == "pickString" + assert "low" in inputs[0]["options"] + assert "medium" in inputs[0]["options"] + assert "high" in inputs[0]["options"] + assert inputs[0]["default"] == "medium" + + def test_enum_with_no_default_uses_first_value(self) -> None: + """Ensure enum parameters without default use first enum value.""" + + def with_mode(mode: Mode) -> None: # noqa: ARG001 + """Mode parameter without default.""" + + cmd_builder = CliCommandBuilder() + spec = cmd_builder.analyze_tool(with_mode) + + inputs = spec.get_input_entries() + assert inputs[0]["default"] == "dev" # First enum value + + +# ============ Tests for list parameters ============ + + +class TestListParameterExecution: + """Tests for list parameter handling through CLI.""" + + def test_list_string_parameter_description(self) -> None: + """Ensure list[str] parameters provide comma-separated guidance.""" + + def with_list_str(items: list[str]) -> None: # noqa: ARG001 + """List of strings.""" + + cmd_builder = CliCommandBuilder() + spec = cmd_builder.analyze_tool(with_list_str) + + inputs = spec.get_input_entries() + assert inputs[0]["type"] == "promptString" + assert "comma-separated" in inputs[0]["description"].lower() + assert "alpha, beta, gamma" in inputs[0]["description"] + + def test_list_int_parameter_description(self) -> None: + """Ensure list[int] parameters provide integer-specific guidance.""" + + def with_list_int(numbers: list[int]) -> None: # noqa: ARG001 + """List of integers.""" + + cmd_builder = CliCommandBuilder() + spec = cmd_builder.analyze_tool(with_list_int) + + inputs = spec.get_input_entries() + assert "integer" in inputs[0]["description"].lower() + assert "1, 2, 3" in inputs[0]["description"] + + def test_list_with_defaults_serializes_correctly(self) -> None: + """Ensure list defaults are serialized to comma-separated strings.""" + + def with_list_default(values: list[int] = [10, 20, 30]) -> None: # noqa: ARG001, B006 + """List with default.""" + + cmd_builder = CliCommandBuilder() + spec = cmd_builder.analyze_tool(with_list_default) + + inputs = spec.get_input_entries() + assert inputs[0]["default"] == "10, 20, 30" + + +# ============ Tests for edge cases ============ + + +class TestEdgeCases: + """Tests for edge cases in parameter handling.""" + + def test_empty_string_default_is_preserved(self) -> None: + """Ensure empty string defaults are preserved.""" + + def with_empty_default(text: str = "") -> None: # noqa: ARG001 + """Tool with empty string default.""" + + cmd_builder = CliCommandBuilder() + spec = cmd_builder.analyze_tool(with_empty_default) + + inputs = spec.get_input_entries() + assert inputs[0]["default"] == "" + assert inputs[0]["type"] == "promptString" + + def test_zero_integer_default_is_not_falsy(self) -> None: + """Ensure zero integer defaults are preserved and not treated as falsy.""" + + def with_zero(count: int = 0) -> None: # noqa: ARG001 + """Tool with zero default.""" + + cmd_builder = CliCommandBuilder() + spec = cmd_builder.analyze_tool(with_zero) + + inputs = spec.get_input_entries() + assert inputs[0]["default"] == 0 + + def test_none_default_for_optional_string(self) -> None: + """Ensure None defaults for optional strings are preserved.""" + + def with_optional(text: str | None = None) -> None: # noqa: ARG001 + """Tool with optional parameter.""" + + cmd_builder = CliCommandBuilder() + spec = cmd_builder.analyze_tool(with_optional) + + inputs = spec.get_input_entries() + assert inputs[0]["default"] is None + + def test_empty_list_default(self) -> None: + """Ensure empty list defaults are handled correctly.""" + + def with_empty_list(items: list[str] = []) -> None: # noqa: ARG001, B006 + """Tool with empty list default.""" + + cmd_builder = CliCommandBuilder() + spec = cmd_builder.analyze_tool(with_empty_list) + + inputs = spec.get_input_entries() + assert inputs[0]["default"] == "" + + def test_parameter_with_special_characters_in_default(self) -> None: + """Ensure defaults with special characters are preserved.""" + + def with_special_chars(text: str = "hello,world!") -> None: # noqa: ARG001 + """Tool with special characters in default.""" + + cmd_builder = CliCommandBuilder() + spec = cmd_builder.analyze_tool(with_special_chars) + + inputs = spec.get_input_entries() + assert inputs[0]["default"] == "hello,world!" + + +# ============ Tests for command building ============ + + +class TestCommandBuilding: + """Tests for building complete commands from tool specs.""" + + def test_command_includes_all_parameters_in_order(self) -> None: + """Ensure command includes all parameters in the correct order.""" + + def multi_param(name: str, count: int = 1, verbose: bool = False) -> None: # noqa: ARG001 + """Multiple parameters.""" + + cmd_builder = CliCommandBuilder() + spec = cmd_builder.analyze_tool(multi_param) + + command = spec.build_command() + + # Verify all parameters are in the command + assert "--count" in command or "count" in command + assert "--verbose" in command or "verbose" in command + assert "${input:multi_param_name}" in command + + def test_command_with_no_parameters(self) -> None: + """Ensure commands with no parameters are built correctly.""" + + def no_params() -> None: + """Tool with no parameters.""" + + cmd_builder = CliCommandBuilder() + spec = cmd_builder.analyze_tool(no_params) + + command = spec.build_command() + assert "no-params" in command + assert "${input:" not in command # No input placeholders + + def test_command_with_custom_program_name(self) -> None: + """Ensure custom program names are used in commands.""" + + def example(name: str) -> None: # noqa: ARG001 + """Example tool.""" + + cmd_builder = CliCommandBuilder(program_name="mytool") + spec = cmd_builder.analyze_tool(example) + + command = spec.build_command(program_name="mytool") + assert "mytool" in command + + def test_command_with_custom_prefix(self) -> None: + """Ensure custom command prefixes are used.""" + + def example(name: str) -> None: # noqa: ARG001 + """Example tool.""" + + cmd_builder = CliCommandBuilder(command_prefix="python -m ") + spec = cmd_builder.analyze_tool(example) + + command = spec.build_command(command_prefix="python -m ") + assert command.startswith("python -m ") + + +# ============ Tests for potential issues ============ + + +class TestPotentialIssues: + """Tests highlighting potential issues with current implementation. + + These tests identify areas where the generated commands might not work + correctly when executed through the actual CLI. + """ + + @pytest.mark.xfail(reason="Empty string from tasks.json might not parse correctly in Typer") + def test_empty_string_input_execution(self) -> None: + """ + Test whether empty strings from task inputs are correctly passed to the CLI. + + POTENTIAL ISSUE: When a user provides an empty string in a tasks.json input, + it's unclear if Typer will correctly receive and parse an empty string versus + treating it as a missing argument. This needs verification. + """ + + def process_text(text: str) -> None: # noqa: ARG001 + """Process text input.""" + + cmd_builder = CliCommandBuilder() + spec = cmd_builder.analyze_tool(process_text) + + # The generated command would look like: + # toolit process-text "" + # Question: Does Typer handle the empty string correctly? + args = spec.get_argument_strings() + assert args[0] == '"${input:process_text_text}"' + + @pytest.mark.xfail(reason="Comma-separated list parsing needs Typer configuration") + def test_comma_separated_list_input_execution(self) -> None: + """ + Test whether comma-separated list inputs are correctly parsed by Typer. + + POTENTIAL ISSUE: The tasks.json input provides "item1, item2, item3", + but Typer expects list[str] to be specified multiple times as: + --items item1 --items item2 --items item3 + + The current implementation doesn't handle the conversion from comma-separated + strings to repeated option flags. This needs a custom Typer callback or parser. + """ + + def process_items(items: list[str]) -> None: # noqa: ARG001 + """Process list of items.""" + + cmd_builder = CliCommandBuilder() + spec = cmd_builder.analyze_tool(process_items) + + inputs = spec.get_input_entries() + description = inputs[0]["description"] + assert "comma-separated" in description.lower() + + # The generated command would be: + # toolit process-items "item1, item2, item3" + # But Typer might not correctly parse this as a list without custom handling. + + @pytest.mark.xfail(reason="Boolean string conversion needs Typer configuration") + def test_bool_string_conversion_in_typer(self) -> None: + """ + Test whether string "True"/"False" from tasks.json are converted to bool by Typer. + + POTENTIAL ISSUE: The tasks.json input provides "True" or "False" as strings, + but Typer needs to convert these to actual boolean values. Typer's default + behavior might not handle this conversion without custom configuration. + """ + + def set_flag(enabled: bool = False) -> None: # noqa: ARG001 + """Set a boolean flag.""" + + cmd_builder = CliCommandBuilder() + spec = cmd_builder.analyze_tool(set_flag) + + inputs = spec.get_input_entries() + assert inputs[0]["options"] == ["True", "False"] + + # The generated command would be: + # toolit set-flag --enabled True + # Question: Does Typer correctly convert the string "True" to bool True? + + +# ============ Question for design discussion ============ + + +class TestDesignQuestions: + """Tests and questions about the overall design. + + These highlight areas where design decisions need to be made. + """ + + def test_how_should_list_parameters_be_handled_in_tasks_json(self) -> None: + """ + Question: How should list parameters be input through tasks.json? + + Current approach: + - Input: "item1, item2, item3" (user types comma-separated values) + - Command: toolit process-items "item1, item2, item3" + - Typer expectation: --items item1 --items item2 --items item3 + + Alternative approaches: + 1. Use a custom separator character (e.g., semicolon or pipe) + 2. Use a Typer callback to parse comma-separated strings + 3. Require quoted JSON format: '["item1", "item2"]' + 4. Use a different input mechanism entirely + """ + pass + + def test_how_should_empty_strings_be_handled(self) -> None: + """ + Question: How should empty string values be handled in tasks.json inputs? + + Current approach: + - User leaves input blank or types nothing + - Input default is empty string "" + - Generated command: toolit command-name "" + + Issues: + - Unclear if shell will pass empty string to Typer + - Unclear if Typer will treat it as None vs empty string + - Might conflict with required string parameters + + Should empty strings be: + 1. Not allowed for required parameters? + 2. Converted to None for optional parameters? + 3. Treated as a special marker? + """ + pass diff --git a/tests/cli_test.py b/tests/cli_test.py index b72f63d..095d2e4 100644 --- a/tests/cli_test.py +++ b/tests/cli_test.py @@ -181,6 +181,113 @@ def returns_wrong_type() -> int: # type: ignore[return-value] assert "must return a string command" in result.output +def test_cli_bool_option_true_string_is_received_as_true() -> None: + """Ensure passing 'True' string for a bool option results in Python True.""" + captured: dict[str, bool] = {} + + def bool_tool(is_enabled: bool = False) -> None: + captured["is_enabled"] = is_enabled + + create_apps_and_register.register_command(bool_tool, name="test-bool-true-string") + runner = CliRunner() + result = runner.invoke(create_apps_and_register.app, ["test-bool-true-string", "--is-enabled", "True"]) + + assert result.exit_code == 0, f"CLI failed: {result.output}" + assert captured["is_enabled"] is True + + +def test_cli_bool_option_false_string_is_received_as_false() -> None: + """Ensure passing 'False' string for a bool option results in Python False.""" + captured: dict[str, bool] = {} + + def bool_tool(is_enabled: bool = True) -> None: + captured["is_enabled"] = is_enabled + + create_apps_and_register.register_command(bool_tool, name="test-bool-false-string") + runner = CliRunner() + result = runner.invoke(create_apps_and_register.app, ["test-bool-false-string", "--is-enabled", "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]] = {} + + def list_str_tool(items: list[str]) -> None: + captured["items"] = items + + create_apps_and_register.register_command(list_str_tool, name="test-list-str-comma") + runner = CliRunner() + result = runner.invoke(create_apps_and_register.app, ["test-list-str-comma", "alpha, beta, gamma"]) + + assert result.exit_code == 0, f"CLI failed: {result.output}" + assert captured["items"] == ["alpha", "beta", "gamma"] + + +def test_cli_list_int_comma_separated_single_arg_is_split_and_converted() -> None: + """Ensure a single comma-separated string is split and converted to list[int].""" + 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-comma") + runner = CliRunner() + result = runner.invoke(create_apps_and_register.app, ["test-list-int-comma", "1, 2, 3"]) + + assert result.exit_code == 0, f"CLI failed: {result.output}" + assert captured["numbers"] == [1, 2, 3] + + +def test_cli_list_enum_comma_separated_single_arg_is_split_and_converted() -> None: + """Ensure a single comma-separated string is split and converted to list[Enum].""" + captured: dict[str, list[object]] = {} + + class Severity(enum.Enum): + LOW = "low" + HIGH = "high" + + def list_enum_tool(levels: list[Severity]) -> None: + captured["levels"] = levels + + create_apps_and_register.register_command(list_enum_tool, name="test-list-enum-comma") + runner = CliRunner() + result = runner.invoke(create_apps_and_register.app, ["test-list-enum-comma", "low, high"]) + + assert result.exit_code == 0, f"CLI failed: {result.output}" + assert captured["levels"] == [Severity.LOW, Severity.HIGH] + + +def test_cli_optional_str_empty_string_becomes_none() -> None: + """Ensure an empty string for str | None parameter is converted to None.""" + captured: dict[str, str | None] = {} + + def optional_str_tool(text: str | None = None) -> None: + captured["text"] = text + + create_apps_and_register.register_command(optional_str_tool, name="test-optional-str-empty") + runner = CliRunner() + result = runner.invoke(create_apps_and_register.app, ["test-optional-str-empty", "--text", ""]) + + assert result.exit_code == 0, f"CLI failed: {result.output}" + assert captured["text"] is None + + +def test_cli_required_str_empty_string_is_rejected() -> None: + """Ensure an empty string for a required str parameter is rejected with an error.""" + + def required_str_tool(text: str) -> None: # noqa: ARG001 + pass + + create_apps_and_register.register_command(required_str_tool, name="test-required-str-empty") + runner = CliRunner() + result = runner.invoke(create_apps_and_register.app, ["test-required-str-empty", ""]) + + assert result.exit_code != 0, "Expected non-zero exit code for empty required string" + + def test_clitool_runtime_propagates_subprocess_exit_code(monkeypatch: pytest.MonkeyPatch) -> None: class DummyCompletedProcess: """Completed-process stand-in with return code for subprocess mocks.""" diff --git a/tests/create_tasks_json_test.py b/tests/create_tasks_json_test.py index 640ae03..6bf8fad 100644 --- a/tests/create_tasks_json_test.py +++ b/tests/create_tasks_json_test.py @@ -1,256 +1,128 @@ -"""Tests for create_tasks_json type annotation handling.""" +"""Tests for tasks.json generation and structure. -import enum -import inspect -from typing import Any, Optional +This module tests the _TaskJsonBuilder class and ensures that tasks.json +is properly structured with correct task entries and input metadata. +""" + +from types import FunctionType +from typing import Any, cast import pytest from toolit import clitool -from toolit.create_tasks_json import TaskJsonBuilder, _annotation_to_string, _create_typer_option_name # 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.""" - - -def _tool_with_typing_optional(input_dataset_name: str | None = None) -> None: - """Tool with a typing.Optional argument.""" - - -def _tool_without_type_hint(to_print) -> None: # type: ignore[no-untyped-def] # noqa: ANN001 - """Tool without a type hint on a parameter.""" - - -def _tool_with_multiple_params_missing_hint(name, value: str) -> None: # type: ignore[no-untyped-def] # noqa: ANN001 - """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() - - args = builder._create_args_for_tool(_tool_with_pep604_optional) # noqa: SLF001 - - assert args == ['--input-dataset-name "${input:_tool_with_pep604_optional_input_dataset_name}"'] - assert builder.inputs[0]["description"] == "Enter value for input_dataset_name (str | None)" - assert builder.inputs[0]["default"] is None +from toolit.cli_command_builder import CliCommandBuilder +from toolit.create_tasks_json import _TaskJsonBuilder -def test_create_args_for_tool_handles_typing_optional() -> None: - """Ensure typing.Optional[str] annotations do not crash and are rendered in descriptions.""" - builder = TaskJsonBuilder() +def _as_function(func: Any) -> FunctionType: + """Cast a Python function to FunctionType for strict type checks.""" + return cast(FunctionType, func) - args = builder._create_args_for_tool(_tool_with_typing_optional) # noqa: SLF001 - assert args == ['--input-dataset-name "${input:_tool_with_typing_optional_input_dataset_name}"'] - assert builder.inputs[0]["description"] == "Enter value for input_dataset_name (str | None)" - assert builder.inputs[0]["default"] is None +# ============ Tests for task entry creation ============ -@pytest.mark.parametrize( - ("annotation", "expected"), - [ - (inspect.Parameter.empty, "str"), - (Any, "Any"), - (list[str], "list[str]"), - (dict[str, int], "dict[str, int]"), - (str | None, "str | None"), - (Optional[int], "int | None"), - ], -) -def test_annotation_to_string_formats_common_and_complex_types(annotation: Any, expected: str) -> None: - """Ensure annotation string conversion supports unions, optionals, and generics.""" - assert _annotation_to_string(annotation) == expected +def test_task_json_builder_creates_task_entry_from_spec() -> None: + """Ensure _TaskJsonBuilder creates properly formatted task entries.""" + @clitool + def run_script(name: str) -> str: + """Run a shell script.""" + return f"echo {name}" -def test_create_args_for_tool_raises_on_missing_type_hint() -> None: - """Ensure a missing type hint raises ValueError with an instructive message.""" - builder = TaskJsonBuilder() - - with pytest.raises( - ValueError, match="Parameter 'to_print' in function '_tool_without_type_hint' is missing a type annotation" - ): - builder._create_args_for_tool(_tool_without_type_hint) - - -def test_create_args_for_tool_error_message_includes_fix_hint() -> None: - """Ensure the error message tells the user how to fix the missing annotation.""" - builder = TaskJsonBuilder() - - with pytest.raises(ValueError, match=r"def _tool_without_type_hint\(to_print: str\) -> None"): - builder._create_args_for_tool(_tool_without_type_hint) - - -def test_create_args_for_tool_raises_on_first_missing_hint_in_mixed_params() -> None: - """Ensure the error reports the specific parameter that is missing the annotation.""" - builder = TaskJsonBuilder() - - 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) - + cmd_builder = CliCommandBuilder() + builder = _TaskJsonBuilder(cmd_builder) + spec = cmd_builder.analyze_tool(_as_function(run_script)) -def test_create_args_for_tool_enum_creates_picklist_input() -> None: - """Ensure enum parameters create pickString input type in tasks.json.""" - builder = TaskJsonBuilder() + builder._create_task_entry(spec) - args = builder._create_args_for_tool(_tool_with_enum_param) # noqa: SLF001 + assert len(builder.tasks) == 1 + task = builder.tasks[0] - assert args == ['"${input:_tool_with_enum_param_color}"'] - assert builder.inputs[0]["type"] == "pickString" - assert builder.inputs[0]["options"] == ["red", "green", "blue"] + assert task["label"] == "Run Script" + assert task["type"] == "shell" + assert task["command"].endswith('toolit run-script "${input:run_script_name}"') + assert task["detail"] == "Run a shell script." + assert task["problemMatcher"] == [] -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() +def test_task_json_builder_omits_detail_when_no_docstring() -> None: + """Ensure detail field is omitted when tool has no docstring.""" - args = builder._create_args_for_tool(_tool_with_enum_param) # noqa: SLF001 + @clitool + def run_without_docstring(name: str) -> str: # type: ignore[no-untyped-def] + pass - assert builder.inputs[0]["default"] == "red" + cmd_builder = CliCommandBuilder() + builder = _TaskJsonBuilder(cmd_builder) + spec = cmd_builder.analyze_tool(_as_function(run_without_docstring)) + # Docstring is None, so we'll set it explicitly to test + spec.docstring = None + builder._create_task_entry(spec) -def test_create_args_for_tool_enum_respects_provided_default() -> None: - """Ensure enum parameters use the provided default value if specified.""" - builder = TaskJsonBuilder() + task = builder.tasks[0] + assert "detail" not in task - args = builder._create_args_for_tool(_tool_with_enum_param_with_default) # noqa: SLF001 - assert args == ['--color "${input:_tool_with_enum_param_with_default_color}"'] - assert builder.inputs[0]["default"] == Color.RED.value +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 + """Tool with multiple parameters.""" -def test_create_args_for_tool_multiple_enum_params() -> None: - """Ensure multiple enum parameters are all correctly handled in tasks.json.""" - builder = TaskJsonBuilder() + cmd_builder = CliCommandBuilder() + builder = _TaskJsonBuilder(cmd_builder) + spec = cmd_builder.analyze_tool(_as_function(multi_param)) - args = builder._create_args_for_tool(_tool_with_multiple_enum_params) # noqa: SLF001 + builder._create_task_entry(spec) + builder.inputs.extend(spec.get_input_entries()) - assert args == [ - '--color "${input:_tool_with_multiple_enum_params_color}"', - '--environment "${input:_tool_with_multiple_enum_params_environment}"', - ] 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() + assert builder.inputs[0]["id"] == "multi_param_name" + assert builder.inputs[1]["id"] == "multi_param_count" - 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_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 + """A simple tool.""" -def test_create_args_for_tool_list_int_serializes_default_values() -> None: - """Ensure list[int] defaults are serialized to comma-separated text.""" - builder = TaskJsonBuilder() - - args = builder._create_args_for_tool(_tool_with_list_int_param) # noqa: SLF001 - - assert args == ['--numbers "${input:_tool_with_list_int_param_numbers}"'] - 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() - - args = builder._create_args_for_tool(_tool_with_list_enum_param) # noqa: SLF001 - - assert args == ['--colors "${input:_tool_with_list_enum_param_colors}"'] - 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() - - args = builder._create_args_for_tool(_tool_with_optional_list_param) # noqa: SLF001 - - assert args == ['--values "${input:_tool_with_optional_list_param_values}"'] - assert builder.inputs[0]["default"] is None + cmd_builder = CliCommandBuilder() + builder = _TaskJsonBuilder(cmd_builder) + spec = cmd_builder.analyze_tool(_as_function(simple_tool)) + builder.process_tool(simple_tool) + tasks_json = builder.create_tasks_json() -def test_create_typer_option_name_replaces_underscores() -> None: - """Ensure option names follow Typer's kebab-case convention.""" - assert _create_typer_option_name("string_input") == "--string-input" + assert "version" in tasks_json + assert tasks_json["version"] == "2.0.0" + assert "tasks" in tasks_json + assert "inputs" in tasks_json + assert isinstance(tasks_json["tasks"], list) + assert isinstance(tasks_json["inputs"], list) -def test_process_tool_clitool_creates_task_and_inputs() -> None: - """Ensure clitool functions are processed into task and input entries.""" +def test_task_json_builder_final_json_structure() -> None: + """Ensure the final tasks.json has correct structure with tasks and inputs.""" @clitool - def run_script(name: str) -> str: - return f"echo {name}" - - builder = TaskJsonBuilder() - builder.process_tool(run_script) - - assert len(builder.tasks) == 1 - assert len(builder.inputs) == 1 - assert builder.tasks[0]["command"].endswith('toolit run-script "${input:run_script_name}"') - assert builder.inputs[0]["id"] == "run_script_name" + def example_tool(name: str, enabled: bool = False) -> None: # noqa: ARG001 + """Example tool.""" + + cmd_builder = CliCommandBuilder() + builder = _TaskJsonBuilder(cmd_builder) + spec = cmd_builder.analyze_tool(_as_function(example_tool)) + builder.process_tool(example_tool) + + tasks_json = builder.create_tasks_json() + + assert len(tasks_json["tasks"]) == 1 + assert len(tasks_json["inputs"]) >= 2 + + task = tasks_json["tasks"][0] + assert task["label"] == "Example Tool" + assert "example-tool" in task["command"] diff --git a/toolit/cli_command_builder.py b/toolit/cli_command_builder.py new file mode 100644 index 0000000..1708ba6 --- /dev/null +++ b/toolit/cli_command_builder.py @@ -0,0 +1,341 @@ +"""Build complete CLI commands with rich metadata for tool functions. + +Scope: this module handles complete tool inspection including parameter analysis, +VS Code input metadata generation, command-line name formatting, and shell command +assembly. The command builder is self-contained and returns rich domain objects. +""" + +import enum +import inspect +import shutil +import types +from dataclasses import dataclass +from typing import Any, Callable, Union, get_args, get_origin + + +@dataclass +class ParameterSpec: + """Complete specification for a single tool parameter. + + Includes both CLI-specific information (option names, argument building) + and VS Code input metadata (type, description, default, options). + """ + + name: str + annotation: Any + default: Any + + # CLI metadata + input_id: str + option_name: str # e.g., '--param-name' + uses_option: bool # whether parameter has a default (uses option flag) + + # VS Code input metadata + input_type: str # 'promptString', 'pickString', etc. + input_options: dict[str, Any] # options for pickString, etc. + input_description: str + input_default: Any + + def get_argument_string(self) -> str: + """Get this parameter's argument string for command building.""" + input_ref: str = f'"${{input:{self.input_id}}}"' + if self.uses_option: + return f"{self.option_name} {input_ref}" + return input_ref + + def to_input_entry(self) -> dict[str, Any]: + """Convert to VS Code input entry for tasks.json.""" + entry: dict[str, Any] = { + "id": self.input_id, + "type": self.input_type, + "description": self.input_description, + "default": self.input_default, + } + entry.update(self.input_options) + return entry + + +@dataclass +class ToolCommandSpec: + """Rich specification for building a tool command with full metadata.""" + + tool: Callable[..., Any] + tool_name: str + display_name: str + docstring: str | None + parameters: dict[str, ParameterSpec] # param_name -> spec + + def get_argument_strings(self) -> list[str]: + """Get all argument strings in parameter order.""" + return [param.get_argument_string() for param in self.parameters.values()] + + def get_input_entries(self) -> list[dict[str, Any]]: + """Get all VS Code input entries for tasks.json.""" + return [param.to_input_entry() for param in self.parameters.values()] + + def iter_parameters(self) -> list[ParameterSpec]: + """Iterate parameters in order.""" + return list(self.parameters.values()) + + @property + def command_name(self) -> str: + """Get the Typer command name derived from tool name.""" + return self.tool_name.replace("_", "-").lower() + + def build_command(self, program_name: str = "toolit", command_prefix: str | None = None) -> str: + """Build the complete shell command string for this tool spec. + + Args: + program_name: The program/command name (default: 'toolit'). + command_prefix: Optional prefix like 'uv run --no-sync '. If None, auto-detects uv. + + Returns: + Complete shell command string ready for execution. + """ + if command_prefix is None: + command_prefix = "uv run --no-sync " if shutil.which("uv") else "" + + args: list[str] = self.get_argument_strings() + rendered_args: str = f" {' '.join(args)}" if args else "" + return f"{command_prefix}{program_name} {self.command_name}{rendered_args}" + + +class CliCommandBuilder: + """Expert analyzer for tool commands with rich metadata generation.""" + + def __init__(self, program_name: str = "toolit", command_prefix: str | None = None) -> None: + """Initialize command builder settings. + + When command_prefix is omitted, uv is used when available. + """ + self.program_name: str = program_name + self.command_prefix: str = command_prefix if command_prefix is not None else self._detect_command_prefix() + + @staticmethod + def create_typer_command_name(tool: Callable[..., Any]) -> str: + """Create a Typer command name from a tool function name.""" + return tool.__name__.replace("_", "-").lower() + + @staticmethod + def create_typer_option_name(param_name: str) -> str: + """Create a Typer option name from a function parameter name.""" + return f"--{param_name.replace('_', '-')}" + + @staticmethod + def create_display_name(tool: Callable[..., Any]) -> str: + """Create a user-facing display name from a tool function name.""" + return tool.__name__.replace("_", " ").title() + + @staticmethod + def create_group_name(tool: Callable[..., Any]) -> str: + """Create a user-facing group label for grouped tools.""" + return "Group: " + tool.__name__.replace("_", " ").title() + + @staticmethod + def _detect_command_prefix() -> str: + """Detect command prefix, preferring uv when available.""" + return "uv run --no-sync " if shutil.which("uv") else "" + + @staticmethod + def _is_enum(annotation: Any) -> bool: + """Check if annotation is an Enum type.""" + return isinstance(annotation, type) and issubclass(annotation, enum.Enum) + + @staticmethod + def _is_bool(annotation: Any) -> bool: + """Check if annotation is a bool type.""" + return annotation is bool + + @staticmethod + def _unwrap_union_annotations(annotation: Any) -> list[Any]: + """Return union members for X | Y or 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] + + @staticmethod + def _extract_enum_type(annotation: Any) -> type[enum.Enum] | None: + """Extract enum type from annotation, including optional/union wrappers.""" + for candidate in CliCommandBuilder._unwrap_union_annotations(annotation): + if candidate in {None, type(None)}: + continue + if CliCommandBuilder._is_enum(candidate): + return candidate + return None + + @staticmethod + def _contains_bool(annotation: Any) -> bool: + """Check whether annotation contains bool directly or via union/optional.""" + return any( + CliCommandBuilder._is_bool(candidate) + for candidate in CliCommandBuilder._unwrap_union_annotations(annotation) + ) + + @staticmethod + def _extract_list_item_type(annotation: Any) -> Any | None: + """Extract list item type from annotation, including optional/union wrappers.""" + for candidate in CliCommandBuilder._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 + + @staticmethod + def _annotation_to_string(annotation: Any) -> str: + """Convert Python type annotations to readable strings.""" + result: str = "" + + if annotation == inspect.Parameter.empty: + result = "str" + elif annotation is Any: + result = "Any" + elif annotation is None or annotation is type(None): + result = "None" + else: + 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): + result = " | ".join(CliCommandBuilder._annotation_to_string(arg) for arg in args) + elif origin is not None: + origin_name = getattr(origin, "__name__", str(origin).replace("typing.", "")) + if args: + args_repr = ", ".join(CliCommandBuilder._annotation_to_string(arg) for arg in args) + result = f"{origin_name}[{args_repr}]" + else: + result = origin_name + elif hasattr(annotation, "__name__"): + result = annotation.__name__ + else: + result = str(annotation).replace("typing.", "") + + return result + + @staticmethod + def _build_list_description(param_name: str, list_item_type: Any) -> str: + """Build 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 CliCommandBuilder._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 = CliCommandBuilder._annotation_to_string(list_item_type) + return f"Enter comma-separated values for {param_name} ({item_type_name})" + + def _build_input_metadata(self, param: inspect.Parameter) -> tuple[str, dict[str, Any], str, Any]: + """Build VS Code input metadata for a single parameter. + + Returns: (input_type, input_options, description, default_value) + """ + annotation = param.annotation + input_type: str = "promptString" + input_options: dict[str, Any] = {} + description: str = f"Enter value for {param.name} ({self._annotation_to_string(annotation)})" + default_value: Any = "" if param.default == inspect.Parameter.empty else param.default + + list_item_type = self._extract_list_item_type(annotation) + if list_item_type is not None: + description = self._build_list_description(param.name, list_item_type) + if param.default == inspect.Parameter.empty or param.default is None: + default_value = "" if param.default == inspect.Parameter.empty else None + else: + # Serialize list defaults + rendered_items: list[str] = [] + for item in param.default: + if isinstance(item, enum.Enum): + rendered_items.append(str(item.value)) + else: + rendered_items.append(str(item)) + default_value = ", ".join(rendered_items) + return input_type, input_options, description, default_value + + enum_type = self._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 self._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 analyze_tool(self, tool: Callable[..., Any]) -> ToolCommandSpec: + """Analyze tool and return complete specification for building command and inputs. + + Args: + tool: The tool function to analyze. + + Returns: + ToolCommandSpec with all metadata needed for command and input generation. + + Raises: + ValueError: If a parameter lacks a type annotation. + """ + sig = inspect.signature(tool) + parameters: dict[str, ParameterSpec] = {} + + for param in sig.parameters.values(): + if param.name == "self": + continue + + annotation = param.annotation + if annotation is inspect.Parameter.empty: + msg = ( + f"Parameter '{param.name}' in function '{tool.__name__}' is missing a type annotation. " + f"Please add a type hint, e.g.: def {tool.__name__}({param.name}: str) -> None" + ) + raise ValueError(msg) + + # Build CLI metadata + input_id: str = f"{tool.__name__}_{param.name}" + option_name: str = self.create_typer_option_name(param.name) + uses_option: bool = param.default is not inspect.Parameter.empty + + # Build VS Code input metadata + input_type, input_options, description, default_value = self._build_input_metadata(param) + + parameters[param.name] = ParameterSpec( + name=param.name, + annotation=annotation, + default=param.default, + input_id=input_id, + option_name=option_name, + uses_option=uses_option, + input_type=input_type, + input_options=input_options, + input_description=description, + input_default=default_value, + ) + + return ToolCommandSpec( + tool=tool, + tool_name=tool.__name__, + display_name=self.create_display_name(tool), + docstring=tool.__doc__, + parameters=parameters, + ) diff --git a/toolit/create_apps_and_register.py b/toolit/create_apps_and_register.py index 87f13a1..10f8ccc 100644 --- a/toolit/create_apps_and_register.py +++ b/toolit/create_apps_and_register.py @@ -2,13 +2,15 @@ from __future__ import annotations +import enum import inspect import os import subprocess +import types from functools import wraps import typer from collections.abc import Callable -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Union, get_args, get_origin from toolit.constants import MARKER_TOOL, ToolitTypesEnum @@ -45,9 +47,9 @@ def register_command( msg = f"Command function {command_func} is not callable." raise TypeError(msg) - command_to_register = 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_func) + command_to_register = _create_clitool_runtime_wrapper(command_to_register) app.command(name=name, rich_help_panel=rich_help_panel)(command_to_register) @@ -55,6 +57,150 @@ def register_command( mcp.tool(name)(command_func) +def _unwrap_union_members(annotation: Any) -> list[Any]: + """Return members for X | Y / Union[X, Y], or a single-element list otherwise.""" + origin = get_origin(annotation) + union_type = getattr(types, "UnionType", None) + if origin is Union or (union_type is not None and isinstance(annotation, union_type)): + return list(get_args(annotation)) + return [annotation] + + +def _extract_list_item_type(annotation: Any) -> Any | 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: Any) -> 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: Any) -> 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) == 2 + + +def _is_required_str(annotation: Any, default: Any) -> 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: Any) -> 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[Any], item_type: Any) -> list[Any]: + """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 # type: ignore[return-value] + 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 _create_type_coercion_wrapper(func: Callable[..., Any]) -> Callable[..., Any]: + """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, Any]] = {} + + for param in sig.parameters.values(): + ann = param.annotation + + list_item_type = _extract_list_item_type(ann) + if list_item_type is not None: + coercions[param.name] = ("list", list_item_type) + # Expose list[str] (or list[str] | None) to Typer so it skips type conversion. + new_ann: Any = (list[str] | None) if _is_optional_list(ann) else list[str] + new_params.append(param.replace(annotation=new_ann)) + continue + + if _contains_bool(ann): + coercions[param.name] = ("bool", None) + bool_default = "False" if param.default is inspect.Parameter.empty else str(param.default) + new_params.append(param.replace(annotation=str, default=bool_default)) + continue + + if _is_optional_str(ann): + coercions[param.name] = ("optional_str", None) + new_params.append(param) + continue + + if _is_required_str(ann, param.default): + coercions[param.name] = ("required_str", None) + new_params.append(param) + continue + + new_params.append(param) + + if not coercions: + return func + + new_sig = sig.replace(parameters=new_params) + + @wraps(func) + def _wrapper(*args: Any, **kwargs: Any) -> Any: + for param_name, (coercion_type, extra) in coercions.items(): + if param_name not in kwargs: + continue + value = kwargs[param_name] + + if coercion_type == "list": + kwargs[param_name] = _coerce_list_value(value, extra) + elif coercion_type == "bool": + kwargs[param_name] = str(value).lower() == "true" + elif coercion_type == "optional_str": + if value == "": + kwargs[param_name] = None + elif coercion_type == "required_str": + if value == "": + typer.secho(f"Error: '{param_name}' cannot be empty.", fg=typer.colors.RED) + raise typer.Exit(code=1) + + return func(*args, **kwargs) + + _wrapper.__signature__ = new_sig + # 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, Any] = { + 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[..., Any]) -> Callable[..., None]: """Wrap a clitool function so its returned command string runs in a shell.""" diff --git a/toolit/create_tasks_json.py b/toolit/create_tasks_json.py index c026fd1..f83ef57 100644 --- a/toolit/create_tasks_json.py +++ b/toolit/create_tasks_json.py @@ -1,12 +1,17 @@ -"""Create a vscode tasks.json file based on the tools discovered in the project.""" +"""Generate VS Code tasks.json entries from discovered tools. + +Scope: this module builds tasks.json structure by consuming rich metadata from +CliCommandBuilder. It delegates all tool inspection and metadata generation to +the command builder. +""" -import enum import json -import shutil -import typer -import types -import inspect import pathlib +from types import FunctionType +from typing import Any + +import typer + from toolit.auto_loader import ( clitool_strategy, get_items_from_folder, @@ -15,30 +20,14 @@ tool_group_strategy, tool_strategy, ) +from toolit.cli_command_builder import CliCommandBuilder from toolit.config import load_devtools_folder from toolit.constants import ToolitTypesEnum -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.""" typer.echo(f"Creating tasks.json at {output_file_path}") @@ -53,7 +42,7 @@ def create_vscode_tasks_json() -> None: tools = [] tools.extend(get_plugin_tools()) - json_builder = TaskJsonBuilder() + json_builder = _TaskJsonBuilder(CliCommandBuilder()) for tool in tools: json_builder.process_tool(tool) tasks_json: dict[str, Any] = json_builder.create_tasks_json() @@ -63,225 +52,35 @@ def create_vscode_tasks_json() -> None: json.dump(tasks_json, f, indent=4) -def _is_enum(annotation: Any) -> bool: # noqa: ANN401 - """Check if the annotation is an Enum type.""" - return isinstance(annotation, type) and issubclass(annotation, enum.Enum) - - -def _is_bool(annotation: Any) -> bool: # noqa: ANN401 - """Check if the annotation is a bool type.""" - 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 = "" +class _TaskJsonBuilder: + """Build tasks.json payloads from tool command specs.""" - if annotation == inspect.Parameter.empty: - result = "str" - elif annotation is Any: - result = "Any" - elif annotation is None or annotation is type(None): - result = "None" - else: - 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): - result = " | ".join(_annotation_to_string(arg) for arg in args) - elif origin is not None: - origin_name = getattr(origin, "__name__", str(origin).replace("typing.", "")) - if args: - args_repr = ", ".join(_annotation_to_string(arg) for arg in args) - result = f"{origin_name}[{args_repr}]" - else: - result = origin_name - elif hasattr(annotation, "__name__"): - result = annotation.__name__ - else: - result = str(annotation).replace("typing.", "") - - return result - - -def _create_typer_command_name(tool: FunctionType) -> str: - """Create a Typer command name from a tool function name.""" - return tool.__name__.replace("_", "-").lower() - - -def _create_display_name(tool: FunctionType) -> str: - """Create a display name from a tool function name.""" - return tool.__name__.replace("_", " ").title() - - -def _create_typer_option_name(param_name: str) -> str: - """Create a Typer option name from a function parameter name.""" - return f"--{param_name.replace('_', '-')}" - - -class TaskJsonBuilder: - """Class to build tasks.json inputs and argument mappings.""" - - def __init__(self) -> None: + def __init__(self, cli_command_builder: CliCommandBuilder) -> None: """Initialize the object.""" + self.cli_command_builder = cli_command_builder self.inputs: list[dict[str, Any]] = [] - self.input_id_map: dict[tuple[str, str], str] = {} self.tasks: list[dict[str, Any]] = [] - @staticmethod - def _build_command_prefix() -> str: - """Build command prefix for task commands based on uv availability.""" - return "uv run --no-sync " if shutil.which("uv") else "" - - 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) - args: list[str] = [] - for param in sig.parameters.values(): - if param.name == "self": - continue - input_id: str = f"{tool.__name__}_{param.name}" - self.input_id_map[tool.__name__, param.name] = input_id - - annotation = param.annotation - if annotation is inspect.Parameter.empty: - msg = ( - f"Parameter '{param.name}' in function '{tool.__name__}' is missing a type annotation. " - f"Please add a type hint, e.g.: def {tool.__name__}({param.name}: str) -> None" - ) - raise ValueError( - msg, - ) - input_type, input_options, description, default_value = self._build_input_metadata(param) - - input_entry: dict[str, Any] = { - "id": input_id, - "type": input_type, - "description": description, - "default": default_value, - } - input_entry.update(input_options) - self.inputs.append(input_entry) - input_value_ref = f'"${{input:{input_id}}}"' - if param.default is inspect.Parameter.empty: - args.append(input_value_ref) - else: - args.append(f"{_create_typer_option_name(param.name)} {input_value_ref}") - return args - - def _create_task_entry(self, tool: FunctionType, args: list[str]) -> None: - """Create a task entry for a given tool.""" - name_as_typer_command: str = _create_typer_command_name(tool) - display_name: str = _create_display_name(tool) - command_prefix: str = self._build_command_prefix() + def _create_task_entry(self, spec) -> None: # ToolCommandSpec + """Create a task entry from a tool command spec.""" + command: str = spec.build_command(self.cli_command_builder.program_name, self.cli_command_builder.command_prefix) task: dict[str, Any] = { - "label": display_name, + "label": spec.display_name, "type": "shell", - "command": f"{command_prefix}toolit {name_as_typer_command}" + (f" {' '.join(args)}" if args else ""), + "command": command, "problemMatcher": [], } - if tool.__doc__: - task["detail"] = tool.__doc__.strip() + if spec.docstring: + task["detail"] = spec.docstring.strip() self.tasks.append(task) - def _create_task_group_entry(self, tool: FunctionType, tool_type: ToolitTypesEnum) -> None: + def _create_task_group_entry(self, tool: FunctionType, tool_type) -> None: # ToolitTypesEnum """Create a task group entry for a given tool.""" - group_name: str = "Group: " + tool.__name__.replace("_", " ").title() + group_name: str = self.cli_command_builder.create_group_name(tool) tools: list[FunctionType] = tool() # Call the tool to get the list of tools in the group task: dict[str, Any] = { "label": group_name, - "dependsOn": [f"{_create_display_name(t)}" for t in tools], + "dependsOn": [self.cli_command_builder.create_display_name(t) for t in tools], "problemMatcher": [], } if tool_type == ToolitTypesEnum.SEQUENTIAL_GROUP: @@ -294,19 +93,19 @@ def process_tool(self, tool: FunctionType) -> None: """Process a single tool to create its task entry and inputs.""" tool_type = get_toolit_type(tool) if tool_type in {ToolitTypesEnum.TOOL, ToolitTypesEnum.CLITOOL}: - args = self._create_args_for_tool(tool) - self._create_task_entry(tool, args) + spec = self.cli_command_builder.analyze_tool(tool) + self._create_task_entry(spec) + self.inputs.extend(spec.get_input_entries()) elif tool_type in {ToolitTypesEnum.SEQUENTIAL_GROUP, ToolitTypesEnum.PARALLEL_GROUP}: self._create_task_group_entry(tool, tool_type) def create_tasks_json(self) -> dict[str, Any]: """Create the final tasks.json structure.""" - tasks_json: dict[str, Any] = { + return { "version": "2.0.0", "tasks": self.tasks, "inputs": self.inputs, } - return tasks_json if __name__ == "__main__": From 99878c03451ea34c00303b4bd8975fa2b0863c7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20M=C3=B8ldrup?= <14809761+martinmoldrup@users.noreply.github.com> Date: Sun, 3 May 2026 17:47:42 +0200 Subject: [PATCH 5/9] Fixes to fix certain edge cases, improve the testing Co-authored-by: Copilot --- .github/copilot-instructions.md | 31 +++++++++++++++ tests/cli_command_builder_test.py | 15 +++++++- tests/cli_integration_test.py | 62 ++++++++++++++++++++++++++++++ toolit/cli_command_builder.py | 16 +++++++- toolit/constants.py | 1 + toolit/create_apps_and_register.py | 14 +++++-- 6 files changed, 133 insertions(+), 6 deletions(-) create mode 100644 .github/copilot-instructions.md diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..142dee7 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,31 @@ +# Copilot Instructions For This Repository + +## Design Principles + +- Prefer explicit data models over loosely structured dictionaries when representing domain concepts. +- Favor Domain-Driven Design (DDD) thinking for new features and refactors when it fits the change: + - Keep domain concepts, language, and invariants clear. + - Organize code around domain behavior and boundaries, not only technical layers. + - Avoid leaking infrastructure details into core domain logic. +- Keep business rules close to domain models and domain services. + +## Command Execution + +- Use `uv run` when project Python dependencies or the project environment are needed. +- This applies to tests, linting, type-checking, scripts, and local tooling commands that rely on the repository environment. +- It is acceptable to run commands without `uv run` when the `.venv` or project dependencies are not needed. +- Prefer forms like: + - `uv run pytest` + - `uv run ruff check .` + - `uv run mypy .` + - `uv run python