How to Build and Deploy a Python Project: A Complete Step-by-Step Guide for Beginners

Timothy KimutaiTimothy Kimutai
24 min read

So, you’ve got an idea for a Python project—awesome! But what’s next?

Whether you're creating a data science tool, a web scraper, an automation script, or a full web app, turning your idea into a live project can feel a bit daunting, especially if you're unsure where to begin.

In this guide, I'll take you through the entire Python project lifecycle, from setting up your environment to deploying your finished product. Think of it as your personal roadmap, perfect for beginners eager to build real-world projects, and also useful for experienced developers looking to streamline their workflow.

Let’s jump in and turn that idea into a reality. We’ll build a practical example throughout this guide: a Task Management CLI Tool that showcases real-world development patterns.


Project Planning: Setting the Foundation

Define Your Goal and Scope

Before writing a single line of code, clearly define what you’re building. For our example project:

Goal: Create a command-line task management tool that allows users to add, list, complete, and delete tasks.

Scope:

  • Add new tasks with descriptions and priorities

  • List all tasks or filter by status

  • Mark tasks as complete

  • Delete tasks

  • Store data persistently in a JSON file

  • Provide a clean CLI interface

Success Criteria:

  • Intuitive command-line interface

  • Data persistence between sessions

  • Error handling for edge cases

  • Comprehensive test coverage (>90%)

  • Clear documentation

Select Tools and Frameworks

Choose your tech stack based on project requirements:

For our Task Manager CLI:

  • Core Framework: Pure Python

  • CLI Framework: click (elegant command-line interfaces)

  • Data Storage: JSON files (simple, no database required)

  • Testing: pytest

  • Code Quality: black, flake8, mypy

  • Documentation: MkDocs with material theme

  • Dependency Management: poetry

Decision Matrix Template:

| Tool Category  | Options Considered     | Chosen | Reason                             |
|----------------|------------------------|--------|------------------------------------|
| CLI Framework  | argparse, click        | click  | Better UX, decorators              |
| Testing        | unittest, pytest       | pytest | More features, fixtures            |
| Packaging      | pip, poetry            | poetry | Better dependency resolution       |

Environment Setup: Creating Isolation

Creating a Virtual Environment

Virtual environments prevent dependency conflicts between projects. Here are three common approaches:

Option 1: Using venv (built-in)

# Create virtual environment
python -m venv task-manager-env

# Activate (Linux/Mac)
source task-manager-env/bin/activate

# Activate (Windows)
task-manager-env\Scripts\activate

# Verify activation
which python # Should point to your venv

Option 2: Using conda

# Create environment with specific Python version
conda create -n task-manager python=3.11
conda activate task-manager

Option 3: Using poetry (recommended)

# Install poetry first
curl -sSL https://install.python-poetry.org | python3 -

# Create new project (automatically creates venv)
poetry new task-manager
cd task-manager

Dependency Management

With Poetry:

# Add dependencies
poetry add click colorama
poetry add --group dev pytest black flake8 mypy

# Install all dependencies
poetry install

With pip:

# Install dependencies
pip install click colorama

# Development dependencies
pip install pytest black flake8 mypy

# Generate requirements file
pip freeze > requirements.txt

Configuration Files

pyproject.toml (Poetry)

[tool.poetry]
name = "task-manager"
version = "0.1.0"
description = "A simple CLI task management tool"
authors = ["Your Name your.email@example.com"]
readme = "README.md"
packages = [{include = "task_manager"}]

[tool.poetry.dependencies]
python = "^3.9"
click = "^8.1.0"
colorama = "^0.4.6"

[tool.poetry.group.dev.dependencies]
pytest = "^7.4.0"
black = "^23.7.0"
flake8 = "^6.0.0"
mypy = "^1.5.0"
pytest-cov = "^4.1.0"

[tool.poetry.scripts]
task-manager = "task_manager.cli:main"

[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"

[tool.black]
line-length = 88
target-version = ['py39']

[tool.mypy]
python_version = "3.9"
warn_return_any = true
warn_unused_configs = true
disallow_untyped_defs = true

[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = "test_*.py"
python_classes = "Test*"
python_functions = "test_*"
addopts = "--cov=task_manager --cov-report=html --cov-report=term-missing"

requirements.txt (pip)

click==8.1.7
colorama==0.4.6

# Development dependencies
pytest==7.4.3
black==23.9.1
flake8==6.1.0
mypy==1.6.1
pytest-cov==4.1.0

Project Structure: Organization Matters

Directory Structure

A well-organized project structure improves maintainability and collaboration:

task-manager/
├── task_manager/ # Main package
│ ├── __init__.py
│ ├── cli.py # CLI interface
│ ├── models.py # Data models
│ ├── storage.py # Data persistence
│ └── utils.py # Helper functions
├── tests/ # Test suite
│ ├── __init__.py
│ ├── test_models.py
│ ├── test_storage.py
│ ├── test_cli.py
│ └── fixtures/ # Test data
├── docs/ # Documentation
│ ├── index.md
│ └── api/
├── scripts/ # Utility scripts
│ └── setup_dev.sh
├── .env.example # Environment variables template
├── .gitignore # Git ignore rules
├── README.md # Project documentation
├── pyproject.toml # Project configuration
├── Dockerfile # Container setup
└── .github/ # GitHub workflows
└── workflows/
└── ci.yml

Core Module Structure

task_manager/init.py

"""Task Manager CLI Tool.

A simple command-line task management application.
"""

__version__ = "0.1.0"
__author__ = "Your Name"
__email__ = "your.email@example.com"

from .models import Task, TaskStatus
from .storage import TaskStorage

__all__ = ["Task", "TaskStatus", "TaskStorage"]

task_manager/models.py

"""Data models for the task manager."""

from datetime import datetime
from enum import Enum
from typing import Dict, Any
from dataclasses import dataclass, asdict
import uuid

class TaskStatus(Enum):
"""Task status enumeration."""

PENDING = "pending"
COMPLETED = "completed"

class Priority(Enum):
"""Task priority enumeration."""

LOW = "low"
MEDIUM = "medium"
HIGH = "high"

@dataclass
class Task:
"""Task data model."""

id: str
title: str
description: str = ""
status: TaskStatus = TaskStatus.PENDING
priority: Priority = Priority.MEDIUM
created_at: datetime = None
completed_at: datetime = None

def __post_init__(self):
"""Initialize computed fields."""
if self.created_at is None:
self.created_at = datetime.now()

@classmethod
def create(cls, title: str, description: str = "", priority: Priority = Priority.MEDIUM) -> "Task":
"""Create a new task with generated ID."""
return cls(
id=str(uuid.uuid4()),
title=title,
description=description,
priority=priority
)

def complete(self) -> None:
"""Mark task as completed."""
self.status = TaskStatus.COMPLETED
self.completed_at = datetime.now()

def to_dict(self) -> Dict[str, Any]:
"""Convert task to dictionary."""
data = asdict(self)
# Convert enums to strings
data['status'] = self.status.value
data['priority'] = self.priority.value
# Convert datetime to ISO format
data['created_at'] = self.created_at.isoformat() if self.created_at else None
data['completed_at'] = self.completed_at.isoformat() if self.completed_at else None
return data

@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "Task":
"""Create task from dictionary."""
# Convert strings back to enums
data['status'] = TaskStatus(data['status'])
data['priority'] = Priority(data['priority'])
# Convert ISO strings back to datetime
if data['created_at']:
data['created_at'] = datetime.fromisoformat(data['created_at'])
if data['completed_at']:
data['completed_at'] = datetime.fromisoformat(data['completed_at'])
return cls(**data)

Configuration Files

.env.example

# Task Manager Configuration
TASK_DATA_FILE=~/.task_manager/tasks.json
LOG_LEVEL=INFO
DEBUG=false

.gitignore

# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST

# Virtual environments
venv/
env/
ENV/
task-manager-env/

# IDE
.vscode/
.idea/
*.swp
*.swo

# Testing
.pytest_cache/
.coverage
htmlcov/
.tox/

# Environment variables
.env

# OS
.DS_Store
Thumbs.db

# Application specific
tasks.json
*.log

Version Control: Tracking Changes

Initializing Git Repository

# Initialize repository
git init

# Add all files
git add .

# Initial commit
git commit -m "feat: initial project setup with basic structure

- Add project structure with core modules
- Configure poetry for dependency management
- Set up development tools (black, flake8, mypy)
- Add comprehensive .gitignore
- Create basic data models for tasks"

Writing Meaningful Commit Messages

Follow the Conventional Commits specification:

# Format: (): 
# Types: feat, fix, docs, style, refactor, test, chore

git commit -m "feat(models): add Task model with status and priority"
git commit -m "fix(storage): handle file not found error gracefully"
git commit -m "docs: add API documentation for TaskStorage class"
git commit -m "test: add unit tests for Task model methods"
git commit -m "refactor(cli): extract command handlers to separate functions"

Remote Repository Setup

# Create repository on GitHub/GitLab first, then:
git remote add origin https://github.com/yourusername/task-manager.git
git branch -M main
git push -u origin main

# Create development branch
git checkout -b develop
git push -u origin develop

Branching Strategy

# Feature development
git checkout -b feature/add-priority-filtering
# ... make changes ...
git add .
git commit -m "feat(cli): add priority filtering for list command"
git push origin feature/add-priority-filtering

# Create pull request, then merge and clean up
git checkout main
git pull origin main
git branch -d feature/add-priority-filtering

Development Workflow: Writing Quality Code

Storage Layer Implementation

task_manager/storage.py

"""Data storage and persistence layer."""

import json
import logging
from pathlib import Path
from typing import List, Optional, Dict, Any
from .models import Task, TaskStatus, Priority

logger = logging.getLogger(__name__)

class TaskStorageError(Exception):
"""Custom exception for storage operations."""
pass

class TaskStorage:
"""Handles task data persistence."""

def __init__(self, data_file: Path):
"""Initialize storage with data file path."""
self.data_file = Path(data_file).expanduser()
self.data_file.parent.mkdir(parents=True, exist_ok=True)
logger.info(f"TaskStorage initialized with file: {self.data_file}")

def save_tasks(self, tasks: List[Task]) -> None:
"""Save tasks to file."""
try:
data = [task.to_dict() for task in tasks]
with open(self.data_file, 'w') as f:
json.dump(data, f, indent=2)
logger.info(f"Saved {len(tasks)} tasks to {self.data_file}")
except (IOError, OSError) as e:
error_msg = f"Failed to save tasks: {e}"
logger.error(error_msg)
raise TaskStorageError(error_msg) from e

def load_tasks(self) -> List[Task]:
"""Load tasks from file."""
if not self.data_file.exists():
logger.info("Data file doesn't exist, returning empty task list")
return []

try:
with open(self.data_file, 'r') as f:
data = json.load(f)

tasks = [Task.from_dict(task_data) for task_data in data]
logger.info(f"Loaded {len(tasks)} tasks from {self.data_file}")
return tasks

except json.JSONDecodeError as e:
error_msg = f"Invalid JSON in data file: {e}"
logger.error(error_msg)
raise TaskStorageError(error_msg) from e
except (IOError, OSError) as e:
error_msg = f"Failed to load tasks: {e}"
logger.error(error_msg)
raise TaskStorageError(error_msg) from e

def backup_data(self) -> Optional[Path]:
"""Create a backup of the current data file."""
if not self.data_file.exists():
return None

backup_file = self.data_file.with_suffix('.json.backup')
try:
backup_file.write_text(self.data_file.read_text())
logger.info(f"Created backup: {backup_file}")
return backup_file
except (IOError, OSError) as e:
logger.error(f"Failed to create backup: {e}")
return None

CLI Interface

task_manager/cli.py

"""Command-line interface for task manager."""

import os
import logging
from pathlib import Path
from typing import Optional

import click
from colorama import init, Fore, Style

from .models import Task, TaskStatus, Priority
from .storage import TaskStorage, TaskStorageError

# Initialize colorama for cross-platform colored output
init()

# Configure logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)

class TaskManager:
"""Task manager business logic."""

def __init__(self, storage: TaskStorage):
"""Initialize with storage backend."""
self.storage = storage
self._tasks = None

@property
def tasks(self) -> list[Task]:
"""Get current tasks, loading from storage if needed."""
if self._tasks is None:
self._tasks = self.storage.load_tasks()
return self._tasks

def add_task(self, title: str, description: str = "", priority: Priority = Priority.MEDIUM) -> Task:
"""Add a new task."""
task = Task.create(title=title, description=description, priority=priority)
self.tasks.append(task)
self._save_tasks()
logger.info(f"Added task: {task.title}")
return task

def complete_task(self, task_id: str) -> Optional[Task]:
"""Mark a task as completed."""
task = self._find_task(task_id)
if task:
task.complete()
self._save_tasks()
logger.info(f"Completed task: {task.title}")
return task

def delete_task(self, task_id: str) -> Optional[Task]:
"""Delete a task."""
task = self._find_task(task_id)
if task:
self.tasks.remove(task)
self._save_tasks()
logger.info(f"Deleted task: {task.title}")
return task

def list_tasks(self, status: Optional[TaskStatus] = None, priority: Optional[Priority] = None) -> list[Task]:
"""List tasks with optional filtering."""
filtered_tasks = self.tasks

if status:
filtered_tasks = [t for t in filtered_tasks if t.status == status]

if priority:
filtered_tasks = [t for t in filtered_tasks if t.priority == priority]

return filtered_tasks

def _find_task(self, task_id: str) -> Optional[Task]:
"""Find task by ID or partial ID."""
# Try exact match first
for task in self.tasks:
if task.id == task_id:
return task

# Try partial match
matching_tasks = [t for t in self.tasks if t.id.startswith(task_id)]
if len(matching_tasks) == 1:
return matching_tasks[0]
elif len(matching_tasks) > 1:
raise click.ClickException(f"Ambiguous task ID '{task_id}'. Multiple matches found.")

return None

def _save_tasks(self) -> None:
"""Save tasks to storage."""
try:
self.storage.save_tasks(self.tasks)
except TaskStorageError as e:
raise click.ClickException(f"Failed to save tasks: {e}")

# Global task manager instance
task_manager = None

def get_task_manager() -> TaskManager:
"""Get or create task manager instance."""
global task_manager
if task_manager is None:
data_file = os.getenv('TASK_DATA_FILE', '~/.task_manager/tasks.json')
storage = TaskStorage(Path(data_file))
task_manager = TaskManager(storage)
return task_manager

def format_task(task: Task, show_id: bool = False) -> str:
"""Format task for display."""
status_color = Fore.GREEN if task.status == TaskStatus.COMPLETED else Fore.YELLOW
priority_symbol = {"low": "◦", "medium": "●", "high": "◉"}[task.priority.value]

parts = []
if show_id:
parts.append(f"{Fore.CYAN}{task.id[:8]}{Style.RESET_ALL}")

parts.extend([
f"{status_color}{priority_symbol}{Style.RESET_ALL}",
f"{Fore.WHITE}{task.title}{Style.RESET_ALL}"
])

if task.description:
parts.append(f"{Fore.LIGHTBLACK_EX}- {task.description}{Style.RESET_ALL}")

return " ".join(parts)

@click.group()
@click.option('--debug', is_flag=True, help='Enable debug logging')
def cli(debug: bool):
"""Task Manager CLI - A simple task management tool."""
if debug:
logging.getLogger().setLevel(logging.DEBUG)

@cli.command()
@click.argument('title')
@click.option('--description', '-d', default='', help='Task description')
@click.option('--priority', '-p', type=click.Choice(['low', 'medium', 'high']),
default='medium', help='Task priority')
def add(title: str, description: str, priority: str):
"""Add a new task."""
try:
priority_enum = Priority(priority)
task = get_task_manager().add_task(title, description, priority_enum)
click.echo(f"{Fore.GREEN}{Style.RESET_ALL} Added task: {format_task(task)}")
except Exception as e:
click.echo(f"{Fore.RED}Error: {e}{Style.RESET_ALL}", err=True)
raise click.Abort()

@cli.command()
@click.option('--status', type=click.Choice(['pending', 'completed']), help='Filter by status')
@click.option('--priority', type=click.Choice(['low', 'medium', 'high']), help='Filter by priority')
@click.option('--show-id', is_flag=True, help='Show task IDs')
def list(status: Optional[str], priority: Optional[str], show_id: bool):
"""List tasks."""
try:
status_enum = TaskStatus(status) if status else None
priority_enum = Priority(priority) if priority else None

tasks = get_task_manager().list_tasks(status_enum, priority_enum)

if not tasks:
click.echo(f"{Fore.YELLOW}No tasks found.{Style.RESET_ALL}")
return

click.echo(f"\n{Fore.CYAN}Tasks:{Style.RESET_ALL}")
for task in tasks:
click.echo(f" {format_task(task, show_id)}")
click.echo()

except Exception as e:
click.echo(f"{Fore.RED}Error: {e}{Style.RESET_ALL}", err=True)
raise click.Abort()

@cli.command()
@click.argument('task_id')
def complete(task_id: str):
"""Mark a task as completed."""
try:
task = get_task_manager().complete_task(task_id)
if task:
click.echo(f"{Fore.GREEN}{Style.RESET_ALL} Completed: {format_task(task)}")
else:
click.echo(f"{Fore.RED}Task not found: {task_id}{Style.RESET_ALL}", err=True)
except Exception as e:
click.echo(f"{Fore.RED}Error: {e}{Style.RESET_ALL}", err=True)
raise click.Abort()

@cli.command()
@click.argument('task_id')
@click.confirmation_option(prompt='Are you sure you want to delete this task?')
def delete(task_id: str):
"""Delete a task."""
try:
task = get_task_manager().delete_task(task_id)
if task:
click.echo(f"{Fore.GREEN}{Style.RESET_ALL} Deleted: {task.title}")
else:
click.echo(f"{Fore.RED}Task not found: {task_id}{Style.RESET_ALL}", err=True)
except Exception as e:
click.echo(f"{Fore.RED}Error: {e}{Style.RESET_ALL}", err=True)
raise click.Abort()

def main():
"""Entry point for the CLI."""
cli()

if __name__ == '__main__':
main()

Code Quality Tools

Setup script for development tools:

#!/bin/bash
# scripts/setup_dev.sh

echo "Setting up development environment..."

# Format code
echo "Formatting code with black..."
poetry run black task_manager/ tests/

# Check code style
echo "Checking code style with flake8..."
poetry run flake8 task_manager/ tests/

# Type checking
echo "Running type checks with mypy..."
poetry run mypy task_manager/

# Run tests
echo "Running tests with pytest..."
poetry run pytest

echo "Development setup complete!"

Testing: Ensuring Reliability

Test Structure

tests/conftest.py

"""Pytest configuration and fixtures."""

import pytest
from pathlib import Path
import tempfile
import shutil
from task_manager.storage import TaskStorage
from task_manager.models import Task, Priority, TaskStatus

@pytest.fixture
def temp_dir():
"""Create temporary directory for tests."""
temp_dir = Path(tempfile.mkdtemp())
yield temp_dir
shutil.rmtree(temp_dir)

@pytest.fixture
def storage(temp_dir):
"""Create TaskStorage instance with temporary file."""
data_file = temp_dir / "test_tasks.json"
return TaskStorage(data_file)

@pytest.fixture
def sample_task():
"""Create a sample task for testing."""
return Task.create(
title="Test Task",
description="A task for testing",
priority=Priority.HIGH
)

@pytest.fixture
def sample_tasks():
"""Create multiple sample tasks."""
return [
Task.create("Task 1", "First task", Priority.HIGH),
Task.create("Task 2", "Second task", Priority.MEDIUM),
Task.create("Task 3", "Third task", Priority.LOW),
]

Unit Tests

tests/test_models.py

"""Tests for data models."""

import pytest
from datetime import datetime
from task_manager.models import Task, TaskStatus, Priority

class TestTask:
"""Test cases for Task model."""

def test_task_creation(self):
"""Test basic task creation."""
task = Task.create("Test Task", "Description", Priority.HIGH)

assert task.title == "Test Task"
assert task.description == "Description"
assert task.priority == Priority.HIGH
assert task.status == TaskStatus.PENDING
assert task.id is not None
assert isinstance(task.created_at, datetime)
assert task.completed_at is None

def test_task_completion(self):
"""Test task completion."""
task = Task.create("Test Task")
initial_time = datetime.now()

task.complete()

assert task.status == TaskStatus.COMPLETED
assert task.completed_at is not None
assert task.completed_at >= initial_time

def test_task_serialization(self):
"""Test task to/from dict conversion."""
original_task = Task.create("Test Task", "Description", Priority.HIGH)
original_task.complete()

# Convert to dict and back
task_dict = original_task.to_dict()
restored_task = Task.from_dict(task_dict)

assert restored_task.id == original_task.id
assert restored_task.title == original_task.title
assert restored_task.description == original_task.description
assert restored_task.priority == original_task.priority
assert restored_task.status == original_task.status
assert restored_task.created_at == original_task.created_at
assert restored_task.completed_at == original_task.completed_at

def test_task_dict_format(self):
"""Test task dictionary format."""
task = Task.create("Test Task", "Description", Priority.HIGH)
task_dict = task.to_dict()

assert 'id' in task_dict
assert 'title' in task_dict
assert 'description' in task_dict
assert task_dict['priority'] == 'high'
assert task_dict['status'] == 'pending'
assert 'created_at' in task_dict
assert 'completed_at' in task_dict

tests/test_storage.py

"""Tests for storage layer."""

import pytest
import json
from pathlib import Path
from task_manager.storage import TaskStorage, TaskStorageError
from task_manager.models import Task, Priority

class TestTaskStorage:
"""Test cases for TaskStorage."""

def test_save_and_load_tasks(self, storage, sample_tasks):
"""Test saving and loading tasks."""
# Save tasks
storage.save_tasks(sample_tasks)

# Load tasks
loaded_tasks = storage.load_tasks()

assert len(loaded_tasks) == len(sample_tasks)
assert loaded_tasks[0].title == sample_tasks[0].title
assert loaded_tasks[1].priority == sample_tasks[1].priority

def test_load_empty_file(self, temp_dir):
"""Test loading from non-existent file."""
storage = TaskStorage(temp_dir / "nonexistent.json")
tasks = storage.load_tasks()
assert tasks == []

def test_load_invalid_json(self, temp_dir):
"""Test loading from file with invalid JSON."""
data_file = temp_dir / "invalid.json"
data_file.write_text("invalid json content")

storage = TaskStorage(data_file)

with pytest.raises(TaskStorageError):
storage.load_tasks()

def test_save_to_read_only_location(self, temp_dir):
"""Test saving to read-only location."""
# Create read-only directory
readonly_dir = temp_dir / "readonly"
readonly_dir.mkdir()
readonly_dir.chmod(0o444)

storage = TaskStorage(readonly_dir / "tasks.json")

with pytest.raises(TaskStorageError):
storage.save_tasks([Task.create("Test")])

def test_backup_creation(self, storage, sample_tasks):
"""Test backup file creation."""
# Save tasks first
storage.save_tasks(sample_tasks)

# Create backup
backup_file = storage.backup_data()

assert backup_file is not None
assert backup_file.exists()
assert backup_file.suffix == '.backup'

# Verify backup content
with open(backup_file) as f:
backup_data = json.load(f)
assert len(backup_data) == len(sample_tasks)

Integration Tests

tests/test_cli.py

"""Tests for CLI interface."""

import pytest
from click.testing import CliRunner
from task_manager.cli import cli
import os
import tempfile

@pytest.fixture
def runner():
"""Create CLI test runner."""
return CliRunner()

@pytest.fixture
def temp_data_file():
"""Create temporary data file for CLI tests."""
with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f:
temp_file = f.name

# Set environment variable for CLI
original_value = os.environ.get('TASK_DATA_FILE')
os.environ['TASK_DATA_FILE'] = temp_file

yield temp_file

# Cleanup
if original_value is not None:
os.environ['TASK_DATA_FILE'] = original_value
else:
os.environ.pop('TASK_DATA_FILE', None)

if os.path.exists(temp_file):
os.unlink(temp_file)

class TestCLI:
"""Test cases for CLI interface."""

def test_add_task(self, runner, temp_data_file):
"""Test adding a task via CLI."""
result = runner.invoke(cli, [
'add', 'Test Task',
'--description', 'Test description',
'--priority', 'high'
])

assert result.exit_code == 0
assert 'Added task' in result.output
assert 'Test Task' in result.output

def test_list_empty_tasks(self, runner, temp_data_file):
"""Test listing when no tasks exist."""
result = runner.invoke(cli, ['list'])

assert result.exit_code == 0
assert 'No tasks found' in result.output

def test_add_and_list_tasks(self, runner, temp_data_file):
"""Test adding and listing tasks."""
# Add tasks
runner.invoke(cli, ['add', 'Task 1', '--priority', 'high'])
runner.invoke(cli, ['add', 'Task 2', '--priority', 'low'])

# List tasks
result = runner.invoke(cli, ['list'])

assert result.exit_code == 0
assert 'Task 1' in result.output
assert 'Task 2' in result.output

def test_complete_task(self, runner, temp_data_file):
"""Test completing a task."""
# Add a task first
add_result = runner.invoke(cli, ['add', 'Test Task'])
assert add_result.exit_code == 0

# List with IDs to get task ID
list_result = runner.invoke(cli, ['list', '--show-id'])
task_id = list_result.output.split()[1] # Extract first task ID

# Complete the task
complete_result = runner.invoke(cli, ['complete', task_id[:8]]) # Use partial ID

assert complete_result.exit_code == 0
assert 'Completed' in complete_result.output

def test_delete_task_with_confirmation(self, runner, temp_data_file):
"""Test deleting a task with confirmation."""
# Add a task first
runner.invoke(cli, ['add', 'Task to delete'])

# Get task ID
list_result = runner.invoke(cli, ['list', '--show-id'])
task_id = list_result.output.split()[1]

# Delete with confirmation
result = runner.invoke(cli, ['delete', task_id[:8]], input='y\n')

assert result.exit_code == 0
assert 'Deleted' in result.output

def test_filter_by_status(self, runner, temp_data_file):
"""Test filtering tasks by status."""
# Add and complete some tasks
runner.invoke(cli, ['add', 'Pending Task'])
add_result = runner.invoke(cli, ['add', 'Completed Task'])

# Get task ID and complete it
list_result = runner.invoke(cli, ['list', '--show-id'])
lines = list_result.output.strip().split('\n')
task_line = [line for line in lines if 'Completed Task' in line][0]
task_id = task_line.split()[0]

runner.invoke(cli, ['complete', task_id])

# Filter by status
pending_result = runner.invoke(cli, ['list', '--status', 'pending'])
completed_result = runner.invoke(cli, ['list', '--status', 'completed'])

assert 'Pending Task' in pending_result.output
assert 'Completed Task' not in pending_result.output
assert 'Completed Task' in completed_result.output
assert 'Pending Task' not in completed_result.output

class TestTaskManagerIntegration:
"""Integration tests for TaskManager class."""

def test_task_lifecycle(self, storage):
"""Test complete task lifecycle."""
from task_manager.cli import TaskManager
from task_manager.models import Priority, TaskStatus

manager = TaskManager(storage)

# Add tasks
task1 = manager.add_task("Task 1", "Description 1", Priority.HIGH)
task2 = manager.add_task("Task 2", "Description 2", Priority.LOW)

# List tasks
all_tasks = manager.list_tasks()
assert len(all_tasks) == 2

# Filter by priority
high_priority = manager.list_tasks(priority=Priority.HIGH)
assert len(high_priority) == 1
assert high_priority[0].title == "Task 1"

# Complete task
completed = manager.complete_task(task1.id)
assert completed is not None
assert completed.status == TaskStatus.COMPLETED

# Filter by status
pending_tasks = manager.list_tasks(status=TaskStatus.PENDING)
assert len(pending_tasks) == 1

# Delete task
deleted = manager.delete_task(task2.id)
assert deleted is not None

final_tasks = manager.list_tasks()
assert len(final_tasks) == 1

Test Coverage Configuration

pytest.ini (alternative to pyproject.toml)

[tool:pytest]
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*
addopts =
--cov=task_manager
--cov-report=html
--cov-report=term-missing
--cov-report=xml
--strict-markers
--disable-warnings
markers =
slow: marks tests as slow
integration: marks tests as integration tests
unit: marks tests as unit tests

Running Tests

# Run all tests
poetry run pytest

# Run with coverage
poetry run pytest --cov=task_manager --cov-report=html

# Run specific test file
poetry run pytest tests/test_models.py

# Run with markers
poetry run pytest -m "not slow"

# Run in verbose mode
poetry run pytest -v

# Run and stop on first failure
poetry run pytest -x

Documentation: Making It Accessible

README.md

# Task Manager CLI

A simple, elegant command-line task management tool built with Python.

## Features

- ✅ Add tasks with descriptions and priorities
- 📋 List tasks with filtering options
- ✔️ Mark tasks as completed
- 🗑️ Delete tasks
- 💾 Persistent storage in JSON format
- 🎨 Colorful, intuitive CLI interface

## Installation

### From Source

```bash
git clone https://github.com/yourusername/task-manager.git
cd task-manager
poetry install

Using pip

pip install task-manager-cli

Quick Start

# Add your first task
task-manager add "Buy groceries" --description "Milk, eggs, bread" --priority high
# Add 'poetry run' in case of powershell issues
poetry run task-manager add "Buy groceries" --description "Milk, eggs, bread" --priority high

# List all tasks
task-manager list

# Complete a task (use task ID from list command)
complete abc123

# Delete a task
task-manager delete abc123

Usage

Adding Tasks

# Simple task
task-manager add "Call mom"

# Task with description and priority
task-manager add "Finish project" --description "Complete final review" --priority high

Listing Tasks

# List all tasks
task-manager list

# Filter by status
task-manager list --status pending
task-manager list --status completed

# Filter by priority
task-manager list --priority high

# Show task IDs
task-manager list --show-id

Managing Tasks

# Complete a task
task-manager complete

# Delete a task (with confirmation)
task-manager delete

Configuration

Set environment variables in .env file:

TASK_DATA_FILE=~/.task_manager/tasks.json
LOG_LEVEL=INFO
DEBUG=false

Development

Setup

git clone https://github.com/timothykimutai/task-manager.git
cd task-manager
poetry install
poetry shell

Running Tests

pytest
pytest --cov=task_manager --cov-report=html

Code Quality

# Format code
black task_manager/ tests/

# Lint code
flake8 task_manager/ tests/

# Type checking
mypy task_manager/

Contributing

  • Fork the repository

  • Create a feature branch (git checkout -b feature/amazing-feature)

  • Commit your changes (git commit -m 'Add amazing feature')

  • Push to the branch (git push origin feature/amazing-feature)

  • Open a Pull Request

License

This project is licensed under the MIT License — see the LICENSE file for details.

### API Documentation with MkDocs

**mkdocs.yml**
```yaml
site_name: Task Manager CLI Documentation
site_description: A simple command-line task management tool
site_author: Timothy Kimutai
site_url: https://timothykimutai.github.io/task-manager

theme:
name: material
palette:
- scheme: default
primary: blue
accent: blue
toggle:
icon: material/brightness-7
name: Switch to dark mode
- scheme: slate
primary: blue
accent: blue
toggle:
icon: material/brightness-4
name: Switch to light mode
features:
- navigation.tabs
- navigation.sections
- navigation.expand
- search.highlight

plugins:
- search
- mkdocstrings:
handlers:
python:
options:
docstring_style: google

nav:
- Home: index.md
- User Guide:
- Installation: user-guide/installation.md
- Quick Start: user-guide/quickstart.md
- Commands: user-guide/commands.md
- API Reference:
- Models: api/models.md
- Storage: api/storage.md
- CLI: api/cli.md
- Development:
- Contributing: development/contributing.md
- Testing: development/testing.md

markdown_extensions:
- admonition
- codehilite
- pymdownx.superfences
- pymdownx.tabbed
- toc:
permalink: true

docs/index.md

# Task Manager CLI

Welcome to the Task Manager CLI documentation!

Task Manager is a simple, elegant command-line tool for managing your daily tasks. Built with Python, it provides a clean interface for adding, listing, completing, and organizing your tasks.

## Why Task Manager CLI?

- **Simple**: Intuitive commands that are easy to remember
- **Fast**: Quick task management from your terminal
- **Persistent**: Your tasks are saved automatically
- **Flexible**: Filter and organize tasks by status and priority
- **Beautiful**: Colorful output that's easy to read

## Key Features

!!! tip "Core Functionality"

Add tasks with descriptions and priorities

List tasks with filtering options

Mark tasks as completed

Delete tasks you no longer need

Persistent JSON storage

Colorful CLI interface

## Quick Example

```bash
# Add a high-priority task
$ task-manager add "Review quarterly report" --priority high

# List all pending tasks
$ task-manager list --status pending

# Complete a task
$ task-manager complete abc123

Deployment & Execution

Local Development

# Install in development mode
poetry install

# Run directly
poetry run task-manager add "Test task"

# Or activate shell and run
poetry shell
task-manager add "Test task"

Building Distribution Packages

# Build wheel and source distribution
poetry build

# Output will be in dist/
ls dist/
# task-manager-0.1.0.tar.gz
# task_manager-0.1.0-py3-none-any.whl

Docker Containerization

Dockerfile

FROM python:3.11-slim

# Set environment variables
ENV PYTHONUNBUFFERED=1
ENV PYTHONDONTWRITEBYTECODE=1

# Set work directory
WORKDIR /app

# Install system dependencies
RUN apt-get update && apt-get install -y \
git \
&& rm -rf /var/lib/apt/lists/*

# Install Poetry
RUN pip install poetry

# Copy poetry files
COPY pyproject.toml poetry.lock ./

# Configure poetry: Don't create virtual env, install dependencies
RUN poetry config virtualenvs.create false \
&& poetry install --no-dev --no-interaction --no-ansi

# Copy application
COPY task_manager/ ./task_manager/

# Create data directory
RUN mkdir -p /data

# Set data file location
ENV TASK_DATA_FILE=/data/tasks.json

# Create non-root user
RUN adduser --disabled-password --gecos '' taskuser
RUN chown -R taskuser:taskuser /app /data
USER taskuser

# Entry point
ENTRYPOINT ["python", "-m", "task_manager.cli"]

docker-compose.yml

version: '3.8'

services:
task-manager:
build: .
volumes:
- task_data:/data
environment:
- TASK_DATA_FILE=/data/tasks.json
stdin_open: true
tty: true

volumes:
task_data:

Building and Running with Docker

# Build image
docker build -t task-manager .

# Run container
docker run -it --rm \
-v $(pwd)/data:/data \
task-manager add "Dockerized task"

# Using docker-compose
docker-compose run task-manager list

GitHub Actions CI/CD

.github/workflows/ci.yml

name: CI/CD Pipeline

on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main ]

jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: [3.9, 3.10, 3.11]

steps:
- uses: actions/checkout@v4

- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}

- name: Install Poetry
uses: snok/install-poetry@v1
with:
version: latest
virtualenvs-create: true
virtualenvs-in-project: true

- name: Load cached venv
id: cached-poetry-dependencies
uses: actions/cache@v3
with:
path: .venv
key: venv-${{ runner.os }}-${{ matrix.python-version }}-${{ hashFiles('**/poetry.lock') }}

- name: Install dependencies
if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true'
run: poetry install --no-interaction

- name: Run code quality checks
run: |
poetry run black --check task_manager/ tests/
poetry run flake8 task_manager/ tests/
poetry run mypy task_manager/

- name: Run tests
run: |
poetry run pytest --cov=task_manager --cov-report=xml

- name: Upload coverage to Codecov
uses: codecov/codecov-action@v3
with:
file: ./coverage.xml
fail_ci_if_error: true

build:
needs: test
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'

steps:
- uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: 3.11

- name: Install Poetry
uses: snok/install-poetry@v1

- name: Build package
run: poetry build

- name: Publish to PyPI
if: startsWith(github.ref, 'refs/tags/')
env:
POETRY_PYPI_TOKEN_PYPI: ${{ secrets.PYPI_TOKEN }}
run: poetry publish

docker:
needs: test
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'

steps:
- uses: actions/checkout@v4

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2

- name: Login to DockerHub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}

- name: Build and push
uses: docker/build-push-action@v4
with:
context: .
push: true
tags: |
yourusername/task-manager:latest
yourusername/task-manager:${{ github.sha }}

Publishing to PyPI

# Configure PyPI credentials
poetry config pypi-token.pypi your-token-here

# Build and publish
poetry build
poetry publish

# Or in one command
poetry publish --build

Creating Releases

# Tag release
git tag -a v0.1.0 -m "Release version 0.1.0"
git push origin v0.1.0

# GitHub will automatically create release via Actions

Best Practices & Tips

Code Organization

  1. Follow PEP 8: Use consistent code style

  2. Type Hints: Add type annotations for better code documentation

  3. Docstrings: Document all public functions and classes

  4. Error Handling: Use specific exceptions and provide helpful error messages

  5. Logging: Add appropriate logging for debugging and monitoring

Common Mistakes to Avoid

  1. Poor Error Handling
# ❌ Bad: Generic exception handling
try:
result = risky_operation()
except Exception:
pass

# ✅ Good: Specific exception handling
try:
result = storage.load_tasks()
except FileNotFoundError:
logger.info("No existing tasks file found, starting fresh")
return []
except json.JSONDecodeError as e:
logger.error(f"Corrupted tasks file: {e}")
raise TaskStorageError(f"Cannot parse tasks file: {e}")

2. Hardcoded Values

# ❌ Bad: Hardcoded paths
data_file = "/home/user/.tasks.json"

# ✅ Good: Configurable paths
data_file = os.getenv('TASK_DATA_FILE', '~/.task_manager/tasks.json')
data_file = Path(data_file).expanduser()

3. Missing Input Validation

# ❌ Bad: No validation
def add_task(title: str) -> Task:
return Task.create(title)

# ✅ Good: Input validation
def add_task(title: str) -> Task:
if not title or not title.strip():
raise ValueError("Task title cannot be empty")
if len(title) > 200:
raise ValueError("Task title too long (max 200 characters)")
return Task.create(title.strip())

Maintaining Long-term Projects

  1. Dependency Management
# Regular dependency updates
poetry update

# Check for security vulnerabilities
poetry audit

# Pin major versions in pyproject.toml
click = "^8.1.0" # Allows 8.1.x but not 9.x

2. Code Quality Automation

# Pre-commit hooks
pip install pre-commit
pre-commit install

# .pre-commit-config.yaml
repos:
- repo: https://github.com/psf/black
rev: 23.9.1
hooks:
- id: black
- repo: https://github.com/pycqa/flake8
rev: 6.1.0
hooks:
- id: flake8

3. Documentation Maintenance

  • Keep README updated with new features

  • Update API documentation when interfaces change

  • Maintain changelog for version history

  • Include migration guides for breaking changes

4. Testing Strategy

# Test pyramid: Many unit tests, fewer integration tests
# Unit tests: 70-80%
# Integration tests: 15-25%
# End-to-end tests: 5-10%

# Use property-based testing for complex logic
from hypothesis import given, strategies as st

@given(st.text(min_size=1, max_size=100))
def test_task_title_handling(title):
"""Test task creation with various title inputs."""
assume(title.strip()) # Skip empty strings
task = Task.create(title.strip())
assert task.title == title.strip()

5. Performance Considerations

# Profile your code
import cProfile
import pstats

def profile_function():
profiler = cProfile.Profile()
profiler.enable()

# Your code here
task_manager.list_tasks()

profiler.disable()
stats = pstats.Stats(profiler)
stats.sort_stats('cumulative')
stats.print_stats(10)

# Use appropriate data structures
# For frequent lookups: dict over list
# For ordered data: list or OrderedDict
# For unique items: set

Security Best Practices

  1. Input Sanitization: Validate and sanitize all user inputs

  2. File Permissions: Set appropriate permissions on data files

  3. Dependency Scanning: Regularly scan for vulnerable dependencies

  4. Secrets Management: Never commit secrets to version control

# Use environment variables for sensitive data
import os
from pathlib import Path

def get_data_dir() -> Path:
"""Get data directory with secure permissions."""
data_dir = Path(os.getenv('TASK_DATA_DIR', '~/.task_manager')).expanduser()
data_dir.mkdir(mode=0o700, parents=True, exist_ok=True) # Owner only
return data_dir

Monitoring and Observability

# Add structured logging
import structlog

logger = structlog.get_logger()

def add_task(self, title: str) -> Task:
"""Add a new task with structured logging."""
logger.info("Adding new task", title=title, user_id=self.user_id)

try:
task = Task.create(title)
self.tasks.append(task)
self._save_tasks()

logger.info("Task added successfully",
task_id=task.id,
title=task.title,
total_tasks=len(self.tasks))
return task

except Exception as e:
logger.error("Failed to add task",
title=title,
error=str(e),
exc_info=True)
raise

Conclusion

Building a professional Python project involves much more than writing functional code. By following this comprehensive guide, you’ve learned to:

  • Plan projects effectively with clear goals and scope

  • Set up robust development environments with proper dependency management

  • Structure code for maintainability and scalability

  • Implement comprehensive testing strategies

  • Create quality documentation that serves both users and developers

  • Deploy applications using modern containerization and CI/CD practices

  • Follow best practices that keep projects maintainable over time

The key to successful Python projects lies in treating each phase with equal importance. A well-planned, properly tested, and thoroughly documented project will serve you and your team much better than hastily written code, no matter how clever.

Next Steps

  1. Fork the Example Project: Visit our GitHub repository to see the complete implementation

  2. Build Your Own: Apply these patterns to your next project

  3. Contribute: Help improve this guide by contributing examples and corrections

  4. Share: Pass these practices along to other developers in your community

Sample Project Repository

You can find the complete, working implementation of the Task Manager CLI at: https://github.com/timothykimutai/task-manager

The repository includes:

  • Complete source code with all features implemented

  • Comprehensive test suite with >95% coverage

  • CI/CD pipeline configuration

  • Docker setup for easy deployment

  • Complete documentation built with MkDocs

  • Example workflows and development scripts

Feel free to fork the repository, experiment with the code, and use it as a template for your own projects. Contributions, issues, and suggestions are always welcome!


0
Subscribe to my newsletter

Read articles from Timothy Kimutai directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Timothy Kimutai
Timothy Kimutai

I simplify AI and tech for developers and entrepreneurs. Freelance Data scientist at Upwork. Join 10K+ readers for actionable insights.