Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ All notable changes to this project will be documented in this file.

*NOTE:* Version 0.X.X might have breaking changes in bumps of the minor version number. This is because the project is still in early development and the API is not yet stable. It will still be marked clearly in the release notes.

## [0.8.0] - 29-06-2026
- Fix bug with bool without default value being treated as a required parameter in the CLI. This would make it not match the CLI builder and cause an error in execution.
- Fix bug with CLI builder using the wrong type of quotes, and not parsing correctly in some cases. Now uses single quotes instead of double quotes.

## [0.7.0] - 03-05-2026
- Added support for the Optional and Union type hints in the CLI arguments. This allows for more flexible command definitions and better type checking.
- Improved support for list parameters in the CLI, and handled the vscode tasks generation for list parameters appropriately.
Expand Down
1 change: 0 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ If you want mcp server support, you can install the optional dependency:
```bash
pip install toolit[mcp]
```
Note: MCP support is not available on python 3.9, since it is not supported by the `mcp` package.

## Usage
Add a folder called `devtools` to your project root. Create python modules, you decide the name, in this folder. Add the tool decorator to functions you want to expose as commands.
Expand Down
38 changes: 15 additions & 23 deletions tests/cli_command_builder_test.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,12 @@
"""Tests for CLI command builder with full parameter analysis and metadata generation."""

import enum
import inspect
from types import FunctionType
from typing import Any, Optional, cast

import pytest

import inspect
from toolit.cli_command_builder import CliCommandBuilder
from toolit.constants import OPTIONAL_STR_SENTINEL
from types import FunctionType
from typing import Any, Optional, cast


def _as_function(func: Any) -> FunctionType:
Expand Down Expand Up @@ -139,10 +137,7 @@ def test_create_args_for_tool_handles_pep604_optional() -> None:
inputs = spec.get_input_entries()

assert args == [
'--input-dataset-name "'
f'{OPTIONAL_STR_SENTINEL}'
'${input:_tool_with_pep604_optional_input_dataset_name}'
'"'
f"--input-dataset-name '{OPTIONAL_STR_SENTINEL}${{input:_tool_with_pep604_optional_input_dataset_name}}'",
]
assert inputs[0]["description"] == "Enter value for input_dataset_name (str | None)"
assert inputs[0]["default"] is None
Expand All @@ -157,10 +152,7 @@ def test_create_args_for_tool_handles_typing_optional() -> None:
inputs = spec.get_input_entries()

assert args == [
'--input-dataset-name "'
f'{OPTIONAL_STR_SENTINEL}'
'${input:_tool_with_typing_optional_input_dataset_name}'
'"'
f"--input-dataset-name '{OPTIONAL_STR_SENTINEL}${{input:_tool_with_typing_optional_input_dataset_name}}'",
]
assert inputs[0]["description"] == "Enter value for input_dataset_name (str | None)"
assert inputs[0]["default"] is None
Expand All @@ -174,7 +166,7 @@ def test_create_args_for_tool_raises_on_missing_type_hint() -> None:
cmd_builder = CliCommandBuilder()

with pytest.raises(
ValueError, match="Parameter 'to_print' in function '_tool_without_type_hint' is missing a type annotation"
ValueError, match="Parameter 'to_print' in function '_tool_without_type_hint' is missing a type annotation",
):
cmd_builder.analyze_tool(_as_function(_tool_without_type_hint))

Expand Down Expand Up @@ -206,7 +198,7 @@ def test_create_args_for_tool_enum_creates_picklist_input() -> None:
args = spec.get_argument_strings()
inputs = spec.get_input_entries()

assert args == ['"${input:_tool_with_enum_param_color}"']
assert args == ["'${input:_tool_with_enum_param_color}'"]
assert inputs[0]["type"] == "pickString"
assert inputs[0]["options"] == ["red", "green", "blue"]

Expand All @@ -229,7 +221,7 @@ def test_create_args_for_tool_enum_respects_provided_default() -> None:
args = spec.get_argument_strings()
inputs = spec.get_input_entries()

assert args == ['--color "${input:_tool_with_enum_param_with_default_color}"']
assert args == ["--color '${input:_tool_with_enum_param_with_default_color}'"]
assert inputs[0]["default"] == Color.RED.value


Expand All @@ -242,8 +234,8 @@ def test_create_args_for_tool_multiple_enum_params() -> None:
inputs = spec.get_input_entries()

assert args == [
'--color "${input:_tool_with_multiple_enum_params_color}"',
'--environment "${input:_tool_with_multiple_enum_params_environment}"',
"--color '${input:_tool_with_multiple_enum_params_color}'",
"--environment '${input:_tool_with_multiple_enum_params_environment}'",
]
assert len(inputs) == 2
# First input (color)
Expand All @@ -267,7 +259,7 @@ def test_create_args_for_tool_list_str_uses_promptstring_and_guidance() -> None:
args = spec.get_argument_strings()
inputs = spec.get_input_entries()

assert args == ['"${input:_tool_with_list_str_param_items}"']
assert args == ["'${input:_tool_with_list_str_param_items}'"]
assert inputs[0]["type"] == "promptString"
assert inputs[0]["description"] == "Enter comma-separated text values for items (e.g. alpha, beta, gamma)"
assert inputs[0]["default"] == ""
Expand All @@ -281,7 +273,7 @@ def test_create_args_for_tool_list_int_serializes_default_values() -> None:
args = spec.get_argument_strings()
inputs = spec.get_input_entries()

assert args == ['--numbers "${input:_tool_with_list_int_param_numbers}"']
assert args == ["--numbers '${input:_tool_with_list_int_param_numbers}'"]
assert inputs[0]["description"] == "Enter comma-separated integer values for numbers (e.g. 1, 2, 3)"
assert inputs[0]["default"] == "1, 2, 3"

Expand All @@ -294,7 +286,7 @@ def test_create_args_for_tool_list_enum_serializes_default_values() -> None:
args = spec.get_argument_strings()
inputs = spec.get_input_entries()

assert args == ['--colors "${input:_tool_with_list_enum_param_colors}"']
assert args == ["--colors '${input:_tool_with_list_enum_param_colors}'"]
assert (
inputs[0]["description"]
== "Enter comma-separated enum values for colors. Accepted values: [red, green, blue]. You can also use enum member names."
Expand All @@ -310,7 +302,7 @@ def test_create_args_for_tool_optional_list_keeps_none_default() -> None:
args = spec.get_argument_strings()
inputs = spec.get_input_entries()

assert args == ['--values "${input:_tool_with_optional_list_param_values}"']
assert args == ["--values '${input:_tool_with_optional_list_param_values}'"]
assert inputs[0]["default"] is None


Expand All @@ -325,7 +317,7 @@ def test_create_args_for_tool_bool_creates_picklist_input() -> None:
args = spec.get_argument_strings()
inputs = spec.get_input_entries()

assert args == ['--is-enabled "${input:_tool_with_bool_param_is_enabled}"']
assert args == ["--is-enabled '${input:_tool_with_bool_param_is_enabled}'"]
assert inputs[0]["type"] == "pickString"
assert inputs[0]["options"] == ["True", "False"]
assert inputs[0]["default"] == "False"
Expand Down
12 changes: 12 additions & 0 deletions tests/cli_integration_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,18 @@ def with_bool_true(enabled: bool = True) -> None: # noqa: ARG001
inputs = spec.get_input_entries()
assert inputs[0]["default"] == "True"

def test_bool_without_default(self) -> None:
"""Ensure bool parameters with True default preserve it."""

def with_bool_no_defaults(enabled: bool) -> None: # noqa: ARG001
"""Boolean parameter with True default."""

cmd_builder = CliCommandBuilder()
spec = cmd_builder.analyze_tool(with_bool_no_defaults)

inputs = spec.get_input_entries()
assert inputs[0]["default"] == "False"

def test_bool_command_with_true_string(self) -> None:
"""Ensure 'True' string is correctly converted to bool True."""
cmd = CliCommandBuilder().create_typer_option_name("enabled")
Expand Down
16 changes: 16 additions & 0 deletions tests/cli_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,22 @@ def bool_tool(is_enabled: bool = True) -> None:
assert captured["is_enabled"] is False


def test_cli_bool_option_no_default_string_is_received_as_false() -> None:
"""Ensure passing false, in a function with no default, for a bool option results in Python False."""
captured: dict[str, bool] = {}

def bool_tool(is_enabled: bool) -> None:
captured["is_enabled"] = is_enabled

create_apps_and_register.register_command(bool_tool, name="test-bool-no-default")
runner = CliRunner()
result = runner.invoke(create_apps_and_register.app, ["test-bool-no-default", "False"])

assert result.exit_code == 0, f"CLI failed: {result.output}"
assert captured["is_enabled"] is False



def test_cli_list_str_comma_separated_single_arg_is_split() -> None:
"""Ensure a single comma-separated string is split into a list[str]."""
captured: dict[str, list[str]] = {}
Expand Down
8 changes: 4 additions & 4 deletions tests/create_tasks_json_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ def run_script(name: str) -> str:

assert task["label"] == "Run Script"
assert task["type"] == "shell"
assert task["command"].endswith('toolit run-script "${input:run_script_name}"')
assert task["command"].endswith("toolit run-script '${input:run_script_name}'")
assert task["detail"] == "Run a shell script."
assert task["problemMatcher"] == []

Expand All @@ -50,7 +50,7 @@ def test_task_json_builder_omits_detail_when_no_docstring() -> None:
"""Ensure detail field is omitted when tool has no docstring."""

@clitool
def run_without_docstring(name: str) -> str: # type: ignore[no-untyped-def]
def run_without_docstring(name: str) -> str: # type: ignore[no-untyped-def,empty-body]
pass

cmd_builder = CliCommandBuilder()
Expand All @@ -69,7 +69,7 @@ def test_task_json_builder_collects_input_entries() -> None:
"""Ensure all input entries are collected during processing."""

@clitool
def multi_param(name: str, count: int = 5) -> None: # noqa: ARG001
def multi_param(name: str, count: int = 5) -> None:
"""Tool with multiple parameters."""

cmd_builder = CliCommandBuilder()
Expand All @@ -88,7 +88,7 @@ def test_task_json_builder_create_tasks_json_returns_proper_structure() -> None:
"""Ensure create_tasks_json returns properly formatted output."""

@clitool
def simple_tool(text: str) -> None: # noqa: ARG001
def simple_tool(text: str) -> None:
"""A simple tool."""

cmd_builder = CliCommandBuilder()
Expand Down
4 changes: 2 additions & 2 deletions toolit/cli_command_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,9 @@ class ParameterSpec:
def get_argument_string(self) -> str:
"""Get this parameter's argument string for command building."""
if self.is_optional_string:
input_ref = f'"{OPTIONAL_STR_SENTINEL}${{input:{self.input_id}}}"'
input_ref = f"'{OPTIONAL_STR_SENTINEL}${{input:{self.input_id}}}'"
else:
input_ref = f'"${{input:{self.input_id}}}"'
input_ref = f"'${{input:{self.input_id}}}'"
if self.uses_option:
return f"{self.option_name} {input_ref}"
return input_ref
Expand Down
Loading
Loading