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

104 statements  

1"""Frozen legacy slugify implementation. 

2 

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 

11 

12import re 

13import unicodedata 

14from collections.abc import Iterable 

15from html.entities import name2codepoint 

16from importlib import import_module 

17from typing import Literal 

18 

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

28 

29Backend = Literal['auto', 'text-unidecode', 'unidecode', 'anyascii'] 

30ReplacementStage = Literal['both', 'pre', 'post'] 

31 

32 

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 

48 

49 

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 

66 

67 

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. 

76 

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) 

103 

104 

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

132 

133 if replacements and replacement_stage in ('both', 'pre'): 

134 for old, new in replacements: 

135 text = text.replace(old, new) 

136 

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) 

151 

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) 

158 

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