Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ slugify(

- `algorithm`: `'legacy'` is the permanent default, preserving the historical output pipeline. `'modern'` explicitly opts into the changes below. Unknown values raise `ValueError`.
- `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 lowercase-`x` hexadecimal references. Legacy decodes after transliteration and numeric substitutions are all-or-nothing per reference kind. Modern decodes before transliteration and handles invalid references independently.
- `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.
- `word_boundary`: prefer whole words; shorter later words can fill the budget. If none fit, use a hard cut.
- `save_order`: with word boundaries, stop at the first oversized word instead of skipping it.
Expand Down
3 changes: 2 additions & 1 deletion slugify/slugify.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
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]+')
DISALLOWED_UNICODE_CHARS_PATTERN = re.compile(r'[\W_]+')
Expand Down Expand Up @@ -50,7 +51,7 @@ def _decode_entities(text: str, entities: bool, decimal: bool, hexadecimal: bool
pass
if hexadecimal:
if algorithm == 'modern':
text = HEX_PATTERN.sub(lambda m: _numeric_reference(m, 16), text)
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)
Expand Down
24 changes: 24 additions & 0 deletions test_release.py
Original file line number Diff line number Diff line change
Expand Up @@ -271,3 +271,27 @@ def test_cli_forwarding_and_real_module(self):

if __name__ == '__main__':
unittest.main()


class UppercaseHexReferenceTests(unittest.TestCase):
def test_modern_accepts_both_hexadecimal_prefixes(self):
for prefix in ('x', 'X'):
with self.subTest(prefix=prefix):
self.assertEqual(slugify(f'&#{prefix}41; &#{prefix}e9;', allow_unicode=True), 'a-é')
self.assertEqual(slugify(f'&#{prefix}41;', lowercase=False), 'A')

def test_hexadecimal_opt_out_preserves_reference_filtering(self):
self.assertEqual(slugify('A', hexadecimal=False), 'x41')

def test_legacy_uppercase_reference_output_is_unchanged(self):
self.assertEqual(public_slugify('A'), 'x41')
self.assertEqual(public_slugify('A', algorithm='legacy'), 'x41')

def test_invalid_uppercase_reference_does_not_block_valid_neighbor(self):
self.assertEqual(slugify('� A'), 'x110000-a')
self.assertEqual(slugify('� A'), 'xd800-a')

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')