rukh · lab

// M0 · lección 01

El repositorio: del directorio vacío a la CLI `rukh`

El esqueleto del proyecto, fichero a fichero: la versión de Python, el pyproject con los dos extras de torch en conflicto, el paquete, las rutas, las configuraciones estrictas, la CLI de typer y los dos tests que impiden que el entorno se degrade en silencio.

  • tiempo de trabajo125 min
  • nivel base
  • actualizado el23 de septiembre de 2026

Qué vas a construir

Un directorio vacío convertido en un paquete instalable con su propio comando, rukh, y rukh info ya escrito (responderá en la lección siguiente, cuando exista el informe de entorno). Al terminar tendrás una versión de Python fijada, un torch que no se instala del índice equivocado, configuraciones que se niegan a ignorar una errata y una CLI donde cada módulo enchufará sus subcomandos.

Todo el código está aquí, entero, tal como está en el tag p0. Si copias los bloques en ese orden, tienes el repositorio.

.python-version: el intérprete, fijado para todos

.python-version
3.12

.python-versionlínea 1 · p0

Con este fichero, uvuvGestor de paquetes y entornos de Python (de Astral) que en este proyecto fija el intérprete (.python-version), resuelve las dependencias, escribe el fichero de bloqueo uv.lock y ejecuta todo con uv run. Sustituye a pip, venv y pip-tools a la vez, y es lo que hace que el entorno sea reproducible entre máquinas. usa ese intérprete sin preguntar; si no lo tiene instalado, se lo descarga. Nadie ejecuta el proyecto con 3.11 por accidente.

¿Por qué 3.12 y no 3.13? torch 2.11 con CUDA 12.8 publica wheels para 3.12 y a veces tarda semanas en publicarlos para la versión nueva. Y la sintaxis de genéricos de config.py, más abajo (def load_yaml[T: BaseModel], PEP 695), necesita 3.12 como mínimo.

pyproject.toml: el fichero donde viven las decisiones

Es el fichero que más veces vas a volver a abrir. Va por partes.

pyproject.toml
[project]
name = "rukh"
version = "0.0.1"
description = "Rukh: a chess language model built from scratch (data, training, evaluation, agents)."
readme = "README.md"
license = "MIT"
authors = [{ name = "Borja González" }]
requires-python = ">=3.12,<3.13"
dependencies = [
"typer>=0.27",
"pydantic>=2.13",
"pyyaml>=6",
"mlflow>=3.16",
"python-chess>=1.999",
"duckdb>=1.5",
"polars>=1.44",
"pyarrow>=25",
"zstandard>=0.25",
"numpy>=2",
"huggingface_hub>=1.32",
"datasets>=5",
]

pyproject.tomllíneas 1-22 · p0

Doce dependencias y ningún framework de entrenamiento: el decoder de M2 y el encoder de M3 se escriben a mano con torch, y transformers no entra hasta M4, que trata de ese ecosistema. Las que hay:

  • Para la línea de comandos: typer (la CLI), pydantic (validar configuraciones), pyyaml (leerlas).
  • Para el ajedrez y los datos: python-chess (reglas, SAN, hablar con Stockfish por UCIUCIDos cosas con el mismo nombre. La notación UCI escribe una jugada como casilla de origen, casilla de destino y promoción opcional (e7e8q): no depende del contexto, por eso es la tokenización por defecto. El protocolo UCI es la forma en que hablamos con Stockfish desde python-chess.), duckdb para consultar parquetParquetFormato de fichero columnar y comprimido. Lichess publica sus partidas así en Hugging Face, y DuckDB puede filtrarlas en remoto leyendo solo las columnas y los bloques necesarios (predicate pushdown), lo que hace posible recortar 73 GB a unos pocos sin descargarlo todo. remoto, polars y pyarrow para la parte tabular, zstandard porque los volcados de Lichess vienen comprimidos así, numpy para todo lo demás.
  • Para lo que sale del repo: mlflow registra cada ejecución; huggingface_hub y datasets publican.

requires-python = ">=3.12,<3.13" cierra también la puerta por arriba. Un >=3.12 a secas deja que uv resuelva con 3.14 el día que exista y que torch todavía no tenga wheels.

pyproject.toml
[project.optional-dependencies]
cpu = ["torch>=2.11,<2.12"]
cu128 = ["torch>=2.11,<2.12"]
[project.urls]
Repository = "https://github.com/borja-glez/rukh"
Course = "https://lab.rukh.borjaglez.com"
Demo = "https://rukh.borjaglez.com"
Models = "https://huggingface.co/chorcat"
[project.scripts]
rukh = "rukh.cli:app"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/rukh"]

pyproject.tomllíneas 23-42 · p0

Los dos extras declaran el mismo rango de torch a propósito (un test lo comprueba más abajo). Lo que cambia entre ellos es el índice del que sale el wheel, y eso se configura a continuación.

[project.scripts] convierte rukh en un comando que apunta al objeto app de rukh.cli; aunque la CLI crezca a cincuenta subcomandos, esta línea no cambia.

El paquete vive dentro de src/ (el src layout) para evitar un fallo clásico: sin src/, el directorio del repositorio está en sys.path al ejecutar pytest, y los tests importan el árbol de fuentes en vez del paquete instalado. Si el empaquetado se rompe (un fichero de datos que no se copia en el wheel), los tests siguen en verde y te enteras al publicar.

pyproject.toml
[tool.uv]
conflicts = [[{ extra = "cpu" }, { extra = "cu128" }]]
[tool.uv.sources]
torch = [
{ index = "pytorch-cpu", extra = "cpu" },
{ index = "pytorch-cu128", extra = "cu128" },
]
[[tool.uv.index]]
name = "pytorch-cpu"
url = "https://download.pytorch.org/whl/cpu"
explicit = true
[[tool.uv.index]]
name = "pytorch-cu128"
url = "https://download.pytorch.org/whl/cu128"
explicit = true

pyproject.tomllíneas 43-61 · p0

Este bloque es el corazón del entorno reproducible. torch no se publica en un único wheel: hay uno por versión de CUDA, cada uno en su propio índice. El de CUDA 12.8 (el que necesita una RTX 5090, arquitectura sm_120) está en download.pytorch.org/whl/cu128, y el de CPU (el que usa la CI, que no tiene GPU) en /whl/cpu. [tool.uv.sources] dice de qué índice sale torch según el extra activo.

explicit = true significa “de este índice solo se instala lo que se pida por nombre”. Sin eso, uv buscaría todas las dependencias en los índices de PyTorch, donde hay copias antiguas de paquetes comunes que acabarían ganando a las de PyPI.

conflicts declara que los dos extras son mutuamente excluyentes. Instalados juntos, habría dos wheels del mismo paquete desde dos sitios, y el torch que acaba importado dependería del orden de resolución: a veces GPU, a veces CPU, sin avisar.

pyproject.toml
[dependency-groups]
dev = ["pytest>=9", "ruff>=0.16"]
[tool.pytest.ini_options]
markers = [
"unit: fast CPU tests",
"gpu: needs CUDA",
"slow: long",
"engine: needs Stockfish binary",
]
testpaths = ["tests"]
[tool.ruff]
line-length = 100
target-version = "py312"
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B", "SIM"]

pyproject.tomllíneas 62-80 · p0

dev es un grupo de dependencias, no un extra: no se publica con el paquete ni participa en el conflicto de arriba. Trae pytest y ruff (linter y formateador).

Las cuatro marcas de pytest son la decisión que más se nota en el día a día. Cada test declara qué necesita para correr, y así el mismo código da suites distintas:

Marca Qué necesita Cuándo se ejecuta
unit nada, CPU y rápido siempre: en la CI y cada vez que guardas
engine Stockfish en disco en tu máquina, cuando toques la evaluación
gpu CUDA en tu máquina
slow paciencia a mano

uv run pytest -m unit -q tarda segundos y no toca red ni GPU. Es el comando que vas a escribir cien veces en el curso.

Las reglas de ruff atrapan errores de verdad y no discuten de estilo: E/F (nombres sin usar, imports muertos), I (orden de imports), UP (sintaxis que en 3.12 se escribe mejor), B (bugbear: el mutable como valor por defecto y sus primos) y SIM (simplificaciones). line-length = 100 porque las firmas anotadas de este proyecto no caben en 88 sin partirlas en sitios feos.

El paquete

.gitignore
# Python
__pycache__/
*.py[cod]
*.egg-info/
.venv/
.pytest_cache/
.ruff_cache/
.coverage
htmlcov/
dist/
build/
# Project data and runs (never in git)
/data/
/mlruns/
/tools/
/checkpoints/
/checkpoints/**/*.bin
/artifacts/**/*.bin
# Model weights never belong in git, wherever they are
*.onnx
*.safetensors
*.pt
# Local env
.env
.env.*
*.log
# Editors / OS
.vscode/
.idea/
.DS_Store
Thumbs.db

.gitignorelíneas 1-34 · p0

El bloque del medio es el que importa. /data/, /mlruns/, /tools/ y /checkpoints/ llevan barra inicial: solo la raíz, para que un src/rukh/data/ (que sí se versiona) no caiga dentro. Las tres extensiones de pesos (*.onnx, *.safetensors, *.pt) van sin barra y se ignoran en cualquier sitio, porque el día que exportes un modelo a una carpeta nueva nadie volverá aquí. Un .pt de 200 MB en la historia de git no se quita sin reescribir la historia.

src/rukh/__init__.py
"""Rukh: a chess language model built from scratch."""
__version__ = "0.0.1"

src/rukh/__init__.pylíneas 1-3 · p0

Una constante que va a aparecer en sitios inesperados: en el manifiestoManifiesto de datosFichero JSON que acompaña a cada recorte con los filtros exactos, los meses, los conteos y el hash de cada fichero. Sin manifiesto no hay reproducibilidad: es lo que permite decir qué datos vio un modelo. de los datos, en los tags de cada ejecución de MLflow y en las model cardsModel cardEl README de un repositorio del Hub, y la afirmación pública de qué se publicó y qué se midió. Las de Rukh se generan desde run.json y results.json, no se escriben a mano, y llevan el SHA del fichero y el de los pesos, el muestreo y la suite de cada número, la fecha, lo que no se midió y la licencia. rukh publish cards las regenera todas desde la tabla y sube solo el README.. Es la respuesta a “¿con qué versión del código salió este número?”.

Git: el primer commit y el repositorio de referencia

Antes de seguir, el directorio tiene que ser un repositorio de git. No es burocracia: la lección siguiente etiqueta cada ejecución de MLflow con el SHA del commit, y uno de sus tests lo comprueba. Fuera de un repositorio ese SHA no existe.

Terminal window
git init -b main
git remote add ref https://github.com/borja-glez/rukh
git fetch ref --tags
git checkout p0 -- .gitattributes
git add -A
git commit -m "chore: bootstrap the rukh package"

Si es la primera vez que usas git en esta máquina, el commit te pedirá nombre y correo: git config --global user.name "Tu Nombre" y git config --global user.email tu@correo.

ref es el repositorio del curso, como un remoto más: git fetch baja sus etiquetas (p0 … p6) sin tocar tus ficheros, y a partir de ahí tienes dos herramientas que valen igual en Git Bash y en PowerShell:

  • git checkout p1 -- tests/unit/test_x.py trae un fichero tal como está en una etiqueta. Es como se traen los pocos ficheros que el curso no te hace teclear (una fixture binaria, algún test largo), y cada lección lo dice al principio, en «Qué tecleas y qué traes».
  • git diff p0 -- src/rukh/cli.py compara tu fichero con el del curso. Cuando un test falla y no ves por qué, es lo primero que hay que mirar.

El .gitattributes que acabas de traer empieza por * text=auto eol=lf (y sigue con tres líneas para png, svg y zip): fija los finales de línea en LF aunque trabajes en Windows, para que un fichero que escribes tú y el mismo fichero en p0 sean idénticos byte a byte.

Haz un commit al final de cada lección (git add -A y git commit -m "…"). Cuesta diez segundos y te deja volver al punto exacto en que funcionaba todo; los ficheros grandes (datos, checkpoints, pesos) no entran, porque el .gitignore de arriba los deja fuera.

paths.py: nadie adivina dónde está la raíz

src/rukh/paths.py
"""Project directories.
Every accessor reads the environment at call time so tests (and users) can point the whole
project elsewhere with ``RUKH_HOME`` without re-importing the package.
"""
from __future__ import annotations
import os
from pathlib import Path
_PACKAGE_ROOT = Path(__file__).resolve().parents[2]
def package_root() -> Path:
"""Source tree root (the parent of ``src/``), regardless of ``RUKH_HOME``."""
return _PACKAGE_ROOT
def root() -> Path:
"""Repository root: ``RUKH_HOME`` if set, otherwise the parent of ``src/``."""
home = os.environ.get("RUKH_HOME")
return Path(home).expanduser().resolve() if home else _PACKAGE_ROOT
def data_dir() -> Path:
"""Where raw and derived datasets live (gitignored)."""
return root() / "data"
def mlruns_dir() -> Path:
"""Where the local MLflow store lives (gitignored)."""
return root() / "mlruns"
def tools_dir() -> Path:
"""Where downloaded binaries such as Stockfish live (gitignored)."""
return root() / "tools"

src/rukh/paths.pylíneas 1-38 · p0

paths.py evita una clase entera de fallos: el script que escribe en data/ o en ../data/ según desde dónde lo lances. Tiene dos decisiones dentro.

root() lee os.environ en cada llamada, no al importar el módulo. Si la ruta se calculara una vez en el import, un test que ponga RUKH_HOME con monkeypatch no tendría efecto sobre un módulo ya importado. Leerla cada vez cuesta un acceso a un diccionario.

Y hay dos raíces. package_root() es el árbol de fuentes, fijo; root() es donde viven los datos, y RUKH_HOME la mueve. En M2 querrás entrenar con los datos en otro disco mientras el SHA de git que se registra en la ejecución sigue siendo el del checkout donde está el código. Con una sola raíz, las ejecuciones quedarían etiquetadas con el commit de un directorio que no es un repositorio.

config.py: una clave mal escrita falla al cargar

src/rukh/config.py
"""Strict pydantic configs loaded from YAML."""
from __future__ import annotations
from pathlib import Path
import yaml
from pydantic import BaseModel, ConfigDict
class BaseConfig(BaseModel):
"""Base class for every config: unknown keys are an error, never silently ignored."""
model_config = ConfigDict(extra="forbid")
def load_yaml[T: BaseModel](path: Path, model: type[T]) -> T:
"""Load ``path`` as YAML and validate it against ``model``.
Raises ``ValueError`` when the document is not a mapping and
``pydantic.ValidationError`` when it does not match the model.
"""
raw = yaml.safe_load(Path(path).read_text(encoding="utf-8"))
if raw is None:
raw = {}
if not isinstance(raw, dict):
raise ValueError(f"{path}: expected a YAML mapping, got {type(raw).__name__}")
return model.model_validate(raw)

src/rukh/config.pylíneas 1-28 · p0

Toda configuración del proyecto (datos, entrenamientos, evaluaciones) hereda de BaseConfig, y extra="forbid" es lo único que hace. Con eso, una clave mal escrita en un YAML falla antes de tocar la red o la GPU en vez de quedarse sin aplicar.

Imagina que escribes learnig_rate: 0.0003 en el YAML de un entrenamiento de cuarenta minutos. Con extra="ignore" (el comportamiento por defecto de pydantic) el entrenamiento arranca, usa el valor por defecto y produce un número creíble que responde a una pregunta que nadie hizo. Un fallo ruidoso te hace perder la noche de GPU; este te la hace perder sin que te enteres.

load_yaml[T: BaseModel] es la sintaxis de genéricos de 3.12: load_yaml(path, FetchConfig) devuelve un FetchConfig y no un BaseModel, así que el editor conoce los campos.

Hay dos casos raros tratados a propósito. Un YAML vacío da None y se convierte en {}, para que una configuración con todos los campos por defecto pueda ser un fichero en blanco. Un YAML que es una lista da un ValueError con la ruta en el mensaje: el error de pydantic menciona el modelo pero no el fichero, y con veinte configuraciones lo que necesitas saber es cuál.

cli.py: un subcomando por cada cosa que el proyecto sabe hacer

La CLI es el único punto de entrada al proyecto: al final del curso tendrá unos cuarenta subcomandos, aquí cuatro. Empieza por el andamio.

src/rukh/cli.py
"""Command-line interface: ``info``, ``data fetch``, ``engine check`` and ``mlflow ui``."""
from __future__ import annotations
from pathlib import Path
from typing import Annotated
import typer
from rukh import __version__
app = typer.Typer(
name="rukh",
help="Rukh: chess language model toolkit.",
no_args_is_help=True,
add_completion=False,
)
data_app = typer.Typer(help="Datasets: fetch and describe Lichess games.", no_args_is_help=True)
app.add_typer(data_app, name="data")
mlflow_app = typer.Typer(help="Local MLflow tracking.", no_args_is_help=True)
app.add_typer(mlflow_app, name="mlflow")
engine_app = typer.Typer(help="Stockfish engine utilities.", no_args_is_help=True)
app.add_typer(engine_app, name="engine")

src/rukh/cli.pylíneas 1-23 · p0

Un Typer por grupo de subcomandos, montado en el principal con add_typer: rukh data fetch, rukh engine check y rukh mlflow ui son subgrupos con su propia ayuda.

src/rukh/cli.py
def _version_callback(value: bool) -> None:
if value:
typer.echo(f"rukh {__version__}")
raise typer.Exit()
@app.callback()
def main(
version: Annotated[
bool,
typer.Option(
"--version",
callback=_version_callback,
is_eager=True,
help="Print the package version and exit.",
),
] = False,
) -> None:
"""Rukh: chess language model toolkit."""

src/rukh/cli.pylíneas 24-44 · p0

raise typer.Exit() dentro del callback es la forma que tiene typer de salir con éxito a media ejecución (el ejercicio 01 te pregunta por el resto).

src/rukh/cli.py
@app.command()
def info(
as_json: Annotated[bool, typer.Option("--json", help="Print the report as JSON only.")] = False,
) -> None:
"""Report Python, torch, CUDA, GPU, Stockfish and project directories."""
from rukh.env import EnvReport, collect
report = collect()
if as_json:
typer.echo(report.model_dump_json(indent=2))
return
width = max(len(key) for key in EnvReport.model_fields)
for key, value in report.model_dump().items():
typer.echo(f"{key:<{width}} {value if value is not None else 'null'}")

src/rukh/cli.pylíneas 45-60 · p0

Tres cosas de este bloque se repiten en todos los subcomandos del proyecto:

  • El import está dentro de la función. rukh.env arrastra mlflow, que tarda más de un segundo en importarse; con cuarenta subcomandos importando arriba, rukh --help tardaría varios segundos. cli.py solo importa typer en el nivel del módulo, y cada subcomando importa lo suyo cuando se ejecuta.
  • Todos los subcomandos tienen --json. La salida legible es para ti; la JSON, para los tests y los scripts, que así no parsean texto alineado en columnas ni se rompen cuando cambia el formato.
  • El ancho de la columna se calcula a partir de EnvReport.model_fields, así que un campo nuevo y largo no desalinea nada.
src/rukh/cli.py
@data_app.command("fetch")
def data_fetch(
config: Annotated[
Path,
typer.Option("--config", exists=True, dir_okay=False, readable=True, help="YAML config."),
],
dry_run: Annotated[
bool, typer.Option("--dry-run", help="Print the plan; touch neither network nor disk.")
] = False,
as_json: Annotated[bool, typer.Option("--json", help="Print the plan as JSON only.")] = False,
) -> None:
"""Fetch filtered Lichess games as parquet files plus a manifest."""
from rukh.config import load_yaml
from rukh.data.fetch import FetchConfig, run
cfg = load_yaml(config, FetchConfig)
fetch_plan = run(cfg, dry_run=dry_run)
if as_json:
typer.echo(fetch_plan.model_dump_json(indent=2))
return
typer.echo(f"dataset: {cfg.dataset}")
typer.echo(f"months: {', '.join(fetch_plan.months)}")
typer.echo(f"mode: {'dry-run (nothing written)' if dry_run else 'fetched'}")
typer.echo("query:")
for line in fetch_plan.query.splitlines():
typer.echo(f" {line}")
typer.echo(f"out_dir: {fetch_plan.out_dir}")
typer.echo("outputs:")
for path in fetch_plan.out_paths:
typer.echo(f" {path}")
typer.echo(f"manifest: {fetch_plan.manifest_path}")

src/rukh/cli.pylíneas 61-93 · p0

Con exists=True, dir_okay=False, readable=True, typer comprueba el fichero de --config antes de entrar en la función: una errata en la ruta da un mensaje sobre el fichero y no un FileNotFoundError en medio de una traza.

La consulta SQL que imprime --dry-run es la que la lección de los datos publica como «estos son los filtros», y sale del mismo constructor que usa la descarga de verdad. El filtro documentado y el aplicado no pueden separarse.

src/rukh/cli.py
@mlflow_app.command("ui")
def mlflow_ui(
host: Annotated[str, typer.Option("--host", help="Interface to bind.")] = "127.0.0.1",
port: Annotated[int, typer.Option("--port", help="Port to listen on.")] = 5000,
) -> None:
"""Open the MLflow UI on the local SQLite store (blocks until stopped)."""
from rukh.tracking import serve_ui, tracking_uri
typer.echo(f"mlflow ui on http://{host}:{port} (store {tracking_uri()})")
raise typer.Exit(code=serve_ui(host=host, port=port))
@engine_app.command("check")
def engine_check_cmd(
elo: Annotated[int, typer.Option("--elo", help="UCI_Elo to configure.")] = 1400,
plies: Annotated[int, typer.Option("--plies", help="Half-moves to play.")] = 40,
seed: Annotated[int, typer.Option("--seed", help="Seed for the random opponent.")] = 0,
as_json: Annotated[bool, typer.Option("--json", help="Print the result as JSON only.")] = False,
) -> None:
"""Locate Stockfish, set UCI_Elo and play a short game against a random mover."""
from rukh.engine import EngineCheckResult, EngineError, EngineNotFound, engine_check
try:
result = engine_check(elo=elo, plies=plies, seed=seed)
except (EngineNotFound, EngineError, ValueError) as exc:
typer.echo(f"error: {exc}", err=True)
raise typer.Exit(code=1) from exc
if as_json:
typer.echo(result.model_dump_json(indent=2))
return
width = max(len(key) for key in EngineCheckResult.model_fields)
for key, value in result.model_dump().items():
typer.echo(f"{key:<{width}} {value if value is not None else 'null'}")

src/rukh/cli.pylíneas 94-128 · p0

host = "127.0.0.1" y no 0.0.0.0 porque la interfaz de MLflow no tiene autenticación: no conviene publicarla en la red local por defecto.

En engine check, las tres excepciones esperadas se convierten en un mensaje en stderr y código de salida 1. Una traza de veinte líneas de chess.engine no te dice que falta descargar Stockfish; error: Stockfish not found: run ... sí. raise ... from exc conserva la causa por si el problema es otro.

// Ejercicio 01Lee lo que acabas de ejecutar

¿Por qué rukh data sin más imprime la ayuda y no un error? ¿Y por qué rukh --version funciona aunque no haya ningún subcomando?

// SoluciónVer la solución

rukh data imprime la ayuda del subgrupo en vez de un error de uso, porque su Typer se creó con no_args_is_help=True. rukh --version funciona porque la opción lleva is_eager=True: typer ejecuta su callback antes de comprobar que hay un subcomando, y el callback sale con typer.Exit().

Los tests que impiden que el entorno se degrade

Tres ficheros: las fixtures que comparte toda la suite, los tests de config.py y uno raro que comprueba configuración en vez de código.

Las fixtures y los tests de config.py son maquinaria de pytest; puedes saltarlos en la primera lectura, pero el «Ejecútalo» de esta sección los necesita. Tecléalos cuando llegues a él o, si lo saltas, tráelos: git checkout p0 -- tests/conftest.py tests/unit/test_config.py. El de uv.lock, justo después, sí merece leerse y teclearse.

// Teststests/conftest.py y test_config.py: la fixture rukh_home y los errores de carga del YAML
tests/conftest.py
"""Shared pytest fixtures for the rukh test suite."""
from __future__ import annotations
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[1]
@pytest.fixture
def repo_root() -> Path:
"""Absolute path to the repository root (the folder containing pyproject.toml)."""
return REPO_ROOT
@pytest.fixture
def rukh_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
"""Point RUKH_HOME at a temporary directory so tests never touch the real data/mlruns."""
monkeypatch.setenv("RUKH_HOME", str(tmp_path))
return tmp_path

tests/conftest.pylíneas 1-22 · p0

rukh_home es la fixture más usada del repositorio y la razón por la que paths.root() lee el entorno en cada llamada. El test que la pide trabaja contra un directorio temporal, sin tocar el data/ ni el mlruns/ de verdad. El que se olvida y escribe en mlruns/ deja la base de MLflow sucia, y el siguiente test falla por un motivo que no tiene nada que ver.

tests/unit/test_config.py
"""Tests for rukh.config: strict pydantic configs loaded from YAML."""
from __future__ import annotations
from pathlib import Path
import pytest
from pydantic import ValidationError
from rukh.config import BaseConfig, load_yaml
pytestmark = pytest.mark.unit
class DemoConfig(BaseConfig):
name: str
steps: int = 10
def test_base_config_forbids_unknown_keys() -> None:
with pytest.raises(ValidationError):
DemoConfig(name="x", unknown=1) # type: ignore[call-arg]
def test_load_yaml_returns_model(tmp_path: Path) -> None:
path = tmp_path / "demo.yaml"
path.write_text("name: demo\nsteps: 3\n", encoding="utf-8")
cfg = load_yaml(path, DemoConfig)
assert cfg == DemoConfig(name="demo", steps=3)
def test_load_yaml_rejects_unknown_keys(tmp_path: Path) -> None:
path = tmp_path / "demo.yaml"
path.write_text("name: demo\ntypo: 1\n", encoding="utf-8")
with pytest.raises(ValidationError):
load_yaml(path, DemoConfig)
def test_load_yaml_rejects_non_mapping(tmp_path: Path) -> None:
path = tmp_path / "demo.yaml"
path.write_text("- just\n- a list\n", encoding="utf-8")
with pytest.raises(ValueError, match="mapping"):
load_yaml(path, DemoConfig)

tests/unit/test_config.pylíneas 1-43 · p0

pytestmark = pytest.mark.unit marca todos los tests del fichero de una vez; es el patrón de todo el repositorio. DemoConfig es un modelo de juguete: se prueba BaseConfig, y cuando FetchConfig cambie estos tests no se tocan. El último comprueba que el mensaje menciona mapping, es decir, que el error del YAML que es una lista sigue siendo el nuestro (con la ruta) y no el de pydantic.

tests/unit/test_lock_guard.py
"""Guard the torch pin: both extras must resolve the same torch range and lock the same version."""
from __future__ import annotations
import re
import tomllib
from pathlib import Path
import pytest
pytestmark = pytest.mark.unit
TORCH_SPEC_PREFIX = "torch>=2.11,<2.12"
def _torch_spec(extras: dict[str, list[str]], name: str) -> str:
specs = [dep for dep in extras[name] if re.match(r"^torch\b", dep)]
assert len(specs) == 1, f"extra {name!r} must declare exactly one torch requirement"
return specs[0]
def test_extras_pin_the_same_torch_range(repo_root: Path) -> None:
pyproject = tomllib.loads((repo_root / "pyproject.toml").read_text(encoding="utf-8"))
extras = pyproject["project"]["optional-dependencies"]
cpu = _torch_spec(extras, "cpu")
cu128 = _torch_spec(extras, "cu128")
assert cpu == cu128
assert cpu.startswith(TORCH_SPEC_PREFIX)
def test_extras_are_declared_as_conflicting(repo_root: Path) -> None:
pyproject = tomllib.loads((repo_root / "pyproject.toml").read_text(encoding="utf-8"))
conflicts = pyproject["tool"]["uv"]["conflicts"]
assert [{"extra": "cpu"}, {"extra": "cu128"}] in conflicts

tests/unit/test_lock_guard.pylíneas 1-34 · p0

tests/unit/test_lock_guard.py
def test_lock_has_torch_2_11_for_both_indexes(repo_root: Path) -> None:
lock = tomllib.loads((repo_root / "uv.lock").read_text(encoding="utf-8"))
torch_entries = [pkg for pkg in lock["package"] if pkg["name"] == "torch"]
assert torch_entries, "uv.lock has no torch entry"
by_index: dict[str, set[str]] = {"cpu": set(), "cu128": set()}
for entry in torch_entries:
index_url = entry.get("source", {}).get("registry", "")
if index_url.endswith("/whl/cpu"):
by_index["cpu"].add(entry["version"])
elif index_url.endswith("/whl/cu128"):
by_index["cu128"].add(entry["version"])
assert by_index["cpu"], "no torch entry locked from the pytorch-cpu index"
assert by_index["cu128"], "no torch entry locked from the pytorch-cu128 index"
assert any(v.endswith("+cpu") for v in by_index["cpu"]), by_index
assert any(v.endswith("+cu128") for v in by_index["cu128"]), by_index
for index, versions in by_index.items():
for version in versions:
assert version.startswith("2.11."), f"torch from {index} locked at {version}"

tests/unit/test_lock_guard.pylíneas 35-54 · p0

Es el test que más raro parece y uno de los que más protegen. Lee pyproject.toml y uv.lock como datos, con tomllib, y comprueba tres cosas que ninguna herramienta comprueba por ti:

  1. Los dos extras declaran una sola dependencia de torch, con el mismo rango. Evita que alguien suba cu128 a torch>=2.12 para probar algo, la CI siga en verde porque usa cpu y, a partir de ahí, las dos mitades del proyecto midan con versiones distintas.
  2. Los dos extras siguen declarados como conflicto: nadie ha borrado esa línea para que dejara de molestar.
  3. El fichero de bloqueo tiene una entrada de torch de cada índice (+cpu, +cu128), las dos en la serie 2.11. Como mira el uv.lock versionado y no el entorno instalado, falla en el commit que lo degrada y no tres semanas después en la máquina de alguien.

// Ejercicio 02Rompe el fichero de bloqueo y mira quién se queja

En una copia del repositorio, cambia el extra cu128 a torch>=2.12,<2.13 en pyproject.toml y ejecuta uv run --frozen pytest -m unit -q. ¿Qué test falla y con qué mensaje? Después déjalo como estaba y cambia line-length de ruff a 88: ¿qué comando se queja y cuántos ficheros?

// SoluciónVer la solución

Falla test_extras_pin_the_same_torch_range, en el assert cpu == cu128, antes de llegar a comprobar el prefijo. El mensaje de pytest enseña los dos especificadores.

El --frozen importa. Sin él, uv run ve que pyproject.toml ya no casa con uv.lock y vuelve a resolver antes de lanzar pytest: si encuentra un torch 2.12 para el índice cu128, reescribe el lock y además falla test_lock_has_torch_2_11_for_both_indexes; si no lo encuentra, se para con un error de resolución y pytest ni arranca. Con --frozen usa el lock tal cual y el único que se queja es el test de los extras. Por eso, al deshacer el cambio, devuelve también uv.lock a su estado (git checkout -- pyproject.toml uv.lock, o uv lock después de restaurar pyproject.toml) antes de seguir.

Con line-length = 88, uv run ruff format --check . falla y lista los ficheros que reformatearía (en p0, prácticamente todos los de src/). ruff check . también avisa de las líneas que pasan de 88 con la regla E501.

Qué has aprendido

El esqueleto del proyecto, y las tres decisiones que lleva dentro: un intérprete fijado y un torch que sale del índice correcto o falla; configuraciones que rechazan lo que no entienden; y una CLI donde cada módulo enchufa sus subcomandos sin que arranque más lento.

Cómo se mide: uv run rukh --help lista los grupos data, engine y mlflow y el subcomando info, uv run rukh --version imprime rukh 0.0.1, uv run ruff check . y uv run ruff format --check . están limpios, y uv run pytest -m unit -q pasa los siete tests que hay hasta ahora (cuatro de config, tres de lock_guard).

Lo siguiente es el juez. rukh info todavía no funciona porque le falta env.py, y env.py necesita saber dónde está Stockfish y cuál es la base de MLflow: las dos cosas que hacen que un número de este curso se pueda comparar con otro.