v0.166.0
  1import re
  2import unicodedata
  3from typing import Any
  4
  5from plain.utils.functional import SimpleLazyObject, keep_lazy_text, lazy
  6from plain.utils.regex_helper import _lazy_re_compile
  7
  8# Set up regular expressions
  9_re_words = _lazy_re_compile(r"<[^>]+?>|([^<>\s]+)", re.DOTALL)
 10_re_chars = _lazy_re_compile(r"<[^>]+?>|(.)", re.DOTALL)
 11_re_tag = _lazy_re_compile(r"<(/)?(\S+?)(?:(\s*/)|\s.*?)?>", re.DOTALL)
 12
 13
 14class Truncator(SimpleLazyObject):
 15    """
 16    An object used to truncate text, either by characters or words.
 17    """
 18
 19    _wrapped: str  # Override parent type since we always store str
 20
 21    def __init__(self, text: Any):
 22        super().__init__(lambda: str(text))
 23
 24    def add_truncation_text(self, text: str, truncate: str | None = None) -> str:
 25        if truncate is None:
 26            truncate = "%(truncated_text)s…"
 27        if "%(truncated_text)s" in truncate:
 28            return truncate % {"truncated_text": text}
 29        # The truncation text didn't contain the %(truncated_text)s string
 30        # replacement argument so just append it to the text.
 31        if text.endswith(truncate):
 32            # But don't append the truncation text if the current text already
 33            # ends in this.
 34            return text
 35        return f"{text}{truncate}"
 36
 37    def chars(self, num: int, truncate: str | None = None, html: bool = False) -> str:
 38        """
 39        Return the text truncated to be no longer than the specified number
 40        of characters.
 41
 42        `truncate` specifies what should be used to notify that the string has
 43        been truncated, defaulting to a translatable string of an ellipsis.
 44        """
 45        self._setup()
 46        length = int(num)
 47        text = unicodedata.normalize("NFC", self._wrapped)
 48
 49        # Calculate the length to truncate to (max length - end_text length)
 50        truncate_len = length
 51        for char in self.add_truncation_text("", truncate):
 52            if not unicodedata.combining(char):
 53                truncate_len -= 1
 54                if truncate_len == 0:
 55                    break
 56        if html:
 57            return self._truncate_html(length, truncate, text, truncate_len, False)
 58        return self._text_chars(length, truncate, text, truncate_len)
 59
 60    def _text_chars(
 61        self, length: int, truncate: str | None, text: str, truncate_len: int
 62    ) -> str:
 63        """Truncate a string after a certain number of chars."""
 64        s_len = 0
 65        end_index = None
 66        for i, char in enumerate(text):
 67            if unicodedata.combining(char):
 68                # Don't consider combining characters
 69                # as adding to the string length
 70                continue
 71            s_len += 1
 72            if end_index is None and s_len > truncate_len:
 73                end_index = i
 74            if s_len > length:
 75                # Return the truncated string
 76                return self.add_truncation_text(text[: end_index or 0], truncate)
 77
 78        # Return the original string since no truncation was necessary
 79        return text
 80
 81    def words(self, num: int, truncate: str | None = None, html: bool = False) -> str:
 82        """
 83        Truncate a string after a certain number of words. `truncate` specifies
 84        what should be used to notify that the string has been truncated,
 85        defaulting to ellipsis.
 86        """
 87        self._setup()
 88        length = int(num)
 89        if html:
 90            return self._truncate_html(length, truncate, self._wrapped, length, True)
 91        return self._text_words(length, truncate)
 92
 93    def _text_words(self, length: int, truncate: str | None) -> str:
 94        """
 95        Truncate a string after a certain number of words.
 96
 97        Strip newlines in the string.
 98        """
 99        words = self._wrapped.split()
100        if len(words) > length:
101            words = words[:length]
102            return self.add_truncation_text(" ".join(words), truncate)
103        return " ".join(words)
104
105    def _truncate_html(
106        self,
107        length: int,
108        truncate: str | None,
109        text: str,
110        truncate_len: int,
111        words: bool,
112    ) -> str:
113        """
114        Truncate HTML to a certain number of chars (not counting tags and
115        comments), or, if words is True, then to a certain number of words.
116        Close opened tags if they were correctly closed in the given HTML.
117
118        Preserve newlines in the HTML.
119        """
120        if words and length <= 0:
121            return ""
122
123        html4_singlets = (
124            "br",
125            "col",
126            "link",
127            "base",
128            "img",
129            "param",
130            "area",
131            "hr",
132            "input",
133        )
134
135        # Count non-HTML chars/words and keep note of open tags
136        pos = 0
137        end_text_pos = 0
138        current_len = 0
139        open_tags = []
140
141        regex = _re_words if words else _re_chars
142
143        while current_len <= length:
144            m = regex.search(text, pos)
145            if not m:
146                # Checked through whole string
147                break
148            pos = m.end(0)
149            if m[1]:
150                # It's an actual non-HTML word or char
151                current_len += 1
152                if current_len == truncate_len:
153                    end_text_pos = pos
154                continue
155            # Check for tag
156            tag = _re_tag.match(m[0])
157            if not tag or current_len >= truncate_len:
158                # Don't worry about non tags or tags after our truncate point
159                continue
160            closing_tag, tagname, self_closing = tag.groups()
161            # Element names are always case-insensitive
162            tagname = tagname.lower()
163            if self_closing or tagname in html4_singlets:
164                pass
165            elif closing_tag:
166                # Check for match in open tags list
167                try:
168                    i = open_tags.index(tagname)
169                except ValueError:
170                    pass
171                else:
172                    # SGML: An end tag closes, back to the matching start tag,
173                    # all unclosed intervening start tags with omitted end tags
174                    open_tags = open_tags[i + 1 :]
175            else:
176                # Add it to the start of the open tags list
177                open_tags.insert(0, tagname)
178
179        if current_len <= length:
180            return text
181        out = text[:end_text_pos]
182        truncate_text = self.add_truncation_text("", truncate)
183        if truncate_text:
184            out += truncate_text
185        # Close any tags still open
186        for tag in open_tags:
187            out += f"</{tag}>"
188        # Return string
189        return out
190
191
192@keep_lazy_text
193def slugify(value: Any, allow_unicode: bool = False) -> str:
194    """
195    Convert to ASCII if 'allow_unicode' is False. Convert spaces or repeated
196    dashes to single dashes. Remove characters that aren't alphanumerics,
197    underscores, or hyphens. Convert to lowercase. Also strip leading and
198    trailing whitespace, dashes, and underscores.
199    """
200    value = str(value)
201    if allow_unicode:
202        value = unicodedata.normalize("NFKC", value)
203    else:
204        value = (
205            unicodedata.normalize("NFKD", value)
206            .encode("ascii", "ignore")
207            .decode("ascii")
208        )
209    value = re.sub(r"[^\w\s-]", "", value.lower())
210    return re.sub(r"[-\s]+", "-", value).strip("-_")
211
212
213def pluralize(singular: str, plural: str, number: int) -> str:
214    if number == 1:
215        return singular
216    else:
217        return plural
218
219
220def pluralize_lazy(singular: str, plural: str, number: int | str) -> Any:
221    def _lazy_number_unpickle(
222        func: Any, resultclass: Any, number: Any, kwargs: dict[str, Any]
223    ) -> Any:
224        return lazy_number(func, resultclass, number=number, **kwargs)
225
226    def lazy_number(
227        func: Any, resultclass: Any, number: int | str | None = None, **kwargs: Any
228    ) -> Any:
229        if isinstance(number, int):
230            kwargs["number"] = number
231            proxy = lazy(func, resultclass)(**kwargs)
232        else:
233            original_kwargs = kwargs.copy()
234
235            class NumberAwareString(resultclass):
236                def __bool__(self) -> bool:
237                    return bool(kwargs["singular"])
238
239                def _get_number_value(self, values: dict[str, Any]) -> Any:
240                    try:
241                        return values[number]  # ty: ignore[invalid-argument-type]
242                    except KeyError:
243                        raise KeyError(
244                            f"Your dictionary lacks key '{number}'. Please provide "
245                            "it, because it is required to determine whether "
246                            "string is singular or plural."
247                        )
248
249                def _translate(self, number_value: int) -> str:
250                    kwargs["number"] = number_value
251                    return func(**kwargs)
252
253                def format(self, *args: Any, **kwargs: Any) -> str:
254                    number_value = (
255                        self._get_number_value(kwargs) if kwargs and number else args[0]
256                    )
257                    return self._translate(number_value).format(*args, **kwargs)
258
259                def __mod__(self, rhs: Any) -> str:
260                    if isinstance(rhs, dict) and number:
261                        number_value = self._get_number_value(rhs)
262                    else:
263                        number_value = rhs
264                    translated = self._translate(number_value)
265                    try:
266                        translated %= rhs
267                    except TypeError:
268                        # String doesn't contain a placeholder for the number.
269                        pass
270                    return translated
271
272            proxy = lazy(lambda **kwargs: NumberAwareString(), NumberAwareString)(
273                **kwargs
274            )
275            proxy.__reduce__ = lambda: (
276                _lazy_number_unpickle,
277                (func, resultclass, number, original_kwargs),
278            )
279        return proxy
280
281    return lazy_number(pluralize, str, singular=singular, plural=plural, number=number)