- Updated the terminology from "components" to "programs" across the codebase, including in config loading, saving, and manifest specifications. - Introduced a new `stacks.py` file to handle lifecycle actions for development stacks, implementing handlers for Python and React Vite stacks. - Adjusted tests to reflect the new program structure and ensure proper functionality. - Revised documentation to align with the new terminology and structure, ensuring clarity on the purpose and configuration of programs, services, and jobs.
344 lines
7.9 KiB
Markdown
344 lines
7.9 KiB
Markdown
# Python Tools in Castle
|
|
|
|
How to build CLI tools following Unix philosophy.
|
|
|
|
## Principles
|
|
|
|
- Each tool does one thing well
|
|
- Read from stdin or file argument, write to stdout
|
|
- Compose via pipes: `pdf2md doc.pdf | gpt "summarize this"`
|
|
- Status messages go to stderr (don't interfere with piping)
|
|
- Exit 0 on success, 1 on error
|
|
|
|
## Stack
|
|
|
|
| Layer | Choice |
|
|
|-------|--------|
|
|
| **CLI** | argparse |
|
|
| **Package manager** | uv (never pip) |
|
|
| **Build** | hatchling |
|
|
| **Testing** | pytest |
|
|
| **Linting** | ruff (shared `ruff.toml` at repo root) |
|
|
| **Type checking** | pyright (shared `pyrightconfig.json` at repo root) |
|
|
| **Python** | 3.11+ minimum |
|
|
|
|
## Project layout
|
|
|
|
Each tool is an independent project under `components/` with its own `pyproject.toml`:
|
|
|
|
```
|
|
components/my-tool/
|
|
├── src/my_tool/
|
|
│ ├── __init__.py
|
|
│ └── main.py # Entry point
|
|
├── tests/
|
|
│ └── test_main.py
|
|
├── pyproject.toml
|
|
└── CLAUDE.md
|
|
```
|
|
|
|
Examples: `components/pdf2md/`, `components/gpt/`, `components/protonmail/`
|
|
|
|
## Creating a new tool
|
|
|
|
```bash
|
|
castle create my-tool --stack python-cli --description "Does something"
|
|
cd components/my-tool && uv sync
|
|
```
|
|
|
|
This scaffolds the project and registers it in `castle.yaml`.
|
|
|
|
## pyproject.toml
|
|
|
|
```toml
|
|
[project]
|
|
name = "my-tool"
|
|
version = "0.1.0"
|
|
description = "Does something useful"
|
|
requires-python = ">=3.11"
|
|
dependencies = []
|
|
|
|
[project.scripts]
|
|
my-tool = "my_tool.main:main"
|
|
|
|
[build-system]
|
|
requires = ["hatchling"]
|
|
build-backend = "hatchling.build"
|
|
|
|
[tool.hatch.build.targets.wheel]
|
|
packages = ["src/my_tool"]
|
|
|
|
[dependency-groups]
|
|
dev = ["pytest>=7.0.0"]
|
|
|
|
[tool.ruff.lint.isort]
|
|
known-first-party = ["my_tool"]
|
|
```
|
|
|
|
After `uv tool install --editable .`, the command is in PATH.
|
|
|
|
## Tool implementation patterns
|
|
|
|
### Simple tool: stdin/stdout
|
|
|
|
The most common pattern. Read from a file argument or stdin, process, write
|
|
to stdout.
|
|
|
|
```python
|
|
#!/usr/bin/env python3
|
|
"""
|
|
my-tool: Brief description
|
|
|
|
Usage:
|
|
my-tool [options] [FILE]
|
|
cat input.txt | my-tool
|
|
|
|
Examples:
|
|
my-tool input.txt
|
|
my-tool input.txt -o output.txt
|
|
cat input.txt | my-tool > output.txt
|
|
"""
|
|
|
|
import argparse
|
|
import sys
|
|
|
|
|
|
def process(data: str) -> str:
|
|
"""Core logic — pure function, easy to test."""
|
|
return data.upper()
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(
|
|
description="Brief description",
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
)
|
|
parser.add_argument("input", nargs="?", help="Input file (default: stdin)")
|
|
parser.add_argument("-o", "--output", help="Output file (default: stdout)")
|
|
parser.add_argument("--version", action="version", version="my-tool 1.0.0")
|
|
args = parser.parse_args()
|
|
|
|
# Read
|
|
if args.input:
|
|
with open(args.input) as f:
|
|
data = f.read()
|
|
else:
|
|
data = sys.stdin.read()
|
|
|
|
# Process
|
|
result = process(data)
|
|
|
|
# Write
|
|
if args.output:
|
|
with open(args.output, "w") as f:
|
|
f.write(result)
|
|
print(f"Wrote to {args.output}", file=sys.stderr)
|
|
else:
|
|
print(result, end="")
|
|
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|
|
```
|
|
|
|
### Tool with subcommands
|
|
|
|
For complex tools with multiple operations:
|
|
|
|
```python
|
|
def cmd_init(args: argparse.Namespace) -> int:
|
|
"""Initialize a collection."""
|
|
directory = args.directory or "."
|
|
# ...
|
|
print(f"Initialized in {directory}")
|
|
return 0
|
|
|
|
|
|
def cmd_query(args: argparse.Namespace) -> int:
|
|
"""Search a collection."""
|
|
# ...
|
|
return 0
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description="Manage collections")
|
|
parser.add_argument("--version", action="version", version="1.0.0")
|
|
subparsers = parser.add_subparsers(dest="command")
|
|
|
|
init_p = subparsers.add_parser("init", help="Initialize")
|
|
init_p.add_argument("directory", nargs="?")
|
|
init_p.add_argument("--name", help="Collection name")
|
|
init_p.add_argument("--force", action="store_true")
|
|
|
|
query_p = subparsers.add_parser("query", help="Search")
|
|
query_p.add_argument("query", help="Search query")
|
|
query_p.add_argument("--limit", type=int, default=20)
|
|
|
|
args = parser.parse_args()
|
|
|
|
if args.command == "init":
|
|
return cmd_init(args)
|
|
elif args.command == "query":
|
|
return cmd_query(args)
|
|
else:
|
|
parser.print_help()
|
|
return 1
|
|
```
|
|
|
|
### Tool with external processes
|
|
|
|
When wrapping system commands:
|
|
|
|
```python
|
|
import subprocess
|
|
import sys
|
|
|
|
|
|
def convert(input_file: str, output_file: str) -> int:
|
|
try:
|
|
subprocess.run(
|
|
["pandoc", input_file, "-o", output_file],
|
|
check=True,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
return 0
|
|
except subprocess.CalledProcessError as e:
|
|
print(f"Error: {e}", file=sys.stderr)
|
|
if e.stderr:
|
|
print(e.stderr, file=sys.stderr)
|
|
return 1
|
|
except FileNotFoundError:
|
|
print("Error: pandoc not found. Install with: apt install pandoc",
|
|
file=sys.stderr)
|
|
return 1
|
|
```
|
|
|
|
Always use `check=True` and `capture_output=True` with subprocess. Handle
|
|
`FileNotFoundError` for missing system dependencies.
|
|
|
|
## Error handling
|
|
|
|
```python
|
|
def main() -> int:
|
|
try:
|
|
result = do_work()
|
|
print(result)
|
|
return 0
|
|
except SpecificError as e:
|
|
print(f"Error: {e}", file=sys.stderr)
|
|
return 1
|
|
except FileNotFoundError as e:
|
|
print(f"Error: file not found: {e}", file=sys.stderr)
|
|
return 1
|
|
```
|
|
|
|
Rules:
|
|
- Normal output goes to **stdout** (enables piping)
|
|
- Error messages go to **stderr**
|
|
- Status/progress messages go to **stderr**
|
|
- Return **0** for success, **1** for error
|
|
- Entry point: `sys.exit(main())`
|
|
|
|
## Piping
|
|
|
|
Tools compose naturally via Unix pipes:
|
|
|
|
```bash
|
|
# Convert and summarize
|
|
pdf2md paper.pdf | gpt "summarize this"
|
|
|
|
# Process a batch
|
|
for f in *.pdf; do pdf2md "$f" > "${f%.pdf}.md"; done
|
|
|
|
# Chain extractors
|
|
cat doc.txt | text-extractor | jq .content
|
|
```
|
|
|
|
## Testing
|
|
|
|
Tests use pytest. For standalone tools, test via subprocess to exercise the
|
|
real CLI interface:
|
|
|
|
```python
|
|
import subprocess
|
|
import sys
|
|
|
|
|
|
class TestCLI:
|
|
def test_version(self) -> None:
|
|
result = subprocess.run(
|
|
[sys.executable, "-m", "my_tool.main", "--version"],
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
assert "my-tool" in result.stdout
|
|
|
|
def test_stdin(self) -> None:
|
|
result = subprocess.run(
|
|
[sys.executable, "-m", "my_tool.main"],
|
|
input="hello\n",
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
assert result.returncode == 0
|
|
assert "hello" in result.stdout
|
|
|
|
def test_file_input(self, tmp_path) -> None:
|
|
input_file = tmp_path / "input.txt"
|
|
input_file.write_text("test data")
|
|
result = subprocess.run(
|
|
[sys.executable, "-m", "my_tool.main", str(input_file)],
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
assert result.returncode == 0
|
|
assert "test data" in result.stdout
|
|
```
|
|
|
|
For unit testing core logic, import the function directly and test it as a
|
|
pure function.
|
|
|
|
## Commands
|
|
|
|
```bash
|
|
uv sync # Install deps
|
|
uv run my-tool --help # Run the tool
|
|
uv run pytest tests/ -v # Run tests
|
|
uv run ruff check . # Lint
|
|
uv run ruff format . # Format
|
|
```
|
|
|
|
## Registering in castle.yaml
|
|
|
|
```yaml
|
|
programs:
|
|
my-tool:
|
|
description: Does something useful
|
|
source: components/my-tool
|
|
install:
|
|
path:
|
|
alias: my-tool
|
|
```
|
|
|
|
Tools with system dependencies declare them in the component:
|
|
|
|
```yaml
|
|
programs:
|
|
pdf2md:
|
|
description: Convert PDF files to Markdown
|
|
source: components/pdf2md
|
|
install:
|
|
path:
|
|
alias: pdf2md
|
|
tool:
|
|
system_dependencies: [pandoc, poppler-utils]
|
|
```
|
|
|
|
Tools live in the `programs:` section. If a tool also runs on a schedule,
|
|
add a separate entry in the `jobs:` section referencing the component.
|
|
|
|
See @docs/component-registry.md for the full registry reference.
|