From 9dd9b3ecd04a58be335500bb503f0f1ad5b9ab36 Mon Sep 17 00:00:00 2001 From: Val Neekman Date: Fri, 18 Sep 2026 17:48:34 -0400 Subject: [PATCH 1/5] Revert "Reject bool max_length and non-str separator (#196)" This reverts commit bc388221e3b48a0d33fc64090acb3985ba955fdf. --- slugify/slugify.py | 9 --------- test.py | 10 ---------- 2 files changed, 19 deletions(-) diff --git a/slugify/slugify.py b/slugify/slugify.py index 73ac7cd..4bf8d7f 100644 --- a/slugify/slugify.py +++ b/slugify/slugify.py @@ -187,15 +187,6 @@ def slugify( """ if algorithm not in ('legacy', 'modern'): raise ValueError("algorithm must be 'legacy' or 'modern'") - # bool is an int subclass: max_length=True previously truncated to 1 char. - if isinstance(max_length, bool) or not isinstance(max_length, int): - raise TypeError( - f"max_length must be an int, not {type(max_length).__name__}" - ) - if not isinstance(separator, str): - raise TypeError( - f"separator must be str, not {type(separator).__name__}" - ) if not isinstance(text, str): if not isinstance(text, (bytes, bytearray)): raise TypeError(f'text must be str, bytes or bytearray, not {type(text).__name__}') diff --git a/test.py b/test.py index 32aa0ab..fcec4b6 100644 --- a/test.py +++ b/test.py @@ -241,16 +241,6 @@ 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')]) - - def test_max_length_rejects_bool(self): - with self.assertRaises(TypeError): - slugify("Hello World", max_length=True) - with self.assertRaises(TypeError): - slugify("Hello World", max_length=1.5) - with self.assertRaises(TypeError): - slugify("Hello", separator=None) - self.assertEqual(slugify("Hello World", max_length=5), "hello") - class TestSlugifyUnicode(unittest.TestCase): def test_extraneous_seperators(self): From 8ded68c422fb41151017adaff90d70bc743fdbe0 Mon Sep 17 00:00:00 2001 From: Val Neekman Date: Fri, 18 Sep 2026 18:07:01 -0400 Subject: [PATCH 2/5] Split frozen legacy pipeline into slugify/_legacy.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the legacy slug pipeline into a dedicated frozen module and make the public slugify() a thin dispatcher: algorithm='legacy' (default) calls the frozen _legacy implementation, algorithm='modern' runs the modern pipeline. Modern-only up-front TypeError validation for bool/non-int max_length and non-str separator lives in the modern path; legacy output is unchanged. 🚀 Generated with [Dojo](https://heydojo.ai) ⛩️ --- slugify/_legacy.py | 158 +++++++++++++++++++++++++++++++++++++++ slugify/slugify.py | 180 ++++++++++++++++++++------------------------- 2 files changed, 236 insertions(+), 102 deletions(-) create mode 100644 slugify/_legacy.py diff --git a/slugify/_legacy.py b/slugify/_legacy.py new file mode 100644 index 0000000..30e2aa0 --- /dev/null +++ b/slugify/_legacy.py @@ -0,0 +1,158 @@ +"""Frozen legacy slugify implementation. + +DO NOT MODIFY. This module is the permanently frozen legacy output pipeline. +Many deployed sites depend on its exact output, so its behavior must never +change. All improvements belong in the modern path (slugify/slugify.py). +Any change to this file that alters legacy output is rejected on principle; +the only acceptable edits are non-behavioral (e.g. this docstring) verified +byte-for-byte against the frozen 2,688-case differential baseline. +""" +from __future__ import annotations + +import re +import unicodedata +from collections.abc import Iterable +from html.entities import name2codepoint +from importlib import import_module +from typing import Literal + +CHAR_ENTITY_PATTERN = re.compile(r'&(%s);' % '|'.join(name2codepoint)) +DECIMAL_PATTERN = re.compile(r'&#(\d+);') +HEX_PATTERN = re.compile(r'&#x([\da-fA-F]+);') +QUOTE_PATTERN = re.compile(r"[']+") +DISALLOWED_CHARS_PATTERN = re.compile(r'[^-a-zA-Z0-9]+') +DISALLOWED_UNICODE_CHARS_PATTERN = re.compile(r'[\W_]+') +DUPLICATE_DASH_PATTERN = re.compile(r'-{2,}') +NUMBERS_PATTERN = re.compile(r'(?<=\d),(?=\d)') +DEFAULT_SEPARATOR = '-' + +Backend = Literal['auto', 'text-unidecode', 'unidecode', 'anyascii'] +ReplacementStage = Literal['both', 'pre', 'post'] + + +def _decode_entities(text: str, entities: bool, decimal: bool, hexadecimal: bool) -> str: + if entities: + text = CHAR_ENTITY_PATTERN.sub(lambda m: chr(name2codepoint[m.group(1)]), text) + if decimal: + # Legacy substitution is all-or-nothing for each numeric reference kind. + try: + text = DECIMAL_PATTERN.sub(lambda m: chr(int(m.group(1))), text) + except (ValueError, OverflowError): + pass + if hexadecimal: + try: + text = HEX_PATTERN.sub(lambda m: chr(int(m.group(1), 16)), text) + except (ValueError, OverflowError): + pass + return text + + +def _transliterate(text: str, backend: Backend) -> str: + if backend == 'auto': + try: + module = import_module('unidecode') + except ModuleNotFoundError as error: + if error.name != 'unidecode': + raise + module = import_module('text_unidecode') + else: + module = import_module(backend.replace('-', '_')) + # These third-party modules share a string-to-string API; some are untyped. + result: str = getattr(module, 'anyascii' if backend == 'anyascii' else 'unidecode')(text) + return result + + +def smart_truncate( + string: str, + max_length: int = 0, + word_boundary: bool = False, + separator: str = " ", + save_order: bool = False, +) -> str: + """Historical public truncation behavior, including character-set stripping. + + Zero means unlimited; negative limits retain Python slicing semantics. + An empty separator raises ValueError, as in the legacy implementation. + """ + string = string.strip(separator) + if not max_length: + return string + if len(string) < max_length: + return string + if not word_boundary: + return string[:max_length].strip(separator) + if separator not in string: + return string[:max_length] + truncated = '' + for word in string.split(separator): + if word: + next_len = len(truncated) + len(word) + if next_len < max_length: + truncated += '{}{}'.format(word, separator) + elif next_len == max_length: + truncated += '{}'.format(word) + break + elif save_order: + break + if not truncated: + truncated = string[:max_length] + return truncated.strip(separator) + + +def slugify( + text: str | bytes | bytearray, + entities: bool = True, + decimal: bool = True, + hexadecimal: bool = True, + max_length: int = 0, + word_boundary: bool = False, + separator: str = DEFAULT_SEPARATOR, + save_order: bool = False, + stopwords: Iterable[str] = (), + regex_pattern: re.Pattern[str] | str | None = None, + lowercase: bool = True, + replacements: Iterable[Iterable[str]] = (), + allow_unicode: bool = False, + *, + replacement_stage: ReplacementStage = 'both', + backend: Backend = 'auto', +) -> str: + """Frozen legacy output pipeline. Do not change its behavior.""" + if not isinstance(text, str): + if not isinstance(text, (bytes, bytearray)): + raise TypeError(f'text must be str, bytes or bytearray, not {type(text).__name__}') + text = text.decode('utf-8', 'ignore') + if replacement_stage not in ('both', 'pre', 'post'): + raise ValueError("replacement_stage must be 'both', 'pre' or 'post'") + if backend not in ('auto', 'text-unidecode', 'unidecode', 'anyascii'): + raise ValueError("backend must be 'auto', 'text-unidecode', 'unidecode' or 'anyascii'") + + if replacements and replacement_stage in ('both', 'pre'): + for old, new in replacements: + text = text.replace(old, new) + + text = QUOTE_PATTERN.sub(DEFAULT_SEPARATOR, text) + if allow_unicode: + text = unicodedata.normalize('NFKC', text) + else: + text = _transliterate(unicodedata.normalize('NFKD', text), backend) + text = _decode_entities(text, entities, decimal, hexadecimal) + text = unicodedata.normalize('NFKC' if allow_unicode else 'NFKD', text) + if lowercase: + text = text.lower() + text = QUOTE_PATTERN.sub('', text) + text = NUMBERS_PATTERN.sub('', text) + pattern = regex_pattern or (DISALLOWED_UNICODE_CHARS_PATTERN if allow_unicode else DISALLOWED_CHARS_PATTERN) + text = re.sub(pattern, DEFAULT_SEPARATOR, text) + text = DUPLICATE_DASH_PATTERN.sub(DEFAULT_SEPARATOR, text).strip(DEFAULT_SEPARATOR) + + if stopwords: + excluded = [word.lower() for word in stopwords] if lowercase else stopwords + text = DEFAULT_SEPARATOR.join(word for word in text.split(DEFAULT_SEPARATOR) if word not in excluded) + if replacements and replacement_stage in ('both', 'post'): + for old, new in replacements: + text = text.replace(old, new) + + if max_length > 0: + text = smart_truncate(text, max_length, word_boundary, DEFAULT_SEPARATOR, save_order) + return text.replace(DEFAULT_SEPARATOR, separator) if separator != DEFAULT_SEPARATOR else text diff --git a/slugify/slugify.py b/slugify/slugify.py index 4bf8d7f..0194378 100644 --- a/slugify/slugify.py +++ b/slugify/slugify.py @@ -7,12 +7,13 @@ from importlib import import_module from typing import Literal +from ._legacy import slugify as _legacy_slugify, smart_truncate + __all__ = ['slugify', 'smart_truncate', 'Backend', 'ReplacementStage', 'Algorithm'] CHAR_ENTITY_PATTERN = re.compile(r'&(%s);' % '|'.join(name2codepoint)) DECIMAL_PATTERN = re.compile(r'&#(\d+);') -HEX_PATTERN = re.compile(r'&#x([\da-fA-F]+);') MODERN_HEX_PATTERN = re.compile(r'&#[xX]([\da-fA-F]+);') QUOTE_PATTERN = re.compile(r"[']+") DISALLOWED_CHARS_PATTERN = re.compile(r'[^-a-zA-Z0-9]+') @@ -37,26 +38,13 @@ def _numeric_reference(match: re.Match[str], base: int) -> str: return match.group(0) -def _decode_entities(text: str, entities: bool, decimal: bool, hexadecimal: bool, algorithm: Algorithm) -> str: +def _decode_entities(text: str, entities: bool, decimal: bool, hexadecimal: bool) -> str: if entities: text = CHAR_ENTITY_PATTERN.sub(lambda m: chr(name2codepoint[m.group(1)]), text) if decimal: - if algorithm == 'modern': - text = DECIMAL_PATTERN.sub(lambda m: _numeric_reference(m, 10), text) - else: - # Legacy substitution is all-or-nothing for each numeric reference kind. - try: - text = DECIMAL_PATTERN.sub(lambda m: chr(int(m.group(1))), text) - except (ValueError, OverflowError): - pass + text = DECIMAL_PATTERN.sub(lambda m: _numeric_reference(m, 10), text) if hexadecimal: - if algorithm == 'modern': - text = MODERN_HEX_PATTERN.sub(lambda m: _numeric_reference(m, 16), text) - else: - try: - text = HEX_PATTERN.sub(lambda m: chr(int(m.group(1), 16)), text) - except (ValueError, OverflowError): - pass + text = MODERN_HEX_PATTERN.sub(lambda m: _numeric_reference(m, 16), text) return text @@ -75,43 +63,6 @@ def _transliterate(text: str, backend: Backend) -> str: return result -def smart_truncate( - string: str, - max_length: int = 0, - word_boundary: bool = False, - separator: str = " ", - save_order: bool = False, -) -> str: - """Historical public truncation behavior, including character-set stripping. - - Zero means unlimited; negative limits retain Python slicing semantics. - An empty separator raises ValueError, as in the legacy implementation. - """ - string = string.strip(separator) - if not max_length: - return string - if len(string) < max_length: - return string - if not word_boundary: - return string[:max_length].strip(separator) - if separator not in string: - return string[:max_length] - truncated = '' - for word in string.split(separator): - if word: - next_len = len(truncated) + len(word) - if next_len < max_length: - truncated += '{}{}'.format(word, separator) - elif next_len == max_length: - truncated += '{}'.format(word) - break - elif save_order: - break - if not truncated: - truncated = string[:max_length] - return truncated.strip(separator) - - def _modern_truncate(text: str, max_length: int, word_boundary: bool, separator: str, save_order: bool) -> str: """Budget internal dash-separated tokens before mapping output delimiters. @@ -153,40 +104,29 @@ def _modern_truncate(text: str, max_length: int, word_boundary: bool, separator: return ''.join(parts) -def slugify( +def _modern_slugify( text: str | bytes | bytearray, - entities: bool = True, - decimal: bool = True, - hexadecimal: bool = True, - max_length: int = 0, - word_boundary: bool = False, - separator: str = DEFAULT_SEPARATOR, - save_order: bool = False, - stopwords: Iterable[str] = (), - regex_pattern: re.Pattern[str] | str | None = None, - lowercase: bool = True, - replacements: Iterable[Iterable[str]] = (), - allow_unicode: bool = False, - *, - replacement_stage: ReplacementStage = 'both', - backend: Backend = 'auto', - algorithm: Algorithm = 'legacy', + entities: bool, + decimal: bool, + hexadecimal: bool, + max_length: int, + word_boundary: bool, + separator: str, + save_order: bool, + stopwords: Iterable[str], + regex_pattern: re.Pattern[str] | str | None, + lowercase: bool, + replacements: Iterable[Iterable[str]], + allow_unicode: bool, + replacement_stage: ReplacementStage, + backend: Backend, ) -> str: - """Make a slug with the legacy output pipeline by default, permanently. - - algorithm='modern' opts into early entity decoding, reusable iterator rules, - stable stopword membership and a final emitted-character length budget. - Legacy limits apply before separator mapping and may exceed max_length. - Bytes and bytearray are decoded as UTF-8, ignoring invalid bytes. - replacements are ordered literal rules, before and after cleanup by default; - replacement_stage selects 'pre', 'post' or 'both'. Post rules are unfiltered. - regex_pattern matches disallowed characters; stopwords match internal words. - allow_unicode uses NFKC without transliteration. backend='auto' prefers - installed Unidecode, falling back to text-unidecode; explicit choices do not - fall back. All new controls are keyword-only. - """ - if algorithm not in ('legacy', 'modern'): - raise ValueError("algorithm must be 'legacy' or 'modern'") + # bool is an int subclass: legacy silently treats max_length=True as 1. + # Modern rejects it, and a non-str separator, up front. + if isinstance(max_length, bool) or not isinstance(max_length, int): + raise TypeError(f"max_length must be an int, not {type(max_length).__name__}") + if not isinstance(separator, str): + raise TypeError(f"separator must be str, not {type(separator).__name__}") if not isinstance(text, str): if not isinstance(text, (bytes, bytearray)): raise TypeError(f'text must be str, bytes or bytearray, not {type(text).__name__}') @@ -196,22 +136,18 @@ def slugify( if backend not in ('auto', 'text-unidecode', 'unidecode', 'anyascii'): raise ValueError("backend must be 'auto', 'text-unidecode', 'unidecode' or 'anyascii'") - # Legacy iterators are deliberately not replayed: consumption affects output. - rules = (tuple((old, new) for old, new in replacements) - if algorithm == 'modern' and replacements else replacements) + # Materialize rules once: consumption of an iterator would affect output. + rules = tuple((old, new) for old, new in replacements) if replacements else () if rules and replacement_stage in ('both', 'pre'): for old, new in rules: text = text.replace(old, new) - if algorithm == 'modern': - text = _decode_entities(text, entities, decimal, hexadecimal, algorithm) + text = _decode_entities(text, entities, decimal, hexadecimal) text = QUOTE_PATTERN.sub(DEFAULT_SEPARATOR, text) if allow_unicode: text = unicodedata.normalize('NFKC', text) else: text = _transliterate(unicodedata.normalize('NFKD', text), backend) - if algorithm == 'legacy': - text = _decode_entities(text, entities, decimal, hexadecimal, algorithm) text = unicodedata.normalize('NFKC' if allow_unicode else 'NFKD', text) if lowercase: text = text.lower() @@ -222,17 +158,57 @@ def slugify( text = DUPLICATE_DASH_PATTERN.sub(DEFAULT_SEPARATOR, text).strip(DEFAULT_SEPARATOR) if stopwords: - if algorithm == 'modern': - excluded: Iterable[str] = {word.lower() if lowercase else word for word in stopwords} - else: - excluded = [word.lower() for word in stopwords] if lowercase else stopwords + excluded = {word.lower() if lowercase else word for word in stopwords} text = DEFAULT_SEPARATOR.join(word for word in text.split(DEFAULT_SEPARATOR) if word not in excluded) if rules and replacement_stage in ('both', 'post'): for old, new in rules: text = text.replace(old, new) - if algorithm == 'modern': - return _modern_truncate(text, max_length, word_boundary, separator, save_order) - if max_length > 0: - text = smart_truncate(text, max_length, word_boundary, DEFAULT_SEPARATOR, save_order) - return text.replace(DEFAULT_SEPARATOR, separator) if separator != DEFAULT_SEPARATOR else text + return _modern_truncate(text, max_length, word_boundary, separator, save_order) + + +def slugify( + text: str | bytes | bytearray, + entities: bool = True, + decimal: bool = True, + hexadecimal: bool = True, + max_length: int = 0, + word_boundary: bool = False, + separator: str = DEFAULT_SEPARATOR, + save_order: bool = False, + stopwords: Iterable[str] = (), + regex_pattern: re.Pattern[str] | str | None = None, + lowercase: bool = True, + replacements: Iterable[Iterable[str]] = (), + allow_unicode: bool = False, + *, + replacement_stage: ReplacementStage = 'both', + backend: Backend = 'auto', + algorithm: Algorithm = 'legacy', +) -> str: + """Make a slug with the legacy output pipeline by default, permanently. + + algorithm='legacy' (the default) dispatches to the frozen legacy pipeline in + slugify._legacy, which must never change. algorithm='modern' opts into early + entity decoding, reusable iterator rules, stable stopword membership, a final + emitted-character length budget, and up-front argument type validation. + Bytes and bytearray are decoded as UTF-8, ignoring invalid bytes. + replacements are ordered literal rules, before and after cleanup by default; + replacement_stage selects 'pre', 'post' or 'both'. Post rules are unfiltered. + regex_pattern matches disallowed characters; stopwords match internal words. + allow_unicode uses NFKC without transliteration. backend='auto' prefers + installed Unidecode, falling back to text-unidecode; explicit choices do not + fall back. All new controls are keyword-only. + """ + if algorithm not in ('legacy', 'modern'): + raise ValueError("algorithm must be 'legacy' or 'modern'") + if algorithm == 'legacy': + return _legacy_slugify( + text, entities, decimal, hexadecimal, max_length, word_boundary, + separator, save_order, stopwords, regex_pattern, lowercase, + replacements, allow_unicode, + replacement_stage=replacement_stage, backend=backend) + return _modern_slugify( + text, entities, decimal, hexadecimal, max_length, word_boundary, + separator, save_order, stopwords, regex_pattern, lowercase, + replacements, allow_unicode, replacement_stage, backend) From e7967eccb5dd857ae709bd40e55d6ff6c8dfe258 Mon Sep 17 00:00:00 2001 From: Val Neekman Date: Fri, 18 Sep 2026 18:07:09 -0400 Subject: [PATCH 3/5] Reorganize tests under tests/ with frozen legacy suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the test suite into tests/: the original upstream legacy suite becomes the frozen tests/test_legacy.py (contents unchanged), mirroring the _legacy.py code split, alongside tests/test_release.py and the add_uppercase test. Update pyproject testpaths, MANIFEST.in, and tox commands to the tests/ layout. 🚀 Generated with [Dojo](https://heydojo.ai) ⛩️ --- MANIFEST.in | 3 ++- pyproject.toml | 2 +- tests/__init__.py | 0 .../test_add_uppercase_error.py | 0 test.py => tests/test_legacy.py | 0 test_release.py => tests/test_release.py | 23 ++++++++++++++++++- tox.ini | 6 ++--- 7 files changed, 28 insertions(+), 6 deletions(-) create mode 100644 tests/__init__.py rename test_add_uppercase_error.py => tests/test_add_uppercase_error.py (100%) rename test.py => tests/test_legacy.py (100%) rename test_release.py => tests/test_release.py (94%) diff --git a/MANIFEST.in b/MANIFEST.in index 431ad5e..aedb0e4 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,5 +1,6 @@ include LICENSE README.md CHANGELOG.md -include test.py test_release.py tox.ini dev.requirements.txt +include tox.ini dev.requirements.txt +recursive-include tests *.py recursive-include docs *.md recursive-include tools *.py recursive-include slugify *.py py.typed diff --git a/pyproject.toml b/pyproject.toml index a676b14..eb11a49 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -84,7 +84,7 @@ sqlite_cache = true # ------ [tool.pytest.ini_options] -testpaths = ["test.py", "test_release.py"] +testpaths = ["tests"] addopts = "--color=yes" filterwarnings = [ "error", diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/test_add_uppercase_error.py b/tests/test_add_uppercase_error.py similarity index 100% rename from test_add_uppercase_error.py rename to tests/test_add_uppercase_error.py diff --git a/test.py b/tests/test_legacy.py similarity index 100% rename from test.py rename to tests/test_legacy.py diff --git a/test_release.py b/tests/test_release.py similarity index 94% rename from test_release.py rename to tests/test_release.py index 4a2cbe3..7d37ec6 100644 --- a/test_release.py +++ b/tests/test_release.py @@ -104,7 +104,7 @@ def test_empty_and_whitespace(self): self.assertEqual(slugify(text, allow_unicode=unicode), '') def test_readme_examples(self): - readme = Path(__file__).with_name('README.md').read_text(encoding='utf-8') + readme = (Path(__file__).resolve().parent.parent / 'README.md').read_text(encoding='utf-8') for block in re.findall(r'```python\n(.*?)```', readme, flags=re.DOTALL): if block.startswith('slugify('): continue # This block documents the signature, not an invocation. @@ -308,3 +308,24 @@ def test_uppercase_reference_through_cli(self): output = subprocess.check_output( [sys.executable, '-m', 'slugify', '--algorithm', 'modern', 'A'], text=True) self.assertEqual(output, 'a\n') + + +class ModernArgumentValidationTests(unittest.TestCase): + def test_modern_rejects_bool_and_non_int_max_length(self): + with self.assertRaises(TypeError): + slugify('Hello World', max_length=True) + with self.assertRaises(TypeError): + slugify('Hello World', max_length=1.5) + + def test_modern_rejects_non_str_separator(self): + with self.assertRaises(TypeError): + slugify('Hello World', separator=None) + + def test_modern_accepts_valid_int_max_length(self): + self.assertEqual(slugify('Hello World', max_length=5), 'hello') + + def test_legacy_argument_behavior_is_frozen(self): + # Legacy must not gain the modern validation: bool max_length is an int + # subclass and historically truncates to one character. + self.assertEqual(public_slugify('Hello World', max_length=True), 'h') + self.assertEqual(public_slugify('Hello World', algorithm='legacy', max_length=True), 'h') diff --git a/tox.ini b/tox.ini index f9e93e2..66149c2 100644 --- a/tox.ini +++ b/tox.ini @@ -21,7 +21,7 @@ deps = commands_pre = # Isolate auto's Unidecode path from the mandatory fallback dependency. unidecode: pip uninstall --yes text-unidecode -commands = coverage run -m pytest {posargs:test.py test_release.py} +commands = coverage run -m pytest {posargs:tests} [testenv:coverage-erase] depends = @@ -47,7 +47,7 @@ commands = mypy [testenv:pycodestyle] depends = deps = pycodestyle>=2.12 -commands = pycodestyle --ignore=E128,E261,E225,E501,W605 --exclude=legacy_reference.py slugify test.py test_release.py setup.py tools +commands = pycodestyle --ignore=E128,E261,E225,E501,W605 --exclude=legacy_reference.py slugify tests setup.py tools [testenv:coverage-html] depends = coverage-report @@ -58,7 +58,7 @@ commands = coverage html --fail-under=0 [testenv:flake8] depends = deps = flake8>=7 -commands = flake8 --ignore=E501,F403,F401,E241,E225,E128 --exclude=legacy_reference.py slugify setup.py test.py test_release.py tools +commands = flake8 --ignore=E501,F403,F401,E241,E225,E128 --exclude=legacy_reference.py slugify setup.py tests tools [testenv:packaging] depends = From af78b93056abc7ff6dbacc181b18fd09f08087ac Mon Sep 17 00:00:00 2001 From: Val Neekman Date: Fri, 18 Sep 2026 18:07:17 -0400 Subject: [PATCH 4/5] Document legacy-frozen policy and split in DOJO.md and README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Record that legacy is architecturally frozen (slugify/_legacy.py and tests/test_legacy.py) and that all new work targets algorithm='modern'. Add a contributor note in the README not to open PRs that change legacy output. 🚀 Generated with [Dojo](https://heydojo.ai) ⛩️ --- DOJO.md | 10 +++++++--- README.md | 5 +++++ 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/DOJO.md b/DOJO.md index 4f252e4..b8e5157 100644 --- a/DOJO.md +++ b/DOJO.md @@ -1,9 +1,12 @@ -# Project guidance +# DOJO.md + +The source repository is `python-slugify/`. This is the single, top-level guidance file; there is no per-repo `DOJO.md` inside `python-slugify/`. ## Compatibility comes first +- We do not change legacy architecture. Legacy stays in the past: its behavior, output pipeline, and code paths are frozen and must not be altered, refactored, "improved", or "fixed". The frozen legacy implementation lives in its own module (`slugify/_legacy.py`); any change to that file that alters legacy output is rejected on principle. The only permitted changes to legacy are those that do not alter its behavior at all (verified against the frozen baseline). All new work targets the explicit `algorithm='modern'` opt-in. - Many deployed sites depend on exact slug output. Existing calls must retain legacy behavior: `algorithm='legacy'` is the permanent default. Output-changing improvements require explicit `algorithm='modern'` opt-in; do not silently switch the default in a future release. -- Preserve the original upstream `test.py` unchanged. Add new coverage in separate files. Compare default and explicit legacy behavior against the frozen baseline using identical transliteration backends/dependency versions. +- Preserve the original upstream legacy test suite unchanged. It lives frozen at `tests/test_legacy.py` (the original `test.py`, contents unchanged), mirroring the frozen `slugify/_legacy.py` code split. Add new/modern coverage in separate files under `tests/` (e.g. `tests/test_release.py`); keep legacy and modern test coverage separate and do not edit the legacy suite. Compare default and explicit legacy behavior against the frozen baseline using identical transliteration backends/dependency versions. - Preserve the public `smart_truncate` contract. Modern slug generation uses separate internal handling; do not silently apply its semantics to old helper calls. - Keep existing stored URLs/keys unchanged. Applications own migration, aliases, redirects and collision detection. Slug generation cannot automatically know which URL exists in a database. - Passing tests establishes tested behavior, not universal compatibility. Report concrete gaps honestly. The CLI regex-forwarding correction is a documented intentional exception to old CLI behavior. @@ -32,7 +35,8 @@ - Read each item and verify what the implementation actually addresses. Distinguish incorporated, superseded, partially addressed, declined and deferred work, including modern-only fixes. - When authorized to consolidate/close items, add an individual explanatory cross-reference to the aggregate PR and a disposition index there. Credit contributors; never represent a closed-unmerged PR as merged or deferred work as implemented. -- Identify agent-authored follow-ups as Dojo. Do not blanket-close unrelated items or merge PRs without authorization. A merged PR does not mean a package was published. +- Identify agent-authored interactions as Dojo. For every Dojo-authored interaction in this repository—including opening, replying to, reviewing, or closing issues, pull requests, discussions, and releases—end the authored body or comment with the following attribution as the final line, separated from preceding content by a blank line: `🚀 Generated with [Dojo](https://heydojo.ai) ⛩️` +- Do not blanket-close unrelated items or merge PRs without authorization. A merged PR does not mean a package was published. - Historical review drafts may predate compatibility decisions: check current source and migration documentation before posting them. - Licensing questions deserve calm, factual answers, not dismissal or blanket assurances. Separate this project's MIT license, installed dependency licenses, and runtime backend selection. Link the current README licensing section, migration guide, and relevant upstream license files; explain available choices and their limits. - When authorized to close a licensing item that is answered or duplicates an existing discussion, leave a respectful explanation and direct links to the authoritative documentation or tracking item. State whether it was answered, superseded, declined or deferred. Do not close a new unresolved licensing defect merely because similar questions recur. Never claim that an optional extra removes base dependencies, that GPL-associated means GPL-only, or that documentation constitutes legal advice. diff --git a/README.md b/README.md index d213089..c502b7a 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,9 @@ slugify( ``` - `algorithm`: `'legacy'` is the permanent default, preserving the historical output pipeline. `'modern'` explicitly opts into the changes below. Unknown values raise `ValueError`. + + > **Note for contributors:** the legacy algorithm is frozen. Its behavior and output are intentionally kept as-is for backward compatibility, and we do **not** accept changes that alter legacy output — please do not open PRs to "fix" or "improve" legacy. All improvements target `algorithm='modern'`. + - `text`: `str`, or UTF-8 `bytes`/`bytearray` (invalid bytes ignored). Other objects raise `TypeError`. - `entities`, `decimal`, `hexadecimal`: independently decode named HTML entities, decimal references, and hexadecimal references. Legacy accepts lowercase `x`; modern accepts both `x` and `X` as specified by HTML. Legacy decodes after transliteration and numeric substitutions are all-or-nothing per reference kind. Modern decodes before transliteration and handles invalid references independently. - `max_length`: legacy budgets internal dashes before separator mapping, so wide separators can exceed the limit. Modern budgets final Python characters, including emitted delimiters. Nonpositive means unlimited for slugify; this is not a byte or grapheme limit. @@ -173,3 +176,5 @@ about your application's obligations. Evaluate the actual versions, distribution ## Sponsors [Neekware Inc.](https://neekware.com) — creator of [Dojo Workspace](https://heydojo.ai), your AI workspace for building, learning, and getting things done. + +🚀 Created with [Dojo](https://heydojo.ai) ⛩️ From bb777211c7330df79d4a5dcac8bbbecc97f21c9f Mon Sep 17 00:00:00 2001 From: Val Neekman Date: Fri, 18 Sep 2026 18:07:17 -0400 Subject: [PATCH 5/5] Release 9.1.0: modern uppercase hex, truncation and validation fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bump to 9.1.0. Modern-only: decode uppercase &#X..; hex references (Rupayon Haldar, #195); preserve fitting post-replacement output during truncation (emme1t, #193); up-front argument type validation (Jon Bailey, #196). Fix add_uppercase_char atomicity (Cristian Ramirez, #194). Legacy output unchanged. 🚀 Generated with [Dojo](https://heydojo.ai) ⛩️ --- CHANGELOG.md | 7 +++++++ slugify/__version__.py | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d761db..b569334 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## 9.1.0 — unreleased + +- Modern mode only: decode uppercase `&#X..;` hexadecimal references as HTML allows, in addition to lowercase `&#x..;`. Legacy output is unchanged (Rupayon Haldar, #195). +- Modern mode only: preserve post-replacement output when the whole slug already fits `max_length`, so intentional repeated/trailing delimiters are not collapsed at the length limit. Public `smart_truncate` is unchanged (emme1t, #193). +- Fix `add_uppercase_char` to apply insertions atomically, leaving the input list unchanged if iteration fails. Built-in transliteration tables are unaffected (Cristian Ramirez, #194). +- Modern mode only: validate argument types up front, raising `TypeError` for a `bool`/non-int `max_length` (`bool` is an `int` subclass) or a non-str `separator`. Legacy behavior is unchanged and still treats `max_length=True` as its historical single-character truncation (Jon Bailey, #196). + ## 9.0.0 — unreleased - Add keyword-only `algorithm='legacy'` (permanent default) and explicit `algorithm='modern'` opt-in, plus CLI `--algorithm`. Preserve historical default entity ordering, numeric handling, iterator consumption, separator truncation, and public `smart_truncate` behavior. diff --git a/slugify/__version__.py b/slugify/__version__.py index cd29a25..f2d81aa 100644 --- a/slugify/__version__.py +++ b/slugify/__version__.py @@ -5,4 +5,4 @@ __url__ = 'https://github.com/un33k/python-slugify' __license__ = 'SPDX-License-Identifier: MIT' __copyright__ = 'Copyright 2022 Val Neekman @ Neekware Inc.' -__version__ = '9.0.0' +__version__ = '9.1.0'