Skip to content

Treat the typographic apostrophe family as a separator in modern mode (#3) - #10

Open
lomeliDev wants to merge 1 commit into
masterfrom
issue/3-modern-typographic-apostrophe-u-2018-u-2019-slugs-di
Open

lomeliDev wants to merge 1 commit into
masterfrom
issue/3-modern-typographic-apostrophe-u-2018-u-2019-slugs-di

Conversation

@lomeliDev

Copy link
Copy Markdown
Owner

Issue

Referencia (sin solicitud de cierre automático): #3
«Modern: typographic apostrophe (U+2018/U+2019) slugs differently from ASCII '»

Causa raíz

slugify/slugify.py resolvía sólo el apóstrofe ASCII en el paso de comillas previo a la
normalización (QUOTE_PATTERN = re.compile(r"[']+") → separador). Un apóstrofe tipográfico no
coincidía ahí, sobrevivía al paso, el backend lo transliteraba a ' ASCII y entonces lo borraba el
segundo paso (QUOTE_PATTERN.sub('', text)). De ahí la divergencia:

entrada antes (modern) ahora (modern) legacy (sin cambio)
don't (U+0027) don-t don-t don-t
don’t (U+2019) dont don-t dont
don‘t (U+2018) dont don-t dont
donʼt (U+02BC) dont don-t dont

El arreglo une U+2018, U+2019 y U+02BC al ' ASCII en ese primer paso, que es justo lo que
modern ya producía con allow_unicode=True (don-t), así que el cambio alinea los dos modos de
modern entre sí.

Cambio mínimo

  • slugify/slugify.py: nueva constante MODERN_APOSTROPHE_PATTERN = re.compile(r"['\u02bc\u2018\u2019]+"),
    usada sólo en el paso previo a la normalización. El segundo paso (QUOTE_PATTERN.sub('', text))
    se deja ASCII a propósito: las comillas fonéticas que genera el backend se siguen borrando, no
    se convierten en separador (distinción upstream docs: nota de prueba 3 en README.md (1791997097) #17/docs: ejemplo de smart_truncate con save_order en el README #23; Іванько sigue dando ivanko).
    Clausa añadida al docstring de slugify() que enumera los deltas de modern.
  • tests/test_modern_unicode.py (nuevo): 8 tests de regresión.
  • tests/test_release.py: golden actualizado (ver «Desviaciones de alcance»).

Legacy intacto: slugify/_legacy.py no se toca y el diferencial congelado de 2.688 casos sigue
pasando (tests/test_release.py::test_legacy_goldens_and_differential).

Propagación: qué otros caminos comparten el mismo caso

No usé ninguna lección de memoria durable (la búsqueda en el cerebro compartido devolvió 0 resultados
para python-slugify modern algorithm apostrophe QUOTE_PATTERN unicode regex lesson, en modo léxico
degradado; los dos catálogos MEMORY.md locales están vacíos). Aun así relevé el patrón
«comillas sólo-ASCII en el paso de entrada» en todo el repo:

Camino Mismo caso Decisión
slugify/slugify.py:18 QUOTE_PATTERN sí queda sólo para el paso post-transliteración, donde ASCII-only es intencional
slugify/_legacy.py:22,137,146 sí congelado por AGENTS.md; legacy sigue dando dont (fijado por test)
tools/legacy_reference.py:20,119,161 sí oráculo congelado (7b6d5d9); tocarlo rompería el diferencial de 2.688 casos
slugify/special.py no sólo tablas de mayúsculas/pre-traducción, sin manejo de comillas
slugify/__main__.py no no preprocesa comillas; reenvía a slugify(), así que el CLI hereda el arreglo

CLI verificado: python -m slugify --algorithm modern 'don’t' → don-t; python -m slugify 'don’t'
(legacy por defecto) → dont.

Evidencia

Vista roja (el test distingue el efecto)

Sólo se revierte el código de producción; los tests se quedan. Bytecode limpiado entre corrida y
corrida (rm -rf slugify/__pycache__ tests/__pycache__ + -p no:cacheprovider).

$ git show HEAD:slugify/slugify.py > slugify/slugify.py   # quita ÚNICAMENTE el arreglo
$ rm -rf slugify/__pycache__ tests/__pycache__
$ env/bin/python -m pytest -p no:cacheprovider tests/test_modern_unicode.py tests/test_release.py -q
SUBFAILED(run='‘ʼ') tests/test_modern_unicode.py::ModernApostropheFamilyTests::test_apostrophe_runs_collapse_to_a_single_separator - AssertionError: 'dont' != 'don-t'
- dont
+ don-t
?    +
SUBFAILED(apostrophe='ʼ', allow_unicode=False) tests/test_modern_unicode.py::ModernApostropheFamilyTests::test_typographic_apostrophe_matches_ascii - AssertionError: 'dont' != 'don-t'
- dont
+ don-t
?    +
SUBFAILED(apostrophe='ʼ', allow_unicode=True) tests/test_modern_unicode.py::ModernApostropheFamilyTests::test_typographic_apostrophe_matches_ascii - AssertionError: 'donʼt' != 'don-t'
- donʼt
?    ^
+ don-t
?    ^
SUBFAILED(apostrophe='‘', allow_unicode=False) tests/test_modern_unicode.py::ModernApostropheFamilyTests::test_typographic_apostrophe_matches_ascii - AssertionError: 'dont' != 'don-t'
- dont
+ don-t
?    +
SUBFAILED(apostrophe='’', allow_unicode=False) tests/test_modern_unicode.py::ModernApostropheFamilyTests::test_typographic_apostrophe_matches_ascii - AssertionError: 'dont' != 'don-t'
- dont
+ don-t
?    +
FAILED tests/test_modern_unicode.py::ModernApostropheFamilyTests::test_typographic_apostrophe_matches_ascii - AssertionError: 'babys-shoes' != 'baby-s-shoes'
- babys-shoes
+ baby-s-shoes
?     +
FAILED tests/test_release.py::ReleaseRegressionTests::test_normalization_ordinals_dashes_and_phonetic_quotes - AssertionError: 'babys-shoes' != 'baby-s-shoes'
- babys-shoes
+ baby-s-shoes
?     +
21 failed, 47 passed, 7 skipped, 138 subtests passed in 1.01s

Vista verde (suite COMPLETA, no sólo el test nuevo)

$ cp env/slugify_fixed.py slugify/slugify.py   # restaura el arreglo
$ rm -rf slugify/__pycache__ tests/__pycache__
$ env/bin/python -m pytest -p no:cacheprovider tests -q
........................................................................ [ 51%]
.....................................................................    [100%]
141 passed, 7 skipped, 172 subtests passed in 0.66s

Base antes del cambio, mismo entorno: 133 passed, 5 skipped, 136 subtests passed (+8 tests nuevos;
los 2 skips extra son los backends unidecode/anyascii ausentes en este entorno).

Puerta de calidad reproducida y sobre qué versiones

Workflows leídos: .github/workflows/taller-ci.yml (check requerido en PRs del worker; matriz
3.11, 3.12, fail-fast: false), .github/workflows/main.yml (pull_request, un job Linux que
corre tox completo sobre pypy3.11 y 3.10–3.14), .github/workflows/ci.yml y dev.yml
(sólo push a ci/staging y sandbox/dev; no aplican a este PR).

Reproducido aquí sólo con lo ya instalado y sin salir del worktree (venv local env/,
gitignoreado, con pip install -e . pytest flake8 pycodestyle, es decir exactamente el install de
taller-ci):

  • Python 3.11.2 — única versión de la matriz de taller-ci presente en esta máquina.
    • python -m pytest tests → 141 passed, 7 skipped
    • flake8 --ignore=E501,F403,F401,E241,E225,E128 --exclude=legacy_reference.py slugify setup.py tests tools → exit 0
    • pycodestyle --ignore=E128,E261,E225,E501,W605 --exclude=legacy_reference.py slugify tests setup.py tools → exit 0
  • Extras del tox.ini reproducidos sobre 3.11.2: mypy (strict, packages = slugify) →
    Success: no issues found in 6 source files.

No reproducidas (exigirían descargar intérpretes o escribir fuera del worktree, prohibido aquí):
Python 3.12 de taller-ci y pypy3.11, 3.10, 3.12, 3.13, 3.14 de main.yml, así como los entornos
tox por backend (unidecode sin text-unidecode, anyascii) y tox -e packaging
(tools/check_dist.py construye en directorios temporales externos). Sintaxis de riesgo revisada al
leer los workflows y el diff: el cambio no introduce sintaxis nueva — una constante re.compile
con escapes \uXXXX dentro de string crudo (soportados por re desde Python 3.3) y f-strings que el
repo ya usa; nada de match, walrus, genéricos PEP 695 ni nada posterior a 3.10. Riesgo de parseo en
versiones viejas: ninguno identificado.

Alcance del diff

git diff --cached --stat del commit 30721fc:

 slugify/slugify.py           |   8 +++-
 tests/test_modern_unicode.py | 110 +++++++++++++++++++++++++++++++++++++++++++
 tests/test_release.py        |   4 +-
 3 files changed, 119 insertions(+), 3 deletions(-)

Sin lockfiles, sin ficheros ajenos a la Issue, sin secretos ni configuración local
(.env*, .mcp.json, .claude/settings*.json siguen gitignoreados y fuera del commit). Los
artefactos de trabajo (venv env/, caches) están gitignoreados y no se commitearon.

Desviación respecto a los Archivos: de la Issue (que listaba slugify/slugify.py y
tests/test_modern_unicode.py): se toca además tests/test_release.py, y es forzoso —
test_normalization_ordinals_dashes_and_phonetic_quotes fijaba como golden de modern justo el
comportamiento que la Issue declara bug (slugify('Baby’s shoes') == 'babys-shoes'). Sin actualizarlo
la suite queda roja. Se cambió a 'baby-s-shoes' y se añadió una aserción legacy
(public_slugify('Baby’s shoes') == 'babys-shoes') que fija la divergencia congelada. Esto sigue la
convención del repo: cf6cd3d (un33k#201) también actualizó tests/test_release.py dentro de un PR de
feature.

Fuera de alcance / seguimiento

  1. Documentación contradictoria (no tocada). docs/release-9/migration.md (~líneas 71-72) dice:
    «ASCII and curly apostrophes remain distinct; use explicit pre replacements if your application
    needs a consistent apostrophe boundary». Tras este cambio esa frase es falsa para modern
    (sigue siendo cierta para legacy). No la edité porque la convención del repo, verificada en
    git log, es que los PRs de feature tocan sólo código+tests y los ajustes de docs/CHANGELOG van en
    commits docs:/Release aparte (p. ej. c9e9698 no tocó docs; 54d81f6 fue un commit docs:
    independiente). Digo dónde está para que el maintainer decida: puedo incluirla en este PR si se
    prefiere. Propongo algo como: «Legacy keeps ASCII and curly apostrophes distinct; modern maps the
    U+2018/U+2019/U+02BC apostrophe family to a separator like ASCII '. Backend-generated phonetic
    quotes are still removed in both modes.»
  2. CHANGELOG.md sin tocar: no hay sección «Unreleased» y el número de versión es decisión de release
    (AGENTS.md).
  3. docs/release-9/historical-review.md (tabla T3, fila Baby’s shoes → babys-shoes) se deja tal
    cual: es un registro de mediciones fechado, y AGENTS.md pide preservar las notas históricas como
    evidencia datada.
  4. Caracteres «quote-like» deliberadamente fuera de la familia. La Issue nombra U+2018/U+2019 y
    U+02BC; no amplié el conjunto. Estos siguen igual que hoy y siguen coincidiendo con legacy
    (dont), fijado por test_quote_like_characters_outside_the_family_are_unchanged:
    U+201B ‛, U+2032 ′, U+FF07 ', U+02B9 ʹ, U+05F3 ׳.
    Gap concreto que reporto honestamente: U+201B, U+2032 y U+05F3 sí dan don-t con
    allow_unicode=True, o sea que siguen siendo internamente inconsistentes entre los dos modos de
    modern (U+FF07 da dont en ambos y U+02B9 conserva el carácter en modo unicode). Si se quiere la
    familia completa, eso es una Issue nueva, no este arreglo.

🚀 Generated with Dojo ⛩️

…#3)

Modern resolved only ASCII "'" in the pre-normalization quote pass, so a
typographic apostrophe survived it, was transliterated to "'" and then
deleted by the post-transliteration pass: "don't" slugged "don-t" while
"don’t" slugged "dont". U+2018, U+2019 and U+02BC now join ASCII "'" in
that first pass, matching what modern already produced under
allow_unicode. Quotes a backend generates stay ASCII-only and are still
removed, preserving the upstream #17/#23 distinction, so "Іванько" still
slugs "ivanko".

Legacy output is untouched and the frozen 2,688-case differential still
passes. The one modern golden that encoded the old behavior
("Baby’s shoes" -> "babys-shoes") is updated, alongside a new legacy
assertion pinning the frozen divergence.

🚀 Generated with [Dojo](https://heydojo.ai) ⛩️
@lomeliDev

Copy link
Copy Markdown
Owner Author

Revisión adversarial independiente — APROBABLE (con nota de alcance)

Revisado por quien no escribió el código, worktree propio (origin/pr/10), import resuelto al worktree, Python 3.11.2.

Vista roja/verde (efecto, no forma). Revertí slugify/slugify.py a origin/master dejando los tests, con bytecode limpio (-p no:cacheprovider):

# SIN el cambio: 21 failed  (p. ej. test_typographic_apostrophe_matches_ascii: 'dont' != 'don-t')
# CON el cambio: suite COMPLETA -> 141 passed, 7 skipped, 172 subtests passed   (base 133 + 8)

Efecto verificado (modern unifica la familia; legacy congelado):

0x27  modern=don-t  legacy=don-t
0x2019 modern=don-t  legacy=dont
0x2018 modern=don-t  legacy=dont
0x02bc modern=don-t  legacy=dont

El diferencial legacy congelado de 2.688 casos (test_legacy_goldens_and_differential) sigue en verde, y Компьютер -> kompiuter confirma que las comillas fonéticas que genera el backend se siguen borrando (no se vuelven separador): la distinción upstream #17/#23 se respeta porque el 2º paso queda ASCII a propósito.

Puerta fiel al CI (taller-ci), reproducida: flake8 exit 0, pycodestyle exit 0, pytest 141 passed. CI del fork verde en 3.11/3.12.

Nota de alcance (no bloqueante). El diff toca tests/test_release.py, fuera del Archivos: de la issue (slugify.py, test_modern_unicode.py). Es forzoso y correcto: el golden test_normalization_ordinals_dashes_and_phonetic_quotes fijaba justo el comportamiento-bug ('Baby's shoes' -> 'babys-shoes'); sin actualizarlo la suite queda roja. El PR lo cambia a 'baby-s-shoes' y añade una aserción que fija la divergencia legacy congelada. Bien resuelto. Gap divulgado honestamente por el autor (U+201B/U+2032/U+05F3 siguen inconsistentes con allow_unicode=True): queda correctamente fuera, ceñido a la familia que nombra la issue (U+2018/U+2019/U+02BC); es material para una issue nueva.

Sin reparos sobre el código.

— revisión adversarial automática, sesión Claude Code (independiente del autor).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant