Skip to content

Commit e13a613

Browse files
Merge pull request #5 from martinmoldrup/features/add-extendable-configuration-options
Refactor configuration loading and enhance CLI functionality
2 parents 90c9c40 + 186ecd9 commit e13a613

File tree

12 files changed

+225
-16
lines changed

12 files changed

+225
-16
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@ All notable changes to this project will be documented in this file.
33

44
*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.
55

6+
## [0.4.0] - 14-09-2025
7+
- Add ability to customize configurations in a toolit.ini or pyproject.toml file in the project root. Makes the devtools folder configurable, and allows plugins to add their own configurations.
8+
69
## [0.3.0] - 09-09-2025
710
- Added toolit `create-vscode-tasks-json` CLI command to create the `.vscode/tasks.json` file from the command line.
811
- Improved error handling and user feedback when the `devtools` folder does not exist.

README.md

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,14 @@ toolit --help # To see available commands
3636
toolit my-command --to_print "Hello, Toolit!" # To run your command
3737
```
3838

39+
### Customizing the DevTools Folder
40+
By default, Toolit looks for a folder named `devtools` in the project root. You can customize this by creating a `toolit.ini` or use your `pyproject.toml` file in your project root with the following content:
41+
42+
```toml
43+
[toolit]
44+
tools_folder = "tools"
45+
```
46+
3947
## Create the VS code tasks.json file
4048
You can automatically create a `tasks.json` file for Visual Studio Code to run your ToolIt commands directly from the editor. This is useful for integrating your development tools into your workflow.
4149

@@ -76,10 +84,13 @@ Toolit supports a plugin system that allows you to create and share your own too
7684

7785
To create a plugin, follow these steps:
7886
1. Create a new Python package for your plugin. You can use tools like `setuptools`, `poetry` or `uv` to set up your package structure.
79-
2. In your package, create a module where you define your tools using the `@tool` decorator.
80-
3. Make sure to include `toolit` as a dependency in your package's `setup.py` or `pyproject.toml`.
81-
4. Register your plugin with Toolit by adding an entry point in your `setup.py` or `pyproject.toml`, so Toolit can discover your tools when the package is installed. The entry point is called `toolit_plugins`.
82-
5. Publish your package to PyPI or install it from a git repository where you need it.
87+
2. In your package, create one or several modules where you define your tools using the `@tool` decorator.
88+
3. You can include your own user-configurations, and load them using the `get_config_value` function from the `toolit.config` module.
89+
4. Make sure to include `toolit` as a dependency in your package's `setup.py` or `pyproject.toml`.
90+
5. Register your plugin with Toolit by adding an entry point in your `setup.py` or `pyproject.toml`, so Toolit can discover your tools when the package is installed. The entry point is called `toolit_plugins`.
91+
6. Publish your package to PyPI or install it from a git repository where you need it.
92+
93+
See an example plugin here: [toolit-azure-devops-trunk-based-branching](https://github.com/martinmoldrup/toolit-azure-devops-trunk-based-branching)
8394

8495
## Contributing
8596
We welcome contributions to Toolit! If you have ideas for new features, improvements, or bug fixes, please open an issue or submit a pull request on our GitHub repository. We appreciate your feedback and support in making Toolit even better for the community.

pyproject.toml

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
[project]
22
name = "toolit"
3-
version = "0.3.0"
3+
version = "0.4.0"
44
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."
55
readme = "README.md"
66
requires-python = ">=3.9" # mcp[cli] requires Python 3.10+
77
dependencies = [
8-
"typer"
8+
"toml",
9+
"typer",
910
]
1011
classifiers = [
1112
"Framework :: Pytest",
@@ -44,7 +45,7 @@ dev = [
4445
"pytest-asyncio>=0.26.0",
4546
"requests>=2.32.4",
4647
"ruff>=0.11.13",
47-
"toml>=0.10.2",
48+
"types-toml>=0.10.8.20240310",
4849
]
4950

5051
[project.optional-dependencies]

tests/configuration_loader_test.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
"""Tests for configuration loading functions in toolit.config."""
2+
3+
import toml
4+
import pytest
5+
import pathlib
6+
from toolit import config
7+
from toolit.constants import ConfigFileKeys
8+
9+
10+
def create_toml_file(path: pathlib.Path, data: dict) -> None:
11+
"""Helper to create a TOML file with given data."""
12+
with open(path, "w", encoding="utf-8") as f:
13+
toml.dump(data, f)
14+
15+
16+
def test_load_ini_config_returns_empty_for_missing_file(tmp_path: pathlib.Path) -> None:
17+
"""Test load_ini_config returns empty dict if file does not exist."""
18+
file_path: pathlib.Path = tmp_path / "toolit.ini"
19+
result: dict[str, str] = config.load_ini_config(file_path)
20+
assert result == {}
21+
22+
23+
def test_load_ini_config_reads_toolit_section(tmp_path: pathlib.Path) -> None:
24+
"""Test load_ini_config reads 'toolit' section from ini file."""
25+
file_path: pathlib.Path = tmp_path / "toolit.ini"
26+
data: dict[str, dict[str, str]] = {"toolit": {"foo": "bar"}}
27+
create_toml_file(file_path, data)
28+
result: dict[str, str] = config.load_ini_config(file_path)
29+
assert result == {"foo": "bar"}
30+
31+
32+
def test_load_ini_config_reads_flat_config(tmp_path: pathlib.Path) -> None:
33+
"""Test load_ini_config returns flat config if no 'toolit' section."""
34+
file_path: pathlib.Path = tmp_path / "toolit.ini"
35+
data: dict[str, str] = {"foo": "bar"}
36+
create_toml_file(file_path, data)
37+
result: dict[str, str] = config.load_ini_config(file_path)
38+
assert result == {"foo": "bar"}
39+
40+
41+
def test_load_pyproject_config_returns_empty_for_missing_file(tmp_path: pathlib.Path) -> None:
42+
"""Test load_pyproject_config returns empty dict if file does not exist."""
43+
file_path: pathlib.Path = tmp_path / "pyproject.toml"
44+
result: dict[str, str] = config.load_pyproject_config(file_path)
45+
assert result == {}
46+
47+
48+
def test_load_pyproject_config_reads_toolit_section(tmp_path: pathlib.Path) -> None:
49+
"""Test load_pyproject_config reads 'toolit' section from pyproject.toml."""
50+
file_path: pathlib.Path = tmp_path / "pyproject.toml"
51+
data: dict[str, dict[str, str]] = {"toolit": {"foo": "bar"}}
52+
create_toml_file(file_path, data)
53+
result: dict[str, str] = config.load_pyproject_config(file_path)
54+
assert result == {"foo": "bar"}
55+
56+
57+
def test_load_pyproject_config_returns_empty_if_no_toolit_section(tmp_path: pathlib.Path) -> None:
58+
"""Test load_pyproject_config returns empty dict if no 'toolit' section."""
59+
file_path: pathlib.Path = tmp_path / "pyproject.toml"
60+
data: dict[str, str] = {"foo": "bar"}
61+
create_toml_file(file_path, data)
62+
result: dict[str, str] = config.load_pyproject_config(file_path)
63+
assert result == {}
64+
65+
66+
def test_get_config_value_returns_value(monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path) -> None:
67+
"""Test get_config_value returns correct value from config."""
68+
config_data: dict[str, str] = {"mykey": "myvalue"}
69+
monkeypatch.setattr(config, "_load_config", lambda: config_data)
70+
result: str | None = config.get_config_value("mykey")
71+
assert result == "myvalue"
72+
73+
74+
def test_get_config_value_returns_default(monkeypatch: pytest.MonkeyPatch) -> None:
75+
"""Test get_config_value returns default if key not found."""
76+
monkeypatch.setattr(config, "_load_config", lambda: {})
77+
result: str | None = config.get_config_value("missing", default="defaultval")
78+
assert result == "defaultval"
79+
80+
81+
def test_load_devtools_folder_returns_configured_path(monkeypatch: pytest.MonkeyPatch) -> None:
82+
"""Test load_devtools_folder returns configured folder path."""
83+
folder: str = "custom_tools_folder"
84+
monkeypatch.setattr(
85+
config,
86+
"get_config_value",
87+
lambda key, default=None: folder if key == ConfigFileKeys.TOOLS_FOLDER else default,
88+
)
89+
result: pathlib.Path = config.load_devtools_folder()
90+
assert str(result) == folder
91+
92+
93+
def test_load_devtools_folder_returns_default_path() -> None:
94+
"""Test load_devtools_folder returns default folder path if not configured."""
95+
result: pathlib.Path = config.load_devtools_folder()
96+
assert str(result) == ConfigFileKeys.TOOLS_FOLDER_DEFAULT

tests/conftest.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
import sys
2+
import os
3+
# Add the root directory to the path
4+
sys.path.append(os.path.join(os.path.dirname(__file__), '..'))

toolit/__init__.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
"""Public API for the package."""
22

3+
from .config import get_config_value
34
from .decorators import tool
45

5-
__all__ = ["tool"]
6+
__all__ = [
7+
"get_config_value",
8+
"tool",
9+
]

toolit/cli.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
11
"""CLI entry point for the toolit package."""
2-
import pathlib
32
from .auto_loader import load_tools_from_folder, load_tools_from_plugins, register_command
3+
from .config import load_devtools_folder
44
from .create_apps_and_register import app
55
from .create_tasks_json import create_vscode_tasks_json
66

7-
PATH = pathlib.Path() / "devtools"
8-
load_tools_from_folder(PATH)
7+
load_tools_from_folder(load_devtools_folder())
98
load_tools_from_plugins()
109
register_command(create_vscode_tasks_json)
1110

toolit/config.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
"""
2+
Load configurations for packages.
3+
4+
The toolit package lets the user change configurations (azure devops pipeline id, folder for tools, etc.).
5+
User can define the configuration by either:
6+
- Creating a `toolit.ini` toml file in the current working directory
7+
- Adding to the `pyproject.toml` file in the current working directory
8+
"""
9+
from __future__ import annotations
10+
11+
import toml
12+
import pathlib
13+
from .constants import ConfigFileKeys
14+
from functools import lru_cache
15+
from typing import Callable, overload
16+
17+
18+
def load_ini_config(file_path: pathlib.Path) -> dict[str, str]:
19+
"""Load configuration from a toolit.ini file."""
20+
if not file_path.is_file():
21+
return {}
22+
configurations = toml.load(file_path)
23+
if "toolit" in configurations:
24+
return configurations["toolit"]
25+
return configurations
26+
27+
28+
def load_pyproject_config(file_path: pathlib.Path) -> dict[str, str]:
29+
"""Load configuration from a pyproject.toml file."""
30+
if not file_path.is_file():
31+
return {}
32+
config = toml.load(file_path)
33+
return config.get("toolit", {})
34+
35+
36+
CONFIG_FILENAMES: dict[str, Callable[[pathlib.Path], dict[str, str]]] = {
37+
"toolit.ini": load_ini_config,
38+
"pyproject.toml": load_pyproject_config,
39+
}
40+
41+
42+
@lru_cache(maxsize=1)
43+
def _load_config() -> dict[str, str]:
44+
"""Load configuration from toolit.ini or pyproject.toml, only once."""
45+
config: dict[str, str] = {}
46+
for filename, loader in CONFIG_FILENAMES.items():
47+
file_path = pathlib.Path.cwd() / filename
48+
file_config = loader(file_path)
49+
config.update(file_config)
50+
51+
return config
52+
53+
54+
@overload
55+
def get_config_value(key: str, default: None = None) -> str | None: ...
56+
57+
@overload
58+
def get_config_value(key: str, default: str) -> str: ...
59+
60+
61+
def get_config_value(key: str, default: str | None = None) -> str | None:
62+
"""Get a configuration value by key with type-safe default."""
63+
config: dict[str, str] = _load_config()
64+
return config.get(key, default)
65+
66+
67+
def load_devtools_folder() -> pathlib.Path:
68+
"""Load the tools folder path from configuration or use default."""
69+
folder = get_config_value(ConfigFileKeys.TOOLS_FOLDER, ConfigFileKeys.TOOLS_FOLDER_DEFAULT)
70+
return pathlib.Path(folder)

toolit/constants.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,13 @@
55
MARKER_TOOL = "__toolit_tool_type__"
66

77

8+
class ConfigFileKeys:
9+
"""Namespace for the different configuration file keys for user configuration."""
10+
11+
TOOLS_FOLDER: str = "tools_folder"
12+
TOOLS_FOLDER_DEFAULT: str = "devtools"
13+
14+
815
class ToolitTypesEnum(enum.Enum):
916
"""Enum for the different types of toolit tools."""
1017

toolit/create_tasks_json.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,12 @@
1212
tool_group_strategy,
1313
tool_strategy,
1414
)
15+
from toolit.config import load_devtools_folder
1516
from toolit.constants import ToolitTypesEnum
1617
from types import FunctionType
1718
from typing import Any
1819

19-
PATH: pathlib.Path = pathlib.Path() / "devtools"
20+
PATH: pathlib.Path = load_devtools_folder()
2021
output_file_path: pathlib.Path = pathlib.Path() / ".vscode" / "tasks.json"
2122

2223

0 commit comments

Comments
 (0)