pyproject.toml, lockfiles y la estructura src/ — así se organiza un proyecto moderno
De setup.py a pyproject.toml, compilaciones reproducibles con uv.lock, __init__.py y la estructura src/ — y cómo convertir una carpeta plana de scripts en un paquete real con un comando.
¿Cómo quieres aprender?
El problema que vamos a resolver
El trabajo de clasificación de tickets empezó como unos cuantos scripts en una carpeta: clean.py, score.py, run_triage.py. La persona autora se pone dentro de la carpeta, ejecuta python run_triage.py y todo funciona. Luego, a lo largo de tres meses:
- Un compañero puso un script de informes en una subcarpeta
scripts/, y falló conModuleNotFoundError: No module named 'score'— aunquescore.pyestá justo ahí, en la carpeta de al lado. - Alguien creó un archivo llamado
secrets.pyen la carpeta para guardar una clave de API, y de repente el módulosecretsde Python se "rompió" en otro script. - Se incorporó una persona nueva; el viejo
setup.pyy elrequirements.txtlistaban los paquetes de dos formas distintas, y nadie sabía cuál era la correcta.
No hay ningún bug en el código; el problema está en la estructura. En este capítulo convertiremos esta carpeta en un paquete de Python de verdad — con un pyproject.toml, un lockfile (archivo de bloqueo) y una estructura src/ —, que es como se organiza casi cualquier repositorio moderno de Python en una empresa.
Al terminar este capítulo podrás
- Explicar por qué Python pasó de
setup.pyapyproject.toml, y leer las secciones principales de unpyproject.toml - Explicar qué fija
uv.lock, y usaruv lock,uv syncyuv sync --lockeden el momento adecuado - Crear un proyecto con estructura
src/conuv init --packagey--lib, y explicar qué hace__init__.py - Reconocer las dos trampas de importación de una carpeta plana, y mostrar por qué la estructura
src/las detecta pronto - Convertir una carpeta plana de scripts en un paquete con un punto de entrada de línea de comandos
Requisitos previos: Git, ramas y Pull Requests, además de uv init, uv add y uv run del capítulo anterior.
De setup.py a pyproject.toml
En proyectos de Python más antiguos verás a menudo un archivo como este:
from setuptools import find_packages, setup
setup(
name="ticket-triage",
version="0.1.0",
packages=find_packages(),
install_requires=["httpx"],
entry_points={"console_scripts": ["triage=ticket_triage.cli:main"]},
)Y junto a él un requirements.txt, a menudo con una lista distinta. El problema de fondo de setup.py es que es un programa: para saber siquiera el nombre del proyecto o sus dependencias hay que ejecutarlo, y como es Python, cualquiera puede meter lo que quiera dentro — leer archivos, condiciones, incluso llamadas a la red. Las herramientas nunca podían afirmar nada sobre él con certeza.
pyproject.toml contiene la misma información como una declaración — un archivo TOML sencillo que se lee, nunca se ejecuta. Es ya el estándar de Python, y uv, los editores, las herramientas de test y los linters lo leen. Esto es lo que genera uv init --package ticket-triage:
[project]
name = "ticket-triage"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
authors = [
{ name = "Nadia Karim", email = "[email protected]" }
]
requires-python = ">=3.12"
dependencies = []
[project.scripts]
ticket-triage = "ticket_triage:main"
[build-system]
requires = ["uv_build>=0.9.1,<0.10.0"]
build-backend = "uv_build"(uv toma el nombre y el correo del autor de tu configuración de Git.) Tres secciones:
[project]— la identidad del proyecto y sus dependencias.uv addgestiona por ti la listadependencies.[project.scripts]— comandos de línea de comandos.ticket-triage = "ticket_triage:main"significa: crea un comando llamadoticket-triageque llama a la funciónmaindel paqueteticket_triage.[build-system]— la herramienta que convierte el proyecto en un paquete instalable (el "build backend").uvusa su propiouv_build; en otros proyectos puede que veashatchlingosetuptools— la misma tarea.
Fíjate en que el proyecto se llama ticket-triage (con guion) pero el paquete de Python es ticket_triage (con guion bajo). En los nombres de Python no se permiten guiones — import ticket-triage sería una resta — así que uv lo convierte por ti.
Lockfiles: de "funcionaba" a "siempre funciona igual"
En pyproject.toml, las dependencias se escriben como rangos: "httpx>=0.28.1" significa 0.28.1 o cualquier versión posterior. Es deliberado — estás diciendo qué versiones funcionarán. Pero instalar hoy e instalar dentro de tres meses puede traer dos versiones distintas, por no hablar de las dependencias de esas dependencias.
uv.lock cierra esa brecha. Registra la versión exacta y los hashes de los archivos de cada paquete — incluidos los que están más abajo en la cadena. Si está en el repositorio, cada máquina, el CI y el servidor de producción instalan exactamente lo mismo. Eso es una "compilación reproducible".
Cuatro comandos, cuatro funciones:
uv lock— leepyproject.tomly crea o actualiza el lockfile. No instala nada.uv sync— hace que.venvcoincida exactamente con el lockfile; actualiza antes el lockfile si hace falta.uv sync --locked— lo mismo, pero si el lockfile está desactualizado se detiene en vez de actualizarlo.uv lock --check— no cambia nada; solo informa de si el lockfile coincide conpyproject.toml.
uv add y uv remove actualizan ambos archivos a la vez, así que en el trabajo normal no necesitas llamar a estos por separado. El problema aparece cuando alguien edita pyproject.toml a mano. Supongamos que alguien cambia dependencies = [] por dependencies = ["httpx>=0.28"] a mano y no ejecuta uv lock:
uv sync --locked
Resolved 8 packages in 57ms
The lockfile at `uv.lock` needs to be updated, but `--locked` was provided. To update the lockfile, run `uv lock`.Por eso el CI siempre ejecuta uv sync --locked: así ninguna versión sin revisar puede colarse en silencio.
Unuv syncnormal no da error en este caso. Si lo ejecutas sin--lockeden la misma situación,uvvuelve a resolver las versiones en silencio, reescribeuv.lockpor su cuenta y sigue adelante. En tu propia máquina es cómodo; en el CI significa que se está probando algo distinto de lo que hay en el repositorio.
Paquetes, __init__.py y la estructura src/
Un módulo es un archivo .py. Un paquete es una carpeta de módulos con un __init__.py dentro. __init__.py cumple dos funciones: marca la carpeta como paquete importable, y es el archivo que se ejecuta primero cuando escribes import ticket_triage — así que ahí puede vivir lo que quieras presentar como la "puerta de entrada" del paquete.
Tres formas de uv init:
uv init— un proyecto de script:main.pyypyproject.toml, sin sistema de build. Para trabajos de uno o dos archivos y experimentos.uv init --package— un paquete de aplicación: estructurasrc/, un sistema de build y un comando en[project.scripts].uv init --lib— una biblioteca que otros importarán: estructurasrc/y sistema de build, sin comando, más un archivopy.typedvacío que indica a los verificadores de tipos que el paquete incluye información de tipos.
La estructura que genera uv init --package ticket-triage:
ticket-triage/
├── .git/
├── .gitignore
├── .python-version
├── README.md
├── pyproject.toml
└── src/
└── ticket_triage/
└── __init__.pyEl paquete no está en la raíz del proyecto, sino dentro de una carpeta src/. Para entender por qué existe ese nivel extra, primero mira qué sale mal en una carpeta plana.
Dos trampas en una carpeta plana
Trampa uno: dónde busca Python. Cuando ejecutas un script, Python busca primero los imports en la carpeta donde está el script — no en la carpeta en la que estás parado en la terminal. En la carpeta plana, from score import priority funciona solo porque score.py y el script comparten carpeta. El scripts/report.py del compañero:
from score import priority
print(priority("Database breach reported"))Desde dentro de la carpeta triage-scripts y ejecutando python scripts/report.py:
Traceback (most recent call last):
File "scripts\report.py", line 1, in <module>
from score import priority
ModuleNotFoundError: No module named 'score'(En tu máquina el traceback muestra la ruta completa; aquí se ha acortado.) Python buscó en scripts/, y allí no hay ningún score.py. Que un import funcione depende de dónde esté el archivo — pocas reglas son tan frágiles para un proyecto.
Trampa dos: colisiones de nombres. Por la misma regla, los archivos de la carpeta del script se buscan antes que los módulos propios de Python. Alguien crea secrets.py en la carpeta para guardar una clave, y el new_id.py que está al lado quiere el módulo estándar secrets:
import secrets
print(len(secrets.token_hex(8)))Traceback (most recent call last):
File "new_id.py", line 3, in <module>
print(len(secrets.token_hex(8)))
^^^^^^^^^^^^^^^^^
AttributeError: module 'secrets' has no attribute 'token_hex'import secrets no dio error — encontró el secrets.py del proyecto y lo importó. El error llega después, cuando el código pide algo que ese archivo no tiene. En proyectos de IA esto pasa a menudo con archivos experimentales llamados openai.py, anthropic.py o httpx.py, que ocultan justo la biblioteca que intentas usar.
Cómo la estructura src/ detecta estos errores pronto
En una estructura src/ cambian dos cosas.
Primero, todo vive bajo un nombre único. Los módulos ya no son clean, score, secrets — son ticket_triage.clean, ticket_triage.score. Ningún archivo del proyecto puede ocultar la biblioteca estándar u otro paquete, y los imports muestran por sí solos qué código es tuyo.
Segundo, el paquete no se puede importar sin estar instalado. Probémoslo desde la raíz del proyecto con python normal:
python -c "import ticket_triage"
Traceback (most recent call last):
File "<string>", line 1, in <module>
ModuleNotFoundError: No module named 'ticket_triage'Ese fallo es intencionado. Si el paquete estuviera en la raíz del proyecto, este comando funcionaría — Python encontraría la carpeta directamente — y nunca sabrías si la configuración de pyproject.toml es correcta. Dentro de src/, solo hay una forma de llegar al paquete: instalarlo. Y uv run lo hace cada vez — instala el proyecto en .venv como "editable", lo que significa que el paquete instalado apunta de vuelta a tu carpeta src/, así que no hace falta reinstalar tras cada cambio de código:
uv run python -c "import ticket_triage; print(ticket_triage.priority('OUTAGE'))"
highAsí, cualquier error de empaquetado aparece en tu propia máquina, en el primer uv run — no el día del despliegue. Por ejemplo, si la carpeta del paquete tiene mal el nombre o falta __init__.py:
× Failed to build `ticket-triage @
│ file:///.../ticket-triage`
╰─▶ Expected a Python module at: src\ticket_triage\__init__.py(La ruta está acortada.) El build backend deduce el nombre del paquete a partir del nombre del proyecto — ticket-triage a src/ticket_triage/ — y se detiene si no encuentra nada ahí. Si quieres un nombre distinto a propósito, indícalo con module-name bajo [tool.uv.build-backend] en pyproject.toml.
Un ejemplo completo
Ahora convirtamos la carpeta plana del inicio del capítulo. Tras ejecutar uv init --package ticket-triage --python 3.12 en una carpeta vecina y mover los archivos antiguos dentro del paquete, la estructura queda así:
ticket-triage/
├── pyproject.toml
├── uv.lock
├── scripts/
│ └── report.py
└── src/
└── ticket_triage/
├── __init__.py
├── clean.py
├── cli.py
└── score.pyUn cambio en pyproject.toml — un nombre de comando más corto, que apunta al nuevo módulo cli:
[project.scripts]
triage = "ticket_triage.cli:main"src/ticket_triage/clean.py — el clean.py de antes, ahora con anotaciones de tipo (type hints) y un docstring:
import re
def clean_text(text: str) -> str:
"""Lowercase the text and collapse every run of whitespace to one space."""
return re.sub(r"\s+", " ", text).strip().lower()src/ticket_triage/score.py — el único cambio real es la línea del import: from clean import ... pasa a ser from ticket_triage.clean import ...:
from ticket_triage.clean import clean_text
URGENT_WORDS = ("outage", "down", "breach")
def priority(text: str) -> str:
words = clean_text(text).split()
return "high" if any(word in URGENT_WORDS for word in words) else "normal"src/ticket_triage/__init__.py — la puerta de entrada del paquete:
from ticket_triage.score import priority
__all__ = ["priority"]scripts/report.py — el script que se rompió antes, ahora importando por el nombre del paquete:
from ticket_triage import priority
print(priority("Database breach reported"))Y src/ticket_triage/cli.py — el punto de entrada de línea de comandos que reemplaza al antiguo run_triage.py:
import sys
from ticket_triage.score import priority
DEMO_TICKETS = ["Login page is slow", "Payments DOWN for all users"]
def main() -> None:
tickets = sys.argv[1:] or DEMO_TICKETS
for ticket in tickets:
print(f"{priority(ticket):<6} {ticket}")
if __name__ == "__main__":
main()Ejecutado desde la raíz del proyecto:
uv run triage
uv run triage "Service down since morning" "Typo on the pricing page"
uv run scripts/report.py
uv lock --checknormal Login page is slow
high Payments DOWN for all users
high Service down since morning
normal Typo on the pricing page
high
Resolved 1 package in 2msLa primera ejecución de uv run triage imprime algunas líneas antes de esto — creando .venv, y luego Building ticket-triage y Built ticket-triage — porque uv está instalando el proyecto por primera vez; después ya no aparecen. La última línea viene de uv lock --check, y su código de salida es 0: el lockfile coincide con pyproject.toml.
Las decisiones de esta conversión:
Imports absolutos (from ticket_triage.clean import ...). Los imports relativos también funcionan dentro de un paquete — from .clean import clean_text — pero los absolutos se leen igual desde cualquier archivo y son más fáciles de buscar. Sea cual sea el que elijas, usa un solo estilo por proyecto.
Solo priority en __init__.py. El código externo (scripts/report.py, los tests futuros, otros servicios) solo conoce from ticket_triage import priority. Si más adelante cambia el reparto entre score.py y clean.py, el código externo no se rompe. __all__ deja escrita esa lista pública de forma explícita.
Un cli.py aparte. Con la lógica (score.py) y la entrada/salida (cli.py) separadas, priority se puede llamar desde cualquier sitio — un test, una API — sin sys.argv ni print. Cuando sys.argv[1:] está vacío se ejecutan los tickets de demostración, así que ejecutarlo sin argumentos también muestra algo.
f"{priority(ticket):<6}". :<6 alinea el texto a la izquierda en seis caracteres, así que los tickets empiezan en la misma columna tanto después de high como de normal.
Hay una limitación que conviene conocer: clean_text separa las palabras solo por espacios en blanco, así que en "The site is down." la palabra es down. — con el punto final incluido — y no coincide con URGENT_WORDS. Más adelante en el curso llegarán mejores formas de extraer palabras de un texto; este capítulo trata de la estructura, y como la estructura está bien, esa mejora se hará en clean.py y en ningún otro sitio.
Cuando falla
ModuleNotFoundError: No module named 'clean' Los archivos se movieron dentro del paquete, pero algún archivo sigue con el import antiguo from clean import clean_text. Dentro de un paquete no existe ningún módulo de primer nivel llamado clean; escribe from ticket_triage.clean import clean_text. Tras la conversión, busca en todo el proyecto from clean y from score.
ModuleNotFoundError: No module named 'ticket_triage' Se ejecutó con python normal en vez de con uv run, así que no se usó el entorno donde está instalado el paquete. Ejecútalo con uv run .... Esto es la estructura src/ funcionando como debe, no un error.
Expected a Python module at: src\ticket_triage\__init__.py El paquete no está en la carpeta que el build backend espera según el nombre del proyecto — la carpeta tiene mal el nombre (src/triage/, src/ticket-triage/), o falta __init__.py. Corrige el nombre de la carpeta, añade __init__.py, o indica el nombre real con module-name.
The lockfile at uv.lock needs to be updated, but --locked was provided. Se editó pyproject.toml a mano y no se ejecutó uv lock, o el lockfile no se subió al repositorio. Ejecuta uv lock y haz commit de los dos archivos juntos. La próxima vez, cambia las dependencias con uv add y uv remove — mantienen ambos archivos sincronizados.
AttributeError: module 'secrets' has no attribute 'token_hex' El proyecto contiene un archivo con el nombre de un módulo de la biblioteca estándar (secrets.py, random.py, logging.py) que oculta el real. Renombra el archivo — y guarda las claves en .env, no en un archivo .py.
error: Failed to spawn: triag seguido de Caused by: program not found El nombre que se pasa a uv run es distinto del de [project.scripts] — aquí, una errata. Revisa el nombre en pyproject.toml; tras renombrarlo, uv run reinstala el proyecto por sí solo y crea el nuevo comando.
Paso 4 de 6 — Predice
Comprueba lo que has entendido
Se ejecuta clean_text sobre un ticket desordenado. ¿Qué se imprime?
import re
def clean_text(text: str) -> str:
return re.sub(r"\s+", " ", text).strip().lower()
print(repr(clean_text(" Payments DOWN\tsince 9 ")))- A`' payments down since 9 '`
- B`'payments down since 9'`
- C`'payments down\tsince 9'`
- D`'Payments DOWN since 9'`
Tras pasar de la carpeta plana a una estructura src/, uv run triage da ModuleNotFoundError: No module named 'clean'. clean.py está justo al lado, en src/ticket_triage/clean.py. ¿Cuál es el error?
# src/ticket_triage/score.py
from clean import clean_text
URGENT_WORDS = ("outage", "down", "breach")
def priority(text: str) -> str:
words = clean_text(text).split()
return "high" if any(word in URGENT_WORDS for word in words) else "normal"- A`clean.py` tiene que volver a la raíz del proyecto, porque ahí es donde Python busca
- BEl import sigue con el estilo de carpeta plana; dentro del paquete debe ser `from ticket_triage.clean import clean_text`
- CCon un `__init__.py` en `src/ticket_triage/`, los archivos vecinos no se pueden importar; hay que borrarlo
- D`uv run triage` solo mira `cli.py`; los imports de `score.py` nunca se ejecutan
En un PR, alguien editó pyproject.toml a mano para añadir un paquete. El uv sync --locked del CI falla: The lockfile at uv.lock needs to be updated. ¿Cuál es la solución correcta?
- AQuitar `--locked` del CI para que el CI actualice el lockfile por sí mismo
- BAñadir `uv.lock` a `.gitignore` para que cada persona genere su propio lockfile
- CEjecutar `uv lock` en tu máquina (o usar `uv add` desde el principio) y hacer commit del `uv.lock` actualizado en el mismo PR
- DBorrar `uv.lock`, porque sin lockfile este error no puede aparecer
Jugar es parte de Pro
+30 XP te esperan
Juega este capítulo con Pro
Leer es gratis y el capítulo está abierto entero. Los primeros 5 capítulos de cada curso gratuito se pueden jugar gratis. Desde el capítulo 6, responder las preguntas, ganar XP y hacer crecer tu racha es parte de Pro.
Desbloquea el juego con ProTu turno
Construye tú mismo la carpeta plana del inicio del capítulo y conviértela. Primero pon tres archivos en una carpeta triage-scripts/: clean.py (el clean_text de arriba, sin type hints), score.py (que empiece con from clean import clean_text) y run_triage.py, que imprime la prioridad de dos tickets de demostración. Después:
- Al lado, crea un proyecto nuevo con
uv init --package ticket-triage --python 3.12, mueve los archivos asrc/ticket_triage/y escribe cada import conticket_triage. - Convierte
run_triage.pyen una funciónmain()dentro decli.py, como en este capítulo (con los mismos dos tickets de demostración), y añadetriage = "ticket_triage.cli:main"a[project.scripts] - Expón solo
prioritydesde__init__.py, y úsalo desdescripts/report.pyconfrom ticket_triage import priority - Ejecuta
uv lock --checkpara confirmar que el lockfile coincide, y haz commit depyproject.toml,uv.lock,src/yscripts/en un solo commit — sin.venv
Desde la raíz del proyecto, uv run triage seguido de uv run scripts/report.py debe dar exactamente esto:
normal Login page is slow
high Payments DOWN for all users
highDespués, rómpelo a propósito, dos veces. La primera, cambia el import de score.py por from clean import clean_text y mira qué error aparece, y en qué línea. La otra, crea un secrets.py dentro de src/ticket_triage/, añade import secrets y print(len(secrets.token_hex(8))) a cli.py, y ejecuta uv run triage — ¿sigue funcionando aquí la trampa de la carpeta plana, y por qué no?
Paso 6 de 6
Reto — el quiz del capítulo
Diez preguntas de fácil a difícil. Las últimas son difíciles a propósito.
Juega con Pro