CLI To-Do Manager: Production-Ready Task Management

120 mintext

Theory & Concepts

CLI To-Do Manager: Production-Ready Task Management

Building a command-line to-do application teaches the complete software development lifecycle: from parsing user input with argparse to persisting data with JSON, from designing clean object models to writing comprehensive unit tests. This isn't a toy project-it's production-ready code following software engineering best practices. You'll learn separation of concerns (model-manager-storage-CLI layers), error handling, data validation, atomic file operations, and test-driven development. The skills here apply to any Python application you'll build.

💡 Why This Matters: Every Python developer needs CLI skills. Whether you're building dev tools, automation scripts, or data pipelines, you'll need argparse for interfaces, JSON for configuration, and unit tests for reliability. This project teaches the full stack: a well-designed Task model with enums for type safety, TaskManager for business logic, TaskStorage for persistence with atomic writes (preventing data corruption), and TodoCLI connecting it all. The architecture-separation of concerns, dependency injection, testable components-is how professional software is built. Master this project and you can build any CLI tool!


Project Architecture: Separation of Concerns

The Four-Layer Design

Layer separation prevents spaghetti code!

┌─────────────────────────────────────────────────────┐
│ CLI LAYER │
│ (TodoCLI: argparse, command routing, display) │
└───────────────────────┬─────────────────────────────┘
┌─────────────────────────────────────────────────────┐
│ BUSINESS LOGIC LAYER │
│ (TaskManager: add, remove, update, filter tasks) │
└───────────────────────┬─────────────────────────────┘
┌─────────────────────────────────────────────────────┐
│ PERSISTENCE LAYER │
│ (TaskStorage: JSON save/load, atomic writes) │
└───────────────────────┬─────────────────────────────┘
┌─────────────────────────────────────────────────────┐
│ MODEL LAYER │
│ (Task: data representation, serialization) │
└─────────────────────────────────────────────────────┘
 
Why this architecture?
 
1. TESTABILITY:
- Each layer can be tested independently
- Mock dependencies (e.g., test TaskManager without disk I/O)
- Unit tests run fast (no file system access needed)
 
2. MAINTAINABILITY:
- Change JSON to SQLite? Only touch TaskStorage
- Add web UI? Create WebUI class, reuse TaskManager
- Modify task fields? Only touch Task model
 
3. REUSABILITY:
- TaskManager works with any storage backend
- Task model works in CLI, web, or API
- Clear interfaces between layers
 
4. CLARITY:
- Each class has single responsibility
- Easy to understand what each component does
- New developers can navigate codebase

The Task Model: Foundation

A well-designed model is crucial:

python
class Task:
"""
Task representation with all essential fields.
Design decisions:
1. Immutable ID (set at creation, never changes)
2. Enums for type safety (Priority, Status)
3. Optional fields (completed_at, tags)
4. Validation in __init__ (fail fast!)
5. Serialization support (to_dict/from_dict)
"""
def __init__(
self,
task_id: int,
title: str,
priority: Priority = Priority.MEDIUM,
status: Status = Status.TODO,
created_at: Optional[str] = None,
completed_at: Optional[str] = None,
tags: Optional[List[str]] = None
):
# VALIDATION: Fail fast on bad input
if not title or not title.strip():
raise ValueError("Task title cannot be empty")
self.id = task_id
self.title = title.strip()
self.priority = priority
self.status = status
self.created_at = created_at or datetime.now().isoformat()
self.completed_at = completed_at
self.tags = tags or []
Key design patterns:
1. TYPE SAFETY with Enums:
priority: Priority (not string!)
- Catches typos at runtime
- IDE autocomplete works
- Clear valid values
2. VALIDATION:
Empty title raises ValueError immediately
- Don't let bad data into system
- Fail early, fail loudly
3. DEFAULTS:
created_at defaults to now
tags defaults to empty list
- Convenient for common case
- Optional for flexibility
4. IMMUTABILITY:
ID set at creation, never modified
- Identity never changes
- Safe to use as dictionary key

Argparse: Professional CLI Interfaces

Command Structure

Subcommands pattern:

python
parser = argparse.ArgumentParser(
prog='todo',
description='📋 Production-ready CLI To-Do Manager',
formatter_class=argparse.RawDescriptionHelpFormatter
)
# Create subparsers for commands
subparsers = parser.add_subparsers(dest='command')
# ADD command
add_parser = subparsers.add_parser('add', help='Add a new task')
add_parser.add_argument('title', help='Task description')
add_parser.add_argument('--priority', '-p',
choices=['low', 'medium', 'high'],
default='medium')
add_parser.add_argument('--tags', '-t', nargs='+')
# LIST command
list_parser = subparsers.add_parser('list', help='List tasks')
list_parser.add_argument('--status', '-s',
choices=['todo', 'in_progress', 'done'])
# DONE command
done_parser = subparsers.add_parser('done', help='Complete task')
done_parser.add_argument('id', type=int, help='Task ID')
Usage:
$ todo add "Buy groceries" --priority high --tags shopping
$ todo list --status todo
$ todo done 1
Why subparsers?
1. CLEAR STRUCTURE:
- Each command has its own parser
- Different arguments per command
- Help text per command: `todo add --help`
2. VALIDATION:
- Type checking: `type=int` ensures integer
- Choices: `choices=['low', 'medium', 'high']`
- Required vs optional arguments
3. DISCOVERABILITY:
- `todo --help` lists all commands
- `todo add --help` shows add arguments
- Self-documenting interface

Argument Types and Validation

Built-in validation:

python
# Positional argument (required)
parser.add_argument('title', help='Task description')
# Optional argument with short form
parser.add_argument('--priority', '-p', default='medium')
# Choices (validation)
parser.add_argument('--priority',
choices=['low', 'medium', 'high'])
# Multiple values
parser.add_argument('--tags', nargs='+') # One or more
parser.add_argument('--tags', nargs='*') # Zero or more
# Type conversion
parser.add_argument('id', type=int) # Converts to int
parser.add_argument('--threshold', type=float)
# Mutually exclusive
group = parser.add_mutually_exclusive_group()
group.add_argument('--verbose', action='store_true')
group.add_argument('--quiet', action='store_true')
# Store true/false
parser.add_argument('--force', action='store_true')
parser.add_argument('--no-color', action='store_true')
Error handling:
$ todo done abc
usage: todo done [-h] id
todo done: error: argument id: invalid int value: 'abc'
Automatic validation! User gets clear error.

JSON Persistence: Safe File Operations

Atomic Write Pattern

Prevent data corruption:

python
def save(self, manager: TaskManager) -> None:
"""
Save tasks with atomic write.
PROBLEM: What if program crashes during write?
- File left half-written
- Data corrupted
- All tasks lost!
SOLUTION: Atomic write pattern
1. Write to temporary file
2. If successful, rename to target
3. If fails, temp deleted, original safe
Rename is atomic operation on most filesystems!
"""
data = {
"tasks": [task.to_dict() for task in manager.tasks],
"next_id": manager._next_id,
"saved_at": datetime.now().isoformat()
}
# Write to temp file
temp_file = self.filepath.with_suffix('.tmp')
try:
with temp_file.open('w', encoding='utf-8') as f:
json.dump(data, f, indent=2, ensure_ascii=False)
# Atomic rename (key step!)
temp_file.replace(self.filepath)
except Exception as e:
# Clean up on error
if temp_file.exists():
temp_file.unlink()
raise IOError(f"Failed to save tasks: {e}")
Why this is critical:
Scenario: User has 100 tasks, adds #101, crashes during save
Without atomic write:
- File partially written
- JSON corrupted
- Cannot parse
- ALL 100 TASKS LOST!
With atomic write:
- Crash during temp file write original unchanged
- Crash after rename new file complete
- User never loses data
This is PROFESSIONAL-GRADE file handling!

JSON Best Practices

Serialization and deserialization:

python
# SERIALIZATION (Python → JSON)
class Task:
def to_dict(self) -> Dict[str, Any]:
"""Convert task to JSON-serializable dict."""
return {
"id": self.id,
"title": self.title,
"priority": self.priority.value, # Enum string
"status": self.status.value,
"created_at": self.created_at,
"completed_at": self.completed_at,
"tags": self.tags
}
# Save to file
with file.open('w', encoding='utf-8') as f:
json.dump(data, f,
indent=2, # Pretty print
ensure_ascii=False) # Allow Unicode
# DESERIALIZATION (JSON → Python)
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'Task':
"""Create task from dict."""
return cls(
task_id=data["id"],
title=data["title"],
priority=Priority(data.get("priority", "medium")),
status=Status(data.get("status", "todo")),
created_at=data.get("created_at"),
completed_at=data.get("completed_at"),
tags=data.get("tags", [])
)
# Load from file
with file.open('r', encoding='utf-8') as f:
data = json.load(f)
tasks = [Task.from_dict(t) for t in data["tasks"]]
Error handling:
try:
data = json.load(f)
except json.JSONDecodeError as e:
raise IOError(f"Corrupted file: {e}")
# Validate structure
if not isinstance(data, dict) or "tasks" not in data:
raise ValueError("Invalid file format")
Best practices:
1. ENCODING: Always specify 'utf-8'
- Default encoding varies by platform
- UTF-8 handles Unicode properly
2. INDENTATION: Use indent=2 for readability
- File is human-readable
- Easy to debug
- Minor size cost acceptable
3. BACKWARD COMPATIBILITY:
- Use .get() with defaults
- Handle missing fields gracefully
- Old files still load
4. VALIDATION:
- Check JSON structure after load
- Validate field types
- Fail with clear error messages

Unit Testing: Test-Driven Development

Testing Pyramid

Different types of tests:

/│\
/ │ \
/ │ \
/ E2E \ Few: Slow, brittle, expensive
/────────\
/ \
/ Integration\ Some: Test components together
/──────────────\
/ \
/ Unit Tests \ Many: Fast, focused, cheap
/────────────────────\
 
Our test strategy:
 
UNIT TESTS (majority):
- Test Task model
- Test TaskManager logic
- Test TaskStorage (with temp files)
- Fast (milliseconds)
- No external dependencies
 
INTEGRATION TESTS (minimal):
- Test CLI end-to-end
- Test actual file I/O
- Slower but comprehensive
 
Why focus on unit tests:
 
1. FAST FEEDBACK:
- Run hundreds in seconds
- Quick development cycle
- Catch bugs immediately
 
2. PRECISE DIAGNOSIS:
- Test fails → know exact function
- No need to debug complex flows
- Fix is obvious
 
3. REFACTORING CONFIDENCE:
- Change implementation safely
- Tests ensure behavior unchanged
- Fearless refactoring!

Test Structure

Well-organized tests:

python
class TestTask(unittest.TestCase):
"""Unit tests for Task model."""
def test_task_creation(self):
"""Test basic task creation."""
task = Task(1, "Test task")
self.assertEqual(task.id, 1)
self.assertEqual(task.title, "Test task")
self.assertEqual(task.priority, Priority.MEDIUM)
def test_task_empty_title_raises_error(self):
"""Test that empty title raises ValueError."""
with self.assertRaises(ValueError):
Task(1, "")
def test_task_serialization(self):
"""Test task to_dict and from_dict."""
original = Task(1, "Test", priority=Priority.HIGH)
# Serialize → deserialize
data = original.to_dict()
restored = Task.from_dict(data)
self.assertEqual(restored.id, original.id)
self.assertEqual(restored.priority, original.priority)
Test naming convention:
test_<what>_<condition>_<expected>
Examples:
- test_task_empty_title_raises_error
- test_add_task_increments_id
- test_list_tasks_by_status_filters_correctly
AAA Pattern (Arrange-Act-Assert):
def test_update_task_title(self):
# ARRANGE: Set up test data
manager = TaskManager()
task = manager.add_task("Old title")
# ACT: Perform action being tested
success = manager.update_task(task.id, title="New title")
# ASSERT: Verify results
self.assertTrue(success)
self.assertEqual(task.title, "New title")
This makes tests readable and maintainable!

Testing Best Practices

Write testable code:

python
# TESTABLE: Dependency injection
class TodoCLI:
def __init__(self, storage_path: Path):
self.storage = TaskStorage(storage_path)
# ...
# Test with temporary path
cli = TodoCLI(Path("/tmp/test.json"))
# NOT TESTABLE: Hardcoded path
class BadCLI:
def __init__(self):
self.storage = TaskStorage(Path.home() / ".todo.json")
# Can't test without modifying home directory! ✗
# TESTABLE: Methods return values
def add_task(self, title: str) -> Task:
task = Task(self._next_id, title)
self.tasks.append(task)
return task # Can verify returned task
# NOT TESTABLE: Methods have side effects only
def bad_add_task(self, title: str):
task = Task(self._next_id, title)
self.tasks.append(task)
print(f"Added {title}") # Hard to test printing
# TESTABLE: Small, focused methods
def get_task(self, task_id: int) -> Optional[Task]:
"""One clear purpose."""
for task in self.tasks:
if task.id == task_id:
return task
return None
# NOT TESTABLE: Large, complex methods
def bad_complex_method(self, task_id, new_title, check_permission, log_changes, ...):
# 100 lines of code
# Multiple responsibilities
# Hard to test all paths ✗
Test coverage:
- Aim for 80%+ coverage on business logic
- 100% on critical paths (data persistence)
- Don't obsess over 100% everywhere
Run tests frequently:
$ python -m unittest discover
$ pytest # If using pytest
Continuous Integration:
Run tests on every commit
Catch bugs before merge
Maintain code quality

Software Engineering Best Practices

Code Organization

Professional project structure:

todo/
├── todo.py # Main CLI entry point
├── models/
│ ├── __init__.py
│ ├── task.py # Task model
│ └── enums.py # Priority, Status
├── managers/
│ ├── __init__.py
│ └── task_manager.py # Business logic
├── storage/
│ ├── __init__.py
│ └── json_storage.py # Persistence
├── cli/
│ ├── __init__.py
│ └── parser.py # Argparse setup
├── tests/
│ ├── __init__.py
│ ├── test_task.py
│ ├── test_manager.py
│ └── test_storage.py
├── requirements.txt # Dependencies
├── setup.py # Installation
└── README.md # Documentation
 
For this lesson, we kept it in one file for clarity.
Real projects: Split into modules!

Error Handling

Graceful failure:

python
# GOOD: Specific exceptions, clear messages
def add_task(self, title: str) -> Task:
if not title or not title.strip():
raise ValueError("Task title cannot be empty")
# ...
# GOOD: Catch specific exceptions
try:
data = json.load(f)
except json.JSONDecodeError as e:
print(f"Corrupted file: {e}", file=sys.stderr)
sys.exit(1)
except FileNotFoundError:
print("File not found", file=sys.stderr)
sys.exit(1)
except Exception as e:
print(f"Unexpected error: {e}", file=sys.stderr)
sys.exit(1)
# BAD: Bare except, silent failure
try:
data = json.load(f)
except: # Catches everything, even KeyboardInterrupt!
pass # Silently fails, user has no idea!
Error message guidelines:
1. SPECIFIC: "Task #5 not found" not "Error"
2. ACTIONABLE: "File corrupted. Delete ~/.todo.json to reset"
3. USER-FRIENDLY: No stack traces in production
4. LOG DETAILS: Print details to stderr, not stdout
Exit codes:
0: Success
1: General error
2: Usage error (wrong arguments)
Use consistently for scripting!

Type Hints

Modern Python practices:

python
# GOOD: Type hints everywhere
def add_task(
self,
title: str,
priority: Priority = Priority.MEDIUM,
tags: Optional[List[str]] = None
) -> Task:
"""Add task with type checking."""
# Implementation
def list_tasks(
self,
status: Optional[Status] = None
) -> List[Task]:
"""Return filtered task list."""
# Implementation
Benefits:
1. IDE SUPPORT:
- Autocomplete knows return types
- Catches type errors before running
- Refactoring is safer
2. DOCUMENTATION:
- Types are clearer than prose
- Self-documenting code
- Less need for comments
3. RUNTIME CHECKING:
# Use mypy for static type checking
$ mypy todo.py
# Finds errors like:
manager.add_task(123) # Error: Expected str, got int
4. MAINTENANCE:
- Easier for others to understand
- Harder to introduce bugs
- Refactoring confidence

Practical Application Patterns

Configuration

Flexible configuration:

python
# Environment variables
import os
DEFAULT_PATH = Path.home() / ".todo.json"
storage_path = Path(os.getenv("TODO_FILE", DEFAULT_PATH))
# Config file
import configparser
config = configparser.ConfigParser()
config.read(Path.home() / ".todorc")
storage_path = Path(config.get("storage", "path",
fallback=DEFAULT_PATH))
# Command-line override
parser.add_argument("--file", type=Path,
default=DEFAULT_PATH,
help="Task file path")

Extensibility

Easy to extend:

python
# Adding new command: 5 steps
# 1. Add subparser
archive_parser = subparsers.add_parser('archive',
help='Archive task')
archive_parser.add_argument('id', type=int)
# 2. Add handler method
def cmd_archive(self, args) -> None:
task = self.manager.get_task(args.id)
if task:
task.archived = True
print(f"Archived task #{args.id}")
# 3. Update Task model
class Task:
def __init__(self, ..., archived=False):
self.archived = archived
# 4. Update serialization
def to_dict(self):
return {
...
"archived": self.archived
}
# 5. Add tests
def test_archive_task(self):
# Test implementation
That's it! Clean separation makes adding features easy.

Common Pitfalls and Solutions

❌ Pitfall 1: Not Handling Corrupted Files

python
# WRONG: Assumes file is always valid
with file.open('r') as f:
data = json.load(f) # Crashes if corrupted!
# CORRECT: Handle corruption gracefully
try:
with file.open('r') as f:
data = json.load(f)
except json.JSONDecodeError:
print("Corrupted file! Creating backup...")
file.rename(file.with_suffix('.corrupted'))
data = {"tasks": [], "next_id": 1}

❌ Pitfall 2: Mutating Lists While Iterating

python
# WRONG: Modifying list while iterating
for task in self.tasks:
if task.status == Status.DONE:
self.tasks.remove(task) # Skips elements!
# CORRECT: Create new list or iterate backwards
self.tasks = [t for t in self.tasks if t.status != Status.DONE]
# OR
for i in range(len(self.tasks) - 1, -1, -1):
if self.tasks[i].status == Status.DONE:
self.tasks.pop(i)

❌ Pitfall 3: Not Using Enums

python
# WRONG: String comparisons everywhere
if task.priority == "high": # Typo: "hihg"
# ...
# CORRECT: Enums catch typos
if task.priority == Priority.HIGH: # Typo caught!
# ...
# Enums provide:
# - Type safety
# - Autocomplete
# - Clear valid values
# - Refactoring support

Key Takeaways

  1. Architecture: Separate concerns (model-manager-storage-CLI)
  2. Argparse: Subcommands for clean CLI interfaces
  3. JSON: Atomic writes prevent corruption
  4. Testing: Unit tests for reliability and refactoring
  5. Type Hints: Modern Python with mypy checking
  6. Enums: Type-safe constants for status/priority
  7. Error Handling: Specific exceptions, clear messages
  8. Validation: Fail fast on bad input
  9. Extensibility: Easy to add features with clean design

💡 Master Tip: This project teaches professional Python development patterns you'll use in every project. The separation of concerns-Task (model), TaskManager (logic), TaskStorage (persistence), TodoCLI (interface)-is how all well-designed software works. The atomic write pattern prevents data loss (critical for any file-based app). The argparse subcommands pattern scales to complex CLIs. The unit testing approach gives you confidence to refactor fearlessly. These aren't academic exercises-this is how you build production software that doesn't break, lose data, or confuse users!


Extensions and Next Steps

Ways to enhance this project:

  1. Database Backend: Replace JSON with SQLite
  2. Due Dates: Add deadline field and sorting
  3. Recurring Tasks: Template system for daily/weekly tasks
  4. Search: Full-text search across titles and descriptions
  5. Export: Generate reports (CSV, HTML, PDF)
  6. Sync: Cloud sync with REST API
  7. Multi-User: Add authentication and user isolation
  8. GUI: Build TUI with rich or textual

Each extension builds on the solid foundation you've created!

Lesson Content

Build a professional command-line to-do application from scratch: master argparse for CLI interfaces, implement JSON persistence with file handling, design clean task operations with comprehensive unit tests, apply software engineering best practices, and create a deployable Python project.

Code Example977 lines

Section 1 of 18 • Lesson 1 of 5