Partilhar: WhatsApp
aulify
UC UC00618 · T. Desenv. Software

Ficha 02 · OOP, bibliotecas, projecto

Classes, herança, módulos, ficheiros, web
Versão · Aluno
Tempo · 60 minutos
Cotação · 100 pontos
Aluno(a)
Turma
Data
Objectivos da ficha

Parte I · OOP

Exercício 1 · Classe simples (15 pts)

Cria classe Pessoa com: - Atributos: nome, idade, email. - Método: saudar() retorna "Olá, sou X". - Método: é_adulto() retorna True/False. - __repr__(). - __eq__().

from dataclasses import dataclass


class Pessoa:
    def __init__(self, nome: str, idade: int, email: str):
        self.nome = nome
        self.idade = idade
        self.email = email

    def saudar(self) -> str:
        return f"Olá, sou {self.nome}"

    def é_adulto(self) -> bool:
        return self.idade >= 18

    def __repr__(self):
        return f"Pessoa(nome={self.nome!r}, idade={self.idade}, email={self.email!r})"

    def __eq__(self, other):
        if not isinstance(other, Pessoa):
            return NotImplemented
        return (
            self.nome == other.nome
            and self.idade == other.idade
            and self.email == other.email
        )

    def __hash__(self):
        return hash((self.nome, self.idade, self.email))


# Testar
ana = Pessoa("Ana", 25, "ana@email.com")
joao = Pessoa("João", 16, "joao@email.com")
ana2 = Pessoa("Ana", 25, "ana@email.com")

print(ana.saudar())          # Olá, sou Ana
print(ana.é_adulto())        # True
print(joao.é_adulto())       # False

print(ana)                   # Pessoa(nome='Ana', idade=25, email='ana@email.com')
print(ana == ana2)           # True
print(ana == joao)           # False

Versão mais Pythonic com dataclass:

from dataclasses import dataclass


@dataclass
class Pessoa:
    nome: str
    idade: int
    email: str

    def saudar(self) -> str:
        return f"Olá, sou {self.nome}"

    def é_adulto(self) -> bool:
        return self.idade >= 18


# Mesma funcionalidade, muito menos código
# __init__, __repr__, __eq__ são gerados automaticamente

Boas práticas demonstradas:

  1. Type hints nos atributos e parâmetros.
  2. Docstrings (omitidas por brevidade, mas recomendadas).
  3. __repr__ com !r para mostrar repr dos valores (com aspas em strings).
  4. __eq__ verifica tipo (isinstance) antes de comparar.
  5. NotImplemented (não False) quando tipos não comparáveis — permite Python decidir.
  6. __hash__ necessário se quer usar em sets/dicts (deve ser baseado nos mesmos campos de __eq__).
  7. Dataclass elimina boilerplate.

Funcionalidades extras:

@dataclass(frozen=True)  # imutável
class Pessoa:
    nome: str
    idade: int
    email: str

frozen=True: impede modificação após criação (mais seguro, hashable).

@dataclass(order=True)
class Pessoa:
    nome: str
    idade: int
    email: str

order=True: gera __lt__, __le__, __gt__, __ge__ automaticamente (comparações).

Exercício 2 · Herança (15 pts)

Cria hierarquia: - Veiculo (base): marca, modelo, ano. - Carro (herda): + número de portas. - Mota (herda): + tipo (sport/touring).

Cada um com métodos próprios.

from dataclasses import dataclass, field
from typing import Optional


@dataclass
class Veiculo:
    marca: str
    modelo: str
    ano: int
    quilometragem: int = 0

    def info(self) -> str:
        return f"{self.marca} {self.modelo} ({self.ano})"

    def é_antigo(self) -> bool:
        from datetime import datetime
        idade = datetime.now().year - self.ano
        return idade > 25

    def conduzir(self, km: int) -> None:
        if km < 0:
            raise ValueError("Km não pode ser negativo")
        self.quilometragem += km

    def __str__(self):
        return f"{self.info()}{self.quilometragem} km"


@dataclass
class Carro(Veiculo):
    portas: int = 4
    combustivel: str = "gasolina"

    def info(self) -> str:
        base = super().info()
        return f"{base} | {self.portas} portas | {self.combustivel}"


@dataclass
class Mota(Veiculo):
    tipo: str = "touring"  # sport, touring, custom
    cilindrada: int = 125  # cm³

    def info(self) -> str:
        base = super().info()
        return f"{base} | {self.tipo} | {self.cilindrada}cc"

    def é_grande_cilindrada(self) -> bool:
        return self.cilindrada > 500


# Testar
carro = Carro(marca="Toyota", modelo="Corolla", ano=2020, portas=4)
mota = Mota(marca="Yamaha", modelo="MT-07", ano=2022, cilindrada=689, tipo="sport")
antigo = Veiculo(marca="Mercedes", modelo="180", ano=1990)

print(carro.info())
# Toyota Corolla (2020) | 4 portas | gasolina

print(mota.info())
# Yamaha MT-07 (2022) | sport | 689cc

print(antigo.é_antigo())            # True
print(mota.é_grande_cilindrada())   # True

carro.conduzir(100)
print(carro)
# Toyota Corolla (2020) | 4 portas | gasolina — 100 km

# Polimorfismo
veiculos = [carro, mota, antigo]
for v in veiculos:
    print(v.info())

Princípios OOP demonstrados:

1. Herança: - Carro e Mota herdam de Veiculo. - Reutilizam atributos e métodos.

2. Override (sobrescrever): - info() é override em cada subclasse. - Chamam super().info() para reutilizar.

3. Especialização: - Carro adiciona portas, combustivel. - Mota adiciona tipo, cilindrada.

4. Polimorfismo: - Lista de veiculos mistura tipos. - Cada um responde a info() adequadamente.

5. Encapsulamento parcial: - conduzir() valida input. - Atributos públicos por simplicidade (não-Python sempre privados).

Alternativa com Protocol (duck typing):

from typing import Protocol

class Movível(Protocol):
    def conduzir(self, km: int) -> None: ...

def viajar(v: Movível, km: int):
    v.conduzir(km)
    print(f"Viajaram {km}km!")

Alternativa com Abstract Base Classes (ABCs):

from abc import ABC, abstractmethod

class Veiculo(ABC):
    @abstractmethod
    def info(self) -> str:
        pass

class Carro(Veiculo):
    def info(self):
        return "Carro"

# Tentar criar Veiculo directamente dá erro
# v = Veiculo()  # TypeError: can't instantiate abstract class

Quando usar herança: - "É um" (Carro é um Veículo). - Múltiplas variantes do mesmo conceito. - Polimorfismo necessário.

Quando NÃO usar herança: - "Tem um" → usar composição. - Hierarquias profundas (> 3 níveis) — geralmente sinal de over-engineering.

Composição vs herança:

# Em vez de herança múltipla complexa:
class Motor:
    def ligar(self): print("brrr")

class Veiculo:
    def __init__(self, motor: Motor):
        self.motor = motor   # composição

    def ligar(self):
        self.motor.ligar()

# Mais flexível, menos acoplado

Parte II · Bibliotecas

Exercício 3 · Ficheiros e JSON (15 pts)

Escreve programa que: - Carrega dados de utilizadores de JSON. - Adiciona novo utilizador. - Guarda de volta.

import json
from pathlib import Path
from datetime import datetime
from dataclasses import dataclass, asdict


@dataclass
class Utilizador:
    nome: str
    email: str
    idade: int
    criado_em: str = ""

    def __post_init__(self):
        if not self.criado_em:
            self.criado_em = datetime.now().isoformat()


class GestorUtilizadores:
    """Gestão de utilizadores persistidos em JSON."""

    def __init__(self, ficheiro: str = "utilizadores.json"):
        self.ficheiro = Path(ficheiro)
        self.utilizadores: list[dict] = self._carregar()

    def _carregar(self) -> list[dict]:
        """Carrega utilizadores do ficheiro JSON."""
        if not self.ficheiro.exists():
            return []
        try:
            return json.loads(self.ficheiro.read_text(encoding="utf-8"))
        except json.JSONDecodeError as e:
            print(f"⚠️  Erro ao ler ficheiro: {e}")
            return []

    def _guardar(self) -> None:
        """Guarda utilizadores no ficheiro JSON."""
        self.ficheiro.write_text(
            json.dumps(self.utilizadores, indent=2, ensure_ascii=False),
            encoding="utf-8"
        )

    def adicionar(self, utilizador: Utilizador) -> bool:
        """Adiciona novo utilizador. Retorna False se email já existe."""
        if self.buscar_por_email(utilizador.email):
            print(f"⚠️  Email {utilizador.email} já registado")
            return False

        self.utilizadores.append(asdict(utilizador))
        self._guardar()
        print(f"✓ Adicionado: {utilizador.nome}")
        return True

    def buscar_por_email(self, email: str) -> dict | None:
        """Busca utilizador por email."""
        for u in self.utilizadores:
            if u["email"].lower() == email.lower():
                return u
        return None

    def listar(self) -> None:
        """Imprime todos os utilizadores."""
        if not self.utilizadores:
            print("Sem utilizadores.")
            return

        print(f"\n📋 {len(self.utilizadores)} utilizadores:")
        print("-" * 50)
        for u in self.utilizadores:
            print(f"  {u['nome']} ({u['idade']}) — {u['email']}")

    def remover(self, email: str) -> bool:
        """Remove utilizador por email."""
        original_count = len(self.utilizadores)
        self.utilizadores = [
            u for u in self.utilizadores 
            if u["email"].lower() != email.lower()
        ]

        if len(self.utilizadores) < original_count:
            self._guardar()
            print(f"✓ Removido: {email}")
            return True

        print(f"⚠️  Não encontrado: {email}")
        return False


def main():
    """Programa interactivo."""
    gestor = GestorUtilizadores("utilizadores.json")

    while True:
        print("\n--- GESTOR DE UTILIZADORES ---")
        print("1. Adicionar")
        print("2. Listar")
        print("3. Buscar por email")
        print("4. Remover")
        print("0. Sair")

        opcao = input("\nOpção: ").strip()

        if opcao == "1":
            try:
                nome = input("Nome: ").strip()
                email = input("Email: ").strip()
                idade = int(input("Idade: "))

                novo = Utilizador(nome=nome, email=email, idade=idade)
                gestor.adicionar(novo)
            except ValueError:
                print("⚠️  Idade deve ser número inteiro")

        elif opcao == "2":
            gestor.listar()

        elif opcao == "3":
            email = input("Email a buscar: ").strip()
            u = gestor.buscar_por_email(email)
            if u:
                print(f"\n  Encontrado: {u['nome']} ({u['idade']})")
                print(f"  Criado em: {u['criado_em']}")
            else:
                print("⚠️  Não encontrado")

        elif opcao == "4":
            email = input("Email a remover: ").strip()
            gestor.remover(email)

        elif opcao == "0":
            print("👋 Adeus!")
            break

        else:
            print("⚠️  Opção inválida")


if __name__ == "__main__":
    main()

Exemplo de uso:

--- GESTOR DE UTILIZADORES ---
1. Adicionar
2. Listar
3. Buscar por email
4. Remover
0. Sair

Opção: 1
Nome: Ana Silva
Email: ana@email.com
Idade: 25
✓ Adicionado: Ana Silva

Opção: 1
Nome: João Costa
Email: joao@email.com
Idade: 30
✓ Adicionado: João Costa

Opção: 2

📋 2 utilizadores:
--------------------------------------------------
  Ana Silva (25) — ana@email.com
  João Costa (30) — joao@email.com

Opção: 0
👋 Adeus!

Ficheiro gerado utilizadores.json:

[
  {
    "nome": "Ana Silva",
    "email": "ana@email.com",
    "idade": 25,
    "criado_em": "2026-05-22T14:32:15.123456"
  },
  {
    "nome": "João Costa",
    "email": "joao@email.com",
    "idade": 30,
    "criado_em": "2026-05-22T14:33:20.654321"
  }
]

Princípios aplicados:

  1. Classes organizam código (vs funções soltas).
  2. Dataclass para Utilizador (boilerplate reduzido).
  3. Pathlib em vez de strings de paths.
  4. Type hints em todos os métodos.
  5. Métodos privados (_carregar, _guardar) com underscore.
  6. JSON com indent + ensure_ascii=False (legível, suporta acentos).
  7. Tratamento de erros (file não existe, JSON inválido, ValueError).
  8. Encoding explícito (utf-8).
  9. Operadores idiomáticos: list comprehension para filter.

Extensões possíveis:

Exercício 4 · API externa com requests (10 pts)

Escreve script que busca dados de uma API e mostra resultado.

Exemplo — Busca info de utilizador GitHub:

import requests
from typing import Optional


def buscar_utilizador_github(username: str) -> Optional[dict]:
    """
    Busca informação de utilizador GitHub.

    Returns dict com info ou None se não existe.
    """
    url = f"https://api.github.com/users/{username}"

    try:
        response = requests.get(url, timeout=10)

        if response.status_code == 200:
            return response.json()
        elif response.status_code == 404:
            print(f"⚠️  Utilizador '{username}' não encontrado")
            return None
        else:
            print(f"⚠️  Erro HTTP {response.status_code}")
            return None

    except requests.exceptions.Timeout:
        print("⚠️  Timeout — API demorou demasiado")
        return None
    except requests.exceptions.ConnectionError:
        print("⚠️  Sem conexão à internet")
        return None
    except requests.exceptions.RequestException as e:
        print(f"⚠️  Erro: {e}")
        return None


def buscar_repos(username: str, max_repos: int = 5) -> list[dict]:
    """Busca repos do utilizador."""
    url = f"https://api.github.com/users/{username}/repos"
    params = {"sort": "stars", "per_page": max_repos}

    try:
        response = requests.get(url, params=params, timeout=10)
        response.raise_for_status()  # levanta excepção se erro
        return response.json()
    except requests.exceptions.RequestException:
        return []


def mostrar_info(user: dict, repos: list[dict]) -> None:
    """Imprime info formatada do utilizador."""
    print("\n" + "=" * 50)
    print(f"👤 {user['name'] or user['login']}")
    print("=" * 50)

    if user.get('bio'):
        print(f"\n💬 {user['bio']}")

    print(f"\n📍 Localização: {user.get('location', 'N/A')}")
    print(f"🏢 Empresa: {user.get('company', 'N/A')}")
    print(f"🔗 Site: {user.get('blog', 'N/A') or 'N/A'}")
    print(f"📧 Email: {user.get('email', 'N/A') or 'N/A'}")

    print(f"\n📊 Estatísticas:")
    print(f"   Repos públicos: {user['public_repos']}")
    print(f"   Followers: {user['followers']}")
    print(f"   Following: {user['following']}")

    print(f"\n📅 Conta criada: {user['created_at'][:10]}")

    if repos:
        print(f"\n🌟 Top {len(repos)} repositórios:")
        for repo in repos:
            stars = repo['stargazers_count']
            lang = repo.get('language') or 'N/A'
            desc = repo.get('description') or '(sem descrição)'
            print(f"   ⭐ {stars} | {lang:12} | {repo['name']}")
            print(f"     {desc}")

    print("\n" + "=" * 50)


def main():
    """Programa principal."""
    username = input("Username GitHub: ").strip()

    if not username:
        print("Username obrigatório")
        return

    print(f"\n🔍 A buscar '{username}'...")

    user = buscar_utilizador_github(username)
    if user is None:
        return

    repos = buscar_repos(username, max_repos=5)
    mostrar_info(user, repos)


if __name__ == "__main__":
    main()

Exemplo de output:

Username GitHub: torvalds

🔍 A buscar 'torvalds'...

==================================================
👤 Linus Torvalds
==================================================

💬 (sem bio)

📍 Localização: Portland, OR
🏢 Empresa: Linux Foundation
🔗 Site: N/A
📧 Email: N/A

📊 Estatísticas:
   Repos públicos: 7
   Followers: 200000
   Following: 0

📅 Conta criada: 2011-09-03

🌟 Top 5 repositórios:
    175000 | C            | linux
     Linux kernel source tree
    1500   | C            | subsurface-for-dirk
     Dive log program
   ...

==================================================

Princípios aplicados:

  1. requests: standard para HTTP em Python.
  2. Timeout: previne hang infinito.
  3. Status codes: tratar 200, 404, outros.
  4. Excepções específicas:
  5. Timeout.
  6. ConnectionError.
  7. RequestException (parent).
  8. response.json(): parse automático.
  9. response.raise_for_status(): levanta excepção em 4xx/5xx.
  10. Query params: params=dict.
  11. Type hints.
  12. Optional para retornos que podem ser None.
  13. Output formatado com emojis e alinhamento.

Outras APIs públicas para praticar:

Boas práticas adicionais para APIs:

# Headers customizados
response = requests.get(
    url,
    headers={
        "User-Agent": "MyApp/1.0",
        "Accept": "application/json",
        "Authorization": f"Bearer {token}"
    }
)

# POST com JSON
response = requests.post(
    "https://api.example.com/users",
    json={"name": "Ana", "email": "ana@x.pt"}
)

# Sessions (reutilizar conexão)
session = requests.Session()
session.headers["Authorization"] = "Bearer token"
r1 = session.get("https://api.example.com/users")
r2 = session.get("https://api.example.com/posts")

# Streaming downloads (ficheiros grandes)
with requests.get(url, stream=True) as r:
    with open("file.zip", "wb") as f:
        for chunk in r.iter_content(chunk_size=8192):
            f.write(chunk)

# Rate limiting (não martelar API)
import time
for url in urls:
    response = requests.get(url)
    process(response)
    time.sleep(1)  # 1 req/segundo

Bibliotecas alternativas / complementares:

Parte III · Projecto

Exercício 5 · Web scraping básico (15 pts)

Escreve script que extrai títulos de notícias de uma página web.

import requests
from bs4 import BeautifulSoup
from datetime import datetime


def buscar_noticias(url: str, seletor: str) -> list[dict]:
    """
    Faz scraping de notícias.

    Args:
        url: URL da página.
        seletor: CSS selector para os títulos.

    Returns:
        Lista de dicts com título e link.
    """
    try:
        headers = {
            "User-Agent": "Mozilla/5.0 (educational scraper)"
        }
        response = requests.get(url, headers=headers, timeout=10)
        response.raise_for_status()

        soup = BeautifulSoup(response.content, "html.parser")

        noticias = []
        for elemento in soup.select(seletor):
            titulo = elemento.get_text(strip=True)
            link = elemento.get("href", "")

            if titulo:
                noticias.append({
                    "titulo": titulo,
                    "link": link if link.startswith("http") else f"{url}{link}",
                    "extraido_em": datetime.now().isoformat()
                })

        return noticias

    except requests.exceptions.RequestException as e:
        print(f"Erro de rede: {e}")
        return []


def guardar_noticias(noticias: list[dict], ficheiro: str = "noticias.json") -> None:
    """Guarda notícias em JSON."""
    import json
    from pathlib import Path

    Path(ficheiro).write_text(
        json.dumps(noticias, indent=2, ensure_ascii=False),
        encoding="utf-8"
    )
    print(f"✓ {len(noticias)} notícias guardadas em {ficheiro}")


def main():
    """Demo do scraper."""

    # Exemplo: site de notícias técnico (Hacker News)
    print("📰 A buscar notícias do Hacker News...")

    url = "https://news.ycombinator.com/"
    # Selector CSS para os títulos
    seletor = ".titleline > a"

    noticias = buscar_noticias(url, seletor)

    if not noticias:
        print("⚠️  Sem notícias encontradas")
        return

    # Mostrar top 10
    print(f"\n📋 Top {min(10, len(noticias))} notícias:\n")
    for i, n in enumerate(noticias[:10], 1):
        print(f"{i:2}. {n['titulo']}")
        if n['link']:
            print(f"    🔗 {n['link']}")

    # Guardar
    guardar_noticias(noticias)


if __name__ == "__main__":
    main()

Instalação:

pip install requests beautifulsoup4

Exemplo output:

📰 A buscar notícias do Hacker News...

📋 Top 10 notícias:

 1. New AI model achieves human-level performance on...
    🔗 https://example.com/article1
 2. Python 3.13 released with major improvements
    🔗 https://example.com/article2
 ...

✓ 30 notícias guardadas em noticias.json

BeautifulSoup — métodos úteis:

from bs4 import BeautifulSoup

html = "<html>...</html>"
soup = BeautifulSoup(html, "html.parser")

# Encontrar primeiro
title = soup.find("h1")
title.text

# Encontrar todos
links = soup.find_all("a")

# CSS selectors (mais poderoso)
items = soup.select(".item")
nav_links = soup.select("nav > ul > li > a")

# Atributos
link = soup.find("a")
href = link.get("href")
classes = link.get("class")  # lista

# Text
text = element.get_text(strip=True)
text = element.text.strip()

# Navegar
parent = element.parent
siblings = element.find_next_siblings()
children = element.children

# Filtrar
soup.find("div", class_="article")
soup.find("a", href=re.compile("github"))

Ética de scraping:

Respeitar robots.txt:

from urllib.robotparser import RobotFileParser

rp = RobotFileParser()
rp.set_url("https://example.com/robots.txt")
rp.read()

if rp.can_fetch("MyBot", "https://example.com/page"):
    # OK fazer scraping
    pass

Rate limiting (não martelar):

import time
for url in urls:
    response = requests.get(url)
    time.sleep(2)  # 1 req cada 2 segundos

User-Agent honesto:

headers = {
    "User-Agent": "MyScraper/1.0 (contact@email.com)"
}

Atribuir crédito à fonte.

Não fazer: - Scraping de dados pessoais sem consentimento (GDPR!). - Bypass de paywalls (ilegal). - DDoS-like loads (rate limit). - Ignorar Terms of Service. - Republicar conteúdo sem permissão.

APIs > scraping quando disponíveis. Sites como Reddit, Twitter, GitHub têm APIs oficiais.

Alternativas a BeautifulSoup:

Quando usar Selenium/Playwright: - Site usa JavaScript para renderizar conteúdo. - Precisa de simular cliques, scroll. - Login obrigatório.

from selenium import webdriver
from selenium.webdriver.common.by import By

driver = webdriver.Chrome()
driver.get("https://example.com")
elements = driver.find_elements(By.CSS_SELECTOR, ".item")
for el in elements:
    print(el.text)
driver.quit()

Exercício 6 · Testes com pytest (15 pts)

Escreve testes para função calcular_imc:

# imc.py
def calcular_imc(peso: float, altura: float) -> float:
    """Calcula IMC."""
    if not isinstance(peso, (int, float)):
        raise TypeError("Peso deve ser número")
    if not isinstance(altura, (int, float)):
        raise TypeError("Altura deve ser número")
    if peso <= 0:
        raise ValueError("Peso deve ser positivo")
    if altura <= 0:
        raise ValueError("Altura deve ser positiva")

    return peso / (altura ** 2)


def classificar_imc(imc: float) -> str:
    """Classifica IMC segundo OMS."""
    if imc < 18.5:
        return "Abaixo do peso"
    elif imc < 25:
        return "Normal"
    elif imc < 30:
        return "Excesso de peso"
    elif imc < 35:
        return "Obesidade I"
    elif imc < 40:
        return "Obesidade II"
    return "Obesidade III"
# test_imc.py
import pytest
from imc import calcular_imc, classificar_imc


class TestCalcularIMC:
    """Testes para calcular_imc."""

    def test_valor_normal(self):
        """IMC normal."""
        imc = calcular_imc(70, 1.75)
        assert 22.85 < imc < 22.87

    def test_peso_baixo(self):
        """Pessoa magra."""
        imc = calcular_imc(50, 1.75)
        assert imc < 18.5

    def test_peso_alto(self):
        """Pessoa com obesidade."""
        imc = calcular_imc(120, 1.70)
        assert imc > 30

    def test_peso_zero_levanta_erro(self):
        """Peso zero deve levantar ValueError."""
        with pytest.raises(ValueError, match="Peso deve ser positivo"):
            calcular_imc(0, 1.75)

    def test_peso_negativo_levanta_erro(self):
        """Peso negativo deve levantar ValueError."""
        with pytest.raises(ValueError):
            calcular_imc(-10, 1.75)

    def test_altura_zero_levanta_erro(self):
        """Altura zero deve levantar ValueError."""
        with pytest.raises(ValueError, match="Altura deve ser positiva"):
            calcular_imc(70, 0)

    def test_altura_negativa_levanta_erro(self):
        """Altura negativa deve levantar ValueError."""
        with pytest.raises(ValueError):
            calcular_imc(70, -1.75)

    def test_peso_string_levanta_erro(self):
        """Peso como string deve levantar TypeError."""
        with pytest.raises(TypeError, match="Peso deve ser número"):
            calcular_imc("setenta", 1.75)

    def test_altura_lista_levanta_erro(self):
        """Altura como lista deve levantar TypeError."""
        with pytest.raises(TypeError):
            calcular_imc(70, [1, 75])

    @pytest.mark.parametrize("peso, altura, esperado", [
        (60, 1.65, 22.04),
        (80, 1.80, 24.69),
        (50, 1.70, 17.30),
        (100, 1.75, 32.65),
    ])
    def test_varios_valores(self, peso, altura, esperado):
        """Teste parametrizado com vários valores."""
        imc = calcular_imc(peso, altura)
        assert abs(imc - esperado) < 0.01


class TestClassificarIMC:
    """Testes para classificar_imc."""

    @pytest.mark.parametrize("imc, classificacao", [
        (15, "Abaixo do peso"),
        (18.4, "Abaixo do peso"),
        (18.5, "Normal"),
        (22, "Normal"),
        (24.9, "Normal"),
        (25, "Excesso de peso"),
        (29.9, "Excesso de peso"),
        (30, "Obesidade I"),
        (34.9, "Obesidade I"),
        (35, "Obesidade II"),
        (39.9, "Obesidade II"),
        (40, "Obesidade III"),
        (50, "Obesidade III"),
    ])
    def test_classificacao(self, imc, classificacao):
        """Teste parametrizado de classificações."""
        assert classificar_imc(imc) == classificacao

    def test_limites(self):
        """Testa valores no limite."""
        assert classificar_imc(18.4999) == "Abaixo do peso"
        assert classificar_imc(18.5) == "Normal"
        assert classificar_imc(24.9999) == "Normal"
        assert classificar_imc(25) == "Excesso de peso"


class TestIntegracao:
    """Testes de integração — combinar funções."""

    def test_calcular_e_classificar(self):
        """Cenário completo."""
        peso, altura = 70, 1.75
        imc = calcular_imc(peso, altura)
        classificacao = classificar_imc(imc)

        assert 22 < imc < 23
        assert classificacao == "Normal"

    def test_pessoa_obesa(self):
        peso, altura = 110, 1.70
        imc = calcular_imc(peso, altura)
        classificacao = classificar_imc(imc)

        assert imc > 30
        assert "Obesidade" in classificacao

Correr:

# Instalar
pip install pytest

# Correr testes
pytest

# Output verbose
pytest -v

# Apenas testes específicos
pytest test_imc.py::TestCalcularIMC

# Apenas teste específico
pytest test_imc.py::TestCalcularIMC::test_valor_normal

# Com coverage
pip install pytest-cov
pytest --cov=imc --cov-report=html

# Parar no primeiro erro
pytest -x

# Mostrar print()
pytest -s

Output exemplo:

test_imc.py::TestCalcularIMC::test_valor_normal PASSED
test_imc.py::TestCalcularIMC::test_peso_baixo PASSED
test_imc.py::TestCalcularIMC::test_peso_alto PASSED
test_imc.py::TestCalcularIMC::test_peso_zero_levanta_erro PASSED
test_imc.py::TestCalcularIMC::test_peso_negativo_levanta_erro PASSED
test_imc.py::TestCalcularIMC::test_altura_zero_levanta_erro PASSED
test_imc.py::TestCalcularIMC::test_altura_negativa_levanta_erro PASSED
test_imc.py::TestCalcularIMC::test_peso_string_levanta_erro PASSED
test_imc.py::TestCalcularIMC::test_altura_lista_levanta_erro PASSED
test_imc.py::TestCalcularIMC::test_varios_valores[60-1.65-22.04] PASSED
test_imc.py::TestCalcularIMC::test_varios_valores[80-1.8-24.69] PASSED
test_imc.py::TestCalcularIMC::test_varios_valores[50-1.7-17.3] PASSED
test_imc.py::TestCalcularIMC::test_varios_valores[100-1.75-32.65] PASSED
test_imc.py::TestClassificarIMC::test_classificacao[15-Abaixo do peso] PASSED
... (16 tests)

============== 30 passed in 0.05s ==============

Princípios de bom teste:

  1. Classes agrupam testes relacionados (TestCalcularIMC).
  2. Nomes descritivos: test_peso_zero_levanta_erro.
  3. AAA pattern:
  4. Arrange: setup.
  5. Act: executar.
  6. Assert: verificar.
  7. Casos:
  8. Happy path (normal).
  9. Edge cases (zero, negativo, muito grande).
  10. Error cases (tipo errado, valor inválido).
  11. Parametrize: 1 teste, muitos inputs.
  12. pytest.raises para excepções.
  13. match= para verificar mensagem.
  14. Isolados: cada teste independente.

Coverage (cobertura): - 80%+ é bom para a maioria. - 90%+ ideal para código crítico. - 100% geralmente não vale a pena.

Outros tipos de teste:

Mocking (simular dependencies):

from unittest.mock import patch, Mock

def test_buscar_api():
    with patch('requests.get') as mock_get:
        mock_get.return_value.json.return_value = {"data": "test"}
        result = buscar_dados()
        assert result == "test"

Útil quando função depende de: - APIs externas. - Base de dados. - Sistema de ficheiros. - Tempo (datetime.now()).

Exercício 7 · Projecto Flask (10 pts)

Escreve API REST simples em Flask com endpoints CRUD para items.

# app.py
from flask import Flask, jsonify, request, abort
from datetime import datetime
from typing import Optional


app = Flask(__name__)

# Base de dados em memória (em produção: BD real)
items: list[dict] = []
next_id = 1


@app.route("/items", methods=["GET"])
def listar_items():
    """GET /items — lista todos os items."""
    return jsonify(items)


@app.route("/items/<int:item_id>", methods=["GET"])
def buscar_item(item_id: int):
    """GET /items/<id> — busca item específico."""
    item = next((i for i in items if i["id"] == item_id), None)
    if item is None:
        abort(404, description=f"Item {item_id} não encontrado")
    return jsonify(item)


@app.route("/items", methods=["POST"])
def criar_item():
    """POST /items — cria novo item."""
    global next_id

    data = request.get_json()

    if not data:
        abort(400, description="Body JSON obrigatório")

    if "nome" not in data:
        abort(400, description="Campo 'nome' obrigatório")

    novo_item = {
        "id": next_id,
        "nome": data["nome"],
        "descricao": data.get("descricao", ""),
        "preco": data.get("preco", 0),
        "criado_em": datetime.now().isoformat()
    }

    items.append(novo_item)
    next_id += 1

    return jsonify(novo_item), 201


@app.route("/items/<int:item_id>", methods=["PUT"])
def atualizar_item(item_id: int):
    """PUT /items/<id> — actualiza item."""
    item = next((i for i in items if i["id"] == item_id), None)
    if item is None:
        abort(404, description=f"Item {item_id} não encontrado")

    data = request.get_json()
    if not data:
        abort(400, description="Body JSON obrigatório")

    # Actualizar campos permitidos
    for campo in ["nome", "descricao", "preco"]:
        if campo in data:
            item[campo] = data[campo]

    item["actualizado_em"] = datetime.now().isoformat()

    return jsonify(item)


@app.route("/items/<int:item_id>", methods=["DELETE"])
def remover_item(item_id: int):
    """DELETE /items/<id> — remove item."""
    global items

    item = next((i for i in items if i["id"] == item_id), None)
    if item is None:
        abort(404, description=f"Item {item_id} não encontrado")

    items = [i for i in items if i["id"] != item_id]
    return "", 204  # No Content


@app.errorhandler(404)
def not_found(e):
    return jsonify({"error": "Not Found", "message": str(e.description)}), 404


@app.errorhandler(400)
def bad_request(e):
    return jsonify({"error": "Bad Request", "message": str(e.description)}), 400


@app.route("/")
def index():
    """Root endpoint com documentação."""
    return jsonify({
        "name": "Items API",
        "version": "1.0",
        "endpoints": {
            "GET /items": "Lista todos os items",
            "GET /items/<id>": "Busca item específico",
            "POST /items": "Cria item (body: nome, descricao, preco)",
            "PUT /items/<id>": "Actualiza item",
            "DELETE /items/<id>": "Remove item",
        }
    })


if __name__ == "__main__":
    app.run(debug=True, port=5000)

Instalar e correr:

pip install flask

python app.py
# * Running on http://127.0.0.1:5000

Testar com curl:

# Listar (vazio inicialmente)
curl http://localhost:5000/items
# []

# Criar
curl -X POST http://localhost:5000/items \
  -H "Content-Type: application/json" \
  -d '{"nome": "Caneta", "preco": 1.50}'
# {"id": 1, "nome": "Caneta", "preco": 1.5, ...}

# Criar outro
curl -X POST http://localhost:5000/items \
  -H "Content-Type: application/json" \
  -d '{"nome": "Caderno", "descricao": "100 folhas", "preco": 3.00}'
# {"id": 2, ...}

# Listar
curl http://localhost:5000/items
# [{"id": 1, ...}, {"id": 2, ...}]

# Buscar específico
curl http://localhost:5000/items/1

# Actualizar
curl -X PUT http://localhost:5000/items/1 \
  -H "Content-Type: application/json" \
  -d '{"preco": 2.00}'

# Remover
curl -X DELETE http://localhost:5000/items/1

# Tentar buscar removido
curl http://localhost:5000/items/1
# {"error": "Not Found", ...}

Boas práticas demonstradas:

  1. REST conventions:
  2. GET (read), POST (create), PUT (update), DELETE.
  3. Status codes (200, 201, 204, 400, 404).
  4. Resources como nomes (/items não /getItems).

  5. JSON I/O:

  6. jsonify() para response.
  7. request.get_json() para body.

  8. Error handling:

  9. abort() para erros.
  10. Custom error handlers para JSON consistente.

  11. Validation:

  12. Body obrigatório.
  13. Campos obrigatórios.

  14. Type hints.

  15. Documentação inline (root endpoint).

Para produção, adicionar:

# 1. Base de dados real (SQLAlchemy)
from flask_sqlalchemy import SQLAlchemy

app.config["SQLALCHEMY_DATABASE_URI"] = "postgresql://..."
db = SQLAlchemy(app)

class Item(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    nome = db.Column(db.String(100), nullable=False)
    # ...

# 2. Validação com Marshmallow
from marshmallow import Schema, fields, validate

class ItemSchema(Schema):
    nome = fields.Str(required=True, validate=validate.Length(min=1, max=100))
    preco = fields.Float(required=True, validate=validate.Range(min=0))

# 3. Autenticação
from flask_jwt_extended import JWTManager, jwt_required

@app.route("/items", methods=["POST"])
@jwt_required()
def criar_item():
    ...

# 4. Rate limiting
from flask_limiter import Limiter

limiter = Limiter(app)

@app.route("/items")
@limiter.limit("100 per hour")
def listar_items():
    ...

# 5. CORS
from flask_cors import CORS

CORS(app)

# 6. Logging
import logging
logging.basicConfig(level=logging.INFO)

# 7. Deploy com gunicorn (não Flask dev server)
# pip install gunicorn
# gunicorn app:app

Alternativas a Flask:

FastAPI exemplo (alternativa moderna):

from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from typing import Optional

app = FastAPI()

class Item(BaseModel):
    nome: str
    preco: float
    descricao: Optional[str] = None

items_db: list[dict] = []

@app.get("/items")
def listar():
    return items_db

@app.post("/items", status_code=201)
def criar(item: Item):
    novo = {**item.dict(), "id": len(items_db) + 1}
    items_db.append(novo)
    return novo

Vantagens FastAPI: - Validação automática com Pydantic. - Documentação OpenAPI auto-gerada (em /docs). - Async support nativo. - Type hints em todo o lado. - Performance excelente.

Exercício 8 · Projecto pessoal (15 pts)

Propõe estrutura de projecto Python para uma app pessoal à tua escolha.

Exemplo: Tracker de Hábitos CLI

Conceito: app de linha de comando para rastrear hábitos diários (exercício, leitura, meditação).

Funcionalidades: - Adicionar / remover hábitos. - Marcar feito hoje. - Ver histórico (streak, estatísticas). - Visualização semanal/mensal. - Exportar para CSV.

Estrutura do projecto:

habit-tracker/
├── venv/                    # virtual environment (não commit)
├── habit_tracker/           # código fonte
│   ├── __init__.py
│   ├── __main__.py         # entry point: python -m habit_tracker
│   ├── cli.py              # CLI interface
│   ├── models.py           # Classes (Habit, etc.)
│   ├── storage.py          # JSON / SQLite
│   ├── stats.py            # Cálculos estatísticos
│   ├── visualize.py        # Heatmap, gráficos
│   └── utils.py            # helpers
├── tests/                   # testes
│   ├── __init__.py
│   ├── test_models.py
│   ├── test_storage.py
│   └── test_stats.py
├── docs/                    # documentação
│   ├── README.md
│   ├── USER_GUIDE.md
│   └── DEVELOPMENT.md
├── .gitignore
├── .pre-commit-config.yaml  # hooks (black, flake8)
├── pyproject.toml           # configuração projecto
├── README.md
├── LICENSE
├── CHANGELOG.md
└── requirements.txt

pyproject.toml (moderno setup):

[build-system]
requires = ["setuptools>=61.0", "wheel"]
build-backend = "setuptools.build_meta"

[project]
name = "habit-tracker"
version = "0.1.0"
description = "CLI tool for tracking daily habits"
authors = [{name = "Teu Nome", email = "teu@email.com"}]
license = {text = "MIT"}
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
    "click>=8.0",       # CLI framework
    "rich>=13.0",       # Pretty output
    "pydantic>=2.0",    # Validação
]

[project.optional-dependencies]
dev = [
    "pytest>=7.0",
    "pytest-cov>=4.0",
    "black>=23.0",
    "flake8>=6.0",
    "mypy>=1.0",
]

[project.scripts]
habit = "habit_tracker.cli:main"

[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-v --cov=habit_tracker --cov-report=term-missing"

[tool.black]
line-length = 100

[tool.mypy]
strict = true

habit_tracker/models.py:

from datetime import date
from dataclasses import dataclass, field
from typing import Optional


@dataclass
class Habit:
    """Representa um hábito a rastrear."""
    name: str
    description: str = ""
    created_at: date = field(default_factory=date.today)
    completions: list[date] = field(default_factory=list)

    def mark_done(self, day: Optional[date] = None) -> bool:
        """Marca hábito como feito (default: hoje)."""
        day = day or date.today()
        if day not in self.completions:
            self.completions.append(day)
            self.completions.sort()
            return True
        return False

    def current_streak(self) -> int:
        """Calcula streak actual (dias consecutivos)."""
        if not self.completions:
            return 0

        today = date.today()
        streak = 0
        check_date = today

        # Permite ter feito ontem ou hoje
        if check_date not in self.completions:
            check_date = today.replace(day=today.day - 1)

        while check_date in self.completions:
            streak += 1
            check_date = check_date.replace(day=check_date.day - 1)

        return streak

    def completion_rate(self) -> float:
        """% de dias completados desde criação."""
        from datetime import timedelta
        days_since_created = (date.today() - self.created_at).days + 1
        return len(self.completions) / days_since_created * 100

habit_tracker/storage.py:

import json
from pathlib import Path
from datetime import date


class HabitStorage:
    """Persistência em JSON."""

    def __init__(self, path: Path = Path.home() / ".habits.json"):
        self.path = path

    def load(self) -> list[dict]:
        if not self.path.exists():
            return []
        return json.loads(
            self.path.read_text(),
            object_hook=self._deserialize_dates
        )

    def save(self, habits: list[dict]) -> None:
        self.path.write_text(
            json.dumps(habits, default=str, indent=2)
        )

    @staticmethod
    def _deserialize_dates(obj):
        for k, v in obj.items():
            if isinstance(v, str) and len(v) == 10:
                try:
                    obj[k] = date.fromisoformat(v)
                except ValueError:
                    pass
        return obj

habit_tracker/cli.py:

import click
from rich.console import Console
from rich.table import Table
from datetime import date

from .models import Habit
from .storage import HabitStorage


console = Console()
storage = HabitStorage()


@click.group()
def cli():
    """🎯 Habit Tracker — rastreia os teus hábitos diários."""
    pass


@cli.command()
@click.argument("name")
@click.option("--desc", default="", help="Descrição opcional")
def add(name: str, desc: str):
    """Adiciona novo hábito."""
    habits = storage.load()
    habits.append({"name": name, "description": desc, "completions": []})
    storage.save(habits)
    console.print(f"✓ Hábito '[green]{name}[/green]' adicionado!")


@cli.command()
@click.argument("name")
def done(name: str):
    """Marca hábito como feito hoje."""
    habits = storage.load()
    for h in habits:
        if h["name"] == name:
            today = date.today().isoformat()
            if today in h["completions"]:
                console.print(f"⚠️  '{name}' já foi marcado hoje")
            else:
                h["completions"].append(today)
                storage.save(habits)
                console.print(f"✓ '[green]{name}[/green]' feito!")
            return
    console.print(f"❌ Hábito '{name}' não encontrado")


@cli.command()
def list():
    """Lista todos os hábitos com streak."""
    habits = storage.load()
    if not habits:
        console.print("📭 Sem hábitos. Adiciona com: habit add 'nome'")
        return

    table = Table(title="🎯 Os teus hábitos")
    table.add_column("Nome", style="cyan")
    table.add_column("Streak", style="green")
    table.add_column("Total", style="blue")
    table.add_column("Hoje", style="yellow")

    for h in habits:
        today = date.today().isoformat()
        done_today = "✓" if today in h["completions"] else "✗"
        # streak calculado (lógica omitida)
        table.add_row(
            h["name"],
            "5 dias",  # placeholder
            str(len(h["completions"])),
            done_today
        )

    console.print(table)


def main():
    cli()


if __name__ == "__main__":
    main()

Uso:

# Instalar (em dev)
pip install -e .

# Adicionar
habit add "Exercício" --desc "30 min diários"
habit add "Leitura"

# Marcar feito
habit done "Exercício"

# Listar
habit list

Output:

        🎯 Os teus hábitos
┏━━━━━━━━━━━━┳━━━━━━━━━┳━━━━━━━┳━━━━━━━┓
┃ Nome       ┃ Streak  ┃ Total ┃ Hoje  ┃
┡━━━━━━━━━━━━╇━━━━━━━━━╇━━━━━━━╇━━━━━━━┩
│ Exercício  │ 5 dias  │ 12    │ ✓     │
│ Leitura    │ 3 dias  │ 8     │ ✗     │
└────────────┴─────────┴───────┴───────┘

Testes (tests/test_models.py):

import pytest
from datetime import date, timedelta
from habit_tracker.models import Habit


def test_habit_creation():
    h = Habit(name="Exercise")
    assert h.name == "Exercise"
    assert h.completions == []


def test_mark_done_today():
    h = Habit(name="Read")
    assert h.mark_done() is True
    assert date.today() in h.completions


def test_mark_done_twice_same_day():
    h = Habit(name="Read")
    h.mark_done()
    assert h.mark_done() is False  # já feito hoje


def test_current_streak():
    h = Habit(name="Run")
    today = date.today()
    h.completions = [
        today - timedelta(days=2),
        today - timedelta(days=1),
        today,
    ]
    h.completions.sort()
    assert h.current_streak() == 3

README.md:

# Habit Tracker

CLI tool for tracking daily habits with streaks and statistics.

## Install

\`\`\`bash
pip install habit-tracker
\`\`\`

## Usage

\`\`\`bash
habit add "Exercise"
habit done "Exercise"
habit list
\`\`\`

## Development

\`\`\`bash
git clone https://github.com/user/habit-tracker.git
cd habit-tracker
pip install -e ".[dev]"
pytest
\`\`\`

Extensões futuras:

Skills demonstrados:

Estrutura de projecto Python profissional. ✅ OOP (dataclasses, classes). ✅ CLI com click. ✅ Persistência JSON. ✅ Type hints em todo o lado. ✅ Testes com pytest. ✅ Documentação completa. ✅ pyproject.toml moderno. ✅ Rich para output bonito. ✅ Modularização (cli, models, storage separados).

Resultado: projecto que pode ir directo ao GitHub e ao portfolio.