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.7.0] - 27-06-2026
- Added support for the Optional and Union type hints in the CLI arguments. This allows for more flexible command definitions and better type checking.
- Raise an error if a proper type hint is not provided for a parameter in a tool function.

## [0.6.0] - 20-12-2025
- Abandon python 3.9 which is deprecated. Now only support python 3.10 and higher.
- Fix bug with plugin system, in newer python version, where it would raise an exception when loading plugins.
Expand Down
86 changes: 86 additions & 0 deletions tests/create_tasks_json_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
"""Tests for create_tasks_json type annotation handling."""

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


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 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:_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


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()

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

assert args == ['"${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


@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_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)
50 changes: 44 additions & 6 deletions toolit/create_tasks_json.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import enum
import json
import typer
import types
import inspect
import pathlib
from toolit.auto_loader import (
Expand All @@ -15,7 +16,7 @@
from toolit.config import load_devtools_folder
from toolit.constants import ToolitTypesEnum
from types import FunctionType
from typing import Any
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"
Expand Down Expand Up @@ -53,6 +54,38 @@ def _is_bool(annotation: Any) -> bool: # noqa: ANN401
return annotation is bool


def _annotation_to_string(annotation: Any) -> str: # noqa: ANN401
"""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(_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()
Expand Down Expand Up @@ -83,12 +116,17 @@ def _create_args_for_tool(self, tool: FunctionType) -> list[str]:
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: str = "promptString"
input_options: dict[str, Any] = {}
description: str = "Enter value for {param_name} ({type})".format(
param_name=param.name,
type=annotation.__name__ if annotation != inspect.Parameter.empty else "str",
)
description: str = f"Enter value for {param.name} ({_annotation_to_string(annotation)})"
default_value: Any = "" if param.default == inspect.Parameter.empty else param.default

if _is_enum(annotation):
Expand Down Expand Up @@ -150,7 +188,7 @@ def process_tool(self, tool: FunctionType) -> None:
elif tool_type in {ToolitTypesEnum.SEQUENTIAL_GROUP, ToolitTypesEnum.PARALLEL_GROUP}:
self._create_task_group_entry(tool, tool_type)

def create_tasks_json(self) -> dict:
def create_tasks_json(self) -> dict[str, Any]:
"""Create the final tasks.json structure."""
tasks_json: dict[str, Any] = {
"version": "2.0.0",
Expand Down
Loading