Módulos, Pacotes e Organização de Projetos

Módulos, Pacotes e Organização de Projetos

Módulos, pacotes, ambientes virtuais e a estrutura de um projeto Python profissional. Com três correções do que muito material ainda ensina: o __init__.py deixou de ser obrigatório, o comando do interpretador não se chama igual em todo sistema, e o pip freeze registra demais.
Python

• • 22 min de leitura

À medida que um programa cresce, colocar tudo em um único arquivo se torna insustentável. Módulos e pacotes são o mecanismo do Python para dividir código em unidades organizadas, reutilizáveis e independentes. Entender como o Python resolve importações e como estruturar um projeto profissional é essencial para qualquer desenvolvedor que queira além de scripts simples.

O que é um Módulo?

Um módulo é simplesmente um arquivo .py. Qualquer arquivo Python é automaticamente um módulo e pode ser importado por outros arquivos.

# arquivo: matematica.py

PI = 3.14159265358979

def area_circulo(raio):
    """Calcula a área de um círculo."""
    return PI * raio ** 2

def area_retangulo(largura, altura):
    """Calcula a área de um retângulo."""
    return largura * altura

def mdc(a, b):
    """Máximo divisor comum — algoritmo de Euclides."""
    while b:
        a, b = b, a % b
    return a
# arquivo: main.py

import matematica

print(matematica.PI)
print(matematica.area_circulo(5))
print(matematica.mdc(48, 18))

Formas de Importação

# Importa o módulo inteiro — acesso via nome do módulo
import matematica
print(matematica.PI)

# Importa com alias — útil para nomes longos
import matematica as mat
print(mat.area_circulo(3))

# Importa nomes específicos — acesso direto
from matematica import PI, area_circulo
print(PI)
print(area_circulo(3))

# Importa tudo — evite, polui o namespace
from matematica import *

# Módulos da biblioteca padrão
import os
import sys
import json
from datetime import datetime, timedelta
from pathlib import Path
from collections import defaultdict, Counter

O Atributo name

Cada módulo tem um atributo __name__. Quando executado diretamente, vale "__main__"; quando importado, vale o nome do arquivo:

# arquivo: utilitarios.py

def somar(a, b):
    return a + b

def subtrair(a, b):
    return a - b

if __name__ == "__main__":
    # Este bloco só executa quando o arquivo é rodado diretamente
    # Não executa quando o módulo é importado
    print("Testando utilitarios.py")
    print(somar(3, 4))      # 7
    print(subtrair(10, 3))  # 7

Esse padrão é fundamental — permite que um arquivo funcione tanto como módulo reutilizável quanto como script independente.

Pacotes

Um pacote é um diretório que contém módulos e, por convenção, um arquivo __init__.py. Esse arquivo pode estar vazio ou conter código de inicialização do pacote.

Uma correção que vale fazer logo: o __init__.py deixou de ser obrigatório no Python 3.3, com a introdução dos pacotes de espaço de nomes. Um diretório sem ele é importável do mesmo jeito, e quem aprendeu que o arquivo é o que define um pacote acaba perdendo tempo procurando o motivo de algo funcionar sem ele.

Ainda assim, criá-lo continua sendo a recomendação, por três razões práticas. Ele dá um lugar para expor a interface pública do pacote, reunindo num ponto só os nomes que valem a pena importar de fora. Ele torna o pacote regular, o que evita um comportamento surpreendente dos pacotes de espaço de nomes: como eles podem se espalhar por vários diretórios do sys.path, um erro de digitação no nome de uma pasta produz um pacote vazio e importável em vez de um ModuleNotFoundError. E ferramentas de empacotamento e de descoberta de testes ainda tratam os dois casos de forma diferente. A regra prática: use pacote de espaço de nomes quando quiser mesmo dividir um pacote entre distribuições separadas, e __init__.py em todo o resto.

meu_projeto/
├── __init__.py
├── matematica/
│   ├── __init__.py
│   ├── basica.py
│   ├── geometria.py
│   └── estatistica.py
├── utils/
│   ├── __init__.py
│   ├── arquivos.py
│   └── strings.py
└── main.py
# matematica/geometria.py
def area_circulo(raio):
    import math
    return math.pi * raio ** 2

def volume_esfera(raio):
    import math
    return (4/3) * math.pi * raio ** 3
# matematica/__init__.py
from .basica    import somar, subtrair, mdc
from .geometria import area_circulo, volume_esfera

__all__ = ["somar", "subtrair", "mdc", "area_circulo", "volume_esfera"]
# main.py
from matematica import area_circulo, somar
from matematica.geometria import volume_esfera

print(area_circulo(5))
print(volume_esfera(3))
print(somar(2, 3))

Importações Relativas

Dentro de um pacote, use importações relativas para referenciar módulos do mesmo pacote:

# matematica/estatistica.py

from .basica import somar          # importa do mesmo pacote
from ..utils.strings import formatar  # importa do pacote pai

def media(valores):
    return somar(*valores) / len(valores)

. refere-se ao pacote atual; .. refere-se ao pacote pai.

O que decide até onde o .. pode subir não é a estrutura de pastas em disco: é como o código foi importado. O mesmo arquivo funciona ou falha conforme o ponto de entrada.

# de fora de meu_projeto/, com o pacote inteiro visível:
python -c "from meu_projeto.matematica.estatistica import media"
# funciona

# de dentro de meu_projeto/, onde matematica passa a ser o topo:
python -c "from matematica.estatistica import media"
# ImportError: attempted relative import beyond top-level package

No segundo caso o .. tentaria sair do pacote mais alto que o Python conhece, e não existe nada acima disso — o interpretador não sobe para o sistema de arquivos à procura. É a causa mais comum de importação relativa que funcionava ontem: ninguém mexeu no código, mudou-se o diretório de onde o programa é chamado. Daí duas práticas que evitam o problema. Execute módulos de dentro de pacotes com python -m meu_projeto.matematica.estatistica, e não com python caminho/para/estatistica.py, porque a segunda forma põe o arquivo como topo e desmonta a hierarquia. E prefira importação absoluta — from meu_projeto.utils.strings import formatar — em qualquer travessia que suba de pacote, como a PEP 8 recomenda: é mais longa, e é a que não depende de onde o programa foi iniciado.

Ambiente Virtual

Antes de instalar dependências externas, crie sempre um ambiente virtual — um espaço isolado que mantém as dependências de cada projeto separadas:

# Criar ambiente virtual — Linux/macOS
python3 -m venv venv

# Criar ambiente virtual — Windows
py -m venv venv

# Ativar — Linux/macOS
source venv/bin/activate

# Ativar — Windows
venv\Scripts\activate

# Desativar
deactivate

O nome do comando é a primeira pedra do caminho, e varia. No Windows, python3 normalmente não existe — há o py, que é o lançador oficial e aceita escolher a versão com py -3.12. Em muitas distribuições Linux e no macOS existe só python3, porque python foi reservado para a versão 2 por anos. E há ambientes em que ocorre o inverso, com apenas python apontando para uma versão 3 recente. Antes de copiar qualquer comando, confirme com python --version e python3 --version qual dos dois responde, e o quê.

Uma vez ativado o ambiente, o problema desaparece: dentro dele, python e pip apontam para o ambiente virtual em qualquer sistema. Vale ainda saber que python -m pip install ... é mais seguro que pip install ..., porque garante que o pip usado é o do interpretador que se pretende — num computador com várias versões instaladas, o pip solto no PATH pode ser de outra.

Com o ambiente ativo, instale pacotes com pip:

pip install requests
pip install pandas numpy matplotlib
pip install fastapi uvicorn

Gerenciamento de Dependências

# Gerar arquivo de dependências
pip freeze > requirements.txt

# Instalar dependências de um arquivo
pip install -r requirements.txt

# Ver pacotes instalados
pip list

# Informações sobre um pacote
pip show requests

Vale uma ressalva sobre o pip freeze, porque ele é menos útil do que parece: ele registra tudo o que está instalado, sem distinguir o que o projeto pediu do que veio a reboque. Pedir pip install fastapi instala mais de uma dezena de pacotes, e o freeze lista os doze como se fossem decisões do projeto. O arquivo resultante é difícil de manter — ninguém sabe quais linhas podem sair —, e costuma ficar poluído com o que alguém instalou para um teste e esqueceu.

A prática que envelhece melhor é manter à mão um arquivo com as dependências diretas, apenas o que o código realmente importa, e deixar o resolvedor cuidar do resto. Para isso o caminho moderno é declarar as dependências no pyproject.toml, adiante nesta mesma aula, e gerar o arquivo travado a partir dele com ferramentas como pip-tools, uv ou Poetry — assim existem os dois arquivos, cada um com seu papel: um legível, que as pessoas editam, e um completo, que garante que a instalação seja idêntica em toda máquina.

Conteúdo típico de requirements.txt:

requests==2.31.0
pandas==2.1.0
numpy==1.26.0
fastapi==0.104.0
uvicorn==0.24.0

Estrutura de Projeto Profissional

Uma estrutura bem organizada facilita testes, manutenção e colaboração:

meu_projeto/
├── src/
│   └── meu_projeto/
│       ├── __init__.py
│       ├── modelos/
│       │   ├── __init__.py
│       │   ├── usuario.py
│       │   └── produto.py
│       ├── servicos/
│       │   ├── __init__.py
│       │   ├── autenticacao.py
│       │   └── email.py
│       ├── repositorios/
│       │   ├── __init__.py
│       │   └── usuario_repo.py
│       └── utils/
│           ├── __init__.py
│           └── validacao.py
├── tests/
│   ├── __init__.py
│   ├── test_modelos.py
│   └── test_servicos.py
├── docs/
├── .env
├── .gitignore
├── pyproject.toml
├── requirements.txt
└── README.md

pyproject.toml

O arquivo moderno de configuração de projetos Python, substituindo o antigo setup.py:

[build-system]
requires = ["setuptools>=68", "wheel"]
build-backend = "setuptools.backends.legacy:build"

[project]
name = "meu-projeto"
version = "0.1.0"
description = "Descrição do projeto"
requires-python = ">=3.11"
dependencies = [
    "requests>=2.31.0",
    "fastapi>=0.104.0",
]

[project.optional-dependencies]
dev = [
    "pytest>=7.4.0",
    "black>=23.0.0",
    "ruff>=0.1.0",
]

[tool.black]
line-length = 88

[tool.ruff]
line-length = 88
select = ["E", "F", "I"]

O Sistema de Importação por Dentro

Quando você escreve import matematica, Python executa os seguintes passos:

  1. Verifica sys.modules — se já importado, usa o cache
  2. Busca o módulo na ordem: módulos embutidos → sys.path
  3. Carrega e executa o arquivo .py
  4. Armazena em sys.modules
import sys

# Ver onde Python busca módulos
for caminho in sys.path:
    print(caminho)

# Ver módulos já importados
print("json" in sys.modules)   # True se json foi importado

# Adicionar caminho dinamicamente (evite em produção)
sys.path.insert(0, "/caminho/para/meus/modulos")

Exemplo Completo: Pacote de Utilitários

# utils/validacao.py

import re
from dataclasses import dataclass
from typing import List


@dataclass
class ResultadoValidacao:
    valido:  bool
    erros:   List[str]

    def __bool__(self):
        return self.valido


def validar_email(email: str) -> ResultadoValidacao:
    erros = []
    if not email:
        erros.append("E-mail não pode ser vazio.")
    elif not re.match(r"^[\w.+-]+@[\w-]+\.[a-zA-Z]{2,}$", email):
        erros.append("Formato de e-mail inválido.")
    return ResultadoValidacao(valido=not erros, erros=erros)


def validar_senha(senha: str) -> ResultadoValidacao:
    erros = []
    if len(senha) < 8:
        erros.append("Senha deve ter ao menos 8 caracteres.")
    if not re.search(r"[A-Z]", senha):
        erros.append("Senha deve conter ao menos uma letra maiúscula.")
    if not re.search(r"\d", senha):
        erros.append("Senha deve conter ao menos um número.")
    if not re.search(r"[!@#$%^&*]", senha):
        erros.append("Senha deve conter ao menos um caractere especial.")
    return ResultadoValidacao(valido=not erros, erros=erros)


def validar_cadastro(dados: dict) -> ResultadoValidacao:
    todos_erros = []

    email_result = validar_email(dados.get("email", ""))
    if not email_result:
        todos_erros.extend(email_result.erros)

    senha_result = validar_senha(dados.get("senha", ""))
    if not senha_result:
        todos_erros.extend(senha_result.erros)

    return ResultadoValidacao(valido=not todos_erros, erros=todos_erros)


# utils/__init__.py expõe a interface pública do pacote
# from .validacao import validar_email, validar_senha, validar_cadastro
# main.py
from utils.validacao import validar_cadastro

cadastros = [
    {"email": "ana@email.com",  "senha": "Segura@123"},
    {"email": "invalido",       "senha": "fraca"},
    {"email": "bruno@email.com","senha": "SemEspecial1"},
]

for dados in cadastros:
    resultado = validar_cadastro(dados)
    if resultado:
        print(f"[OK] {dados['email']} — cadastro válido")
    else:
        print(f"[ERRO] {dados['email']}:")
        for erro in resultado.erros:
            print(f"  - {erro}")

Módulo é qualquer arquivo .py e pacote é um diretório de módulos — a mecânica é simples, e o que costuma dar trabalho é o sistema de importação em volta dela. Vale reter três correções sobre o que muito material ainda ensina. O __init__.py deixou de ser obrigatório no Python 3.3, embora continue sendo recomendado, porque dá um lugar para a interface pública do pacote e evita que um erro de digitação em nome de pasta produza um pacote vazio e importável. O comando do interpretador não se chama igual em todo lugar: no Windows costuma ser py, em muitos Linux só existe python3, e conferir antes de copiar comando economiza a primeira meia hora. E o pip freeze registra tudo o que está instalado, sem distinguir o que o projeto pediu do que veio a reboque — o arquivo legível de dependências diretas é outro, e hoje mora no pyproject.toml.

A armadilha que mais custa tempo, porém, é a importação relativa. O alcance do .. não é determinado pela estrutura de pastas, e sim por como o programa foi iniciado: o mesmo arquivo importa sem problema quando o pacote de cima está visível e falha com attempted relative import beyond top-level package quando alguém o executa de dentro do diretório. É por isso que módulos dentro de pacotes se executam com python -m pacote.modulo, e não apontando para o arquivo, e é por isso que a PEP 8 prefere importação absoluta em qualquer travessia que suba de nível — mais longa de escrever, e independente de onde o programa foi chamado.

Fontes e leituras recomendadas

Exercícios

Exercício 1

Um módulo dentro de um pacote usa from ..utils.strings import formatar e funciona há meses. Um colega abre o terminal dentro da pasta do pacote e roda python estatistica.py para testar rapidamente uma função — e recebe ImportError: attempted relative import beyond top-level package. Nada no código mudou. Explique.

Ver resposta

✓ Resposta: O alcance de uma importação relativa não é decidido pela estrutura de pastas em disco, e sim pelo pacote ao qual o módulo pertence no momento da execução, que o Python guarda no atributo __package__. Quando o programa entra pelo pacote de cima, o módulo sabe que é meu_projeto.matematica.estatistica, e o .. sobe um nível até meu_projeto, onde utils existe. Ao executar o arquivo diretamente com python estatistica.py, a situação muda por completo: o Python trata aquele arquivo como um script de nível superior, com __name__ igual a "__main__" e sem pacote nenhum. O .. passa a tentar subir acima do topo, e não existe nada acima — o interpretador não sai procurando pastas no sistema de arquivos para descobrir que aquele diretório é parte de um projeto maior. Daí a mensagem. O mesmo acontece, de forma mais sutil, quando alguém entra na pasta meu_projeto e importa matematica.estatistica: aí matematica vira o topo, e o .. quebra de novo. A forma correta de executar um módulo que vive dentro de um pacote é python -m meu_projeto.matematica.estatistica, executada da pasta que contém meu_projeto. O -m importa pelo nome completo, preservando a hierarquia, e ainda ajusta o sys.path de forma adequada. A recomendação de fundo, porém, vai além do comando: a PEP 8 prefere importação absoluta, e a razão é exatamente esta — from meu_projeto.utils.strings import formatar é mais longa de escrever e não depende de onde o programa foi iniciado. Importação relativa é conveniente dentro de um pacote coeso e para movimentação lateral, com um ponto só; qualquer travessia que suba de nível é candidata a virar absoluta.

Exercício 2

Uma equipe mantém requirements.txt gerado com pip freeze. O arquivo tem 87 linhas, o projeto importa 6 bibliotecas, e ninguém sabe dizer quais linhas podem ser removidas. Além disso, uma atualização de segurança de uma dependência exigiu editar o arquivo à mão e quebrou a instalação. Explique a causa e descreva um fluxo melhor.

Ver resposta

✓ Resposta: O pip freeze lista tudo o que está instalado no ambiente, sem distinguir o que o projeto pediu do que veio junto. As 6 bibliotecas importadas trouxeram dezenas de dependências transitivas, e o arquivo as registra todas no mesmo nível, como se cada uma fosse uma decisão da equipe. Some-se o que alguém instalou para um teste e esqueceu de remover, e chega-se às 87 linhas que ninguém sabe interpretar — a informação de quem pediu o quê nunca foi gravada, e não há como recuperá-la lendo o arquivo. O segundo problema decorre do primeiro: editar à mão a versão de uma dependência transitiva desmonta o conjunto que o resolvedor havia calculado, porque as versões travadas foram escolhidas para serem compatíveis entre si. Subir uma sem recalcular as outras produz combinações que o pip aceita instalar e que quebram na importação. O fluxo melhor separa dois arquivos com papéis distintos. O primeiro é escrito por pessoas e lista apenas as dependências diretas, com restrições de versão frouxas — hoje o lugar dele é a seção dependencies do pyproject.toml. O segundo é gerado por ferramenta e trava todas as versões, incluindo as transitivas, para que a instalação seja idêntica em qualquer máquina; ferramentas como pip-tools, uv e Poetry produzem esse arquivo a partir do primeiro. A atualização de segurança passa então a ser feita no arquivo legível, ou por um comando de atualização, e o arquivo travado é regenerado — nunca editado. Vale acrescentar duas práticas que o cenário pede: o arquivo travado deve ser versionado junto do código, para que a instalação de hoje seja reproduzível daqui a um ano, e as dependências de desenvolvimento — teste, formatador, verificador de tipos — pertencem a um grupo separado, para não irem parar no ambiente de produção.

Exercício 3

Um desenvolvedor cria a pasta utilitarios/ com alguns módulos e esquece o __init__.py. Para surpresa dele, from utilitarios.strings import formatar funciona normalmente. Explique por quê, e diga em que situação essa mesma ausência produz um comportamento difícil de diagnosticar.

Ver resposta

✓ Resposta: Funciona porque o __init__.py deixou de ser obrigatório no Python 3.3, quando foram introduzidos os pacotes de espaço de nomes. Um diretório sem esse arquivo continua sendo importável; ele apenas passa a ser um pacote de outro tipo. A regra que muita gente aprendeu — pacote é a pasta que tem __init__.py — descreve o comportamento do Python 2 e da primeira metade do Python 3, e é a origem da surpresa. A diferença entre os dois tipos é o que cria o problema difícil de diagnosticar. Um pacote regular, com __init__.py, mora num diretório só. Um pacote de espaço de nomes pode se espalhar por vários diretórios do sys.path, e essa é justamente a finalidade dele: permitir que partes de um mesmo pacote sejam distribuídas separadamente. A consequência colateral é que qualquer diretório com o nome procurado passa a valer como pacote, mesmo vazio. Se alguém digitar utilitarios com erro em algum lugar, ou se existir uma pasta homônima em outro ponto do sys.path — uma pasta de dados, um diretório de build, uma sobra de refatoração —, o Python encontra o nome, monta um pacote vazio e a importação do módulo interno falha com ModuleNotFoundError apontando para o submódulo, não para a pasta errada que foi encontrada primeiro. O erro descreve o sintoma, e a causa é a pasta que ninguém suspeita. Há ainda dois efeitos menores: ferramentas de descoberta de testes e de empacotamento tratam os dois tipos de forma diferente, e algumas simplesmente não encontram pacotes de espaço de nomes sem configuração extra. A recomendação prática, portanto, não mudou: crie o __init__.py, ainda que vazio, em todo pacote que não precise ser dividido entre distribuições — ele torna o pacote regular, dá um lugar para expor a interface pública, e elimina essa classe de ambiguidade.

Exercício 4

Um tutorial manda rodar python3 -m venv venv. Uma pessoa no Windows recebe Python não foi encontrado; outra, num servidor Linux antigo, cria o ambiente e descobre depois que ele tem Python 3.6. Explique os dois casos e diga o que verificar antes de criar um ambiente virtual.

Ver resposta

✓ Resposta: Os dois casos vêm do mesmo mal-entendido: o nome do comando não é padronizado, e não diz qual versão vai responder. No Windows, a instalação oficial não cria um executável chamado python3. Existe python e existe o lançador py, que é o recomendado por permitir escolher a versão explicitamente com py -3.12 -m venv venv. Há ainda um detalhe que produz a mensagem citada: versões recentes do Windows trazem um atalho falso de python que apenas abre a loja de aplicativos, de modo que o comando existe e não faz nada de útil. No Linux, o inverso: por anos python apontou para a versão 2, e as distribuições padronizaram python3 para a 3 — mas python3 é apenas um apelido para a versão 3 que a distribuição instalou, que num servidor antigo pode ser a 3.6, sem match, sem tomllib, sem as melhorias de mensagem de erro. O comando funcionou; a versão é que não é a esperada. O que verificar antes, então, são duas coisas, e ambas em uma linha cada: qual comando responde e qual versão ele traz, com python --version e python3 --version, ou py --list no Windows para ver todas as instaladas. Havendo várias versões, crie o ambiente a partir da que se quer, chamando-a explicitamente: python3.12 -m venv venv ou py -3.12 -m venv venv. Depois de ativar o ambiente o problema desaparece, porque dentro dele python e pip apontam para o interpretador escolhido, em qualquer sistema — e é por isso que, uma vez ativado, os comandos dos tutoriais voltam a funcionar como escritos. Vale a prática complementar de declarar a versão exigida no pyproject.toml, com requires-python, para que a instalação recuse um interpretador velho em vez de falhar de forma obscura mais adiante.

Exercício 5

Explique o que o bloco if __name__ == "__main__": faz e por que ele é necessário. Em seguida, analise por que colocar toda a lógica de um script dentro desse bloco, em vez de organizá-la em funções, atrapalha o teste e o reaproveitamento.

Ver resposta

✓ Resposta: Todo módulo tem um atributo __name__, e o Python o preenche de forma diferente conforme o caminho de entrada: quando o arquivo é executado diretamente, __name__ vale "__main__"; quando é importado, vale o nome do módulo. A comparação, portanto, responde à pergunta este arquivo está sendo rodado ou está sendo importado?. Ela é necessária porque importar um módulo executa o corpo dele inteiro, de cima a baixo — é assim que as funções e classes passam a existir. Sem o bloco, qualquer código solto no nível do arquivo roda também na importação: um teste que importa o módulo para usar uma função dispara o script completo, uma ferramenta de documentação que inspeciona o arquivo executa o programa, e num projeto com multiprocessamento no Windows o efeito é uma recursão de processos, porque cada processo filho reimporta o módulo principal. Sobre a segunda parte: pôr toda a lógica dentro do bloco resolve o problema da importação acidental e cria outros dois. O primeiro é de teste — nada do que está ali dentro é alcançável de fora, já que o bloco só executa na execução direta, de modo que não há função nenhuma para chamar de um teste. A única forma de testar passa a ser rodar o script inteiro e inspecionar a saída, o que é lento, frágil e incapaz de exercitar casos específicos. O segundo é de reaproveitamento: quando outro programa precisar de um pedaço daquela lógica, não há pedaço a importar, e a saída costuma ser copiar o código, que é como dois trechos quase iguais passam a existir e divergir. A organização que resolve os dois é conhecida: a lógica vive em funções nomeadas, uma função main() orquestra e trata os argumentos da linha de comando, e o bloco if __name__ == "__main__": fica com uma única linha, main(). Assim o arquivo continua executável como script, cada parte é importável e testável em isolamento, e o ponto de entrada fica explícito para quem lê.

Comentários

Mais em Python

Laços de Repetição: for e while
Laços de Repetição: for e while

O for do Python não conta: percorre. A diferença parece cosmética e muda o…

Dicionários: chave, valor e as estruturas do mundo real
Dicionários: chave, valor e as estruturas do mundo real

Dicionários trocam posição por significado e são a estrutura mais usada do…

Estruturas de Controle: if, elif e else
Estruturas de Controle: if, elif e else

Decidir é o que separa um script de um programa. O if, o elif e o else, a…