Empacotando e publicando pacotes Python no PyPI
1. Visão geral
Você escreveu um módulo Python útil e quer que outras pessoas o instalem via pip install. Para isso, você precisa empacotar o código como uma distribuição e enviá-lo para o PyPI (Python Package Index).
Este artigo percorre todo o processo. Você criará a estrutura padrão do projeto, declarará os metadados em pyproject.toml, construirá wheel e sdist, testará no TestPyPI e depois fará o release oficial. No final, mostrarei como automatizar a publicação com GitHub Actions usando Trusted Publishing.
2. Estrutura do projeto
O exemplo ao longo deste artigo é uma pequena biblioteca nonla-order-tools, usada para calcular o total de uma encomenda. Usaremos o src layout:
nonla-order-tools/
├── pyproject.toml
├── README.md
├── LICENSE
├── src/
│ └── nonla_order_tools/
│ ├── __init__.py
│ ├── pricing.py
│ └── cli.py
└── tests/
└── test_pricing.py
O código fica em src/ em vez de estar no diretório raiz. O principal benefício é que ao executar testes, Python não consegue importar acidentalmente o código do diretório de trabalho. Isso força a importação da versão instalada, ajudando você a detectar cedo erros por esquecer de empacotar um arquivo.
Há dois nomes a distinguir:
- Nome da distribuição no PyPI:
nonla-order-tools. É o nome que o usuário digita compip install. - Nome do pacote de importação:
nonla_order_tools. Usado emimport, não pode conter hífens.
2.1. Código da biblioteca
# src/nonla_order_tools/pricing.py
from dataclasses import dataclass, field
from decimal import Decimal
@dataclass(frozen=True)
class OrderLine:
product: str
unit_price: Decimal
quantity: int
@dataclass
class Order:
customer: str
lines: list[OrderLine] = field(default_factory=list)
def add(self, product: str, unit_price: str, quantity: int = 1) -> None:
if quantity <= 0:
raise ValueError("quantity must be positive")
self.lines.append(OrderLine(product, Decimal(unit_price), quantity))
def calculate_total(order: Order, vat_rate: Decimal = Decimal("0.10")) -> Decimal:
subtotal = sum((l.unit_price * l.quantity for l in order.lines), Decimal("0"))
return (subtotal * (1 + vat_rate)).quantize(Decimal("1"))
Order contém uma lista de OrderLine. calculate_total soma o valor de cada linha, multiplica pela taxa de IVA (padrão 10%) e arredonda para a unidade. O código usa Decimal em vez de float para evitar erros de arredondamento em cálculos financeiros.
# src/nonla_order_tools/__init__.py
from importlib.metadata import version
from .pricing import Order, OrderLine, calculate_total
__all__ = ["Order", "OrderLine", "calculate_total"]
__version__ = version("nonla-order-tools")
__version__ é lido a partir dos metadados do pacote instalado. Dessa forma, a versão é declarada apenas uma vez, em pyproject.toml.
Adicione um pequeno comando de linha:
# src/nonla_order_tools/cli.py
import sys
from decimal import Decimal
from .pricing import Order, calculate_total
def main() -> None:
# Uso: order-total <preço> <quantidade> [<preço> <quantidade> ...]
args = sys.argv[1:]
order = Order(customer="cli")
for price, qty in zip(args[::2], args[1::2]):
order.add("item", price, int(qty))
print(calculate_total(order, Decimal("0.10")))
3. Declarando metadados em pyproject.toml
pyproject.toml é o único lugar que descreve como fazer build e as informações do pacote. Você não precisa de setup.py para novos projetos.
[build-system]
requires = ["hatchling>=1.27"]
build-backend = "hatchling.build"
[project]
name = "nonla-order-tools"
version = "0.1.0"
description = "Calcular o total de uma encomenda com IVA"
readme = "README.md"
requires-python = ">=3.10"
license = "MIT"
license-files = ["LICENSE"]
authors = [{ name = "Nón Lá Dev", email = "dev@nonla.example" }]
keywords = ["order", "pricing", "vat"]
classifiers = [
"Programming Language :: Python :: 3",
"Operating System :: OS Independent",
]
dependencies = []
[project.optional-dependencies]
test = ["pytest>=8"]
[project.scripts]
order-total = "nonla_order_tools.cli:main"
Explicando cada seção:
[build-system]informa aopipebuildqual build backend usar. Aqui éhatchling. Você também pode usarsetuptools,flit-coreoupdm-backend. Os metadados em[project]permanecem os mesmos.nameeversionsão obrigatórios. A versão deve seguir o formatoMAJOR.MINOR.PATCH.readmese torna a descrição do projeto no PyPI.requires-pythonimpede a instalação em versões do Python não suportadas.license = "MIT"é uma expressão SPDX conforme o novo padrão. Este padrão requerhatchling>=1.27, então a versão mínima está clara acima.dependencieslista as bibliotecas obrigatórias em tempo de execução, por exemplo["httpx>=0.27"].optional-dependenciescria “extras”. Os usuários podem instalar compip install "nonla-order-tools[test]".[project.scripts]cria o comandoorder-totalnoPATHquando o pacote é instalado. O comando chama a funçãomainno módulocli.
Hatchling encontra automaticamente src/nonla_order_tools porque o nome do diretório corresponde ao nome do projeto normalizado (hífen convertido em underscore). Se os nomes forem diferentes, você precisa declarar manualmente:
[tool.hatch.build.targets.wheel]
packages = ["src/nonla_order_tools"]
4. Escrevendo testes e instalando em modo editável
Antes de publicar, certifique-se que o comportamento principal está correto:
# tests/test_pricing.py
from decimal import Decimal
import pytest
from nonla_order_tools import Order, calculate_total
def test_total_includes_vat():
order = Order(customer="Lan")
order.add("Café", "25000", 2)
order.add("Pão", "20000")
assert calculate_total(order) == Decimal("77000")
def test_rejects_non_positive_quantity():
order = Order(customer="Minh")
with pytest.raises(ValueError, match="positive"):
order.add("Chá", "15000", 0)
O primeiro teste calcula (25000 × 2 + 20000) × 1.1 = 77000. O segundo testa se quantidade zero é rejeitada.
Instale o projeto em modo editável e execute os testes:
python -m venv .venv
source .venv/bin/activate
pip install -e ".[test]"
pytest -q
.. [100%]
2 passed in 0.02s
A flag -e vincula o pacote instalado ao diretório src/. Quando você edita o código, as mudanças entram em efeito imediatamente, sem precisar reinstalar.
5. Construindo wheel e sdist
Instale a ferramenta build e execute-a no diretório raiz:
pip install --upgrade build twine
python -m build
* Creating isolated environment: venv+pip...
* Installing packages in isolated environment:
- hatchling>=1.27
* Building sdist...
* Building wheel from sdist
Successfully built nonla_order_tools-0.1.0.tar.gz and nonla_order_tools-0.1.0-py3-none-any.whl
O diretório dist/ agora contém dois arquivos:
- sdist (
.tar.gz): código-fonte com metadados. Usado quando não há wheel disponível. - wheel (
.whl): versão já construída. O sufixopy3-none-anysignifica que funciona em qualquer SO com Python 3, pois o pacote é Python puro.
Verifique se o wheel contém todos os arquivos necessários:
python -m zipfile -l dist/nonla_order_tools-0.1.0-py3-none-any.whl
File Name Modified Size
nonla_order_tools/__init__.py 2020-02-02 00:00:00 209
nonla_order_tools/cli.py 2020-02-02 00:00:00 363
nonla_order_tools/pricing.py 2020-02-02 00:00:00 786
nonla_order_tools-0.1.0.dist-info/METADATA 2020-02-02 00:00:00 1012
nonla_order_tools-0.1.0.dist-info/WHEEL 2020-02-02 00:00:00 87
nonla_order_tools-0.1.0.dist-info/entry_points.txt 2020-02-02 00:00:00 58
...
Os timestamps fixos são intencionais: hatchling cria builds reproduzíveis. O tamanho dos arquivos no seu computador pode variar ligeiramente.
A seguir, use twine para verificar os metadados, especialmente se o README renderiza corretamente:
twine check dist/*
Checking dist/nonla_order_tools-0.1.0-py3-none-any.whl: PASSED
Checking dist/nonla_order_tools-0.1.0.tar.gz: PASSED
O último passo antes de fazer upload é instalar o wheel em um ambiente limpo e testar a importação:
python -m venv /tmp/verify && /tmp/verify/bin/pip install dist/*.whl
/tmp/verify/bin/python -c "import nonla_order_tools as n; print(n.__version__)"
/tmp/verify/bin/order-total 25000 2 20000 1
0.1.0
77000
Se este passo falhar com ModuleNotFoundError, provavelmente você esqueceu de empacotar um arquivo ou declarou o caminho do pacote incorretamente.
6. Testando no TestPyPI primeiro
TestPyPI é uma cópia independente do PyPI, destinada apenas a testes. Os dois têm contas e tokens separados.
- Crie uma conta no TestPyPI e ative autenticação de dois fatores (2FA).
- Vá para Account settings → API tokens e crie um token. O token começa com
pypi-. - Faça o upload:
twine upload --repository testpypi dist/*
Quando solicitado, digite username __token__ e a senha é a string do token. Para evitar digitar novamente, você pode salvar em ~/.pypirc:
[testpypi]
username = __token__
password = pypi-AgENdGVzdC5weXBpLm9yZwIk...
[pypi]
username = __token__
password = pypi-AgEIcHlwaS5vcmcCJ...
Defina as permissões chmod 600 ~/.pypirc para que outros usuários na máquina não consigam ler o arquivo. Depois, instale para testar a partir do TestPyPI:
pip install \
--index-url https://test.pypi.org/simple/ \
--extra-index-url https://pypi.org/simple/ \
nonla-order-tools
A flag --extra-index-url permite que o pip obtenha dependências do PyPI real, já que TestPyPI geralmente não tem todos os pacotes de dependência.
7. Publicando no PyPI
Quando tudo estiver funcionando, o processo no PyPI real é idêntico, apenas remova --repository:
twine upload dist/*
Uploading distributions to https://upload.pypi.org/legacy/
Uploading nonla_order_tools-0.1.0-py3-none-any.whl
Uploading nonla_order_tools-0.1.0.tar.gz
View at:
https://pypi.org/project/nonla-order-tools/0.1.0/
Agora, qualquer pessoa pode executar pip install nonla-order-tools.
Para releases posteriores, você segue estes passos:
- Aumente a
versionempyproject.toml, por exemplo para0.1.1. - Delete o diretório antigo
dist/:rm -rf dist. - Execute novamente
python -m build,twine check, e depoistwine upload.
Nota: PyPI não permite fazer upload de um arquivo com o mesmo nome de versão já existente, mesmo que você tenha deletado o release anterior. Se você publicar uma versão com defeito por acidente, só pode yanked (desativá-la) e publicar uma nova versão. Esta é a razão para sempre testar no TestPyPI antes.
8. Automatizando a publicação com Trusted Publishing
Armazenar um token de longa duração na CI envolve risco de vazamento. Trusted Publishing elimina completamente os tokens. PyPI confia em um workflow específico do GitHub Actions através de OIDC e emite um token de curta duração para cada execução.
Configuração no PyPI: vá para Publishing do seu projeto, ou use “pending publisher” se o projeto ainda não existe. Aqui você declara o proprietário, o nome do repositório, o nome do arquivo workflow publish.yml e o ambiente pypi.
# .github/workflows/publish.yml
name: Publish to PyPI
on:
release:
types: [published]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: python -m pip install build
- run: python -m build
- uses: actions/upload-artifact@v4
with:
name: dist
path: dist/
publish:
needs: build
runs-on: ubuntu-latest
environment: pypi
permissions:
id-token: write
steps:
- uses: actions/download-artifact@v4
with:
name: dist
path: dist/
- uses: pypa/gh-action-pypi-publish@release/v1
Como o workflow funciona:
- O workflow é executado sempre que você cria um GitHub Release.
- O job
buildcria arquivos emdist/e os armazena como artifact. - O job
publishé separado e tem apenas permissãoid-token: write. Esta permissão permite solicitar um token OIDC, que a actionpypa/gh-action-pypi-publishusa para fazer upload. Você não precisa de nenhum secret. - Separar os dois jobs garante que o step de build, que executa seu código e dependências de build, nunca tenha acesso às permissões de publicação.
9. Erros comuns
- Nome já foi utilizado: PyPI retorna
403ouThe name is too similar to an existing project. Verifique o nome antes de definir e considere adicionar um prefixo próprio comononla-. File already exists: você esqueceu de aumentar a versão, ou o diretóriodist/contém um arquivo antigo. Sempre deletedist/antes de fazer build.- Arquivos de dados faltando (JSON, templates…): se um arquivo não está no diretório do pacote, não será incluído no empacotamento. Coloque-os em
src/nonla_order_tools/e leia usandoimportlib.resources. - Testes passam localmente mas import falha após instalar: este é exatamente o erro que o src layout o ajuda a evitar. Sempre teste instalando o wheel em um venv limpo como mostrado na seção 5.
- Dependências declaradas muito rigidamente: uma biblioteca não deve fixar versões como
requests==2.32.3, pois causará conflitos com os projetos dos usuários. Apenas defina um limite mínimo, comorequests>=2.31. Versões fixadas são apropriadas apenas para aplicações, e devem usar um lock file. - Token vazado: nunca commite
~/.pypircou tokens no repositório. Use tokens limitados por projeto, ou melhor ainda, use Trusted Publishing.
Nota: Nem todo código deve ir para PyPI. Bibliotecas internas de empresa devem usar private indexes (como Artifactory, GitLab Package Registry) ou instalar diretamente do Git. PyPI é um espaço público, e nomes já registrados são muito difíceis de recuperar.
10. Conclusão
O processo moderno de empacotar Python é bem simples. Tudo gira em torno de um arquivo pyproject.toml, um build backend como hatchling, o comando python -m build para criar wheel e sdist, e twine para verificar e fazer upload.
Você deve estabelecer o hábito de seguir essa ordem: executar testes, fazer build, instalar em um venv limpo, testar no TestPyPI e depois publicar no PyPI. Quando o projeto estiver estável, migre para Trusted Publishing com GitHub Actions. A partir daí, cada publicação é apenas criar um Release, e você não precisa mais gerenciar tokens.