Leer lo que una persona escribió: compactref 1.1.0
Quería sacar una versión nueva de mi librería y mis primeras ideas eran meter Pyrefly o subir la versión de Python. Ninguna le cambiaba nada a quien la usa. Lo que sí: aceptar una referencia escrita en minúsculas sin romper la promesa de la 1.0, y probar que es segura sin GIL en vez de suponerlo.
compactref es una librería de Python que genera referencias cortas y legibles, como ORD-20260710-HCP3CSX, a partir de un ULID o un UUID. El identificador largo sigue siendo la clave primaria. La referencia corta es la que una persona lee por teléfono, escribe en un buscador o le dicta a soporte.
Salió en julio, llegó a la 1.0 a los pocos días y hoy la usan tres aplicaciones en producción. La 1.0 vino con una promesa: la API no cambia de forma incompatible sin una 2.0. Desde entonces no le había tocado nada.
Esta semana quise sacar una versión nueva. Esto es lo que entró, lo que no y por qué.
Lo primero que pensé no le servía a nadie
Mis dos ideas iniciales eran agregar Pyrefly, el type checker de Meta que llegó a la 1.0 en mayo, o subir la versión mínima de Python.
Las dos son mantenimiento, no producto. Pyrefly corre en CI y no cambia una sola línea de lo que se publica en PyPI. Subir la mínima de 3.10 a 3.11 le quita algo a quien sigue en 3.10 y no le da nada a nadie: una librería sin dependencias y sin sintaxis moderna en juego no gana casi nada con eso.
Las dos tenían su lugar, pero ninguna justificaba una versión. Lo que sí la justificaba estaba anotado en mi propio roadmap desde la 1.0.
El agente de soporte que escribe en minúsculas
Las referencias pueden usar el alfabeto base32 de Crockford: los dígitos y las letras, sin I, L, O ni U. Las tres primeras se quitaron porque se confunden con 1 y 0, y la U para que el código no forme groserías por accidente.
Crockford diseñó ese alfabeto para sobrevivir a un humano, y su especificación dice cómo leer lo que una persona escribe: minúsculas como mayúsculas, I y L como 1, O como 0. compactref tomó el alfabeto pero no esa tolerancia. Si un agente de soporte escribía hcp3csx, la librería le decía que la referencia no era válida.
Es exactamente la confusión que el carácter de control existe para evitar. La 1.1.0 lo arregla:
from compactref import CROCKFORD_BASE32, CompactRef
orders = CompactRef(
prefix="ORD",
separator="-",
alphabet=CROCKFORD_BASE32,
check=True,
tolerant=True,
)
orders.verify("ORD-20260710-hcp3csx") # True
orders.verify("ORD-2O26O7lO-HCP3CSX") # True: O por 0, l por 1
orders.normalize("ORD-20260710-hcp3csx") # 'ORD-20260710-HCP3CSX'
Por qué no viene activado
Mi roadmap decía que esto era seguro de agregar en cualquier 1.x: aceptar más entradas no cambia nada de lo que se genera ni invalida ninguna referencia guardada. Es verdad, pero incompleto.
El docstring de verify() prometía otra cosa: True significa que el texto es exactamente lo que este esquema habría producido. Hay gente que usa verify() como filtro antes de guardar:
if orders.verify(user_input):
save(user_input)
Si la tolerancia viniera activada, ese código empezaría a guardar referencias en minúsculas, y la siguiente búsqueda exacta en la base de datos no las encontraría. Nada falla, nada avisa, y el problema aparece semanas después.
Por eso tolerant está apagado por defecto y es el último campo de la clase, así ningún argumento posicional se movió. Por eso también existe normalize(): si True ya no significa “esta es la forma guardada”, alguien tiene que devolverte la forma guardada.
La tolerancia es del alfabeto, no del verificador
La segunda decisión fue dónde vive la regla. Pasar a mayúsculas sería un error en un alfabeto que contiene a y A como valores distintos, y leer I como 1 no significa nada en "abcdef". Así que tolerant=True con cualquier alfabeto que no sea el de Crockford lanza ValueError al construir el esquema.
Tampoco toca todo el texto. Solo se reinterpretan los campos que el alfabeto escribe: el sufijo, el carácter de control y la fecha de un esquema con control. El prefijo es una etiqueta que eligió quien configuró el esquema, y los separadores son puntuación, así que los dos se siguen comparando exactos. ord-20260710-hcp3csx se rechaza.
Eso dejó una pregunta abierta que anoté en el roadmap: si alguien que escribe en minúsculas lo escribe todo en minúsculas, la tolerancia se queda un campo corta. Prefiero decidirlo cuando alguien me lo pida con un caso real.
Sin GIL: probarlo en vez de suponerlo
Python 3.15 salió el 9 de octubre, y los builds free-threaded (sin GIL) existen desde la 3.13. El README de compactref recomienda construir el esquema una vez al importar y compartirlo. Sin GIL, “compartirlo” significa que varios hilos lo usan de verdad al mismo tiempo.
Revisé el código: CompactRef es un dataclass congelado y el módulo no guarda estado mutable. Debería ser seguro. Pero “debería” es una afirmación, no una prueba. Así que escribí el test:
barrier = threading.Barrier(THREADS)
def run(_: int) -> list[tuple[str, bool, str]]:
barrier.wait() # todos arrancan juntos
return all_of_them()
with ThreadPoolExecutor(max_workers=THREADS) as pool:
outcomes = list(pool.map(run, range(THREADS)))
assert all(outcome == expected for outcome in outcomes)
Ocho hilos usan el mismo esquema a la vez, y cada uno tiene que obtener exactamente lo mismo que un hilo solo. Hay un segundo test que abre un intérprete limpio y verifica que importar compactref no vuelve a activar el GIL, algo que Python hace por su cuenta, con apenas un warning, al importar cualquier extensión que no declare ser segura sin él.
Los dos pasaron a la primera, y eso me dio desconfianza. Un test que nunca falló no se sabe si puede fallar. Así que metí a propósito un error: un buffer global compartido dentro del encoder, el tipo de “optimización” que alguien podría agregar dentro de un año. El test falló sin GIL y también con GIL. Lo quité, y ahora sé que la prueba tiene dientes.
Con eso, el pyproject.toml declara Free Threading :: 3 - Stable, y el CI corre la suite en 3.14t en Linux y en 3.15t en Linux, macOS y Windows. No usé 4 - Resilient: un solo test de concurrencia no alcanza para decir eso.
Pyrefly, como aviso
Pyrefly sí entró, pero sin poder bloquear. mypy sigue siendo el type checker que decide. Pyrefly saca versión cada mes y agrega reglas nuevas, y una regla nueva que marca código que nadie tocó debería ser una nota en el PR, no un build rojo.
Lo puse como job aparte y no como un paso con continue-on-error dentro del job de lint. Un paso que falla en silencio deja el job en verde, y nadie abre un aviso escondido en un job verde. Como job propio aparece como su propio check.
En la primera corrida encontró nueve Any implícitos que mypy --strict deja pasar: una lista vacía sin tipo y ocho lambdas sin anotar en un test. Ninguno era un bug, pero los corregí para que empiece en cero. Así, lo que reporte de ahora en adelante será nuevo.
Lo que se rompió sin que nadie lo tocara
El primer CI del PR falló en el lint, en unas líneas del README que el PR no había tocado.
Ruff 0.17 empezó a formatear los bloques de código dentro del Markdown. El CI instala la última versión de ruff, así que el mismo commit que pasaba ayer fallaba hoy. Y el formato que quería aplicar desalineaba los comentarios que yo había alineado en columnas a propósito para que los ejemplos se leyeran como una tabla de resultados.
Excluí el Markdown del formateador, pero la causa sigue ahí: las herramientas de desarrollo no tienen techo de versión. Quedó anotado en el roadmap: rangos para ruff y mypy, y subir el techo a propósito, en un commit propio, después de leer qué trae cada versión.
Probar lo que se publica, no lo que se construye
Las versiones de PyPI no se pueden reemplazar, así que antes de publicar hice el ensayo en TestPyPI. Instalé lo que subió, en entornos limpios con 3.10, 3.15 y 3.15t, y corrí la suite completa contra el paquete instalado, no contra el código fuente. También comparé el contenido del wheel de TestPyPI con un build local de main, y después el de PyPI con el de TestPyPI: idénticos, py.typed incluido.
¿Hay competencia?
Busqué en PyPI. Hay vecinos, pero ninguno hace lo mismo:
- shortuuid reescribe un UUID en base57. Si lo recortas deja de ser único, y no te ayuda a saber cuánto.
- sqids y hashids convierten enteros en IDs cortos para URLs, y se pueden decodificar. Parten de números, no de un ULID o un UUID.
- nanoid genera IDs aleatorios: no son deterministas ni salen de un ID existente.
compactref deriva una referencia determinista de un ID que ya tienes, con fecha, namespace y carácter de control, y te da las cuentas para elegir su largo según tu volumen. Es un nicho chico, pero es el problema que yo tenía.
Lo que me llevo
- Lo que pide quien usa la librería suele estar en el roadmap, no en las herramientas nuevas que tienes ganas de probar.
- “Es aditivo” no es lo mismo que “es seguro activarlo por defecto”. Aceptar más entradas no rompe las referencias guardadas, pero sí puede cambiar lo que significa un
True. - Un test que nunca falló no prueba nada todavía. Rómpelo a propósito una vez.
- Lo que no tiene techo de versión cambia aunque tú no hagas nada.
La 1.1.0 está en PyPI, y el detalle completo en el CHANGELOG.
Comentarios