// 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.
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
3.12Con 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.
[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",]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 desdepython-chess.),duckdbpara 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,polarsypyarrowpara la parte tabular,zstandardporque los volcados de Lichess vienen comprimidos así,numpypara todo lo demás. - Para lo que sale del repo:
mlflowregistra cada ejecución;huggingface_hubydatasetspublican.
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.
[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"]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.
[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 = trueEste 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.
[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 = 100target-version = "py312"
[tool.ruff.lint]select = ["E", "F", "I", "UP", "B", "SIM"]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
# Python__pycache__/*.py[cod]*.egg-info/.venv/.pytest_cache/.ruff_cache/.coveragehtmlcov/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_StoreThumbs.dbEl 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.
"""Rukh: a chess language model built from scratch."""
__version__ = "0.0.1"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.
git init -b maingit remote add ref https://github.com/borja-glez/rukhgit fetch ref --tagsgit checkout p0 -- .gitattributesgit add -Agit 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.pytrae 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.pycompara 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
"""Project directories.
Every accessor reads the environment at call time so tests (and users) can point the wholeproject elsewhere with ``RUKH_HOME`` without re-importing the package."""
from __future__ import annotations
import osfrom 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"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
"""Strict pydantic configs loaded from YAML."""
from __future__ import annotations
from pathlib import Path
import yamlfrom 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)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.
"""Command-line interface: ``info``, ``data fetch``, ``engine check`` and ``mlflow ui``."""
from __future__ import annotations
from pathlib import Pathfrom 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")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.
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."""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).
@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'}")Tres cosas de este bloque se repiten en todos los subcomandos del proyecto:
- El
importestá dentro de la función.rukh.envarrastramlflow, que tarda más de un segundo en importarse; con cuarenta subcomandos importando arriba,rukh --helptardaría varios segundos.cli.pysolo importatyperen 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.
@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}")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.
@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'}")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
"""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.fixturedef repo_root() -> Path: """Absolute path to the repository root (the folder containing pyproject.toml).""" return REPO_ROOT
@pytest.fixturedef 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_pathrukh_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 for rukh.config: strict pydantic configs loaded from YAML."""
from __future__ import annotations
from pathlib import Path
import pytestfrom 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)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.
"""Guard the torch pin: both extras must resolve the same torch range and lock the same version."""
from __future__ import annotations
import reimport tomllibfrom 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 conflictsdef 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}"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:
- Los dos extras declaran una sola dependencia de
torch, con el mismo rango. Evita que alguien subacu128atorch>=2.12para probar algo, la CI siga en verde porque usacpuy, a partir de ahí, las dos mitades del proyecto midan con versiones distintas. - Los dos extras siguen declarados como conflicto: nadie ha borrado esa línea para que dejara de molestar.
- El fichero de bloqueo tiene una entrada de
torchde cada índice (+cpu,+cu128), las dos en la serie 2.11. Como mira eluv.lockversionado 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.