Skip to content
Draft
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
179 changes: 179 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,181 @@
# tool-tasks

Zero-dependency Python task runner using pyproject.toml [tool.tasks]

## Features

- **Zero dependencies**: Uses only Python standard library (requires Python 3.11+ for `tomllib`)
- **Multiple task types**:
- Shell commands
- Task aliases
- Task chains
- Python module entry points
- **Simple configuration**: Define tasks in your `pyproject.toml` file
- **Automatic discovery**: Searches for `pyproject.toml` in current directory and parent directories

## Installation

```bash
pip install tool-tasks
```

## Usage

### Basic Usage

```bash
# List available tasks
task --list

# Run a task
task <task-name>

# Run a task with arguments
task <task-name> arg1 arg2
```

### Configuration

Define tasks in your `pyproject.toml` file under the `[tool.tasks]` section:

```toml
[tool.tasks]
# Simple shell command
hello = "echo 'Hello, World!'"

# Task alias (references another task)
greet = "hello"

# Task chain (runs multiple tasks in sequence)
ci = ["test", "lint", "build"]

# Shell command task
test = "pytest tests/"

# Dict-style task with cmd
[tool.tasks.build]
cmd = "python -m build"

# Dict-style task with chain
[tool.tasks.full-test]
chain = ["test", "lint"]

# Python entry point (calls a function from a module)
[tool.tasks.version]
call = "mypackage:print_version"
```

## Task Types

### 1. Shell Commands

Execute shell commands directly:

```toml
[tool.tasks]
test = "pytest tests/"
build = "python -m build"
```

### 2. Task Aliases

Reference other tasks by name:

```toml
[tool.tasks]
test = "pytest tests/"
t = "test" # Alias for test task
```

### 3. Task Chains

Run multiple tasks in sequence. Stops on first failure:

```toml
[tool.tasks]
test = "pytest tests/"
lint = "ruff check ."
ci = ["test", "lint"]
```

### 4. Python Entry Points

Call Python functions directly:

```toml
[tool.tasks.version]
call = "mypackage:print_version"

# Can also call class methods
[tool.tasks.run-server]
call = "mypackage.server:Server.start"
```

The function/method should:
- Return an integer exit code (0 for success, non-zero for failure)
- Return `None` (treated as success)
- Access command-line arguments via `sys.argv`

## Examples

### Example pyproject.toml

```toml
[tool.tasks]
# Development tasks
dev = "python -m myapp --dev"
test = "pytest tests/ -v"
lint = "ruff check ."
format = "ruff format ."

# Build tasks
clean = "rm -rf dist/ build/ *.egg-info"
build = "python -m build"

# Task chains
check = ["lint", "test"]
ci = ["check", "build"]

# Python entry points
[tool.tasks.version]
call = "myapp:print_version"
```

### Running Tasks

```bash
# Run tests
task test

# Run CI pipeline (lint + test + build)
task ci

# Run development server
task dev

# Check version
task version
```

## Development

### Requirements

- Python 3.11 or higher (for `tomllib` from standard library)

### Running Tests

```bash
# Install development dependencies
pip install -e ".[dev]"

# Run tests
pytest tests/

# Run tests with coverage
pytest tests/ --cov=src/tool_tasks --cov-report=term-missing
```

## License

See LICENSE file for details.
47 changes: 47 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"

[tool.poetry]
name = "tool-tasks"
version = "0.1.0"
description = "Zero-dependency Python task runner using pyproject.toml [tool.tasks]"
authors = ["python-build-tools"]
readme = "README.md"
packages = [{include = "tool_tasks", from = "src"}]

[tool.poetry.dependencies]
python = "^3.11"

[tool.poetry.group.dev.dependencies]
pytest = "^8.0.0"
pytest-cov = "^5.0.0"

[tool.poetry.scripts]
task = "tool_tasks.cli:main"

[tool.tasks]
# Shell command example
hello = "echo 'Hello from task runner!'"

# Task alias example
greet = "hello"

# Task chain example
test-all = ["test", "lint"]

# Shell command with alias
test = "python3 -m pytest tests/ -v"
lint = "echo 'Linting...'"

# Dict task with cmd
[tool.tasks.build]
cmd = "echo 'Building project...'"

# Dict task with chain
[tool.tasks.ci]
chain = ["test", "build"]

# Python entry point example
[tool.tasks.version]
call = "tool_tasks:print_version"
9 changes: 9 additions & 0 deletions src/tool_tasks/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
"""Tool Tasks - Zero-dependency Python task runner."""

__version__ = "0.1.0"


def print_version():
"""Print the version."""
print(f"tool-tasks version {__version__}")
return 0
53 changes: 53 additions & 0 deletions src/tool_tasks/cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
"""CLI entry point for the task command."""

import sys
from pathlib import Path

from tool_tasks.config import TaskConfig
from tool_tasks.executor import TaskExecutor


def main():
"""Main entry point for the task CLI."""
# Parse command-line arguments
if len(sys.argv) < 2:
print("Usage: task <task-name> [args...]", file=sys.stderr)
print(" task --list", file=sys.stderr)
sys.exit(1)

# Handle --list option
if sys.argv[1] == "--list":
try:
config = TaskConfig()
tasks = config.list_tasks()
if tasks:
print("Available tasks:")
for task in tasks:
print(f" {task}")
else:
print("No tasks defined in [tool.tasks]")
except FileNotFoundError as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
return

# Get task name and arguments
task_name = sys.argv[1]
task_args = sys.argv[2:]

# Load configuration and execute task
try:
config = TaskConfig()
executor = TaskExecutor(config)
exit_code = executor.execute(task_name, task_args)
sys.exit(exit_code)
except FileNotFoundError as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
except Exception as e:
print(f"Unexpected error: {e}", file=sys.stderr)
sys.exit(1)


if __name__ == "__main__":
main()
75 changes: 75 additions & 0 deletions src/tool_tasks/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
"""Task configuration loader and parser."""

import sys
import tomllib
from pathlib import Path
from typing import Any


class TaskConfig:
"""Load and parse task configuration from pyproject.toml."""

def __init__(self, config_path: Path | None = None):
"""
Initialize TaskConfig.

Args:
config_path: Path to pyproject.toml. If None, searches upward from current directory.
"""
self.config_path = config_path or self._find_pyproject()
self.tasks = self._load_tasks()

def _find_pyproject(self) -> Path:
"""Find pyproject.toml by searching upward from current directory."""
current = Path.cwd()
while current != current.parent:
pyproject = current / "pyproject.toml"
if pyproject.exists():
return pyproject
current = current.parent

# Check root directory
pyproject = current / "pyproject.toml"
if pyproject.exists():
return pyproject

raise FileNotFoundError("pyproject.toml not found in current directory or any parent directory")

def _load_tasks(self) -> dict[str, Any]:
"""Load tasks from pyproject.toml [tool.tasks] section."""
try:
with open(self.config_path, "rb") as f:
data = tomllib.load(f)
except Exception as e:
print(f"Error reading {self.config_path}: {e}", file=sys.stderr)
sys.exit(1)

tasks = data.get("tool", {}).get("tasks", {})
if not tasks:
print(f"No [tool.tasks] section found in {self.config_path}", file=sys.stderr)
sys.exit(1)

return tasks

def get_task(self, task_name: str) -> Any:
"""
Get task configuration by name.

Args:
task_name: Name of the task to retrieve.

Returns:
Task configuration value.

Raises:
KeyError: If task is not found.
"""
if task_name not in self.tasks:
available = ", ".join(sorted(self.tasks.keys()))
print(f"Task '{task_name}' not found. Available tasks: {available}", file=sys.stderr)
sys.exit(1)
return self.tasks[task_name]

def list_tasks(self) -> list[str]:
"""Return list of available task names."""
return sorted(self.tasks.keys())
Loading