From 8bdc4243a86536150840d636c65a3a12c507b533 Mon Sep 17 00:00:00 2001 From: gaoflow Date: Wed, 17 Jun 2026 12:02:39 +0200 Subject: [PATCH] Don't apply self-referential replacements twice Custom replacements run in two passes: once before unidecode and once in the finalize step (so rules can re-fire on tokens that reappear after conversion, per issue #119). For a self-referential rule (the replacement contains its own search value, e.g. ['a', 'aa']) the finalize pass re-fires on the first pass's output and grows the slug each run: slugify('a', replacements=[['a', 'aa']]) returned 'aaaa' instead of 'aa'. Skip only those self-referential rules in the finalize pass. The intentional re-scan is preserved for every other rule, and all existing replacement behavior is unchanged. --- CHANGELOG.md | 1 + slugify/slugify.py | 5 +++++ test.py | 9 +++++++++ 3 files changed, 15 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 537460e..e1b37c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - Fix type annotation issues identified by mypy. - Run CI against pull requests. - Fix package build warnings. +- Fix custom replacements being applied twice when a replacement contains its own search value (e.g. `['a', 'aa']` no longer yields `aaaa`). ## 8.0.4 diff --git a/slugify/slugify.py b/slugify/slugify.py index 9b5f27f..fed8d4b 100644 --- a/slugify/slugify.py +++ b/slugify/slugify.py @@ -185,6 +185,11 @@ def slugify( # finalize user-specific replacements if replacements: for old, new in replacements: + # skip self-referential rules (``old`` present in ``new``); these were + # already applied in the pre-process pass and re-applying them here + # would grow the slug on every run (e.g. ['a', 'aa'] -> 'aaaa'). + if old and old in new: + continue text = text.replace(old, new) # smart truncate if requested diff --git a/test.py b/test.py index fcec4b6..8f4249e 100644 --- a/test.py +++ b/test.py @@ -237,6 +237,15 @@ def test_replacements_german_umlaut_custom(self): r = slugify(txt, replacements=[['Ü', 'UE'], ['ü', 'ue']]) self.assertEqual(r, "ueber-ueber-german-umlaut") + def test_replacements_not_applied_twice(self): + # A replacement whose value contains its own key must not be applied + # twice (pre + finalize pass), which used to grow the slug. + r = slugify('a', replacements=[['a', 'aa']]) + self.assertEqual(r, "aa") + + r = slugify('hello', replacements=[['l', 'll']]) + self.assertEqual(r, "hellllo") + def test_pre_translation(self): self.assertEqual(PRE_TRANSLATIONS, [('Ю', 'U'), ('Щ', 'Sch'), ('У', 'Y'), ('Х', 'H'), ('Я', 'Ya'), ('Ё', 'E'), ('ё', 'e'), ('я', 'ya'), ('х', 'h'), ('у', 'y'), ('щ', 'sch'), ('ю', 'u'), ('Ü', 'Ue'), ('Ö', 'Oe'), ('Ä', 'Ae'), ('ä', 'ae'), ('ö', 'oe'), ('ü', 'ue'), ('Ϋ́', 'Y'), ('Ϋ', 'Y'), ('Ύ', 'Y'), ('Υ', 'Y'), ('Χ', 'Ch'), ('χ', 'ch'), ('Ξ', 'X'), ('ϒ', 'Y'), ('υ', 'y'), ('ύ', 'y'), ('ϋ', 'y'), ('ΰ', 'y')])