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.
Code Example
# CLI To-Do Manager: Complete Production-Ready Implementation# From command-line parsing to unit-tested task management with JSON persistenceimport argparseimport jsonimport sysfrom pathlib import Pathfrom typing import List, Dict, Optional, Anyfrom datetime import datetime, datefrom enum import Enumimport unittestfrom 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.valueclass Status(Enum): """Task completion status.""" TODO = "todo" IN_PROGRESS = "in_progress" DONE = "done" def __str__(self): return self.valueclass 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)