Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/slugify/_legacy.py: 56%
Shortcuts on this page
r m x toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
Shortcuts on this page
r m x toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1"""Frozen legacy slugify implementation.
3DO NOT MODIFY. This module is the permanently frozen legacy output pipeline.
4Many deployed sites depend on its exact output, so its behavior must never
5change. All improvements belong in the modern path (slugify/slugify.py).
6Any change to this file that alters legacy output is rejected on principle;
7the only acceptable edits are non-behavioral (e.g. this docstring) verified
8byte-for-byte against the frozen 2,688-case differential baseline.
9"""
10from __future__ import annotations
12import re
13import unicodedata
14from collections.abc import Iterable
15from html.entities import name2codepoint
16from importlib import import_module
17from typing import Literal
19CHAR_ENTITY_PATTERN = re.compile(r'&(%s);' % '|'.join(name2codepoint))
20DECIMAL_PATTERN = re.compile(r'&#(\d+);')
21HEX_PATTERN = re.compile(r'&#x([\da-fA-F]+);')
22QUOTE_PATTERN = re.compile(r"[']+")
23DISALLOWED_CHARS_PATTERN = re.compile(r'[^-a-zA-Z0-9]+')
24DISALLOWED_UNICODE_CHARS_PATTERN = re.compile(r'[\W_]+')
25DUPLICATE_DASH_PATTERN = re.compile(r'-{2,}')
26NUMBERS_PATTERN = re.compile(r'(?<=\d),(?=\d)')
27DEFAULT_SEPARATOR = '-'
29Backend = Literal['auto', 'text-unidecode', 'unidecode', 'anyascii']
30ReplacementStage = Literal['both', 'pre', 'post']
33def _decode_entities(text: str, entities: bool, decimal: bool, hexadecimal: bool) -> str:
34 if entities:
35 text = CHAR_ENTITY_PATTERN.sub(lambda m: chr(name2codepoint[m.group(1)]), text)
36 if decimal:
37 # Legacy substitution is all-or-nothing for each numeric reference kind.
38 try:
39 text = DECIMAL_PATTERN.sub(lambda m: chr(int(m.group(1))), text)
40 except (ValueError, OverflowError):
41 pass
42 if hexadecimal:
43 try:
44 text = HEX_PATTERN.sub(lambda m: chr(int(m.group(1), 16)), text)
45 except (ValueError, OverflowError):
46 pass
47 return text
50def _transliterate(text: str, backend: Backend) -> str:
51 # Non-behavioral: every backend maps 7-bit ASCII to itself, so skip importing one.
52 if text.isascii():
53 return text
54 if backend == 'auto':
55 try:
56 module = import_module('unidecode')
57 except ModuleNotFoundError as error:
58 if error.name != 'unidecode':
59 raise
60 module = import_module('text_unidecode')
61 else:
62 module = import_module(backend.replace('-', '_'))
63 # These third-party modules share a string-to-string API; some are untyped.
64 result: str = getattr(module, 'anyascii' if backend == 'anyascii' else 'unidecode')(text)
65 return result
68def smart_truncate(
69 string: str,
70 max_length: int = 0,
71 word_boundary: bool = False,
72 separator: str = " ",
73 save_order: bool = False,
74) -> str:
75 """Historical public truncation behavior, including character-set stripping.
77 Zero means unlimited; negative limits retain Python slicing semantics.
78 An empty separator raises ValueError, as in the legacy implementation.
79 """
80 string = string.strip(separator)
81 if not max_length:
82 return string
83 if len(string) < max_length:
84 return string
85 if not word_boundary:
86 return string[:max_length].strip(separator)
87 if separator not in string:
88 return string[:max_length]
89 truncated = ''
90 for word in string.split(separator):
91 if word:
92 next_len = len(truncated) + len(word)
93 if next_len < max_length:
94 truncated += '{}{}'.format(word, separator)
95 elif next_len == max_length:
96 truncated += '{}'.format(word)
97 break
98 elif save_order:
99 break
100 if not truncated:
101 truncated = string[:max_length]
102 return truncated.strip(separator)
105def slugify(
106 text: str | bytes | bytearray,
107 entities: bool = True,
108 decimal: bool = True,
109 hexadecimal: bool = True,
110 max_length: int = 0,
111 word_boundary: bool = False,
112 separator: str = DEFAULT_SEPARATOR,
113 save_order: bool = False,
114 stopwords: Iterable[str] = (),
115 regex_pattern: re.Pattern[str] | str | None = None,
116 lowercase: bool = True,
117 replacements: Iterable[Iterable[str]] = (),
118 allow_unicode: bool = False,
119 *,
120 replacement_stage: ReplacementStage = 'both',
121 backend: Backend = 'auto',
122) -> str:
123 """Frozen legacy output pipeline. Do not change its behavior."""
124 if not isinstance(text, str):
125 if not isinstance(text, (bytes, bytearray)):
126 raise TypeError(f'text must be str, bytes or bytearray, not {type(text).__name__}')
127 text = text.decode('utf-8', 'ignore')
128 if replacement_stage not in ('both', 'pre', 'post'):
129 raise ValueError("replacement_stage must be 'both', 'pre' or 'post'")
130 if backend not in ('auto', 'text-unidecode', 'unidecode', 'anyascii'):
131 raise ValueError("backend must be 'auto', 'text-unidecode', 'unidecode' or 'anyascii'")
133 if replacements and replacement_stage in ('both', 'pre'):
134 for old, new in replacements:
135 text = text.replace(old, new)
137 text = QUOTE_PATTERN.sub(DEFAULT_SEPARATOR, text)
138 if allow_unicode:
139 text = unicodedata.normalize('NFKC', text)
140 else:
141 text = _transliterate(unicodedata.normalize('NFKD', text), backend)
142 text = _decode_entities(text, entities, decimal, hexadecimal)
143 text = unicodedata.normalize('NFKC' if allow_unicode else 'NFKD', text)
144 if lowercase:
145 text = text.lower()
146 text = QUOTE_PATTERN.sub('', text)
147 text = NUMBERS_PATTERN.sub('', text)
148 pattern = regex_pattern or (DISALLOWED_UNICODE_CHARS_PATTERN if allow_unicode else DISALLOWED_CHARS_PATTERN)
149 text = re.sub(pattern, DEFAULT_SEPARATOR, text)
150 text = DUPLICATE_DASH_PATTERN.sub(DEFAULT_SEPARATOR, text).strip(DEFAULT_SEPARATOR)
152 if stopwords:
153 excluded = [word.lower() for word in stopwords] if lowercase else stopwords
154 text = DEFAULT_SEPARATOR.join(word for word in text.split(DEFAULT_SEPARATOR) if word not in excluded)
155 if replacements and replacement_stage in ('both', 'post'):
156 for old, new in replacements:
157 text = text.replace(old, new)
159 if max_length > 0:
160 text = smart_truncate(text, max_length, word_boundary, DEFAULT_SEPARATOR, save_order)
161 return text.replace(DEFAULT_SEPARATOR, separator) if separator != DEFAULT_SEPARATOR else text