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: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@ All notable changes to this project will be documented in this file.

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

## [0.7.0] - 27-06-2026
## [0.7.0] - Unreleased
- Added support for the Optional and Union type hints in the CLI arguments. This allows for more flexible command definitions and better type checking.
- Improved support for list parameters in the CLI, and handled the vscode tasks generation for list parameters appropriately.
- Raise an error if a proper type hint is not provided for a parameter in a tool function.
- Make it possible to invoke the CLI using `python -m toolit` in addition to the `toolit` command, which is useful for environments where the command might not be available (or when there is a specific venv or python version you want to use).

## [0.6.0] - 20-12-2025
- Abandon python 3.9 which is deprecated. Now only support python 3.10 and higher.
Expand Down
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,15 @@ toolit create-vscode-tasks-json
```
NOTE: THIS WILL OVERWRITE YOUR EXISTING `.vscode/tasks.json` FILE IF IT EXISTS!

### List parameter inputs in VS Code tasks

VS Code task inputs do not support native list pickers. For tool parameters typed as `list[str]`, `list[int]`, or `list[Enum]`, Toolit generates a `promptString` input and expects comma-separated values.

Examples:
- `list[str]`: `alice, bob, charlie`
- `list[int]`: `1, 2, 3`
- `list[MyEnum]`: accepts both enum names and enum values, for example `OPTION_A, Option b`

## Chaining Commands
You can chain multiple using the `@sequential_group_of_tools` and `@parallel_group_of_tools` decorators to create more complex workflows. Functions decorated with these decorators should always return a list of callable functions.

Expand Down
8 changes: 4 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "toolit"
version = "0.6.0"
version = "0.7.0"
description = "MCP Server, Typer CLI and vscode tasks in one, provides an easy way to configure your own DevTools and python scripts in a project."
readme = "README.md"
requires-python = ">=3.10"
Expand Down Expand Up @@ -51,12 +51,12 @@ dev = [
mcp = ["mcp[cli]"]

[project.scripts]
toolit = "toolit.cli:app"
toolit = "toolit.cli:main"

[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"

[tool.setuptools]
packages = ["toolit"]

Expand Down
101 changes: 101 additions & 0 deletions tests/cli_test.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import toolit.create_apps_and_register as create_apps_and_register
import toolit.__main__ as main_module
import toolit.cli as cli_module
import enum
import pytest
from typer.testing import CliRunner


Expand Down Expand Up @@ -31,3 +35,100 @@ def test_cli_command_is_registered() -> None:
assert "a-new-command-registered" in result.stdout


def test_module_main_invokes_app(monkeypatch: pytest.MonkeyPatch) -> None:
called: dict[str, bool] = {"value": False}

def fake_main() -> None:
called["value"] = True

monkeypatch.setattr(main_module, "main", fake_main)
main_module.main()
assert called["value"]


def test_cli_list_str_input_is_converted_from_comma_separated_tokens() -> None:
captured: dict[str, list[str]] = {}

def list_str_tool(items: list[str]) -> None:
captured["items"] = items

create_apps_and_register.register_command(
list_str_tool,
name="dummycommand",
)

runner = CliRunner()
result = runner.invoke(
create_apps_and_register.app,
["dummycommand", "alpha", "beta", "gamma"],
)

assert result.exit_code == 0, f"CLI invocation failed with output: {result.output}, captured: {captured}"
assert captured["items"] == ["alpha", "beta", "gamma"]


def test_cli_list_int_input_is_converted_from_comma_separated_tokens() -> None:
captured: dict[str, list[int]] = {}

def list_int_tool(numbers: list[int]) -> None:
captured["numbers"] = numbers

create_apps_and_register.register_command(
list_int_tool,
name="dummycommand",
)

runner = CliRunner()
result = runner.invoke(
create_apps_and_register.app,
["dummycommand", "1", "2", "3"],
)

assert result.exit_code == 0, f"CLI invocation failed with output: {result.output}, captured: {captured}"
assert captured["numbers"] == [1, 2, 3]


def test_cli_list_enum_input_accepts_enum_names_and_values() -> None:
captured: dict[str, list[object]] = {}

class TestLevel(enum.Enum):
LOW = "low"
HIGH = "high"

def list_enum_tool(levels: list[TestLevel]) -> None:
captured["levels"] = levels

create_apps_and_register.register_command(
list_enum_tool,
name="dummycommand",
)

runner = CliRunner()
result = runner.invoke(
create_apps_and_register.app,
["dummycommand", "low", "high"],
)

assert result.exit_code == 0, f"CLI invocation failed with output: {result.output}, captured: {captured}"
assert captured["levels"] == [TestLevel.LOW, TestLevel.HIGH]


def test_cli_optional_list_omitted_preserves_none_default() -> None:
captured: dict[str, list[str] | None] = {}

def optional_list_tool(values: list[str] | None = None) -> None:
captured["values"] = values

create_apps_and_register.register_command(
optional_list_tool,
name="test-optional-list-omitted-preserves-none-default",
)

runner = CliRunner()
result = runner.invoke(
create_apps_and_register.app,
["test-optional-list-omitted-preserves-none-default"],
)

assert result.exit_code == 0, f"CLI invocation failed with output: {result.output}, captured: {captured}"
assert captured["values"] is None
144 changes: 142 additions & 2 deletions tests/create_tasks_json_test.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,29 @@
"""Tests for create_tasks_json type annotation handling."""

import pytest
import enum
import inspect
from toolit.create_tasks_json import TaskJsonBuilder, _annotation_to_string # noqa: PLC2701
from typing import Any, Optional

import pytest

from toolit.create_tasks_json import TaskJsonBuilder, _annotation_to_string # noqa: PLC2701


class Color(enum.Enum):
"""Test enum for colors."""

RED = "red"
GREEN = "green"
BLUE = "blue"


class Environment(str, enum.Enum):
"""Test enum for environments."""

DEV = "development"
STAGING = "staging"
PROD = "production"


def _tool_with_pep604_optional(input_dataset_name: str | None = None) -> None:
"""Tool with a PEP 604 optional argument."""
Expand All @@ -22,6 +41,37 @@ def _tool_with_multiple_params_missing_hint(name, value: str) -> None: # type:
"""Tool where only the first parameter is missing a type hint."""


def _tool_with_enum_param(color: Color) -> None: # noqa: ARG001
"""Tool with an enum parameter."""


def _tool_with_enum_param_with_default(color: Color = Color.RED) -> None: # noqa: ARG001
"""Tool with an enum parameter that has a default value."""


def _tool_with_multiple_enum_params(
color: Color = Color.RED, # noqa: ARG001
environment: Environment | None = None, # noqa: ARG001
) -> None:
"""Tool with multiple enum parameters."""


def _tool_with_list_str_param(items: list[str]) -> None: # noqa: ARG001
"""Tool with a list[str] parameter."""


def _tool_with_list_int_param(numbers: list[int] = [1, 2, 3]) -> None: # noqa: B006, ARG001
"""Tool with a list[int] parameter and list default."""


def _tool_with_list_enum_param(colors: list[Color] = [Color.RED, Color.GREEN]) -> None: # noqa: B006, ARG001
"""Tool with a list[Enum] parameter and list default."""


def _tool_with_optional_list_param(values: list[str] | None = None) -> None: # noqa: ARG001
"""Tool with an optional list parameter."""


def test_create_args_for_tool_handles_pep604_optional() -> None:
"""Ensure str | None annotations do not crash and are rendered in descriptions."""
builder = TaskJsonBuilder()
Expand Down Expand Up @@ -84,3 +134,93 @@ def test_create_args_for_tool_raises_on_first_missing_hint_in_mixed_params() ->

with pytest.raises(ValueError, match="Parameter 'name' in function '_tool_with_multiple_params_missing_hint'"):
builder._create_args_for_tool(_tool_with_multiple_params_missing_hint)


def test_create_args_for_tool_enum_creates_picklist_input() -> None:
"""Ensure enum parameters create pickString input type in tasks.json."""
builder = TaskJsonBuilder()

args = builder._create_args_for_tool(_tool_with_enum_param) # noqa: SLF001

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


def test_create_args_for_tool_enum_sets_default_to_first_choice() -> None:
"""Ensure enum parameters default to the first enum value when no default provided."""
builder = TaskJsonBuilder()

args = builder._create_args_for_tool(_tool_with_enum_param) # noqa: SLF001

assert builder.inputs[0]["default"] == "red"


def test_create_args_for_tool_enum_respects_provided_default() -> None:
"""Ensure enum parameters use the provided default value if specified."""
builder = TaskJsonBuilder()

args = builder._create_args_for_tool(_tool_with_enum_param_with_default) # noqa: SLF001

assert builder.inputs[0]["default"] == Color.RED.value


def test_create_args_for_tool_multiple_enum_params() -> None:
"""Ensure multiple enum parameters are all correctly handled in tasks.json."""
builder = TaskJsonBuilder()

args = builder._create_args_for_tool(_tool_with_multiple_enum_params) # noqa: SLF001

assert len(builder.inputs) == 2
# First input (color)
assert builder.inputs[0]["type"] == "pickString"
assert builder.inputs[0]["options"] == ["red", "green", "blue"]
assert builder.inputs[0]["default"] == "red"
# Second input (environment)
assert builder.inputs[1]["type"] == "pickString"
assert builder.inputs[1]["options"] == ["development", "staging", "production"]
assert builder.inputs[1]["default"] == "development"


def test_create_args_for_tool_list_str_uses_promptstring_and_guidance() -> None:
"""Ensure list[str] parameters use promptString with comma-separated guidance."""
builder = TaskJsonBuilder()

args = builder._create_args_for_tool(_tool_with_list_str_param) # noqa: SLF001

assert args == ['"${input:_tool_with_list_str_param_items}"']
assert builder.inputs[0]["type"] == "promptString"
assert builder.inputs[0]["description"] == "Enter comma-separated text values for items (e.g. alpha, beta, gamma)"
assert builder.inputs[0]["default"] == ""


def test_create_args_for_tool_list_int_serializes_default_values() -> None:
"""Ensure list[int] defaults are serialized to comma-separated text."""
builder = TaskJsonBuilder()

builder._create_args_for_tool(_tool_with_list_int_param) # noqa: SLF001

assert builder.inputs[0]["description"] == "Enter comma-separated integer values for numbers (e.g. 1, 2, 3)"
assert builder.inputs[0]["default"] == "1, 2, 3"


def test_create_args_for_tool_list_enum_serializes_default_values() -> None:
"""Ensure list[Enum] defaults are serialized using enum values."""
builder = TaskJsonBuilder()

builder._create_args_for_tool(_tool_with_list_enum_param) # noqa: SLF001

assert (
builder.inputs[0]["description"]
== "Enter comma-separated enum values for colors. Accepted values: [red, green, blue]. You can also use enum member names."
)
assert builder.inputs[0]["default"] == "red, green"


def test_create_args_for_tool_optional_list_keeps_none_default() -> None:
"""Ensure optional list parameters preserve None as default."""
builder = TaskJsonBuilder()

builder._create_args_for_tool(_tool_with_optional_list_param) # noqa: SLF001

assert builder.inputs[0]["default"] is None
6 changes: 6 additions & 0 deletions toolit/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
"""Module entry point to support `python -m toolit`."""

from toolit.cli import main

if __name__ == "__main__":
main()
9 changes: 7 additions & 2 deletions toolit/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@

register_all_tools_from_folder_and_plugin()

if __name__ == "__main__":
# Run the typer app

def main() -> None:
"""Register tools and run the Typer application."""
app()


if __name__ == "__main__":
main()
Loading
Loading