diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e7e670..00a2ac6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/tests/create_tasks_json_test.py b/tests/create_tasks_json_test.py new file mode 100644 index 0000000..48b64f8 --- /dev/null +++ b/tests/create_tasks_json_test.py @@ -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) diff --git a/toolit/create_tasks_json.py b/toolit/create_tasks_json.py index bd58ca3..fea99db 100644 --- a/toolit/create_tasks_json.py +++ b/toolit/create_tasks_json.py @@ -3,6 +3,7 @@ import enum import json import typer +import types import inspect import pathlib from toolit.auto_loader import ( @@ -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" @@ -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() @@ -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): @@ -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",