Build a CLI in 10 Minutes
Welcome! In this tutorial, you’ll build a Task Manager CLI with robust input validation in just 10 minutes. By the end, you’ll have a working command-line tool that validates user input, provides helpful error messages, and even has tests.
What you’ll learn:
Parse and validate command-line arguments
Create interactive prompts with retry logic
Use multiple parsers (integers, emails, booleans)
Test interactive prompts with
MockInputContext
Prerequisites:
Python 3.11+
Basic Python knowledge (functions, if/else, pattern matching)
Let’s get started!
Step 1: Setup (1 minute)
First, create a new directory and install valid8r:
mkdir task-cli
cd task-cli
pip install valid8r
Or with uv:
mkdir task-cli
cd task-cli
uv init
uv add valid8r
Create a new file called task_cli.py:
touch task_cli.py
Checkpoint: You should be at 1 minute now.
Step 2: Basic Structure (2 minutes)
Open task_cli.py and add the basic CLI structure:
#!/usr/bin/env python
"""Task Manager CLI - Built with valid8r."""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
# Storage file for tasks
TASKS_FILE = Path('tasks.json')
def load_tasks() -> list[dict]:
"""Load tasks from JSON file."""
if TASKS_FILE.exists():
with TASKS_FILE.open() as f:
return json.load(f)
return []
def save_tasks(tasks: list[dict]) -> None:
"""Save tasks to JSON file."""
with TASKS_FILE.open('w') as f:
json.dump(tasks, f, indent=2)
def main() -> int:
"""Main CLI entry point."""
parser = argparse.ArgumentParser(description='Task Manager CLI')
subparsers = parser.add_subparsers(dest='command')
# add subcommand
add_parser = subparsers.add_parser('add', help='Add a new task')
add_parser.add_argument('description', help='Task description')
add_parser.add_argument('--priority', '-p', help='Priority (1-5)')
# list subcommand
subparsers.add_parser('list', help='List all tasks')
args = parser.parse_args()
if args.command == 'add':
return add_task(args)
if args.command == 'list':
return list_tasks()
parser.print_help()
return 1
def add_task(args: argparse.Namespace) -> int:
"""Add a task (we'll add validation next!)."""
task = {
'description': args.description,
'priority': int(args.priority) if args.priority else 3,
}
tasks = load_tasks()
tasks.append(task)
save_tasks(tasks)
print(f'Added: {task["description"]}')
return 0
def list_tasks() -> int:
"""List all tasks."""
tasks = load_tasks()
if not tasks:
print('No tasks yet!')
return 0
for i, task in enumerate(tasks, 1):
print(f'{i}. [{task.get("priority", 3)}] {task["description"]}')
return 0
if __name__ == '__main__':
sys.exit(main())
Try it out:
python task_cli.py add "Review PR" --priority 2
python task_cli.py list
But what happens if someone enters an invalid priority?
python task_cli.py add "Test" --priority "high"
# Crash! ValueError: invalid literal for int()
Let’s fix that with valid8r!
Checkpoint: You should be at 3 minutes now.
Step 3: Add Input Validation (2 minutes)
Now let’s add proper validation. Update your imports and add a priority parser:
#!/usr/bin/env python
"""Task Manager CLI - Built with valid8r."""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
from typing import TYPE_CHECKING
from valid8r import parsers, validators
from valid8r.core.maybe import Failure, Success
if TYPE_CHECKING:
from valid8r import Maybe
# Storage file for tasks
TASKS_FILE = Path('tasks.json')
def parse_priority(text: str) -> Maybe[int]:
"""Parse and validate task priority (1-5).
Priority levels:
1 = Critical
2 = High
3 = Medium (default)
4 = Low
5 = Someday
"""
return parsers.parse_int(text).bind(
validators.between(1, 5, error_message='Priority must be 1-5')
)
This is the magic of valid8r! Let’s break it down:
parsers.parse_int(text)- Tries to parse the string as an integer, returnsSuccess(int)orFailure(error).bind(validators.between(1, 5))- If parsing succeeded, validates the value is between 1 and 5
Now update the add_task function to use our validator:
def add_task(args: argparse.Namespace) -> int:
"""Add a task with validation."""
# Validate priority if provided
priority = 3 # default
if args.priority:
result = parse_priority(args.priority)
match result:
case Success(value):
priority = value
case Failure(error):
print(f'Error: {error}', file=sys.stderr)
return 1
task = {
'description': args.description,
'priority': priority,
}
tasks = load_tasks()
tasks.append(task)
save_tasks(tasks)
print(f'Added: {task["description"]} (priority: {priority})')
return 0
Try it now:
python task_cli.py add "Test" --priority "high"
# Error: Input must be a valid integer
python task_cli.py add "Test" --priority 10
# Error: Priority must be 1-5
python task_cli.py add "Test" --priority 2
# Added: Test (priority: 2)
Nice! Clear error messages instead of crashes.
Checkpoint: You should be at 5 minutes now.
Step 4: Interactive Prompts (2 minutes)
Let’s add an interactive mode that prompts the user for input. Add this import:
from valid8r import parsers, prompt, validators
And add the --interactive flag to your add subcommand:
add_parser.add_argument('--interactive', '-i', action='store_true')
Make the description optional:
add_parser.add_argument('description', nargs='?', help='Task description')
Now add the interactive function:
def add_task_interactive() -> int:
"""Add a task using interactive prompts."""
print('Add a new task')
print('-' * 40)
# Get description
description = prompt.ask(
'Task description: ',
validator=validators.non_empty_string('Description cannot be empty'),
retry=True,
)
if description.is_failure():
return 1
# Get priority with default
priority = prompt.ask(
'Priority (1-5): ',
parser=parse_priority,
default=3,
retry=True,
)
if priority.is_failure():
return 1
task = {
'description': description.value_or(''),
'priority': priority.value_or(3),
}
tasks = load_tasks()
tasks.append(task)
save_tasks(tasks)
print(f'\nAdded: {task["description"]} (priority: {task["priority"]})')
return 0
Update add_task to handle interactive mode:
def add_task(args: argparse.Namespace) -> int:
"""Add a task with validation."""
if args.interactive:
return add_task_interactive()
if not args.description:
print('Error: Description required (or use --interactive)', file=sys.stderr)
return 1
# ... rest of the function
Try it:
python task_cli.py add --interactive
# Task description: Review PR
# Priority (1-5): 2
# Added: Review PR (priority: 2)
The retry=True option means invalid input shows an error and prompts again, instead of failing immediately!
Checkpoint: You should be at 7 minutes now.
Step 5: Email Notifications (2 minutes)
Let’s add email notification support using parse_email. Add a notify flag:
add_parser.add_argument('--notify', '-n', help='Email for notifications')
Update the non-interactive add_task:
def add_task(args: argparse.Namespace) -> int:
"""Add a task with validation."""
if args.interactive:
return add_task_interactive()
if not args.description:
print('Error: Description required (or use --interactive)', file=sys.stderr)
return 1
# Validate priority
priority = 3
if args.priority:
result = parse_priority(args.priority)
match result:
case Success(value):
priority = value
case Failure(error):
print(f'Error: {error}', file=sys.stderr)
return 1
# Validate email if provided
notify_email = None
if args.notify:
result = parsers.parse_email(args.notify)
match result:
case Success(email):
notify_email = f'{email.local}@{email.domain}'
case Failure(error):
print(f'Error: Invalid email: {error}', file=sys.stderr)
return 1
task = {
'description': args.description,
'priority': priority,
'notify_email': notify_email,
}
tasks = load_tasks()
tasks.append(task)
save_tasks(tasks)
print(f'Added: {task["description"]} (priority: {priority})')
if notify_email:
print(f' Will notify: {notify_email}')
return 0
And update the interactive version to ask about notifications:
def add_task_interactive() -> int:
"""Add a task using interactive prompts."""
print('Add a new task')
print('-' * 40)
description = prompt.ask(
'Task description: ',
validator=validators.non_empty_string('Description cannot be empty'),
retry=True,
)
if description.is_failure():
return 1
priority = prompt.ask(
'Priority (1-5): ',
parser=parse_priority,
default=3,
retry=True,
)
if priority.is_failure():
return 1
# Ask about notifications using parse_bool
wants_notification = prompt.ask(
'Enable email notification? (yes/no): ',
parser=parsers.parse_bool,
default=False,
retry=True,
)
notify_email = None
if wants_notification.value_or(False):
email_result = prompt.ask(
'Notification email: ',
parser=parsers.parse_email,
retry=True,
)
if email_result.is_success():
email = email_result.value_or(None)
if email:
notify_email = f'{email.local}@{email.domain}'
task = {
'description': description.value_or(''),
'priority': priority.value_or(3),
'notify_email': notify_email,
}
tasks = load_tasks()
tasks.append(task)
save_tasks(tasks)
print(f'\nAdded: {task["description"]} (priority: {task["priority"]})')
if notify_email:
print(f' Will notify: {notify_email}')
return 0
Try it:
python task_cli.py add "Deploy" --priority 1 --notify "team@company.com"
# Added: Deploy (priority: 1)
# Will notify: team@company.com
python task_cli.py add "Test" --notify "not-an-email"
# Error: Invalid email: Must be a valid email address
Checkpoint: You should be at 9 minutes now.
Step 6: Write a Test (1 minute)
Create test_task_cli.py:
"""Tests for Task Manager CLI."""
import json
from pathlib import Path
import pytest
from valid8r.testing import MockInputContext, assert_maybe_success, assert_maybe_failure
from task_cli import parse_priority, add_task_interactive
class DescribeParsePriority:
"""Tests for priority validation."""
def it_accepts_valid_priorities(self) -> None:
assert assert_maybe_success(parse_priority('1'), 1)
assert assert_maybe_success(parse_priority('3'), 3)
assert assert_maybe_success(parse_priority('5'), 5)
def it_rejects_out_of_range(self) -> None:
assert assert_maybe_failure(parse_priority('0'), '1-5')
assert assert_maybe_failure(parse_priority('6'), '1-5')
def it_rejects_non_integers(self) -> None:
assert parse_priority('high').is_failure()
assert parse_priority('').is_failure()
class DescribeInteractiveMode:
"""Tests for interactive prompts using MockInputContext."""
def it_creates_task_from_prompts(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
tasks_file = tmp_path / 'tasks.json'
monkeypatch.setattr('task_cli.TASKS_FILE', tasks_file)
# Simulate user input
with MockInputContext(['Review PR', '2', 'no']):
exit_code = add_task_interactive()
assert exit_code == 0
with tasks_file.open() as f:
tasks = json.load(f)
assert len(tasks) == 1
assert tasks[0]['description'] == 'Review PR'
assert tasks[0]['priority'] == 2
Run the tests:
pip install pytest
pytest test_task_cli.py -v
Congratulations! You’ve built a complete CLI with validation in 10 minutes!
What We Learned
In this tutorial, you learned how to:
Parse and validate input using
parsers.parse_int()andvalidators.between()Chain validators using
.bind()for pipeline-style validationCreate interactive prompts with
prompt.ask()and automatic retryUse multiple parsers:
parse_int,parse_email,parse_boolHandle errors elegantly with pattern matching on
SuccessandFailureTest interactive code using
MockInputContext
Key Concepts
The Maybe Pattern
valid8r uses Success and Failure types instead of exceptions:
result = parsers.parse_int("42")
match result:
case Success(value):
print(f"Got: {value}")
case Failure(error):
print(f"Error: {error}")
Chaining Validators
Use .bind() to chain parsing and validation:
# Parse as int, then validate range
result = parsers.parse_int(text).bind(validators.between(1, 100))
Interactive Prompts
prompt.ask() handles prompting, parsing, validation, and retries:
age = prompt.ask(
'Your age: ',
parser=parsers.parse_int,
validator=validators.minimum(0),
default=18,
retry=True, # Keep asking until valid
)
Next Steps
Now that you’ve built a basic CLI, explore:
Validators Guide - All available validators and how to combine them
Parsers Reference - Every parser type (dates, URLs, IPs, UUIDs…)
CLI Starter Template - A production-ready CLI template
Testing Guide - Advanced testing patterns
Complete Code
The complete code for this tutorial is available at: examples/tutorial-task-cli/
Happy coding! If you found this tutorial helpful, consider starring valid8r on GitHub.