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 Example

python
# CLI To-Do Manager: Complete Production-Ready Implementation
# From command-line parsing to unit-tested task management with JSON persistence
import argparse
import json
import sys
from pathlib import Path
from typing import List, Dict, Optional, Any
from datetime import datetime, date
from enum import Enum
import unittest
from unittest.mock import patch, mock_open
# ============================================
# TASK MODEL AND ENUMS
# ============================================
class Priority(Enum):
"""Task priority levels."""
LOW = "low"
MEDIUM = "medium"
HIGH = "high"
def __str__(self):
return self.value
class Status(Enum):
"""Task completion status."""
TODO = "todo"
IN_PROGRESS = "in_progress"
DONE = "done"
def __str__(self):
return self.value
class Task:
"""
Task model with all essential fields.
A well-designed model is the FOUNDATION of any application!
Fields:
- id: Unique identifier
- title: Task description
- priority: LOW, MEDIUM, or HIGH
- status: TODO, IN_PROGRESS, or DONE
- created_at: Creation timestamp
- completed_at: Completion timestamp (optional)
- tags: List of tags for categorization
Design principles:
1. Immutable ID (never changes)
2. Required vs optional fields (title required, completed_at optional)
3. Type safety (enums for priority/status)
4. 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
):
"""Initialize a task with validation."""
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 []
def to_dict(self) -> Dict[str, Any]:
"""
Convert task to dictionary for JSON serialization.
CRITICAL for persistence!
"""
return {
"id": self.id,
"title": self.title,
"priority": self.priority.value,
"status": self.status.value,
"created_at": self.created_at,
"completed_at": self.completed_at,
"tags": self.tags
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'Task':
"""
Create task from dictionary (deserialization).
Handles missing fields gracefully for backward compatibility.
"""
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", [])
)
def mark_complete(self):
"""Mark task as completed with timestamp."""
self.status = Status.DONE
self.completed_at = datetime.now().isoformat()
def mark_in_progress(self):
"""Mark task as in progress."""
self.status = Status.IN_PROGRESS
def __str__(self) -> str:
"""Human-readable string representation."""
status_icon = {
Status.TODO: "[ ]",
Status.IN_PROGRESS: "[~]",
Status.DONE: "[]"
}
priority_badge = {
Priority.LOW: "⬇",
Priority.MEDIUM: "➡",
Priority.HIGH: "⬆"
}
tags_str = f" #{', #'.join(self.tags)}" if self.tags else ""
return f"{status_icon[self.status]} [{self.id}] {priority_badge[self.priority]} {self.title}{tags_str}"
def __repr__(self) -> str:
"""Developer-friendly representation."""
return f"Task(id={self.id}, title='{self.title}', priority={self.priority}, status={self.status})"
# ============================================
# TASK MANAGER (BUSINESS LOGIC)
# ============================================
class TaskManager:
"""
Task manager handling all business logic.
SEPARATION OF CONCERNS:
- TaskManager: Business logic (add, remove, update tasks)
- TaskStorage: Persistence logic (save/load from disk)
- CLI: User interface (argument parsing, display)
This separation makes testing easier and code more maintainable!
"""
def __init__(self):
"""Initialize with empty task list."""
self.tasks: List[Task] = []
self._next_id = 1
def add_task(
self,
title: str,
priority: Priority = Priority.MEDIUM,
tags: Optional[List[str]] = None
) -> Task:
"""
Add a new task.
Returns the created task for confirmation.
"""
task = Task(
task_id=self._next_id,
title=title,
priority=priority,
tags=tags
)
self.tasks.append(task)
self._next_id += 1
return task
def remove_task(self, task_id: int) -> bool:
"""
Remove task by ID.
Returns True if removed, False if not found.
"""
for i, task in enumerate(self.tasks):
if task.id == task_id:
self.tasks.pop(i)
return True
return False
def get_task(self, task_id: int) -> Optional[Task]:
"""Get task by ID, or None if not found."""
for task in self.tasks:
if task.id == task_id:
return task
return None
def update_task(
self,
task_id: int,
title: Optional[str] = None,
priority: Optional[Priority] = None,
status: Optional[Status] = None,
tags: Optional[List[str]] = None
) -> bool:
"""
Update task fields.
Only updates provided fields (None = no change).
Returns True if updated, False if task not found.
"""
task = self.get_task(task_id)
if not task:
return False
if title is not None:
if not title.strip():
raise ValueError("Task title cannot be empty")
task.title = title.strip()
if priority is not None:
task.priority = priority
if status is not None:
task.status = status
if status == Status.DONE:
task.mark_complete()
if tags is not None:
task.tags = tags
return True
def list_tasks(
self,
status: Optional[Status] = None,
priority: Optional[Priority] = None,
tag: Optional[str] = None
) -> List[Task]:
"""
List tasks with optional filters.
Filters can be combined (AND logic).
"""
filtered = self.tasks
if status:
filtered = [t for t in filtered if t.status == status]
if priority:
filtered = [t for t in filtered if t.priority == priority]
if tag:
filtered = [t for t in filtered if tag in t.tags]
return filtered
def get_stats(self) -> Dict[str, Any]:
"""Get task statistics."""
total = len(self.tasks)
by_status = {
Status.TODO: 0,
Status.IN_PROGRESS: 0,
Status.DONE: 0
}
by_priority = {
Priority.LOW: 0,
Priority.MEDIUM: 0,
Priority.HIGH: 0
}
for task in self.tasks:
by_status[task.status] += 1
by_priority[task.priority] += 1
return {
"total": total,
"by_status": {str(k): v for k, v in by_status.items()},
"by_priority": {str(k): v for k, v in by_priority.items()},
"completion_rate": (by_status[Status.DONE] / total * 100) if total > 0 else 0
}
# ============================================
# TASK STORAGE (PERSISTENCE LAYER)
# ============================================
class TaskStorage:
"""
Handle task persistence with JSON.
JSON PERSISTENCE BEST PRACTICES:
1. Use Path from pathlib (better than os.path)
2. Handle missing files gracefully
3. Validate data on load
4. Use atomic writes (write to temp, then rename)
5. Handle encoding explicitly (UTF-8)
6. Pretty-print for readability (indent=2)
"""
def __init__(self, filepath: Path):
"""Initialize with file path."""
self.filepath = filepath
def save(self, manager: TaskManager) -> None:
"""
Save tasks to JSON file.
Uses atomic write pattern:
1. Write to temporary file
2. If successful, rename to target
3. If fails, temp file deleted, original untouched
This prevents data corruption!
"""
# Convert tasks to dictionaries
data = {
"tasks": [task.to_dict() for task in manager.tasks],
"next_id": manager._next_id,
"saved_at": datetime.now().isoformat()
}
# Write to temporary file first
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
temp_file.replace(self.filepath)
except Exception as e:
# Clean up temp file on error
if temp_file.exists():
temp_file.unlink()
raise IOError(f"Failed to save tasks: {e}")
def load(self, manager: TaskManager) -> None:
"""
Load tasks from JSON file.
Handles missing file (empty task list).
Validates data structure.
"""
if not self.filepath.exists():
# No file yet = empty task list
return
try:
with self.filepath.open('r', encoding='utf-8') as f:
data = json.load(f)
# Validate structure
if not isinstance(data, dict) or "tasks" not in data:
raise ValueError("Invalid task file format")
# Load tasks
manager.tasks = [Task.from_dict(t) for t in data["tasks"]]
manager._next_id = data.get("next_id", len(manager.tasks) + 1)
except json.JSONDecodeError as e:
raise IOError(f"Corrupted task file: {e}")
except Exception as e:
raise IOError(f"Failed to load tasks: {e}")
# ============================================
# CLI INTERFACE (COMMAND-LINE PARSING)
# ============================================
class TodoCLI:
"""
Command-line interface using argparse.
ARGPARSE BEST PRACTICES:
1. Use subparsers for commands (add, list, remove, etc.)
2. Provide helpful descriptions and examples
3. Validate arguments early
4. Return meaningful exit codes
5. Use mutually exclusive groups where appropriate
"""
def __init__(self, storage_path: Path = Path.home() / ".todo.json"):
"""Initialize CLI with storage path."""
self.storage = TaskStorage(storage_path)
self.manager = TaskManager()
# Load existing tasks
try:
self.storage.load(self.manager)
except IOError as e:
print(f"Warning: {e}", file=sys.stderr)
def create_parser(self) -> argparse.ArgumentParser:
"""
Create argument parser with all commands.
ARCHITECTURE:
Main parser subparsers individual command parsers
"""
parser = argparse.ArgumentParser(
prog='todo',
description='📋 Production-ready CLI To-Do Manager',
epilog='Examples:\n'
' todo add "Buy groceries" --priority high --tags shopping\n'
' todo list --status todo\n'
' todo done 1\n'
' todo remove 2',
formatter_class=argparse.RawDescriptionHelpFormatter
)
subparsers = parser.add_subparsers(dest='command', help='Commands')
# 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',
help='Task priority (default: medium)'
)
add_parser.add_argument(
'--tags', '-t',
nargs='+',
help='Task tags'
)
# LIST command
list_parser = subparsers.add_parser('list', help='List tasks')
list_parser.add_argument(
'--status', '-s',
choices=['todo', 'in_progress', 'done'],
help='Filter by status'
)
list_parser.add_argument(
'--priority', '-p',
choices=['low', 'medium', 'high'],
help='Filter by priority'
)
list_parser.add_argument(
'--tag', '-t',
help='Filter by tag'
)
# DONE command
done_parser = subparsers.add_parser('done', help='Mark task as complete')
done_parser.add_argument('id', type=int, help='Task ID')
# START command
start_parser = subparsers.add_parser('start', help='Mark task as in progress')
start_parser.add_argument('id', type=int, help='Task ID')
# REMOVE command
remove_parser = subparsers.add_parser('remove', help='Remove a task')
remove_parser.add_argument('id', type=int, help='Task ID')
# UPDATE command
update_parser = subparsers.add_parser('update', help='Update task')
update_parser.add_argument('id', type=int, help='Task ID')
update_parser.add_argument('--title', help='New title')
update_parser.add_argument(
'--priority',
choices=['low', 'medium', 'high'],
help='New priority'
)
update_parser.add_argument(
'--tags', '-t',
nargs='+',
help='New tags (replaces existing)'
)
# STATS command
subparsers.add_parser('stats', help='Show task statistics')
return parser
def run(self, args: Optional[List[str]] = None) -> int:
"""
Run CLI with provided arguments.
Returns exit code (0 = success, 1 = error).
"""
parser = self.create_parser()
parsed_args = parser.parse_args(args)
if not parsed_args.command:
parser.print_help()
return 0
try:
# Dispatch to command handler
handler = getattr(self, f'cmd_{parsed_args.command}')
handler(parsed_args)
# Save after every command
self.storage.save(self.manager)
return 0
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
return 1
def cmd_add(self, args) -> None:
"""Handle 'add' command."""
priority = Priority(args.priority)
task = self.manager.add_task(args.title, priority, args.tags)
print(f"✓ Added task #{task.id}: {task.title}")
def cmd_list(self, args) -> None:
"""Handle 'list' command."""
# Parse filters
status = Status(args.status) if args.status else None
priority = Priority(args.priority) if args.priority else None
tasks = self.manager.list_tasks(status, priority, args.tag)
if not tasks:
print("No tasks found.")
return
print(f"\nFound {len(tasks)} task(s):\n")
for task in tasks:
print(f" {task}")
print()
def cmd_done(self, args) -> None:
"""Handle 'done' command."""
success = self.manager.update_task(args.id, status=Status.DONE)
if success:
task = self.manager.get_task(args.id)
print(f"✓ Marked task #{args.id} as complete: {task.title}")
else:
print(f"✗ Task #{args.id} not found")
def cmd_start(self, args) -> None:
"""Handle 'start' command."""
success = self.manager.update_task(args.id, status=Status.IN_PROGRESS)
if success:
task = self.manager.get_task(args.id)
print(f"✓ Started task #{args.id}: {task.title}")
else:
print(f"✗ Task #{args.id} not found")
def cmd_remove(self, args) -> None:
"""Handle 'remove' command."""
task = self.manager.get_task(args.id)
if task:
title = task.title
self.manager.remove_task(args.id)
print(f"✓ Removed task #{args.id}: {title}")
else:
print(f"✗ Task #{args.id} not found")
def cmd_update(self, args) -> None:
"""Handle 'update' command."""
priority = Priority(args.priority) if args.priority else None
success = self.manager.update_task(
args.id,
title=args.title,
priority=priority,
tags=args.tags
)
if success:
task = self.manager.get_task(args.id)
print(f"✓ Updated task #{args.id}: {task.title}")
else:
print(f"✗ Task #{args.id} not found")
def cmd_stats(self, args) -> None:
"""Handle 'stats' command."""
stats = self.manager.get_stats()
print("\n📊 Task Statistics\n")
print(f"Total tasks: {stats['total']}")
print(f"\nBy Status:")
for status, count in stats['by_status'].items():
print(f" {status:12s}: {count}")
print(f"\nBy Priority:")
for priority, count in stats['by_priority'].items():
print(f" {priority:12s}: {count}")
print(f"\nCompletion rate: {stats['completion_rate']:.1f}%\n")
# ============================================
# UNIT TESTS
# ============================================
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)
self.assertEqual(task.status, Status.TODO)
self.assertIsNone(task.completed_at)
def test_task_with_priority(self):
"""Test task with custom priority."""
task = Task(1, "Urgent task", priority=Priority.HIGH)
self.assertEqual(task.priority, Priority.HIGH)
def test_task_empty_title_raises_error(self):
"""Test that empty title raises ValueError."""
with self.assertRaises(ValueError):
Task(1, "")
with self.assertRaises(ValueError):
Task(1, " ")
def test_task_serialization(self):
"""Test task to_dict and from_dict."""
original = Task(
1,
"Test task",
priority=Priority.HIGH,
tags=["urgent", "work"]
)
# Serialize
data = original.to_dict()
# Deserialize
restored = Task.from_dict(data)
self.assertEqual(restored.id, original.id)
self.assertEqual(restored.title, original.title)
self.assertEqual(restored.priority, original.priority)
self.assertEqual(restored.tags, original.tags)
def test_mark_complete(self):
"""Test marking task as complete."""
task = Task(1, "Test task")
self.assertIsNone(task.completed_at)
self.assertEqual(task.status, Status.TODO)
task.mark_complete()
self.assertEqual(task.status, Status.DONE)
self.assertIsNotNone(task.completed_at)
class TestTaskManager(unittest.TestCase):
"""Unit tests for TaskManager."""
def setUp(self):
"""Create fresh manager for each test."""
self.manager = TaskManager()
def test_add_task(self):
"""Test adding a task."""
task = self.manager.add_task("Test task")
self.assertEqual(len(self.manager.tasks), 1)
self.assertEqual(task.id, 1)
self.assertEqual(task.title, "Test task")
def test_add_multiple_tasks(self):
"""Test adding multiple tasks increments IDs."""
task1 = self.manager.add_task("Task 1")
task2 = self.manager.add_task("Task 2")
self.assertEqual(task1.id, 1)
self.assertEqual(task2.id, 2)
self.assertEqual(len(self.manager.tasks), 2)
def test_remove_task(self):
"""Test removing a task."""
task = self.manager.add_task("Test task")
success = self.manager.remove_task(task.id)
self.assertTrue(success)
self.assertEqual(len(self.manager.tasks), 0)
def test_remove_nonexistent_task(self):
"""Test removing task that doesn't exist."""
success = self.manager.remove_task(999)
self.assertFalse(success)
def test_get_task(self):
"""Test retrieving a task by ID."""
task = self.manager.add_task("Test task")
retrieved = self.manager.get_task(task.id)
self.assertIsNotNone(retrieved)
self.assertEqual(retrieved.id, task.id)
self.assertEqual(retrieved.title, task.title)
def test_get_nonexistent_task(self):
"""Test retrieving task that doesn't exist."""
retrieved = self.manager.get_task(999)
self.assertIsNone(retrieved)
def test_update_task_title(self):
"""Test updating task title."""
task = self.manager.add_task("Old title")
success = self.manager.update_task(task.id, title="New title")
self.assertTrue(success)
self.assertEqual(task.title, "New title")
def test_update_task_priority(self):
"""Test updating task priority."""
task = self.manager.add_task("Test task")
success = self.manager.update_task(task.id, priority=Priority.HIGH)
self.assertTrue(success)
self.assertEqual(task.priority, Priority.HIGH)
def test_update_task_status(self):
"""Test updating task status."""
task = self.manager.add_task("Test task")
success = self.manager.update_task(task.id, status=Status.DONE)
self.assertTrue(success)
self.assertEqual(task.status, Status.DONE)
self.assertIsNotNone(task.completed_at)
def test_list_all_tasks(self):
"""Test listing all tasks."""
self.manager.add_task("Task 1")
self.manager.add_task("Task 2")
self.manager.add_task("Task 3")
tasks = self.manager.list_tasks()
self.assertEqual(len(tasks), 3)
def test_list_tasks_by_status(self):
"""Test filtering tasks by status."""
task1 = self.manager.add_task("Task 1")
task2 = self.manager.add_task("Task 2")
task2.mark_complete()
task3 = self.manager.add_task("Task 3")
todo_tasks = self.manager.list_tasks(status=Status.TODO)
done_tasks = self.manager.list_tasks(status=Status.DONE)
self.assertEqual(len(todo_tasks), 2)
self.assertEqual(len(done_tasks), 1)
def test_list_tasks_by_priority(self):
"""Test filtering tasks by priority."""
self.manager.add_task("Low", priority=Priority.LOW)
self.manager.add_task("High", priority=Priority.HIGH)
self.manager.add_task("Medium", priority=Priority.MEDIUM)
high_tasks = self.manager.list_tasks(priority=Priority.HIGH)
self.assertEqual(len(high_tasks), 1)
self.assertEqual(high_tasks[0].priority, Priority.HIGH)
def test_get_stats(self):
"""Test task statistics."""
self.manager.add_task("Task 1", priority=Priority.HIGH)
task2 = self.manager.add_task("Task 2", priority=Priority.LOW)
task2.mark_complete()
stats = self.manager.get_stats()
self.assertEqual(stats['total'], 2)
self.assertEqual(stats['by_status']['done'], 1)
self.assertEqual(stats['by_priority']['high'], 1)
self.assertEqual(stats['completion_rate'], 50.0)
class TestTaskStorage(unittest.TestCase):
"""Unit tests for TaskStorage."""
def setUp(self):
"""Create temporary storage path."""
self.temp_file = Path("/tmp/test_todo.json")
if self.temp_file.exists():
self.temp_file.unlink()
self.storage = TaskStorage(self.temp_file)
self.manager = TaskManager()
def tearDown(self):
"""Clean up temporary file."""
if self.temp_file.exists():
self.temp_file.unlink()
def test_save_and_load(self):
"""Test saving and loading tasks."""
# Add tasks
self.manager.add_task("Task 1", priority=Priority.HIGH)
self.manager.add_task("Task 2", tags=["work"])
# Save
self.storage.save(self.manager)
# Create new manager and load
new_manager = TaskManager()
self.storage.load(new_manager)
# Verify
self.assertEqual(len(new_manager.tasks), 2)
self.assertEqual(new_manager.tasks[0].title, "Task 1")
self.assertEqual(new_manager.tasks[0].priority, Priority.HIGH)
self.assertEqual(new_manager.tasks[1].tags, ["work"])
def test_load_nonexistent_file(self):
"""Test loading when file doesn't exist."""
# Should not raise error, just leave manager empty
self.storage.load(self.manager)
self.assertEqual(len(self.manager.tasks), 0)
def test_save_creates_file(self):
"""Test that save creates the file."""
self.assertFalse(self.temp_file.exists())
self.manager.add_task("Test task")
self.storage.save(self.manager)
self.assertTrue(self.temp_file.exists())
# ============================================
# DEMONSTRATION AND EXAMPLES
# ============================================
def demonstrate_task_operations():
"""Demonstrate basic task operations."""
print("=" * 70)
print("TASK OPERATIONS DEMONSTRATION")
print("=" * 70)
manager = TaskManager()
# Add tasks
print("\n1. Adding tasks...")
task1 = manager.add_task("Write documentation", priority=Priority.HIGH, tags=["work", "urgent"])
task2 = manager.add_task("Buy groceries", priority=Priority.MEDIUM, tags=["personal"])
task3 = manager.add_task("Review pull request", priority=Priority.HIGH, tags=["work"])
print(f" Added: {task1}")
print(f" Added: {task2}")
print(f" Added: {task3}")
# List all tasks
print("\n2. Listing all tasks...")
for task in manager.list_tasks():
print(f" {task}")
# Mark task as complete
print("\n3. Completing task #1...")
manager.update_task(1, status=Status.DONE)
print(f" {manager.get_task(1)}")
# Filter by status
print("\n4. Listing incomplete tasks...")
incomplete = manager.list_tasks(status=Status.TODO)
for task in incomplete:
print(f" {task}")
# Statistics
print("\n5. Task statistics...")
stats = manager.get_stats()
print(f" Total: {stats['total']}")
print(f" Completion rate: {stats['completion_rate']:.1f}%")
def demonstrate_cli_commands():
"""Demonstrate CLI command examples."""
print("\n" + "=" * 70)
print("CLI COMMAND EXAMPLES")
print("=" * 70)
print("""
BASIC COMMANDS:
# Add a task
$ todo add "Write documentation" --priority high --tags work urgent
# List all tasks
$ todo list
# List only high-priority tasks
$ todo list --priority high
# Mark task as complete
$ todo done 1
# Update task
$ todo update 2 --title "Buy groceries and cook dinner" --priority high
# Remove task
$ todo remove 3
# Show statistics
$ todo stats
FILTERING:
# List tasks by status
$ todo list --status todo
$ todo list --status done
# List tasks by tag
$ todo list --tag work
# Combine filters
$ todo list --status todo --priority high
""")
def run_unit_tests():
"""Run all unit tests."""
print("\n" + "=" * 70)
print("RUNNING UNIT TESTS")
print("=" * 70)
# Create test suite
loader = unittest.TestLoader()
suite = unittest.TestSuite()
# Add test classes
suite.addTests(loader.loadTestsFromTestCase(TestTask))
suite.addTests(loader.loadTestsFromTestCase(TestTaskManager))
suite.addTests(loader.loadTestsFromTestCase(TestTaskStorage))
# Run tests
runner = unittest.TextTestRunner(verbosity=2)
result = runner.run(suite)
# Summary
print("\n" + "=" * 70)
if result.wasSuccessful():
print("✅ ALL TESTS PASSED")
else:
print("❌ SOME TESTS FAILED")
print("=" * 70)
return result.wasSuccessful()
if __name__ == "__main__":
# Run demonstrations
demonstrate_task_operations()
demonstrate_cli_commands()
# Run unit tests
success = run_unit_tests()
sys.exit(0 if success else 1)
Section 1 of 18 • Lesson 1 of 5