Python Projects: Build Real Applications
CLI To-Do Manager: Production-Ready Task Management
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 codebaseThe Task Model: Foundation
A well-designed model is crucial:
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 values2. VALIDATION: Empty title raises ValueError immediately - Don't let bad data into system - Fail early, fail loudly3. DEFAULTS: created_at defaults to now tags defaults to empty list - Convenient for common case - Optional for flexibility4. IMMUTABILITY: ID set at creation, never modified - Identity never changes - Safe to use as dictionary keyArgparse: Professional CLI Interfaces
Command Structure
Subcommands pattern:
parser = argparse.ArgumentParser( prog='todo', description='📋 Production-ready CLI To-Do Manager', formatter_class=argparse.RawDescriptionHelpFormatter)# Create subparsers for commandssubparsers = parser.add_subparsers(dest='command')# ADD commandadd_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 commandlist_parser = subparsers.add_parser('list', help='List tasks')list_parser.add_argument('--status', '-s', choices=['todo', 'in_progress', 'done'])# DONE commanddone_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 1Why 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 arguments3. DISCOVERABILITY: - `todo --help` lists all commands - `todo add --help` shows add arguments - Self-documenting interfaceArgument Types and Validation
Built-in validation:
# Positional argument (required)parser.add_argument('title', help='Task description')# Optional argument with short formparser.add_argument('--priority', '-p', default='medium')# Choices (validation)parser.add_argument('--priority', choices=['low', 'medium', 'high'])# Multiple valuesparser.add_argument('--tags', nargs='+') # One or moreparser.add_argument('--tags', nargs='*') # Zero or more# Type conversionparser.add_argument('id', type=int) # Converts to intparser.add_argument('--threshold', type=float)# Mutually exclusivegroup = parser.add_mutually_exclusive_group()group.add_argument('--verbose', action='store_true')group.add_argument('--quiet', action='store_true')# Store true/falseparser.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:
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 saveWithout 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:
# 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 filewith file.open('w', encoding='utf-8') as f: json.dump(data, f, indent=2, # Pretty print ensure_ascii=False) # Allow Unicode# DESERIALIZATION (JSON → Python)@classmethoddef 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 filewith 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 structureif 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 properly2. INDENTATION: Use indent=2 for readability - File is human-readable - Easy to debug - Minor size cost acceptable3. BACKWARD COMPATIBILITY: - Use .get() with defaults - Handle missing fields gracefully - Old files still load4. VALIDATION: - Check JSON structure after load - Validate field types - Fail with clear error messagesUnit 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:
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_correctlyAAA 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:
# TESTABLE: Dependency injectionclass TodoCLI: def __init__(self, storage_path: Path): self.storage = TaskStorage(storage_path) # ...# Test with temporary pathcli = TodoCLI(Path("/tmp/test.json"))# NOT TESTABLE: Hardcoded pathclass BadCLI: def __init__(self): self.storage = TaskStorage(Path.home() / ".todo.json") # Can't test without modifying home directory! ✗# TESTABLE: Methods return valuesdef 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 onlydef 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 methodsdef 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 methodsdef 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% everywhereRun tests frequently: $ python -m unittest discover $ pytest # If using pytestContinuous Integration: Run tests on every commit Catch bugs before merge Maintain code qualitySoftware 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:
# GOOD: Specific exceptions, clear messagesdef add_task(self, title: str) -> Task: if not title or not title.strip(): raise ValueError("Task title cannot be empty") # ...# GOOD: Catch specific exceptionstry: 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 failuretry: 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 production4. LOG DETAILS: Print details to stderr, not stdoutExit codes: 0: Success 1: General error 2: Usage error (wrong arguments) Use consistently for scripting!Type Hints
Modern Python practices:
# GOOD: Type hints everywheredef add_task( self, title: str, priority: Priority = Priority.MEDIUM, tags: Optional[List[str]] = None) -> Task: """Add task with type checking.""" # Implementationdef list_tasks( self, status: Optional[Status] = None) -> List[Task]: """Return filtered task list.""" # ImplementationBenefits:1. IDE SUPPORT: - Autocomplete knows return types - Catches type errors before running - Refactoring is safer2. DOCUMENTATION: - Types are clearer than prose - Self-documenting code - Less need for comments3. RUNTIME CHECKING: # Use mypy for static type checking $ mypy todo.py # Finds errors like: manager.add_task(123) # Error: Expected str, got int4. MAINTENANCE: - Easier for others to understand - Harder to introduce bugs - Refactoring confidencePractical Application Patterns
Configuration
Flexible configuration:
# Environment variablesimport osDEFAULT_PATH = Path.home() / ".todo.json"storage_path = Path(os.getenv("TODO_FILE", DEFAULT_PATH))# Config fileimport configparserconfig = configparser.ConfigParser()config.read(Path.home() / ".todorc")storage_path = Path(config.get("storage", "path", fallback=DEFAULT_PATH))# Command-line overrideparser.add_argument("--file", type=Path, default=DEFAULT_PATH, help="Task file path")Extensibility
Easy to extend:
# Adding new command: 5 steps# 1. Add subparserarchive_parser = subparsers.add_parser('archive', help='Archive task')archive_parser.add_argument('id', type=int)# 2. Add handler methoddef 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 modelclass Task: def __init__(self, ..., archived=False): self.archived = archived# 4. Update serializationdef to_dict(self): return { ... "archived": self.archived }# 5. Add testsdef test_archive_task(self): # Test implementationThat's it! Clean separation makes adding features easy.Common Pitfalls and Solutions
❌ Pitfall 1: Not Handling Corrupted Files
# WRONG: Assumes file is always validwith file.open('r') as f: data = json.load(f) # Crashes if corrupted!# CORRECT: Handle corruption gracefullytry: 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
# WRONG: Modifying list while iteratingfor task in self.tasks: if task.status == Status.DONE: self.tasks.remove(task) # ✗ Skips elements!# CORRECT: Create new list or iterate backwardsself.tasks = [t for t in self.tasks if t.status != Status.DONE]# ORfor 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
# WRONG: String comparisons everywhereif task.priority == "high": # Typo: "hihg" ✗ # ...# CORRECT: Enums catch typosif task.priority == Priority.HIGH: # Typo caught! ✓ # ...# Enums provide:# - Type safety# - Autocomplete# - Clear valid values# - Refactoring supportKey Takeaways
- ✅ Architecture: Separate concerns (model-manager-storage-CLI)
- ✅ Argparse: Subcommands for clean CLI interfaces
- ✅ JSON: Atomic writes prevent corruption
- ✅ Testing: Unit tests for reliability and refactoring
- ✅ Type Hints: Modern Python with mypy checking
- ✅ Enums: Type-safe constants for status/priority
- ✅ Error Handling: Specific exceptions, clear messages
- ✅ Validation: Fail fast on bad input
- ✅ 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:
- Database Backend: Replace JSON with SQLite
- Due Dates: Add deadline field and sorting
- Recurring Tasks: Template system for daily/weekly tasks
- Search: Full-text search across titles and descriptions
- Export: Generate reports (CSV, HTML, PDF)
- Sync: Cloud sync with REST API
- Multi-User: Add authentication and user isolation
- GUI: Build TUI with
richortextual
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.