Ficha 02 · OOP, bibliotecas, projecto
- OOP
- Bibliotecas
- Files
- Projecto
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:
- Type hints nos atributos e parâmetros.
- Docstrings (omitidas por brevidade, mas recomendadas).
__repr__com!rpara mostrar repr dos valores (com aspas em strings).__eq__verifica tipo (isinstance) antes de comparar.NotImplemented(nãoFalse) quando tipos não comparáveis — permite Python decidir.__hash__necessário se quer usar em sets/dicts (deve ser baseado nos mesmos campos de__eq__).- 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:
- Classes organizam código (vs funções soltas).
- Dataclass para Utilizador (boilerplate reduzido).
- Pathlib em vez de strings de paths.
- Type hints em todos os métodos.
- Métodos privados (
_carregar,_guardar) com underscore. - JSON com indent + ensure_ascii=False (legível, suporta acentos).
- Tratamento de erros (file não existe, JSON inválido, ValueError).
- Encoding explícito (
utf-8). - Operadores idiomáticos: list comprehension para filter.
Extensões possíveis:
- Validação de email (regex).
- Hashing de password (bcrypt).
- Filtros: por idade, ordenar.
- Export CSV: para Excel.
- Backup automático antes de mudanças.
- Audit log: ficheiro com histórico.
- Multi-tenant: múltiplos ficheiros por organização.
- CLI args com argparse.
- Web API com Flask.
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:
requests: standard para HTTP em Python.- Timeout: previne hang infinito.
- Status codes: tratar 200, 404, outros.
- Excepções específicas:
Timeout.ConnectionError.RequestException(parent).response.json(): parse automático.response.raise_for_status(): levanta excepção em 4xx/5xx.- Query params:
params=dict. - Type hints.
- Optional para retornos que podem ser None.
- Output formatado com emojis e alinhamento.
Outras APIs públicas para praticar:
- OpenWeather: clima.
- NASA APOD: foto astronómica do dia.
- JSONPlaceholder: API de teste.
- PokéAPI: dados de Pokémon.
- CoinGecko: preços crypto.
- OpenLibrary: livros.
- Movie DB: filmes.
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:
- httpx: moderna, suporta async.
- aiohttp: para async/await.
- urllib (standard library): mais low-level.
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:
- Scrapy: framework completo de scraping.
- lxml: parser rápido (BS pode usar internamente).
- Selenium: para sites JavaScript-heavy.
- Playwright: moderno, suporta JS.
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:
- Classes agrupam testes relacionados (
TestCalcularIMC). - Nomes descritivos:
test_peso_zero_levanta_erro. - AAA pattern:
- Arrange: setup.
- Act: executar.
- Assert: verificar.
- Casos:
- Happy path (normal).
- Edge cases (zero, negativo, muito grande).
- Error cases (tipo errado, valor inválido).
- Parametrize: 1 teste, muitos inputs.
pytest.raisespara excepções.match=para verificar mensagem.- 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:
- Unit tests (mostrados): funções isoladas.
- Integration tests: várias funções em conjunto.
- End-to-end tests: sistema completo.
- Performance tests: tempo e memória.
- Property-based tests (Hypothesis): gera inputs aleatórios.
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:
- REST conventions:
- GET (read), POST (create), PUT (update), DELETE.
- Status codes (200, 201, 204, 400, 404).
-
Resources como nomes (
/itemsnão/getItems). -
JSON I/O:
jsonify()para response.-
request.get_json()para body. -
Error handling:
abort()para erros.-
Custom error handlers para JSON consistente.
-
Validation:
- Body obrigatório.
-
Campos obrigatórios.
-
Type hints.
-
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: moderno, async, com docs automáticas (mais popular para novas APIs).
- Django REST Framework: integração com Django ORM.
- Bottle: micro-framework alternativo.
- Sanic: focado em async/performance.
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:
- 📊 Heatmap visualization (rich, matplotlib).
- 📈 Estatísticas avançadas (longest streak, monthly summary).
- 🔔 Notificações desktop (plyer).
- ☁️ Sync entre dispositivos (Dropbox, GitHub Gist).
- 📱 Versão mobile (Kivy).
- 🤖 Bot Telegram para registar.
- 🌐 Versão web (Flask/FastAPI).
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.