From 371f1919601cdb09adb061d7113b797e7ed993a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20M=C3=B8ldrup?= <14809761+martinmoldrup@users.noreply.github.com> Date: Wed, 29 Apr 2026 17:39:13 +0200 Subject: [PATCH 1/3] Add tests for handling PEP 604 and typing.Optional argument types Co-authored-by: Copilot --- tests/create_tasks_json_test.py | 54 +++++++++++++++++++++++++++++++++ toolit/create_tasks_json.py | 48 ++++++++++++++++++++++++----- 2 files changed, 94 insertions(+), 8 deletions(-) create mode 100644 tests/create_tasks_json_test.py diff --git a/tests/create_tasks_json_test.py b/tests/create_tasks_json_test.py new file mode 100644 index 0000000..a7d3243 --- /dev/null +++ b/tests/create_tasks_json_test.py @@ -0,0 +1,54 @@ +"""Tests for create_tasks_json type annotation handling.""" + +import inspect +from typing import Any, Optional + +import pytest + +from toolit.create_tasks_json import TaskJsonBuilder, _annotation_to_string + + +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: Optional[str] = None) -> None: + """Tool with a typing.Optional argument.""" + + +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) + + 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) + + 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 diff --git a/toolit/create_tasks_json.py b/toolit/create_tasks_json.py index bd58ca3..77987b0 100644 --- a/toolit/create_tasks_json.py +++ b/toolit/create_tasks_json.py @@ -1,10 +1,15 @@ """Create a vscode tasks.json file based on the tools discovered in the project.""" import enum -import json -import typer import inspect +import json import pathlib +import types +from types import FunctionType +from typing import Any, Union, get_args, get_origin + +import typer + from toolit.auto_loader import ( get_items_from_folder, get_plugin_tools, @@ -14,8 +19,6 @@ ) from toolit.config import load_devtools_folder from toolit.constants import ToolitTypesEnum -from types import FunctionType -from typing import Any PATH: pathlib.Path = load_devtools_folder() output_file_path: pathlib.Path = pathlib.Path() / ".vscode" / "tasks.json" @@ -53,6 +56,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() @@ -85,10 +120,7 @@ def _create_args_for_tool(self, tool: FunctionType) -> list[str]: annotation = param.annotation 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): From d1889110fbb39986e353b38117ae1cc96fd43def Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20M=C3=B8ldrup?= <14809761+martinmoldrup@users.noreply.github.com> Date: Wed, 29 Apr 2026 17:48:17 +0200 Subject: [PATCH 2/3] Enhance CLI argument handling by adding support for missing type hints and raising errors for undefined parameters Co-authored-by: Copilot --- CHANGELOG.md | 4 ++++ tests/create_tasks_json_test.py | 32 ++++++++++++++++++++++++++++++++ toolit/create_tasks_json.py | 9 +++++++-- 3 files changed, 43 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e7e670..a5194d9 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 index a7d3243..2c55b6a 100644 --- a/tests/create_tasks_json_test.py +++ b/tests/create_tasks_json_test.py @@ -16,6 +16,14 @@ def _tool_with_typing_optional(input_dataset_name: Optional[str] = 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() @@ -52,3 +60,27 @@ def test_create_args_for_tool_handles_typing_optional() -> 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 77987b0..2ca7ca3 100644 --- a/toolit/create_tasks_json.py +++ b/toolit/create_tasks_json.py @@ -58,7 +58,7 @@ def _is_bool(annotation: Any) -> bool: # noqa: ANN401 def _annotation_to_string(annotation: Any) -> str: # noqa: ANN401 """Convert Python type annotations to readable strings.""" - result: str + result: str = "" if annotation == inspect.Parameter.empty: result = "str" @@ -118,6 +118,11 @@ 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: + raise ValueError( + 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" + ) input_type: str = "promptString" input_options: dict[str, Any] = {} description: str = f"Enter value for {param.name} ({_annotation_to_string(annotation)})" @@ -182,7 +187,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", From f358d1cdfe08dd65535ca12451107c8d6816d321 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20M=C3=B8ldrup?= <14809761+martinmoldrup@users.noreply.github.com> Date: Wed, 29 Apr 2026 17:57:12 +0200 Subject: [PATCH 3/3] Linting fixes --- CHANGELOG.md | 2 +- tests/create_tasks_json_test.py | 16 ++++++++-------- toolit/create_tasks_json.py | 17 +++++++++-------- 3 files changed, 18 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a5194d9..00a2ac6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,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] - 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. +- 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 diff --git a/tests/create_tasks_json_test.py b/tests/create_tasks_json_test.py index 2c55b6a..48b64f8 100644 --- a/tests/create_tasks_json_test.py +++ b/tests/create_tasks_json_test.py @@ -1,18 +1,16 @@ """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 -import pytest - -from toolit.create_tasks_json import TaskJsonBuilder, _annotation_to_string - 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: Optional[str] = None) -> None: +def _tool_with_typing_optional(input_dataset_name: str | None = None) -> None: """Tool with a typing.Optional argument.""" @@ -28,7 +26,7 @@ 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) + 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)" @@ -39,7 +37,7 @@ 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) + 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)" @@ -66,7 +64,9 @@ 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"): + 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) diff --git a/toolit/create_tasks_json.py b/toolit/create_tasks_json.py index 2ca7ca3..fea99db 100644 --- a/toolit/create_tasks_json.py +++ b/toolit/create_tasks_json.py @@ -1,15 +1,11 @@ """Create a vscode tasks.json file based on the tools discovered in the project.""" import enum -import inspect import json -import pathlib -import types -from types import FunctionType -from typing import Any, Union, get_args, get_origin - import typer - +import types +import inspect +import pathlib from toolit.auto_loader import ( get_items_from_folder, get_plugin_tools, @@ -19,6 +15,8 @@ ) 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" @@ -119,10 +117,13 @@ def _create_args_for_tool(self, tool: FunctionType) -> list[str]: annotation = param.annotation if annotation is inspect.Parameter.empty: - raise ValueError( + 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 = f"Enter value for {param.name} ({_annotation_to_string(annotation)})"