0%
Criar um projeto Python completo

Projeto completo

Da concepção à implementação

10-15 min

Criar um projeto Python completo

Parabéns! Você percorreu todos os tutoriais anteriores e adquiriu uma base sólida de conhecimentos em Python. Agora é hora de colocar em prática tudo o que aprendeu desenvolvendo um projeto completo, de ponta a ponta.

Neste tutorial, vamos construir uma aplicação de gerenciamento de tarefas (todo list) que usará uma interface de linha de comando. Este projeto integrará a maioria dos conceitos que abordamos nesta série de tutoriais, especialmente:

  • A programação orientada a objetos
  • O tratamento de exceções
  • A manipulação de arquivos JSON
  • As estruturas de controle
  • As bibliotecas padrão do Python
  • As boas práticas de desenvolvimento

Preparação do projeto

Estrutura do projeto

Vamos começar criando uma estrutura de projeto limpa e organizada:

todo_app/
│
├── todo/
│   ├── __init__.py
│   ├── models.py      # Classes de dados (Task, TaskList)
│   ├── storage.py     # Gerenciamento do armazenamento (JSON)
│   ├── commands.py    # Implementação dos comandos
│   └── cli.py         # Interface de linha de comando
│
├── tests/             # Testes unitários
│   ├── __init__.py
│   ├── test_models.py
│   └── test_storage.py
│
├── data/              # Pasta para armazenar os dados
│   └── tasks.json
│
├── requirements.txt   # Dependências do projeto
├── setup.py           # Configuração de instalação
└── README.md          # Documentação do projeto

Configuração do ambiente virtual

Vamos criar um ambiente virtual para o nosso projeto:

# Criação do ambiente virtual
python -m venv venv

# Ativação do ambiente virtual
# No Windows
venv\Scripts\activate
# No macOS/Linux
source venv/bin/activate

# Instalação das dependências
pip install -e .

Definição das dependências

Vamos criar um arquivo requirements.txt com as dependências necessárias:

# requirements.txt
colorama>=0.4.4    # Para colorir a saída no terminal
pytest>=6.2.5      # Para os testes unitários

E um arquivo setup.py para tornar nosso pacote instalável:

# setup.py
from setuptools import setup, find_packages

setup(
    name="todo-app",
    version="0.1.0",
    packages=find_packages(),
    install_requires=[
        "colorama>=0.4.4",
    ],
    entry_points={
        "console_scripts": [
            "todo=todo.cli:main",
        ],
    },
)

Desenvolvimento do modelo de dados

Vamos começar definindo nossas classes de modelo em todo/models.py:

# todo/models.py
from datetime import datetime
from enum import Enum, auto
from typing import List, Optional, Dict, Any
import uuid

class TaskStatus(Enum):
    """Enum representando os status possíveis de uma tarefa."""
    TODO = auto()
    IN_PROGRESS = auto()
    DONE = auto()
    
    def __str__(self) -> str:
        return self.name.replace("_", " ").title()

class Task:
    """Classe representando uma tarefa."""
    
    def __init__(self, title: str, description: str = "", 
                 status: TaskStatus = TaskStatus.TODO, due_date: Optional[datetime] = None,
                 tags: Optional[List[str]] = None, task_id: Optional[str] = None):
        self.title = title
        self.description = description
        self.status = status
        self.created_at = datetime.now()
        self.updated_at = self.created_at
        self.due_date = due_date
        self.tags = tags or []
        self.id = task_id or str(uuid.uuid4())
    
    def update(self, **kwargs) -> None:
        """Atualiza os atributos da tarefa."""
        for key, value in kwargs.items():
            if hasattr(self, key) and key not in ["id", "created_at"]:
                setattr(self, key, value)
        self.updated_at = datetime.now()
    
    def to_dict(self) -> Dict[str, Any]:
        """Converte a tarefa em dicionário para serialização."""
        return {
            "id": self.id,
            "title": self.title,
            "description": self.description,
            "status": self.status.name,
            "created_at": self.created_at.isoformat(),
            "updated_at": self.updated_at.isoformat(),
            "due_date": self.due_date.isoformat() if self.due_date else None,
            "tags": self.tags
        }
    
    @classmethod
    def from_dict(cls, data: Dict[str, Any]) -> 'Task':
        """Cria uma instância de Task a partir de um dicionário."""
        status = TaskStatus[data["status"]]
        due_date = datetime.fromisoformat(data["due_date"]) if data["due_date"] else None
        
        task = cls(
            title=data["title"],
            description=data["description"],
            status=status,
            due_date=due_date,
            tags=data["tags"],
            task_id=data["id"]
        )
        
        task.created_at = datetime.fromisoformat(data["created_at"])
        task.updated_at = datetime.fromisoformat(data["updated_at"])
        
        return task
    
    def __str__(self) -> str:
        due_str = f", échéance: {self.due_date.strftime('%d/%m/%Y')}" if self.due_date else ""
        tags_str = f", tags: {', '.join(self.tags)}" if self.tags else ""
        return f"{self.title} ({self.status}){due_str}{tags_str}"


class TaskList:
    """Classe representando uma lista de tarefas."""
    
    def __init__(self):
        self.tasks: Dict[str, Task] = {}
    
    def add_task(self, task: Task) -> None:
        """Adiciona uma tarefa à lista."""
        if task.id in self.tasks:
            raise ValueError(f"Une tâche avec l'ID {task.id} existe déjà")
        self.tasks[task.id] = task
    
    def get_task(self, task_id: str) -> Task:
        """Recupera uma tarefa pelo seu ID."""
        if task_id not in self.tasks:
            raise KeyError(f"Aucune tâche trouvée avec l'ID {task_id}")
        return self.tasks[task_id]
    
    def update_task(self, task_id: str, **kwargs) -> Task:
        """Atualiza uma tarefa existente."""
        task = self.get_task(task_id)
        task.update(**kwargs)
        return task
    
    def delete_task(self, task_id: str) -> None:
        """Remove uma tarefa da lista."""
        if task_id not in self.tasks:
            raise KeyError(f"Aucune tâche trouvée avec l'ID {task_id}")
        del self.tasks[task_id]
    
    def filter_tasks(self, status: Optional[TaskStatus] = None, 
                    tag: Optional[str] = None) -> List[Task]:
        """Filtra as tarefas por status e/ou tag."""
        filtered_tasks = list(self.tasks.values())
        
        if status:
            filtered_tasks = [task for task in filtered_tasks if task.status == status]
        
        if tag:
            filtered_tasks = [task for task in filtered_tasks if tag in task.tags]
        
        return filtered_tasks
    
    def to_dict(self) -> Dict[str, Any]:
        """Converte a lista de tarefas em dicionário para serialização."""
        return {
            "tasks": [task.to_dict() for task in self.tasks.values()]
        }
    
    @classmethod
    def from_dict(cls, data: Dict[str, Any]) -> 'TaskList':
        """Cria uma instância de TaskList a partir de um dicionário."""
        task_list = cls()
        for task_data in data.get("tasks", []):
            task = Task.from_dict(task_data)
            task_list.tasks[task.id] = task
        return task_list

Gerenciamento do armazenamento

Agora, vamos criar o módulo de armazenamento para persistir nossas tarefas em JSON:

# todo/storage.py
import json
import os
from pathlib import Path
from typing import Optional
from .models import TaskList

class StorageError(Exception):
    """Exceção lançada em caso de erros de armazenamento."""
    pass

class Storage:
    """Classe que gerencia o armazenamento das tarefas em JSON."""
    
    def __init__(self, file_path: Optional[str] = None):
        if file_path:
            self.file_path = Path(file_path)
        else:
            # Caminho padrão
            data_dir = Path(__file__).parent.parent / "data"
            data_dir.mkdir(exist_ok=True)
            self.file_path = data_dir / "tasks.json"
    
    def save(self, task_list: TaskList) -> None:
        """Salva a lista de tarefas no arquivo JSON."""
        try:
            with open(self.file_path, 'w', encoding='utf-8') as f:
                json.dump(task_list.to_dict(), f, indent=2, ensure_ascii=False)
        except (IOError, PermissionError) as e:
            raise StorageError(f"Erreur lors de la sauvegarde des tâches : {e}")
    
    def load(self) -> TaskList:
        """Carrega a lista de tarefas a partir do arquivo JSON."""
        if not self.file_path.exists():
            return TaskList()
        
        try:
            with open(self.file_path, 'r', encoding='utf-8') as f:
                data = json.load(f)
                return TaskList.from_dict(data)
        except json.JSONDecodeError as e:
            raise StorageError(f"Format JSON invalide : {e}")
        except (IOError, PermissionError) as e:
            raise StorageError(f"Erreur lors du chargement des tâches : {e}")

Implementação dos comandos

Vamos criar o módulo de comandos para gerenciar as operações sobre as tarefas:

# todo/commands.py
from datetime import datetime
from typing import List, Optional, Tuple
from .models import Task, TaskList, TaskStatus
from .storage import Storage, StorageError

class CommandError(Exception):
    """Exceção lançada em caso de erros de comando."""
    pass

class TodoCommands:
    """Classe que implementa os comandos da aplicação."""
    
    def __init__(self, storage: Optional[Storage] = None):
        self.storage = storage or Storage()
        try:
            self.task_list = self.storage.load()
        except StorageError as e:
            # Em caso de erro, começamos com uma lista vazia
            print(f"Attention : {e}")
            self.task_list = TaskList()
    
    def _save(self) -> None:
        """Salva o estado atual das tarefas."""
        try:
            self.storage.save(self.task_list)
        except StorageError as e:
            raise CommandError(f"Erreur de sauvegarde : {e}")
    
    def add(self, title: str, description: str = "", 
           due_date: Optional[str] = None, tags: Optional[str] = None) -> Task:
        """Adiciona uma nova tarefa."""
        # Validação das entradas
        if not title:
            raise CommandError("Le titre ne peut pas être vide")
        
        # Conversão da data de vencimento
        parsed_due_date = None
        if due_date:
            try:
                parsed_due_date = datetime.strptime(due_date, "%d/%m/%Y")
            except ValueError:
                raise CommandError("Format de date invalide. Utilisez JJ/MM/AAAA")
        
        # Tratamento das tags
        tag_list = []
        if tags:
            tag_list = [tag.strip() for tag in tags.split(',')]
        
        # Criação e adição da tarefa
        new_task = Task(
            title=title,
            description=description,
            due_date=parsed_due_date,
            tags=tag_list
        )
        
        try:
            self.task_list.add_task(new_task)
            self._save()
            return new_task
        except ValueError as e:
            raise CommandError(str(e))
    
    def list(self, status: Optional[str] = None, 
            tag: Optional[str] = None) -> List[Task]:
        """Lista as tarefas com filtragem opcional."""
        status_enum = None
        if status:
            try:
                status_enum = TaskStatus[status.upper()]
            except KeyError:
                raise CommandError(f"Statut invalide : {status}")
        
        return self.task_list.filter_tasks(status=status_enum, tag=tag)
    
    def update(self, task_id: str, **kwargs) -> Task:
        """Atualiza uma tarefa existente."""
        # Tratamento especial para alguns campos
        if 'due_date' in kwargs and kwargs['due_date']:
            try:
                kwargs['due_date'] = datetime.strptime(kwargs['due_date'], "%d/%m/%Y")
            except ValueError:
                raise CommandError("Format de date invalide. Utilisez JJ/MM/AAAA")
        
        if 'status' in kwargs and kwargs['status']:
            try:
                kwargs['status'] = TaskStatus[kwargs['status'].upper()]
            except KeyError:
                raise CommandError(f"Statut invalide : {kwargs['status']}")
        
        if 'tags' in kwargs and kwargs['tags']:
            kwargs['tags'] = [tag.strip() for tag in kwargs['tags'].split(',')]
        
        try:
            task = self.task_list.update_task(task_id, **kwargs)
            self._save()
            return task
        except KeyError:
            raise CommandError(f"Aucune tâche trouvée avec l'ID {task_id}")
    
    def delete(self, task_id: str) -> None:
        """Remove uma tarefa."""
        try:
            self.task_list.delete_task(task_id)
            self._save()
        except KeyError:
            raise CommandError(f"Aucune tâche trouvée avec l'ID {task_id}")
    
    def get(self, task_id: str) -> Task:
        """Recupera os detalhes de uma tarefa específica."""
        try:
            return self.task_list.get_task(task_id)
        except KeyError:
            raise CommandError(f"Aucune tâche trouvée avec l'ID {task_id}")

Interface de linha de comando

Por fim, vamos criar a interface de linha de comando em todo/cli.py:

# todo/cli.py
import argparse
import sys
from typing import List
from datetime import datetime
import colorama
from colorama import Fore, Style

from .commands import TodoCommands, CommandError
from .models import TaskStatus

def setup_argparse() -> argparse.ArgumentParser:
    """Configura o parser de argumentos."""
    parser = argparse.ArgumentParser(
        description="Application de gestion de tâches en ligne de commande"
    )
    
    subparsers = parser.add_subparsers(dest="command", help="Commandes disponibles")
    
    # Comando "add"
    add_parser = subparsers.add_parser("add", help="Ajouter une nouvelle tâche")
    add_parser.add_argument("title", help="Titre de la tâche")
    add_parser.add_argument("-d", "--description", help="Description de la tâche")
    add_parser.add_argument("--due", help="Date d'échéance (format JJ/MM/AAAA)")
    add_parser.add_argument("--tags", help="Tags séparés par des virgules")
    
    # Comando "list"
    list_parser = subparsers.add_parser("list", help="Lister les tâches")
    list_parser.add_argument("-s", "--status", help="Filtrer par statut (TODO, IN_PROGRESS, DONE)")
    list_parser.add_argument("-t", "--tag", help="Filtrer par tag")
    
    # Comando "update"
    update_parser = subparsers.add_parser("update", help="Mettre à jour une tâche")
    update_parser.add_argument("id", help="ID de la tâche à mettre à jour")
    update_parser.add_argument("-t", "--title", help="Nouveau titre")
    update_parser.add_argument("-d", "--description", help="Nouvelle description")
    update_parser.add_argument("-s", "--status", help="Nouveau statut (TODO, IN_PROGRESS, DONE)")
    update_parser.add_argument("--due", help="Nouvelle date d'échéance (format JJ/MM/AAAA)")
    update_parser.add_argument("--tags", help="Nouveaux tags séparés par des virgules")
    
    # Comando "get"
    get_parser = subparsers.add_parser("get", help="Afficher les détails d'une tâche")
    get_parser.add_argument("id", help="ID de la tâche")
    
    # Comando "delete"
    delete_parser = subparsers.add_parser("delete", help="Supprimer une tâche")
    delete_parser.add_argument("id", help="ID de la tâche à supprimer")
    
    return parser

def format_task_for_display(task, detailed=False):
    """Formata uma tarefa para exibição."""
    status_colors = {
        TaskStatus.TODO: Fore.RED,
        TaskStatus.IN_PROGRESS: Fore.YELLOW,
        TaskStatus.DONE: Fore.GREEN
    }
    
    color = status_colors.get(task.status, Fore.WHITE)
    
    if not detailed:
        return f"{color}[{task.status}]{Style.RESET_ALL} {task.title} {Fore.BLUE}(ID: {task.id[:8]}){Style.RESET_ALL}"
    
    # Exibição detalhada
    result = [
        f"{Fore.CYAN}ID:{Style.RESET_ALL} {task.id}",
        f"{Fore.CYAN}Titre:{Style.RESET_ALL} {task.title}",
        f"{Fore.CYAN}Statut:{Style.RESET_ALL} {color}{task.status}{Style.RESET_ALL}",
        f"{Fore.CYAN}Créée le:{Style.RESET_ALL} {task.created_at.strftime('%d/%m/%Y %H:%M')}",
        f"{Fore.CYAN}Mise à jour:{Style.RESET_ALL} {task.updated_at.strftime('%d/%m/%Y %H:%M')}"
    ]
    
    if task.description:
        result.append(f"{Fore.CYAN}Description:{Style.RESET_ALL} {task.description}")
    
    if task.due_date:
        result.append(f"{Fore.CYAN}Échéance:{Style.RESET_ALL} {task.due_date.strftime('%d/%m/%Y')}")
    
    if task.tags:
        tags_str = ", ".join(f"{Fore.MAGENTA}{tag}{Style.RESET_ALL}" for tag in task.tags)
        result.append(f"{Fore.CYAN}Tags:{Style.RESET_ALL} {tags_str}")
    
    return "\n".join(result)

def main():
    """Ponto de entrada principal do programa."""
    colorama.init()
    
    parser = setup_argparse()
    args = parser.parse_args()
    
    if not args.command:
        parser.print_help()
        return
    
    commands = TodoCommands()
    
    try:
        if args.command == "add":
            task = commands.add(
                title=args.title,
                description=args.description or "",
                due_date=args.due,
                tags=args.tags
            )
            print(f"{Fore.GREEN}Tâche ajoutée avec succès !{Style.RESET_ALL}")
            print(format_task_for_display(task, detailed=True))
        
        elif args.command == "list":
            tasks = commands.list(
                status=args.status,
                tag=args.tag
            )
            
            if not tasks:
                print(f"{Fore.YELLOW}Aucune tâche ne correspond aux critères.{Style.RESET_ALL}")
                return
            
            print(f"{Fore.CYAN}Tâches ({len(tasks)}):{Style.RESET_ALL}")
            for i, task in enumerate(tasks, 1):
                print(f"{i}. {format_task_for_display(task)}")
        
        elif args.command == "update":
            update_data = {}
            if args.title:
                update_data["title"] = args.title
            if args.description is not None:  # Permitir descrições vazias
                update_data["description"] = args.description
            if args.status:
                update_data["status"] = args.status
            if args.due:
                update_data["due_date"] = args.due
            if args.tags is not None:  # Permitir listas de tags vazias
                update_data["tags"] = args.tags
            
            if not update_data:
                print(f"{Fore.YELLOW}Aucune modification spécifiée.{Style.RESET_ALL}")
                return
            
            task = commands.update(args.id, **update_data)
            print(f"{Fore.GREEN}Tâche mise à jour avec succès !{Style.RESET_ALL}")
            print(format_task_for_display(task, detailed=True))
        
        elif args.command == "get":
            task = commands.get(args.id)
            print(format_task_for_display(task, detailed=True))
        
        elif args.command == "delete":
            commands.delete(args.id)
            print(f"{Fore.GREEN}Tâche supprimée avec succès !{Style.RESET_ALL}")
    
    except CommandError as e:
        print(f"{Fore.RED}Erreur : {e}{Style.RESET_ALL}")
        sys.exit(1)

if __name__ == "__main__":
    main()

Criação do arquivo de inicialização

Para finalizar, vamos criar o arquivo todo/__init__.py para tornar nosso pacote utilizável:

# todo/__init__.py
"""
TodoApp - Um aplicativo de gerenciamento de tarefas em linha de comando
"""

__version__ = "0.1.0"

Utilização do aplicativo

Agora que nosso aplicativo está criado, veja como utilizá-lo:

# Instalar o aplicativo em modo de desenvolvimento
pip install -e .

# Adicionar uma nova tarefa
todo add "Implémenter la fonctionnalité X" --description "Cette tâche consiste à..." --due "31/12/2023" --tags "dev,urgent"

# Listar todas as tarefas
todo list

# Listar as tarefas com um status específico
todo list --status TODO

# Listar as tarefas com uma tag específica
todo list --tag urgent

# Exibir os detalhes de uma tarefa
todo get abc123  # Substitua por um ID real

# Atualizar uma tarefa
todo update abc123 --status IN_PROGRESS

# Marcar uma tarefa como concluída
todo update abc123 --status DONE

# Excluir uma tarefa
todo delete abc123

Teste do aplicativo

Para garantir a qualidade do nosso aplicativo, vamos criar um teste unitário simples para a classe Task em tests/test_models.py:

# tests/test_models.py
import unittest
from datetime import datetime
from todo.models import Task, TaskStatus

class TestTask(unittest.TestCase):
    def test_task_creation(self):
        """Teste da criação de uma tarefa com valores padrão."""
        task = Task("Test Task")
        self.assertEqual(task.title, "Test Task")
        self.assertEqual(task.description, "")
        self.assertEqual(task.status, TaskStatus.TODO)
        self.assertEqual(task.tags, [])
        self.assertIsNone(task.due_date)
    
    def test_task_with_all_fields(self):
        """Teste da criação de uma tarefa com todos os campos especificados."""
        due_date = datetime(2023, 12, 31)
        task = Task(
            title="Test Task",
            description="Test Description",
            status=TaskStatus.IN_PROGRESS,
            due_date=due_date,
            tags=["test", "python"]
        )
        
        self.assertEqual(task.title, "Test Task")
        self.assertEqual(task.description, "Test Description")
        self.assertEqual(task.status, TaskStatus.IN_PROGRESS)
        self.assertEqual(task.due_date, due_date)
        self.assertEqual(task.tags, ["test", "python"])
    
    def test_task_update(self):
        """Teste da atualização de uma tarefa."""
        task = Task("Initial Title")
        old_updated_at = task.updated_at
        
        # Pequena pausa para garantir que updated_at será diferente
        import time
        time.sleep(0.001)
        
        task.update(
            title="New Title",
            description="New Description",
            status=TaskStatus.DONE
        )
        
        self.assertEqual(task.title, "New Title")
        self.assertEqual(task.description, "New Description")
        self.assertEqual(task.status, TaskStatus.DONE)
        self.assertNotEqual(task.updated_at, old_updated_at)
    
    def test_to_dict_and_from_dict(self):
        """Teste da serialização e desserialização de uma tarefa."""
        original_task = Task(
            title="Serialization Test",
            description="Testing serialization",
            tags=["test", "serialization"]
        )
        
        # Converter para dict e depois recriar a tarefa
        task_dict = original_task.to_dict()
        recreated_task = Task.from_dict(task_dict)
        
        self.assertEqual(recreated_task.id, original_task.id)
        self.assertEqual(recreated_task.title, original_task.title)
        self.assertEqual(recreated_task.description, original_task.description)
        self.assertEqual(recreated_task.status, original_task.status)
        self.assertEqual(recreated_task.tags, original_task.tags)

if __name__ == "__main__":
    unittest.main()

Para executar os testes:

python -m pytest tests/

Conclusão

Parabéns! Você criou uma aplicação Python completa que reúne muitos conceitos importantes:

  1. Estrutura de projeto: organização modular com separação de responsabilidades.
  2. POO: uso de classes para representar os dados e o comportamento.
  3. Tratamento de exceções: captura e tratamento adequados dos erros.
  4. Persistência de dados: armazenamento das tarefas no formato JSON.
  5. Interface de linha de comando: API de usuário clara e consistente.
  6. Testes: verificação do bom funcionamento dos componentes.

Este projeto permitiu que você colocasse em prática as habilidades adquiridas ao longo desta série de tutoriais. Agora você pode enriquecê-lo adicionando novas funcionalidades, como:

  • Uma interface gráfica com uma biblioteca como Tkinter ou PyQt
  • Uma API web com Flask ou FastAPI
  • Uma sincronização com serviços de nuvem
  • Lembretes para as tarefas com prazo se aproximando
  • Estatísticas sobre as tarefas concluídas

Não hesite em explorar essas extensões para continuar desenvolvendo suas habilidades em Python.

Esta série de tutoriais está chegando ao fim, mas sua jornada com Python está apenas começando. Continue praticando, explorando e criando projetos que despertem sua paixão. Boa programação!

Comentários

Os comentários são alimentados pelo GitHub Discussions

Conecte-se com o GitHub para participar da discussão

Link copiado!