Skip to content

Commit 625f8f1

Browse files
authored
✨ add create dirs option (#13)
* add create dirs option * cli arguments should be positional * clean up examples * depend on typer-slim * add verbose option * add overwrite protection * update readme --------- Co-authored-by: Bryn Lloyd <12702862+dyollb@users.noreply.github.com>
1 parent 0207215 commit 625f8f1

14 files changed

+1049
-38
lines changed

README.md

Lines changed: 63 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ Create [Typer](https://github.com/tiangolo/typer) command line interfaces from f
1414
- 🔄 Automatic file I/O handling for SimpleITK images and transforms
1515
- 🎯 Type-safe with full type annotation support
1616
- 🚀 Built on Typer for modern CLI experiences
17+
- 🐍 Pythonic CLI design using native `*,` syntax for keyword-only parameters
18+
- 📁 Auto-create output directories with optional overwrite protection
19+
- 📝 Optional verbose logging with Rich integration
1720
- 🐍 Python 3.11+ with modern syntax
1821

1922
## Installation
@@ -22,6 +25,13 @@ Create [Typer](https://github.com/tiangolo/typer) command line interfaces from f
2225
pip install sitk-cli
2326
```
2427

28+
**Optional dependencies:**
29+
30+
```sh
31+
# For enhanced logging output with colors and formatting
32+
pip install sitk-cli[rich]
33+
```
34+
2535
**Requirements:** Python 3.11 or higher
2636

2737
## Quick Start
@@ -58,49 +68,74 @@ if __name__ == "__main__":
5868
1. Calls your function with the loaded objects
5969
1. Saves returned images/transforms to the specified output file
6070

61-
## Usage Examples
71+
## Advanced Features
72+
73+
### Positional vs Named Arguments
6274

63-
### Optional Arguments
75+
Use Python's native `*,` syntax to control whether CLI arguments are positional or named:
6476

6577
```python
6678
@register_command(app)
67-
def median_filter(input: sitk.Image, radius: int = 2) -> sitk.Image:
68-
"""Apply median filtering to an image."""
69-
return sitk.Median(input, [radius] * input.GetDimension())
79+
def process(input: sitk.Image, *, mask: sitk.Image) -> sitk.Image:
80+
"""Mix positional and keyword-only arguments.
81+
82+
CLI: process INPUT OUTPUT --mask MASK
83+
"""
84+
return input * mask
85+
```
86+
87+
Behavior:
88+
89+
- **Required** Image/Transform parameters → **positional** by default
90+
- Parameters **after `*,`****keyword-only** (named options)
91+
- **Optional** parameters (with defaults) → **named options**
92+
- Output → **positional** if any input is positional, otherwise **named**
93+
94+
### Verbose Logging
95+
96+
```python
97+
from sitk_cli import logger, register_command
98+
99+
@register_command(app, verbose=True)
100+
def process_with_logging(input: sitk.Image) -> sitk.Image:
101+
logger.info("Starting processing...")
102+
result = sitk.Median(input, [2] * input.GetDimension())
103+
logger.debug(f"Result size: {result.GetSize()}")
104+
return result
70105
```
71106

72107
```sh
73-
python script.py median-filter --input image.nii.gz --radius 3 --output filtered.nii.gz
108+
python script.py process-with-logging input.nii output.nii -v # INFO level
109+
python script.py process-with-logging input.nii output.nii -vv # DEBUG level
74110
```
75111

76-
### Multiple Inputs with Type Unions
112+
### Overwrite Protection
77113

78114
```python
79-
@register_command(app)
80-
def add_images(
81-
input1: sitk.Image,
82-
input2: sitk.Image | None = None
83-
) -> sitk.Image:
84-
"""Add two images together, or return first if second is not provided."""
85-
if input2 is None:
86-
return input1
87-
return input1 + input2
115+
@register_command(app, overwrite=False)
116+
def protected_process(input: sitk.Image) -> sitk.Image:
117+
"""Prevent accidental overwrites."""
118+
return sitk.Median(input, [2] * input.GetDimension())
88119
```
89120

90-
### Transform Registration
121+
```sh
122+
python script.py protected-process input.nii output.nii
123+
python script.py protected-process input.nii output.nii # Error: file exists
124+
python script.py protected-process input.nii output.nii --force # OK, overwrites
125+
```
126+
127+
Modes: `overwrite=True` (default), `overwrite=False` (requires `--force`), `overwrite="prompt"` (asks user)
128+
129+
Optional parameters with defaults automatically become named options:
91130

92131
```python
93132
@register_command(app)
94-
def register_images(
95-
fixed: sitk.Image,
96-
moving: sitk.Image,
97-
init_transform: sitk.Transform | None = None
98-
) -> sitk.Transform:
99-
"""Register two images and return the computed transform."""
100-
if init_transform is None:
101-
init_transform = sitk.CenteredTransformInitializer(fixed, moving)
102-
# ... registration code ...
103-
return final_transform
133+
def median_filter(input: sitk.Image, radius: int = 2) -> sitk.Image:
134+
"""Apply median filtering to an image.
135+
136+
CLI: median-filter INPUT OUTPUT [--radius 3]
137+
"""
138+
return sitk.Median(input, [radius] * input.GetDimension())
104139
```
105140

106141
## Demo
@@ -116,7 +151,7 @@ git clone https://github.com/dyollb/sitk-cli.git
116151
cd sitk-cli
117152
python -m venv .venv
118153
source .venv/bin/activate # or `.venv\Scripts\activate` on Windows
119-
pip install -e '.[dev]'
154+
pip install -e '.[dev,rich]'
120155
```
121156

122157
### Running Tests

examples/argument_patterns.py

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
"""Comprehensive guide to argument patterns using Python's *, syntax.
2+
3+
This example demonstrates all the ways to control whether CLI arguments
4+
are positional or named using native Python keyword-only parameter syntax.
5+
"""
6+
7+
from __future__ import annotations
8+
9+
from typing import TYPE_CHECKING
10+
11+
import typer
12+
13+
from sitk_cli import register_command
14+
15+
if TYPE_CHECKING:
16+
import SimpleITK as sitk
17+
18+
app = typer.Typer()
19+
20+
21+
# Pattern 1: All positional (default for required Image/Transform parameters)
22+
@register_command(app)
23+
def all_positional(input: sitk.Image, mask: sitk.Image) -> sitk.Image:
24+
"""All required inputs are positional by default.
25+
26+
CLI: all-positional INPUT MASK OUTPUT
27+
"""
28+
return input * mask
29+
30+
31+
# Pattern 2: Mixed positional and named using *, (Pythonic!)
32+
@register_command(app)
33+
def mixed_args(input: sitk.Image, *, mask: sitk.Image) -> sitk.Image:
34+
"""Use *, to make specific parameters keyword-only.
35+
36+
CLI: mixed-args INPUT OUTPUT --mask MASK
37+
38+
The *, separator makes everything after it keyword-only,
39+
so 'mask' becomes a named option while 'input' stays positional.
40+
This is the recommended Python pattern!
41+
"""
42+
return input * mask
43+
44+
45+
# Pattern 3: All keyword-only
46+
@register_command(app)
47+
def all_named(*, input: sitk.Image, mask: sitk.Image) -> sitk.Image:
48+
"""Use *, at start to make ALL parameters keyword-only.
49+
50+
CLI: all-named --input INPUT --mask MASK --output OUTPUT
51+
52+
Note: Output is also named because there are no positional inputs.
53+
"""
54+
return input * mask
55+
56+
57+
# Pattern 4: Optional parameters are always named
58+
@register_command(app)
59+
def optional_params(input: sitk.Image, mask: sitk.Image | None = None) -> sitk.Image:
60+
"""Optional Image parameters (with defaults) are always named.
61+
62+
CLI: optional-params INPUT OUTPUT [--mask MASK]
63+
64+
Even without *,, optional parameters become named options.
65+
"""
66+
if mask is not None:
67+
return input * mask
68+
return input
69+
70+
71+
# Pattern 5: Complex mixing for maximum clarity
72+
@register_command(app)
73+
def segment_regions(
74+
input: sitk.Image,
75+
reference: sitk.Image,
76+
*,
77+
brain_mask: sitk.Image | None = None,
78+
threshold: float = 0.5,
79+
) -> sitk.Image:
80+
"""Recommended pattern: positional for required, keyword-only for optional.
81+
82+
CLI: segment-regions INPUT REFERENCE OUTPUT \\
83+
[--brain-mask MASK] [--threshold 0.7]
84+
85+
Benefits:
86+
- Required inputs (input, reference): positional → concise
87+
- Optional/configuration (brain_mask, threshold): keyword-only → explicit
88+
- This is the most Pythonic and readable CLI pattern!
89+
"""
90+
result = input + reference
91+
if brain_mask is not None:
92+
result = result * brain_mask
93+
return result > threshold
94+
95+
96+
if __name__ == "__main__":
97+
app()

examples/create_directories.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
"""Demonstrates automatic directory creation for output files.
2+
3+
By default, sitk-cli automatically creates parent directories for output
4+
files. This can be disabled with create_dirs=False.
5+
"""
6+
7+
from __future__ import annotations
8+
9+
import SimpleITK as sitk
10+
import typer
11+
12+
from sitk_cli import register_command
13+
14+
app = typer.Typer()
15+
16+
17+
@register_command(app) # create_dirs=True by default
18+
def process_with_auto_dirs(width: int = 100, height: int = 100) -> sitk.Image:
19+
"""Process that auto-creates output directories.
20+
21+
CLI: process-with-auto-dirs OUTPUT --width 100 --height 100
22+
23+
You can specify nested paths like:
24+
process-with-auto-dirs results/2024/output.nii --width 200
25+
26+
The directories 'results/2024/' will be created automatically.
27+
"""
28+
return sitk.Image(width, height, sitk.sitkUInt8)
29+
30+
31+
@register_command(app, create_dirs=False)
32+
def process_no_auto_dirs(width: int = 100, height: int = 100) -> sitk.Image:
33+
"""Process that requires directories to exist.
34+
35+
CLI: process-no-auto-dirs OUTPUT --width 100 --height 100
36+
37+
If you specify a path with directories that don't exist, you'll get an error.
38+
Useful when you want to ensure output paths are pre-validated.
39+
"""
40+
return sitk.Image(width, height, sitk.sitkUInt8)
41+
42+
43+
if __name__ == "__main__":
44+
app()

examples/generator_functions.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
"""Demonstrates functions that generate images without input images.
2+
3+
When a function has no Image/Transform inputs but returns an Image/Transform,
4+
the output parameter is positional by default (unless you use *, syntax).
5+
"""
6+
7+
from __future__ import annotations
8+
9+
import SimpleITK as sitk
10+
import typer
11+
12+
from sitk_cli import register_command
13+
14+
app = typer.Typer()
15+
16+
17+
@register_command(app)
18+
def create_blank() -> sitk.Image:
19+
"""Create a blank 100x100 image.
20+
21+
CLI: create-blank OUTPUT
22+
23+
Output is positional because there are no Image/Transform inputs.
24+
"""
25+
return sitk.Image([100, 100], sitk.sitkUInt8)
26+
27+
28+
@register_command(app)
29+
def create_checkerboard(
30+
size: int = 100,
31+
square_size: int = 10,
32+
) -> sitk.Image:
33+
"""Create a checkerboard pattern.
34+
35+
CLI: create-checkerboard OUTPUT --size 200 --square-size 20
36+
37+
Output is positional, size/square_size are named options with defaults.
38+
"""
39+
image = sitk.Image([size, size], sitk.sitkUInt8)
40+
for y in range(size):
41+
for x in range(size):
42+
if (x // square_size + y // square_size) % 2 == 0:
43+
image.SetPixel([x, y], 255)
44+
return image
45+
46+
47+
@register_command(app)
48+
def create_gradient(
49+
*,
50+
width: int = 100,
51+
height: int = 100,
52+
) -> sitk.Image:
53+
"""Create a horizontal gradient image.
54+
55+
CLI: create-gradient --output OUTPUT --width 200 --height 150
56+
57+
Using *, makes all parameters keyword-only, so output is also named.
58+
"""
59+
image = sitk.Image([width, height], sitk.sitkUInt8)
60+
for y in range(height):
61+
for x in range(width):
62+
image.SetPixel([x, y], int(255 * x / width))
63+
return image
64+
65+
66+
if __name__ == "__main__":
67+
app()

0 commit comments

Comments
 (0)