Coverage for yuio / string.py: 99%
1536 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-29 19:55 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-29 19:55 +0000
1# Yuio project, MIT license.
2#
3# https://github.com/taminomara/yuio/
4#
5# You're free to copy this file to your project and edit it for your needs,
6# just keep this copyright line please :3
8"""
9The higher-level :mod:`yuio.io` module uses strings with xml-like color
10tags to store information about line formatting. Here, on the lower level,
11these strings are parsed and transformed to :class:`ColorizedString`\\ s.
13.. autoclass:: ColorizedString
14 :members:
17.. _pretty-protocol:
19Pretty printing protocol
20------------------------
22Complex message formatting requires knowing capabilities of the target terminal.
23This affects which message decorations are used (Unicode or ASCII), how lines are
24wrapped, and so on. This data is encapsulated in an instance of :class:`ReprContext`:
26.. autoclass:: ReprContext
27 :members:
29Repr context may not always be available when a message is created, though.
30For example, we may know that we will be printing some data, but we don't know
31whether we'll print it to a file or to a terminal.
33The solution is to defer formatting by creating a :type:`Colorable`, i.e. an object
34that defines one of the following special methods:
36``__colorized_str__``, ``__colorized_repr__``
37 This should be a method that accepts a single positional argument,
38 :class:`ReprContext`, and returns a :class:`ColorizedString`.
40 .. tip::
42 Prefer ``__rich_repr__`` for simpler use cases, and only use
43 ``__colorized_repr__`` when you need something advanced.
45 **Example:**
47 .. code-block:: python
49 class MyObject:
50 def __init__(self, value):
51 self.value = value
53 def __colorized_str__(self, ctx: yuio.string.ReprContext):
54 result = yuio.string.ColorizedString()
55 result += ctx.get_color("magenta")
56 result += "MyObject"
57 result += ctx.get_color("normal")
58 result += "("
59 result += ctx.repr(self.value)
60 result += ")"
61 return result
63``__rich_repr__``
64 This method doesn't have any arguments. It should return an iterable of tuples
65 describing object's arguments:
67 - ``yield name, value`` will generate a keyword argument,
68 - ``yield name, value, default`` will generate a keyword argument if value
69 is not equal to default,
70 - if `name` is :data:`None`, it will generate positional argument instead.
72 See the `Rich library documentation`__ for more info.
74 __ https://rich.readthedocs.io/en/stable/pretty.html#rich-repr-protocol
76 **Example:**
78 .. code-block:: python
80 class MyObject:
81 def __init__(self, value1, value2):
82 self.value1 = value1
83 self.value2 = value2
85 def __rich_repr__(self) -> yuio.string.RichReprResult:
86 yield "value1", self.value1
87 yield "value2", self.value2
89.. type:: RichReprResult
90 :canonical: typing.Iterable[tuple[typing.Any] | tuple[str | None, typing.Any] | tuple[str | None, typing.Any, typing.Any]]
92 This is an alias similar to ``rich.repr.Result``, but stricter: it only
93 allows tuples, not arbitrary values.
95 This is done to avoid bugs where you yield a single value which happens to contain
96 a tuple, and Yuio (or Rich) renders it as a named argument.
99.. type:: ColorizedStrProtocol
101 Protocol for objects that define ``__colorized_str__`` method.
103.. type:: ColorizedReprProtocol
105 Protocol for objects that define ``__colorized_repr__`` method.
107.. type:: RichReprProtocol
109 Protocol for objects that define ``__rich_repr__`` method.
111.. type:: Printable
113 Any object that supports printing.
115 Technically, any object supports colorized printing because we'll fall back
116 to ``__repr__`` or ``__str__`` if there are no special methods on it.
118 However, we don't use :class:`typing.Any` to avoid potential errors.
120.. type:: Colorable
121 :canonical: Printable | ColorizedStrProtocol | ColorizedReprProtocol | RichReprProtocol | ~typing.LiteralString | BaseException
123 An object that supports colorized printing.
125 This can be a string, and exception, or any object that follows
126 :class:`ColorizedStrProtocol`. Additionally, you can pass any object that has
127 ``__repr__``, but you'll have to wrap it into :type:`Printable` to confirm
128 your intent to print it.
130.. type:: ToColorable
131 :canonical: Colorable | ~string.templatelib.Template
133 Any object that can be converted to a :type:`Colorable` by formatting it via
134 :class:`Format`.
136.. autofunction:: repr_from_rich
139.. _formatting-utilities:
141Formatting utilities
142--------------------
144.. autoclass:: Format
145 :members:
147.. autoclass:: Repr
148 :members:
150.. autoclass:: TypeRepr
151 :members:
153.. autoclass:: JoinStr
154 :members:
155 :inherited-members:
157.. autoclass:: JoinRepr
158 :members:
159 :inherited-members:
161.. autofunction:: And
163.. autofunction:: Or
165.. autoclass:: Stack
166 :members:
168.. autoclass:: Link
169 :members:
171.. autoclass:: Indent
172 :members:
174.. autoclass:: Md
175 :members:
177.. autoclass:: Rst
178 :members:
180.. autoclass:: Hl
181 :members:
183.. autoclass:: Wrap
184 :members:
186.. autoclass:: WithBaseColor
187 :members:
189.. autoclass:: Hr
190 :members:
192.. autoclass:: Plural
193 :members:
195.. autoclass:: Ordinal
196 :members:
199Parsing color tags
200------------------
202.. autofunction:: colorize
204.. autofunction:: strip_color_tags
207Helpers
208-------
210.. autofunction:: line_width
212.. type:: AnyString
213 :canonical: str | ~yuio.color.Color | ColorizedString | NoWrapMarker | typing.Iterable[AnyString]
215 Any string (i.e. a :class:`str`, a raw colorized string,
216 or a normal colorized string).
218.. autoclass:: LinkMarker
220.. autodata:: NO_WRAP_START
222.. autodata:: NO_WRAP_END
224.. type:: NoWrapMarker
225 NoWrapStart
226 NoWrapEnd
228 Type of a no-wrap marker.
230"""
232from __future__ import annotations
234import abc
235import collections
236import contextlib
237import dataclasses
238import functools
239import os
240import pathlib
241import re
242import reprlib
243import string
244import sys
245import types
246import unicodedata
247from dataclasses import dataclass
248from enum import Enum
250import yuio
251import yuio.color
252import yuio.term
253import yuio.theme
254from yuio.color import Color as _Color
255from yuio.util import UserString as _UserString
256from yuio.util import dedent as _dedent
258import yuio._typing_ext as _tx
259from typing import TYPE_CHECKING
261if TYPE_CHECKING:
262 import typing_extensions as _t
263else:
264 from yuio import _typing as _t
266if sys.version_info >= (3, 14):
267 from string.templatelib import Interpolation as _Interpolation
268 from string.templatelib import Template as _Template
269else:
271 class _Interpolation: ...
273 class _Template: ...
275 _Interpolation.__module__ = "string.templatelib"
276 _Interpolation.__name__ = "Interpolation"
277 _Interpolation.__qualname__ = "Interpolation"
278 _Template.__module__ = "string.templatelib"
279 _Template.__name__ = "Template"
280 _Template.__qualname__ = "Template"
283__all__ = [
284 "NO_WRAP_END",
285 "NO_WRAP_START",
286 "And",
287 "AnyString",
288 "Colorable",
289 "ColorizedReprProtocol",
290 "ColorizedStrProtocol",
291 "ColorizedString",
292 "Esc",
293 "Format",
294 "Hl",
295 "Hr",
296 "Indent",
297 "JoinRepr",
298 "JoinStr",
299 "Link",
300 "LinkMarker",
301 "Md",
302 "NoWrapEnd",
303 "NoWrapMarker",
304 "NoWrapStart",
305 "Or",
306 "Ordinal",
307 "Plural",
308 "Printable",
309 "Repr",
310 "ReprContext",
311 "RichReprProtocol",
312 "RichReprResult",
313 "Rst",
314 "Stack",
315 "ToColorable",
316 "TypeRepr",
317 "WithBaseColor",
318 "Wrap",
319 "colorize",
320 "line_width",
321 "repr_from_rich",
322 "strip_color_tags",
323]
326def line_width(s: str, /) -> int:
327 """
328 Calculates string width when the string is displayed
329 in a terminal.
331 This function makes effort to detect wide characters
332 such as emojis. If does not, however, work correctly
333 with extended grapheme clusters, and so it may fail
334 for emojis with modifiers, or other complex characters.
336 Example where it fails is ``👩🏽💻``. It consists
337 of four code points:
339 - Unicode Character `WOMAN` (``U+1F469``, ``👩``),
340 - Unicode Character `EMOJI MODIFIER FITZPATRICK TYPE-4` (``U+1F3FD``),
341 - Unicode Character `ZERO WIDTH JOINER` (``U+200D``),
342 - Unicode Character `PERSONAL COMPUTER` (``U+1F4BB``, ``💻``).
344 Since :func:`line_width` can't understand that these code points
345 are combined into a single emoji, it treats them separately,
346 resulting in answer `6` (`2` for every code point except `ZERO WIDTH JOINER`)::
348 >>> line_width("\U0001f469\U0001f3fd\U0000200d\U0001f4bb")
349 6
351 In all fairness, detecting how much space such an emoji will take
352 is not so straight forward, as that will depend on unicode capabilities
353 of a specific terminal. Since a lot of terminals will not handle such emojis
354 correctly, I've decided to go with this simplistic implementation.
356 """
358 # Note: it may be better to bundle `wcwidth` and use it instead of the code below.
359 # However, there is an issue that `wcwidth`'s results are not additive.
360 # In the above example, `wcswidth('👩🏽💻')` will see that it is two-spaces wide,
361 # while `sum(wcwidth(c) for c in '👩🏽💻')` will report that it is four-spaces wide.
362 # To render it properly, the widget will have to be aware of extended grapheme
363 # clusters, and generally this will be a lot of headache. Since most terminals
364 # won't handle these edge cases correctly, I don't want to bother.
366 if s.isascii():
367 # Fast path. Note that our renderer replaces unprintable characters
368 # with spaces, so ascii strings always have width equal to their length.
369 return len(s)
370 else:
371 # Long path. It kinda works, but not always, but most of the times...
372 return sum(
373 (unicodedata.east_asian_width(c) in "WF") + 1
374 for c in s
375 if unicodedata.category(c)[0] not in "MC"
376 )
379RichReprResult: _t.TypeAlias = _t.Iterable[
380 tuple[_t.Any] | tuple[str | None, _t.Any] | tuple[str | None, _t.Any, _t.Any]
381]
382"""
383Similar to ``rich.repr.Result``, but only allows tuples, not arbitrary values.
385"""
388@_t.runtime_checkable
389class ColorizedStrProtocol(_t.Protocol):
390 """
391 Protocol for objects that define ``__colorized_str__`` method.
393 """
395 @abc.abstractmethod
396 def __colorized_str__(self, ctx: ReprContext, /) -> ColorizedString: ...
399@_t.runtime_checkable
400class ColorizedReprProtocol(_t.Protocol):
401 """
402 Protocol for objects that define ``__colorized_repr__`` method.
404 """
406 @abc.abstractmethod
407 def __colorized_repr__(self, ctx: ReprContext, /) -> ColorizedString: ...
410@_t.runtime_checkable
411class RichReprProtocol(_t.Protocol):
412 """
413 Protocol for objects that define ``__rich_repr__`` method.
415 """
417 @abc.abstractmethod
418 def __rich_repr__(self) -> _t.Iterable[_t.Any]: ...
421Printable = _t.NewType("Printable", object)
422"""
423Any object that supports printing.
425Technically, any object supports colorized printing because we'll fall back
426to ``__repr__`` or ``__str__`` if there are no special methods on it.
428However, we don't use :class:`typing.Any` to avoid potential errors.
430"""
433Colorable: _t.TypeAlias = (
434 Printable
435 | ColorizedStrProtocol
436 | ColorizedReprProtocol
437 | RichReprProtocol
438 | str
439 | BaseException
440)
441"""
442Any object that supports colorized printing.
444This can be a string, and exception, or any object that follows
445:class:`ColorizedStrProtocol`. Additionally, you can pass any object that has
446``__repr__``, but you'll have to wrap it into :type:`Printable` to confirm
447your intent to print it.
449"""
451ToColorable: _t.TypeAlias = Colorable | _Template
452"""
453Any object that can be converted to a :type:`Colorable` by formatting it via
454:class:`Format`.
456"""
459RichReprProtocolT = _t.TypeVar("RichReprProtocolT", bound=RichReprProtocol)
462def repr_from_rich(cls: type[RichReprProtocolT], /) -> type[RichReprProtocolT]:
463 """repr_from_rich(cls: RichReprProtocol) -> RichReprProtocol
465 A decorator that generates ``__repr__`` from ``__rich_repr__``.
467 :param cls:
468 class that needs ``__repr__``.
469 :returns:
470 always returns `cls`.
471 :example:
472 .. code-block:: python
474 @yuio.string.repr_from_rich
475 class MyClass:
476 def __init__(self, value):
477 self.value = value
479 def __rich_repr__(self) -> yuio.string.RichReprResult:
480 yield "value", self.value
482 ::
484 >>> print(repr(MyClass("plush!")))
485 MyClass(value='plush!')
488 """
490 setattr(cls, "__repr__", _repr_from_rich_impl)
491 return cls
494def _repr_from_rich_impl(self: RichReprProtocol):
495 if rich_repr := getattr(self, "__rich_repr__", None):
496 args = rich_repr()
497 angular = getattr(rich_repr, "angular", False)
498 else:
499 args = []
500 angular = False
502 if args is None:
503 args = [] # `rich_repr` didn't yield?
505 res = []
507 if angular:
508 res.append("<")
509 res.append(self.__class__.__name__)
510 if angular:
511 res.append(" ")
512 else:
513 res.append("(")
515 sep = False
516 for arg in args:
517 if isinstance(arg, tuple):
518 if len(arg) == 3:
519 key, child, default = arg
520 if default == child:
521 continue
522 elif len(arg) == 2:
523 key, child = arg
524 elif len(arg) == 1:
525 key, child = None, arg[0]
526 else:
527 key, child = None, arg
528 else:
529 key, child = None, arg
531 if sep:
532 res.append(" " if angular else ", ")
533 if key:
534 res.append(str(key))
535 res.append("=")
536 res.append(repr(child))
537 sep = True
539 res.append(">" if angular else ")")
541 return "".join(res)
544class NoWrapMarker(Enum):
545 """
546 Type for a no-wrap marker.
548 """
550 NO_WRAP_START = "<no_wrap_start>"
551 NO_WRAP_END = "<no_wrap_end>"
553 def __repr__(self):
554 return f"yuio.string.{self.name}" # pragma: no cover
556 def __str__(self) -> str:
557 return self.value # pragma: no cover
560NoWrapStart: _t.TypeAlias = _t.Literal[NoWrapMarker.NO_WRAP_START]
561"""
562Type of the :data:`NO_WRAP_START` placeholder.
564"""
566NO_WRAP_START: NoWrapStart = NoWrapMarker.NO_WRAP_START
567"""
568Indicates start of a no-wrap region in a :class:`ColorizedString`.
570"""
573NoWrapEnd: _t.TypeAlias = _t.Literal[NoWrapMarker.NO_WRAP_END]
574"""
575Type of the :data:`NO_WRAP_END` placeholder.
577"""
579NO_WRAP_END: NoWrapEnd = NoWrapMarker.NO_WRAP_END
580"""
581Indicates end of a no-wrap region in a :class:`ColorizedString`.
583"""
586@dataclass(slots=True, frozen=True, unsafe_hash=True)
587class LinkMarker:
588 """
589 Indicates start or end of a hyperlink in a colorized string.
591 """
593 url: str | None
594 """
595 Hyperlink's url.
597 """
600@_t.final
601@repr_from_rich
602class ColorizedString:
603 """ColorizedString()
604 ColorizedString(rhs: ColorizedString, /)
605 ColorizedString(*args: AnyString, /)
607 A string with colors.
609 This class is a wrapper over a list of strings, colors, and no-wrap markers.
610 Each color applies to strings after it, right until the next color.
612 :class:`ColorizedString` supports some basic string operations.
613 Most notably, it supports wide-character-aware wrapping
614 (see :meth:`~ColorizedString.wrap`),
615 and ``%``-like formatting (see :meth:`~ColorizedString.percent_format`).
617 Unlike :class:`str`, :class:`ColorizedString` is mutable through
618 the ``+=`` operator and ``append``/``extend`` methods.
620 :param rhs:
621 when constructor gets a single :class:`ColorizedString`, it makes a copy.
622 :param args:
623 when constructor gets multiple arguments, it creates an empty string
624 and appends arguments to it.
627 **String combination semantics**
629 When you append a :class:`str`, it will take on color and no-wrap semantics
630 according to the last appended color and no-wrap marker.
632 When you append another :class:`ColorizedString`, it will not change its colors
633 based on the last appended color, nor will it affect colors of the consequent
634 strings. If appended :class:`ColorizedString` had an unterminated no-wrap region
635 or link region, this region will be terminated after appending.
637 Thus, appending a colorized string does not change current color, no-wrap
638 or link setting::
640 >>> s1 = yuio.string.ColorizedString()
641 >>> s1 += yuio.color.Color.FORE_RED
642 >>> s1 += yuio.string.NO_WRAP_START
643 >>> s1 += "red nowrap text"
644 >>> s1 # doctest: +NORMALIZE_WHITESPACE
645 ColorizedString([yuio.string.NO_WRAP_START,
646 <Color fore=<RED>>,
647 'red nowrap text'])
649 >>> s2 = yuio.string.ColorizedString()
650 >>> s2 += yuio.color.Color.FORE_GREEN
651 >>> s2 += "green text "
652 >>> s2 += s1
653 >>> s2 += " green text continues"
654 >>> s2 # doctest: +NORMALIZE_WHITESPACE
655 ColorizedString([<Color fore=<GREEN>>,
656 'green text ',
657 yuio.string.NO_WRAP_START,
658 <Color fore=<RED>>,
659 'red nowrap text',
660 yuio.string.NO_WRAP_END,
661 <Color fore=<GREEN>>,
662 ' green text continues'])
664 """
666 # Invariants:
667 #
668 # - there is always a color before the first string in `_parts`.
669 # - there are no empty strings in `_parts`.
670 # - for every pair of colors in `_parts`, there is a string between them
671 # (i.e. there are no colors that don't highlight anything).
672 # - every color in `_parts` is different from the previous one
673 # (i.e. there are no redundant color markers).
674 # - `start-no-wrap` and `end-no-wrap` markers form a balanced bracket sequence,
675 # except for the last `start-no-wrap`, which may have no corresponding
676 # `end-no-wrap` yet.
677 # - no-wrap regions can't be nested.
678 # - for every pair of (start-no-wrap, end-no-wrap) markers, there is a string
679 # between them (i.e. no empty no-wrap regions).
681 def __init__(
682 self,
683 /,
684 *args: AnyString,
685 _isolate_colors: bool = True,
686 ):
687 if len(args) == 1 and isinstance(args[0], ColorizedString):
688 content = args[0]
689 self._parts = content._parts.copy()
690 self._last_color = content._last_color
691 self._active_color = content._active_color
692 self._last_url = content._last_url
693 self._active_url = content._active_url
694 self._explicit_newline = content._explicit_newline
695 self._len = content._len
696 self._has_no_wrap = content._has_no_wrap
697 if (width := content.__dict__.get("width", None)) is not None:
698 self.__dict__["width"] = width
699 else:
700 self._parts: list[_Color | NoWrapMarker | LinkMarker | str] = []
701 self._active_color = _Color.NONE
702 self._last_color: _Color | None = None
703 self._last_url: str | None = None
704 self._active_url: str | None = None
705 self._explicit_newline: str = ""
706 self._len = 0
707 self._has_no_wrap = False
709 if not _isolate_colors:
710 # Prevent adding `_Color.NONE` to the front of the string.
711 self._last_color = self._active_color
713 for arg in args:
714 self += arg
716 @property
717 def explicit_newline(self) -> str:
718 """
719 Explicit newline indicates that a line of a wrapped text
720 was broken because the original text contained a new line character.
722 See :meth:`~ColorizedString.wrap` for details.
724 """
726 return self._explicit_newline
728 @property
729 def active_color(self) -> _Color:
730 """
731 Last color appended to this string.
733 """
735 return self._active_color
737 @property
738 def active_url(self) -> str | None:
739 """
740 Last url appended to this string.
742 """
744 return self._active_url
746 @functools.cached_property
747 def width(self) -> int:
748 """
749 String width when the string is displayed in a terminal.
751 See :func:`line_width` for more information.
753 """
755 return sum(line_width(s) for s in self._parts if isinstance(s, str))
757 @property
758 def len(self) -> int:
759 """
760 Line length in bytes, ignoring all colors.
762 """
764 return self._len
766 def append_color(self, color: _Color, /):
767 """
768 Append new color to this string.
770 This operation is lazy, the color will be appended if a non-empty string
771 is appended after it.
773 :param color:
774 color to append.
776 """
778 self._active_color = color
780 def append_link(self, url: str | None, /):
781 """
782 Append new link marker to this string.
784 This operation is lazy, the link marker will be appended if a non-empty string
785 is appended after it.s
787 :param url:
788 link url.
790 """
792 self._active_url = url
794 def start_link(self, url: str, /):
795 """
796 Start hyperlink with the given url.
798 :param url:
799 link url.
801 """
803 self._active_url = url
805 def end_link(self):
806 """
807 End hyperlink.
809 """
811 self._active_url = None
813 def append_str(self, s: str, /):
814 """
815 Append new plain string to this string.
817 :param s:
818 plain string to append.
820 """
822 if not s:
823 return
824 if self._last_url != self._active_url:
825 self._parts.append(LinkMarker(self._active_url))
826 self._last_url = self._active_url
827 if self._last_color != self._active_color:
828 self._parts.append(self._active_color)
829 self._last_color = self._active_color
830 self._parts.append(s)
831 self._len += len(s)
832 self.__dict__.pop("width", None)
834 def append_colorized_str(self, s: ColorizedString, /):
835 """
836 Append new colorized string to this string.
838 :param s:
839 colorized string to append.
841 """
842 if not s:
843 # Nothing to append.
844 return
846 parts = s._parts
848 # Cleanup color at the beginning of the string.
849 for i, part in enumerate(parts):
850 if part in (NO_WRAP_START, NO_WRAP_END) or isinstance(part, LinkMarker):
851 continue
852 elif isinstance(part, str): # pragma: no cover
853 # We never hit this branch in normal conditions because colorized
854 # strings always start with a color. The only way to trigger this
855 # branch is to tamper with `_parts` and break colorized string
856 # invariants.
857 break
859 # First color in the appended string is the same as our last color.
860 # We can remove it without changing the outcome.
861 if part == self._last_color:
862 if i == 0:
863 parts = parts[i + 1 :]
864 else:
865 parts = parts[:i] + parts[i + 1 :]
867 break
869 if self._has_no_wrap:
870 # We're in a no-wrap sequence, we don't need any more markers.
871 parts = filter(lambda part: part not in (NO_WRAP_START, NO_WRAP_END), parts)
873 if self._active_url:
874 # Current url overrides appended urls.
875 parts = filter(lambda part: not isinstance(part, LinkMarker), parts)
877 # Ensure that current url marker is added to the string.
878 # We don't need to do this with colors because `parts` already starts with
879 # a correct color.
880 if self._last_url != self._active_url:
881 self._parts.append(LinkMarker(self._active_url))
882 self._last_url = self._active_url
884 self._parts.extend(parts)
886 if not self._has_no_wrap and s._has_no_wrap:
887 self._has_no_wrap = True
888 self.end_no_wrap()
889 if not self._active_url and s._last_url:
890 self._last_url = s._last_url
892 self._last_color = s._last_color
893 self._len += s._len
894 if (lw := self.__dict__.get("width")) and (rw := s.__dict__.get("width")):
895 self.__dict__["width"] = lw + rw
896 else:
897 self.__dict__.pop("width", None)
899 def append_no_wrap(self, m: NoWrapMarker, /):
900 """
901 Append a no-wrap marker.
903 :param m:
904 no-wrap marker, will be dispatched
905 to :meth:`~ColorizedString.start_no_wrap`
906 or :meth:`~ColorizedString.end_no_wrap`.
908 """
910 if m is NO_WRAP_START:
911 self.start_no_wrap()
912 else:
913 self.end_no_wrap()
915 def start_no_wrap(self):
916 """
917 Start a no-wrap region.
919 String parts within no-wrap regions are not wrapped on spaces; they can be
920 hard-wrapped if `break_long_nowrap_words` is :data:`True`. Whitespaces and
921 newlines in no-wrap regions are preserved regardless of `preserve_spaces`
922 and `preserve_newlines` settings.
924 """
926 if self._has_no_wrap:
927 return
929 self._has_no_wrap = True
930 self._parts.append(NO_WRAP_START)
932 def end_no_wrap(self):
933 """
934 End a no-wrap region.
936 """
938 if not self._has_no_wrap:
939 return
941 if self._parts and self._parts[-1] is NO_WRAP_START:
942 # Empty no-wrap sequence, just remove it.
943 self._parts.pop()
944 else:
945 self._parts.append(NO_WRAP_END)
947 self._has_no_wrap = False
949 def extend(
950 self,
951 parts: _t.Iterable[str | ColorizedString | _Color | NoWrapMarker | LinkMarker],
952 /,
953 ):
954 """
955 Extend string from iterable of raw parts.
957 :param parts:
958 raw parts that will be appended to the string.
960 """
962 for part in parts:
963 self += part
965 def copy(self) -> ColorizedString:
966 """
967 Copy this string.
969 :returns:
970 copy of the string.
972 """
974 return ColorizedString(self)
976 def _split_at(self, i: int, /) -> tuple[ColorizedString, ColorizedString]:
977 l, r = ColorizedString(), ColorizedString()
978 l.extend(self._parts[:i])
979 r._active_color = l._active_color
980 r._active_url = l._active_url
981 r._has_no_wrap = l._has_no_wrap # TODO: waat???
982 r.extend(self._parts[i:])
983 r._active_color = self._active_color
984 return l, r
986 def with_base_color(self, base_color: _Color) -> ColorizedString:
987 """
988 Apply the given color "under" all parts of this string. That is, all colors
989 in this string will be combined with this color on the left:
990 ``base_color | color``.
992 :param base_color:
993 color that will be added under the string.
994 :returns:
995 new string with changed colors, or current string if base color
996 is :attr:`~yuio.color.Color.NONE`.
997 :example:
998 ::
1000 >>> s1 = yuio.string.ColorizedString([
1001 ... "part 1",
1002 ... yuio.color.Color.FORE_GREEN,
1003 ... "part 2",
1004 ... ])
1005 >>> s2 = s1.with_base_color(
1006 ... yuio.color.Color.FORE_RED
1007 ... | yuio.color.Color.STYLE_BOLD
1008 ... )
1009 >>> s2 # doctest: +NORMALIZE_WHITESPACE
1010 ColorizedString([<Color fore=<RED> bold=True>,
1011 'part 1',
1012 <Color fore=<GREEN> bold=True>,
1013 'part 2'])
1015 """
1017 if base_color == _Color.NONE:
1018 return self
1020 res = ColorizedString()
1022 for part in self._parts:
1023 if isinstance(part, _Color):
1024 res.append_color(base_color | part)
1025 else:
1026 res += part
1027 res._active_color = base_color | self._active_color
1028 if self._last_color is not None:
1029 res._last_color = base_color | self._last_color
1031 return res
1033 def as_code(self, color_support: yuio.color.ColorSupport, /) -> list[str]:
1034 """
1035 Convert colors in this string to ANSI escape sequences.
1037 :param color_support:
1038 desired level of color support.
1039 :returns:
1040 raw parts of colorized string with all colors converted to ANSI
1041 escape sequences.
1043 """
1045 if color_support == yuio.color.ColorSupport.NONE:
1046 return [part for part in self._parts if isinstance(part, str)]
1047 else:
1048 parts: list[str] = []
1049 for part in self:
1050 if isinstance(part, LinkMarker):
1051 parts.append("\x1b]8;;")
1052 parts.append(part.url or "")
1053 parts.append("\x1b\\")
1054 elif isinstance(part, str):
1055 parts.append(part)
1056 elif isinstance(part, _Color):
1057 parts.append(part.as_code(color_support))
1058 if self._last_color != _Color.NONE:
1059 parts.append(_Color.NONE.as_code(color_support))
1060 if self._last_url is not None:
1061 parts.append("\x1b]8;;\x1b\\")
1062 return parts
1064 def wrap(
1065 self,
1066 width: int,
1067 /,
1068 *,
1069 preserve_spaces: bool = False,
1070 preserve_newlines: bool = True,
1071 break_long_words: bool = True,
1072 break_long_nowrap_words: bool = False,
1073 overflow: _t.Literal[False] | str = False,
1074 indent: AnyString | int = "",
1075 continuation_indent: AnyString | int | None = None,
1076 ) -> list[ColorizedString]:
1077 """
1078 Wrap a long line of text into multiple lines.
1080 :param width:
1081 desired wrapping width.
1082 :param preserve_spaces:
1083 if set to :data:`True`, all spaces are preserved.
1084 Otherwise, consecutive spaces are collapsed into a single space.
1086 Note that tabs always treated as a single whitespace.
1087 :param preserve_newlines:
1088 if set to :data:`True` (default), text is additionally wrapped
1089 on newline sequences. When this happens, the newline sequence that wrapped
1090 the line will be placed into :attr:`~ColorizedString.explicit_newline`.
1092 If set to :data:`False`, newline sequences are treated as whitespaces.
1094 .. list-table:: Whitespace sequences
1095 :header-rows: 1
1096 :stub-columns: 1
1098 * - Sequence
1099 - `preserve_newlines`
1100 - Result
1101 * - ``\\n``, ``\\r\\n``, ``\\r``
1102 - ``False``
1103 - Treated as a single whitespace.
1104 * - ``\\n``, ``\\r\\n``, ``\\r``
1105 - ``True``
1106 - Creates a new line.
1107 * - ``\\v``, ``\\v\\n``, ``\\v\\r\\n``, ``\\v\\r``
1108 - Any
1109 - Always creates a new line.
1111 :param break_long_words:
1112 if set to :data:`True` (default), words that don't fit into a single line
1113 will be split into multiple lines.
1114 :param break_long_nowrap_words:
1115 if set to :data:`True`, words in no-wrap regions that don't fit
1116 into a single line will be split into multiple lines.
1117 :param overflow:
1118 a symbol that will be added to a line if it doesn't fit the given width.
1119 Pass :data:`False` to keep the overflowing lines without modification.
1120 :param indent:
1121 a string that will be prepended before the first line.
1122 :param continuation_indent:
1123 a string that will be prepended before all subsequent lines.
1124 Defaults to `indent`.
1125 :returns:
1126 a list of individual lines without newline characters at the end.
1128 """
1130 return _TextWrapper(
1131 width,
1132 preserve_spaces=preserve_spaces,
1133 preserve_newlines=preserve_newlines,
1134 break_long_words=break_long_words,
1135 break_long_nowrap_words=break_long_nowrap_words,
1136 overflow=overflow,
1137 indent=indent,
1138 continuation_indent=continuation_indent,
1139 ).wrap(self)
1141 def indent(
1142 self,
1143 indent: AnyString | int = " ",
1144 continuation_indent: AnyString | int | None = None,
1145 ) -> ColorizedString:
1146 """
1147 Indent this string.
1149 :param indent:
1150 this will be prepended to the first line in the string.
1151 Defaults to two spaces.
1152 :param continuation_indent:
1153 this will be prepended to subsequent lines in the string.
1154 Defaults to `indent`.
1155 :returns:
1156 indented string.
1158 """
1160 nowrap_indent = ColorizedString()
1161 nowrap_indent.start_no_wrap()
1162 nowrap_continuation_indent = ColorizedString()
1163 nowrap_continuation_indent.start_no_wrap()
1164 if isinstance(indent, int):
1165 nowrap_indent.append_str(" " * indent)
1166 else:
1167 nowrap_indent += indent
1168 if continuation_indent is None:
1169 nowrap_continuation_indent.append_colorized_str(nowrap_indent)
1170 elif isinstance(continuation_indent, int):
1171 nowrap_continuation_indent.append_str(" " * continuation_indent)
1172 else:
1173 nowrap_continuation_indent += continuation_indent
1175 if not nowrap_indent and not nowrap_continuation_indent:
1176 return self
1178 res = ColorizedString()
1180 needs_indent = True
1181 for part in self._parts:
1182 if not isinstance(part, str) or isinstance(part, Esc):
1183 res += part
1184 continue
1186 for line in _WORDSEP_NL_RE.split(part):
1187 if not line:
1188 continue
1189 if needs_indent:
1190 url = res.active_url
1191 res.end_link()
1192 res.append_colorized_str(nowrap_indent)
1193 res.append_link(url)
1194 nowrap_indent = nowrap_continuation_indent
1195 res.append_str(line)
1196 needs_indent = line.endswith(("\n", "\r", "\v"))
1198 return res
1200 def percent_format(self, args: _t.Any, ctx: ReprContext) -> ColorizedString:
1201 """
1202 Format colorized string as if with ``%``-formatting
1203 (i.e. `printf-style formatting`__).
1205 __ https://docs.python.org/3/library/stdtypes.html#printf-style-string-formatting
1207 :param args:
1208 arguments for formatting. Can be either a tuple of a mapping. Any other
1209 value will be converted to a tuple of one element.
1210 :param ctx:
1211 :class:`ReprContext` that will be passed to ``__colorized_str__``
1212 and ``__colorized_repr__`` when formatting colorables.
1213 :returns:
1214 formatted string.
1215 :raises:
1216 :class:`TypeError`, :class:`ValueError`, :class:`KeyError` if formatting
1217 fails.
1219 """
1221 return _percent_format(self, args, ctx)
1223 def __len__(self) -> int:
1224 return self.len
1226 def __bool__(self) -> bool:
1227 return self.len > 0
1229 def __iter__(self) -> _t.Iterator[_Color | NoWrapMarker | LinkMarker | str]:
1230 return self._parts.__iter__()
1232 def __add__(self, rhs: AnyString) -> ColorizedString:
1233 copy = self.copy()
1234 copy += rhs
1235 return copy
1237 def __radd__(self, lhs: AnyString) -> ColorizedString:
1238 copy = ColorizedString(lhs)
1239 copy += self
1240 return copy
1242 def __iadd__(self, rhs: AnyString) -> ColorizedString:
1243 if isinstance(rhs, str):
1244 self.append_str(rhs)
1245 elif isinstance(rhs, ColorizedString):
1246 self.append_colorized_str(rhs)
1247 elif isinstance(rhs, _Color):
1248 self.append_color(rhs)
1249 elif rhs in (NO_WRAP_START, NO_WRAP_END):
1250 self.append_no_wrap(rhs)
1251 elif isinstance(rhs, LinkMarker):
1252 self.append_link(rhs.url)
1253 else:
1254 self.extend(rhs)
1256 return self
1258 def __eq__(self, value: object) -> bool:
1259 if isinstance(value, ColorizedString):
1260 return self._parts == value._parts
1261 else:
1262 return NotImplemented
1264 def __ne__(self, value: object) -> bool:
1265 return not (self == value)
1267 def __rich_repr__(self) -> RichReprResult:
1268 yield None, self._parts
1269 yield "explicit_newline", self._explicit_newline, ""
1271 def __str__(self) -> str:
1272 return "".join(c for c in self._parts if isinstance(c, str))
1274 def __colorized_str__(self, ctx: ReprContext) -> ColorizedString:
1275 return self
1278AnyString: _t.TypeAlias = (
1279 str
1280 | ColorizedString
1281 | _Color
1282 | NoWrapMarker
1283 | LinkMarker
1284 | _t.Iterable[str | ColorizedString | _Color | NoWrapMarker | LinkMarker]
1285)
1286"""
1287Any string (i.e. a :class:`str`, a raw colorized string, or a normal colorized string).
1289"""
1292_S_SYNTAX = re.compile(
1293 r"""
1294 % # Percent
1295 (?:\((?P<mapping>[^)]*)\))? # Mapping key
1296 (?P<flag>[#0\-+ ]*) # Conversion Flag
1297 (?P<width>\*|\d+)? # Field width
1298 (?:\.(?P<precision>\*|\d*))? # Precision
1299 [hlL]? # Unused length modifier
1300 (?P<format>.) # Conversion type
1301 """,
1302 re.VERBOSE,
1303)
1305_F_SYNTAX = re.compile(
1306 r"""
1307 ^
1308 (?: # Options
1309 (?:
1310 (?P<fill>.)?
1311 (?P<align>[<>=^])
1312 )?
1313 (?P<flags>[+#]*)
1314 (?P<zero>0)?
1315 )
1316 (?: # Width
1317 (?P<width>\d+)?
1318 (?P<width_grouping>[,_])?
1319 )
1320 (?: # Precision
1321 \.
1322 (?P<precision>\d+)?
1323 (?P<precision_grouping>[,_])?
1324 )?
1325 (?: # Type
1326 (?P<type>.)
1327 )?
1328 $
1329 """,
1330 re.VERBOSE,
1331)
1334def _percent_format(
1335 s: ColorizedString, args: object, ctx: ReprContext
1336) -> ColorizedString:
1337 seen_mapping = False
1338 arg_index = 0
1339 res = ColorizedString()
1340 for part in s:
1341 if isinstance(part, str):
1342 pos = 0
1343 for match in _S_SYNTAX.finditer(part):
1344 if pos < match.start():
1345 res.append_str(part[pos : match.start()])
1346 seen_mapping = seen_mapping or bool(match.group("mapping"))
1347 last_color = res.active_color
1348 arg_index, replaced = _percent_format_repl(
1349 match, args, arg_index, last_color, ctx
1350 )
1351 res += replaced
1352 res.append_color(last_color)
1353 pos = match.end()
1354 if pos < len(part):
1355 res.append_str(part[pos:])
1356 else:
1357 res += part
1359 if (isinstance(args, tuple) and arg_index < len(args)) or (
1360 not isinstance(args, tuple)
1361 and (
1362 not hasattr(args, "__getitem__")
1363 or isinstance(args, (str, bytes, bytearray))
1364 )
1365 and not seen_mapping
1366 and not arg_index
1367 ):
1368 raise TypeError("not all arguments converted during string formatting")
1370 return res
1373def _percent_format_repl(
1374 match: _tx.StrReMatch,
1375 args: object,
1376 arg_index: int,
1377 base_color: _Color,
1378 ctx: ReprContext,
1379) -> tuple[int, str | ColorizedString]:
1380 if match.group("format") == "%":
1381 if match.group(0) != "%%":
1382 raise ValueError("unsupported format character '%'")
1383 return arg_index, "%"
1385 if match.group("format") in "rsa":
1386 return _percent_format_repl_str(match, args, arg_index, base_color, ctx)
1388 if mapping := match.group("mapping"):
1389 try:
1390 fmt_arg = args[mapping] # type: ignore
1391 except TypeError:
1392 raise TypeError("format requires a mapping") from None
1393 fmt_arg, added_color = _unwrap_base_color(fmt_arg, ctx.theme)
1394 if added_color:
1395 fmt_args = {mapping: fmt_arg}
1396 else:
1397 fmt_args = args
1398 elif isinstance(args, tuple):
1399 try:
1400 fmt_arg = args[arg_index]
1401 except IndexError:
1402 raise TypeError("not enough arguments for format string")
1403 fmt_arg, added_color = _unwrap_base_color(fmt_arg, ctx.theme)
1404 begin = arg_index + 1
1405 end = arg_index = (
1406 arg_index
1407 + 1
1408 + (match.group("width") == "*")
1409 + (match.group("precision") == "*")
1410 )
1411 fmt_args = (fmt_arg,) + args[begin:end]
1412 elif arg_index == 0:
1413 fmt_args, added_color = _unwrap_base_color(args, ctx.theme)
1414 arg_index += 1
1415 else:
1416 raise TypeError("not enough arguments for format string")
1418 fmt = match.group(0) % fmt_args
1419 if added_color:
1420 added_color = ctx.to_color(added_color)
1421 fmt = ColorizedString([base_color | added_color, fmt])
1422 return arg_index, fmt
1425def _unwrap_base_color(x, theme: yuio.theme.Theme):
1426 color = None
1427 while isinstance(x, WithBaseColor):
1428 x, base_color = x._msg, x._base_color
1429 base_color = theme.to_color(base_color)
1430 if color:
1431 color = color | base_color
1432 else:
1433 color = base_color
1434 else:
1435 return x, color
1438def _percent_format_repl_str(
1439 match: _tx.StrReMatch,
1440 args: object,
1441 arg_index: int,
1442 base_color: _Color,
1443 ctx: ReprContext,
1444) -> tuple[int, str | ColorizedString]:
1445 if width_s := match.group("width"):
1446 if width_s == "*":
1447 if not isinstance(args, tuple):
1448 raise TypeError("* wants int")
1449 try:
1450 width = args[arg_index]
1451 arg_index += 1
1452 except (KeyError, IndexError):
1453 raise TypeError("not enough arguments for format string")
1454 if not isinstance(width, int):
1455 raise TypeError("* wants int")
1456 else:
1457 width = int(width_s)
1458 else:
1459 width = None
1461 if precision_s := match.group("precision"):
1462 if precision_s == "*":
1463 if not isinstance(args, tuple):
1464 raise TypeError("* wants int")
1465 try:
1466 precision = args[arg_index]
1467 arg_index += 1
1468 except (KeyError, IndexError):
1469 raise TypeError("not enough arguments for format string")
1470 if not isinstance(precision, int):
1471 raise TypeError("* wants int")
1472 else:
1473 precision = int(precision_s)
1474 else:
1475 precision = None
1477 if mapping := match.group("mapping"):
1478 try:
1479 fmt_arg = args[mapping] # type: ignore
1480 except TypeError:
1481 raise TypeError("format requires a mapping") from None
1482 elif isinstance(args, tuple):
1483 try:
1484 fmt_arg = args[arg_index]
1485 arg_index += 1
1486 except IndexError:
1487 raise TypeError("not enough arguments for format string") from None
1488 elif arg_index == 0:
1489 fmt_arg = args
1490 arg_index += 1
1491 else:
1492 raise TypeError("not enough arguments for format string")
1494 flag = match.group("flag")
1495 multiline = "+" in flag
1496 highlighted = "#" in flag
1498 res = ctx.convert(
1499 fmt_arg,
1500 match.group("format"), # type: ignore
1501 multiline=multiline,
1502 highlighted=highlighted,
1503 )
1505 align = match.group("flag")
1506 if width is not None and width < 0:
1507 width = -width
1508 align = "<"
1509 elif align == "-":
1510 align = "<"
1511 else:
1512 align = ">"
1513 res = _apply_format(res, width, precision, align, " ")
1515 return arg_index, res.with_base_color(base_color)
1518def _format_interpolation(interp: _Interpolation, ctx: ReprContext) -> ColorizedString:
1519 value = interp.value
1520 if (
1521 interp.conversion is not None
1522 or getattr(type(value), "__format__", None) is object.__format__
1523 or isinstance(value, (str, ColorizedString))
1524 ):
1525 value = ctx.convert(value, interp.conversion, interp.format_spec)
1526 else:
1527 value = ColorizedString(format(value, interp.format_spec))
1529 return value
1532def _apply_format(
1533 value: ColorizedString,
1534 width: int | None,
1535 precision: int | None,
1536 align: str | None,
1537 fill: str | None,
1538):
1539 if precision is not None and value.width > precision:
1540 cut = ColorizedString()
1541 for part in value:
1542 if precision <= 0:
1543 break
1544 if isinstance(part, str):
1545 part_width = line_width(part)
1546 if part_width <= precision:
1547 cut.append_str(part)
1548 precision -= part_width
1549 elif part.isascii():
1550 cut.append_str(part[:precision])
1551 break
1552 else:
1553 for j, ch in enumerate(part):
1554 precision -= line_width(ch)
1555 if precision == 0:
1556 cut.append_str(part[: j + 1])
1557 break
1558 elif precision < 0:
1559 cut.append_str(part[:j])
1560 cut.append_str(" ")
1561 break
1562 break
1563 else:
1564 cut += part
1565 value = cut
1567 if width is not None and width > value.width:
1568 fill = fill or " "
1569 fill_width = line_width(fill)
1570 spacing = width - value.width
1571 spacing_fill = spacing // fill_width
1572 spacing_space = spacing - spacing_fill * fill_width
1573 value.append_color(_Color.NONE)
1574 if not align or align == "<":
1575 value = value + fill * spacing_fill + " " * spacing_space
1576 elif align == ">":
1577 value = fill * spacing_fill + " " * spacing_space + value
1578 else:
1579 left = spacing_fill // 2
1580 right = spacing_fill - left
1581 value = fill * left + value + fill * right + " " * spacing_space
1583 return value
1586__TAG_RE = re.compile(
1587 r"""
1588 <c (?P<tag_open>[a-z0-9 _/@:-]+)> # _Color tag open.
1589 | </c> # _Color tag close.
1590 | \\(?P<punct>[%(punct)s]) # Escape character.
1591 | (?<!`)(`+)(?!`)(?P<code>.*?)(?<!`)\3(?!`) # Inline code block (backticks).
1592 """
1593 % {"punct": re.escape(string.punctuation)},
1594 re.VERBOSE | re.MULTILINE,
1595)
1596__NEG_NUM_RE = re.compile(r"^-(0x[0-9a-fA-F]+|0b[01]+|\d+(e[+-]?\d+)?)$")
1597__FLAG_RE = re.compile(r"^-[-a-zA-Z0-9_]*$")
1600def colorize(
1601 template: str | _Template,
1602 /,
1603 *args: _t.Any,
1604 ctx: ReprContext,
1605 default_color: _Color | str = _Color.NONE,
1606) -> ColorizedString:
1607 """colorize(line: str, /, *args: typing.Any, ctx: ReprContext, default_color: ~yuio.color.Color | str = Color.NONE, parse_cli_flags_in_backticks: bool = False) -> ColorizedString
1608 colorize(line: ~string.templatelib.Template, /, *, ctx: ReprContext, default_color: ~yuio.color.Color | str = Color.NONE, parse_cli_flags_in_backticks: bool = False) -> ColorizedString
1610 Parse color tags and produce a colorized string.
1612 Apply ``default_color`` to the entire paragraph, and process color tags
1613 and backticks within it.
1615 :param line:
1616 text to colorize.
1617 :param args:
1618 if given, string will be ``%``-formatted after parsing.
1619 Can't be given if `line` is :class:`~string.templatelib.Template`.
1620 :param ctx:
1621 :class:`ReprContext` that will be used to look up color tags
1622 and format arguments.
1623 :param default_color:
1624 color or color tag to apply to the entire text.
1625 :returns:
1626 a colorized string.
1628 """
1630 interpolations: list[tuple[int, _Interpolation]] = []
1631 if isinstance(template, _Template):
1632 if args:
1633 raise TypeError("args can't be given with template")
1634 line = ""
1635 index = 0
1636 for part, interp in zip(template.strings, template.interpolations):
1637 line += part
1638 # Each interpolation is replaced by a zero byte so that our regex knows
1639 # there is something.
1640 line += "\0"
1641 index += len(part) + 1
1642 interpolations.append((index, interp))
1643 line += template.strings[-1]
1644 else:
1645 line = template
1647 default_color = ctx.to_color(default_color)
1649 res = ColorizedString(default_color)
1650 stack = [default_color]
1651 last_pos = 0
1652 last_interp = 0
1654 def append_to_res(s: str, start: int):
1655 nonlocal last_interp
1657 index = 0
1658 while (
1659 last_interp < len(interpolations)
1660 and start + len(s) >= interpolations[last_interp][0]
1661 ):
1662 interp_start, interp = interpolations[last_interp]
1663 res.append_str(
1664 s[
1665 index : interp_start
1666 - start
1667 - 1 # This compensates for that `\0` we added above.
1668 ]
1669 )
1670 res.append_colorized_str(
1671 _format_interpolation(interp, ctx).with_base_color(res.active_color)
1672 )
1673 index = interp_start - start
1674 last_interp += 1
1675 res.append_str(s[index:])
1677 for tag in __TAG_RE.finditer(line):
1678 append_to_res(line[last_pos : tag.start()], last_pos)
1679 last_pos = tag.end()
1681 if name := tag.group("tag_open"):
1682 color = stack[-1] | ctx.get_color(name)
1683 res.append_color(color)
1684 stack.append(color)
1685 elif code := tag.group("code"):
1686 code = code.replace("\n", " ")
1687 code_pos = tag.start("code")
1688 if code.startswith(" ") and code.endswith(" ") and not code.isspace():
1689 code = code[1:-1]
1690 code_pos += 1
1691 if __FLAG_RE.match(code) and not __NEG_NUM_RE.match(code):
1692 res.append_color(stack[-1] | ctx.get_color("flag"))
1693 else:
1694 res.append_color(stack[-1] | ctx.get_color("code"))
1695 res.start_no_wrap()
1696 append_to_res(code, code_pos)
1697 res.end_no_wrap()
1698 res.append_color(stack[-1])
1699 elif punct := tag.group("punct"):
1700 append_to_res(punct, tag.start("punct"))
1701 elif len(stack) > 1:
1702 stack.pop()
1703 res.append_color(stack[-1])
1705 append_to_res(line[last_pos:], last_pos)
1707 if args:
1708 return res.percent_format(args, ctx)
1709 else:
1710 return res
1713def strip_color_tags(s: str) -> str:
1714 """
1715 Remove all color tags from a string.
1717 """
1719 raw: list[str] = []
1721 last_pos = 0
1722 for tag in __TAG_RE.finditer(s):
1723 raw.append(s[last_pos : tag.start()])
1724 last_pos = tag.end()
1726 if code := tag.group("code"):
1727 code = code.replace("\n", " ")
1728 if code.startswith(" ") and code.endswith(" ") and not code.isspace():
1729 code = code[1:-1]
1730 raw.append(code)
1731 elif punct := tag.group("punct"):
1732 raw.append(punct)
1734 raw.append(s[last_pos:])
1736 return "".join(raw)
1739class Esc(_UserString):
1740 """
1741 A string that can't be broken during word wrapping even
1742 if `break_long_nowrap_words` is :data:`True`.
1744 """
1746 __slots__ = ()
1749_SPACE_TRANS = str.maketrans("\r\n\t\v\b\f", " ")
1751_WORD_PUNCT = r'[\w!"\'&.,?]'
1752_LETTER = r"[^\d\W]"
1753_NOWHITESPACE = r"[^ \r\n\t\v\b\f]"
1755# Copied from textwrap with some modifications in newline handling
1756_WORDSEP_RE = re.compile(
1757 r"""
1758 ( # newlines and line feeds are matched one-by-one
1759 (?:\r\n|\r|\n|\v\r\n|\v\r|\v\n|\v)
1760 | # any whitespace
1761 [ \t\b\f]+
1762 | # em-dash between words
1763 (?<=%(wp)s) -{2,} (?=\w)
1764 | # word, possibly hyphenated
1765 %(nws)s+? (?:
1766 # hyphenated word
1767 -(?: (?<=%(lt)s{2}-) | (?<=%(lt)s-%(lt)s-))
1768 (?= %(lt)s -? %(lt)s)
1769 | # end of word
1770 (?=[ \r\n\t\v\b\f]|\Z)
1771 | # em-dash
1772 (?<=%(wp)s) (?=-{2,}\w)
1773 )
1774 )"""
1775 % {"wp": _WORD_PUNCT, "lt": _LETTER, "nws": _NOWHITESPACE},
1776 re.VERBOSE,
1777)
1778_WORDSEP_NL_RE = re.compile(r"(\r\n|\r|\n|\v\r\n|\v\r|\v\n|\v)")
1781class _TextWrapper:
1782 def __init__(
1783 self,
1784 width: int,
1785 /,
1786 *,
1787 preserve_spaces: bool,
1788 preserve_newlines: bool,
1789 break_long_words: bool,
1790 break_long_nowrap_words: bool,
1791 overflow: _t.Literal[False] | str,
1792 indent: AnyString | int,
1793 continuation_indent: AnyString | int | None,
1794 ):
1795 self.width = width
1796 self.preserve_spaces: bool = preserve_spaces
1797 self.preserve_newlines: bool = preserve_newlines
1798 self.break_long_words: bool = break_long_words
1799 self.break_long_nowrap_words: bool = break_long_nowrap_words
1800 self.overflow: _t.Literal[False] | str = overflow
1802 self.indent = ColorizedString()
1803 self.indent.start_no_wrap()
1804 self.continuation_indent = ColorizedString()
1805 self.continuation_indent.start_no_wrap()
1806 if isinstance(indent, int):
1807 self.indent.append_str(" " * indent)
1808 else:
1809 self.indent += indent
1810 if continuation_indent is None:
1811 self.continuation_indent.append_colorized_str(self.indent)
1812 elif isinstance(continuation_indent, int):
1813 self.continuation_indent.append_str(" " * continuation_indent)
1814 else:
1815 self.continuation_indent += continuation_indent
1817 self.lines: list[ColorizedString] = []
1819 self.current_line = ColorizedString()
1820 if self.indent:
1821 self.current_line += self.indent
1822 self.current_line_width: int = self.indent.width
1823 self.at_line_start: bool = True
1824 self.at_line_start_or_indent: bool = True
1825 self.has_ellipsis: bool = False
1826 self.add_spaces_before_word: int = 0
1828 self.nowrap_start_index = None
1829 self.nowrap_start_width = 0
1830 self.nowrap_start_added_space = False
1832 def _flush_line(self, explicit_newline=""):
1833 self.current_line._explicit_newline = explicit_newline
1834 self.lines.append(self.current_line)
1836 next_line = ColorizedString()
1838 if self.continuation_indent:
1839 next_line += self.continuation_indent
1841 next_line.append_color(self.current_line.active_color)
1842 next_line.append_link(self.current_line.active_url)
1844 self.current_line = next_line
1845 self.current_line_width: int = self.continuation_indent.width
1846 self.at_line_start = True
1847 self.at_line_start_or_indent = True
1848 self.has_ellipsis = False
1849 self.nowrap_start_index = None
1850 self.nowrap_start_width = 0
1851 self.nowrap_start_added_space = False
1852 self.add_spaces_before_word = 0
1854 def _flush_line_part(self):
1855 assert self.nowrap_start_index is not None
1856 self.current_line, tail = self.current_line._split_at(self.nowrap_start_index)
1857 tail_width = self.current_line_width - self.nowrap_start_width
1858 if (
1859 self.nowrap_start_added_space
1860 and self.current_line._parts
1861 and self.current_line._parts[-1] == " "
1862 ):
1863 # Remove space that was added before no-wrap sequence.
1864 self.current_line._parts.pop()
1865 self._flush_line()
1866 self.current_line += tail
1867 self.current_line.append_color(tail.active_color)
1868 self.current_line.append_link(tail.active_url)
1869 self.current_line_width += tail_width
1871 def _append_str(self, s: str):
1872 self.current_line.append_str(s)
1873 self.at_line_start = False
1874 self.at_line_start_or_indent = self.at_line_start_or_indent and s.isspace()
1876 def _append_word(self, word: str, word_width: int):
1877 if (
1878 self.overflow is not False
1879 and self.current_line_width + word_width > self.width
1880 ):
1881 if isinstance(word, Esc):
1882 if self.overflow:
1883 self._add_ellipsis()
1884 return
1886 word_head_len = word_head_width = 0
1888 for c in word:
1889 c_width = line_width(c)
1890 if self.current_line_width + word_head_width + c_width > self.width:
1891 break
1892 word_head_len += 1
1893 word_head_width += c_width
1895 if word_head_len:
1896 self._append_str(word[:word_head_len])
1897 self.has_ellipsis = False
1898 self.current_line_width += word_head_width
1900 if self.overflow:
1901 self._add_ellipsis()
1902 else:
1903 self._append_str(word)
1904 self.current_line_width += word_width
1905 self.has_ellipsis = False
1907 def _append_space(self):
1908 if self.add_spaces_before_word:
1909 word = " " * self.add_spaces_before_word
1910 self._append_word(word, 1)
1911 self.add_spaces_before_word = 0
1913 def _add_ellipsis(self):
1914 if self.has_ellipsis:
1915 # Already has an ellipsis.
1916 return
1918 if self.current_line_width + 1 <= self.width:
1919 # There's enough space on this line to add new ellipsis.
1920 self._append_str(str(self.overflow))
1921 self.current_line_width += 1
1922 self.has_ellipsis = True
1923 elif not self.at_line_start:
1924 # Modify last word on this line, if there is any.
1925 parts = self.current_line._parts
1926 for i in range(len(parts) - 1, -1, -1):
1927 part = parts[i]
1928 if isinstance(part, str):
1929 if not isinstance(part, Esc):
1930 parts[i] = f"{part[:-1]}{self.overflow}"
1931 self.has_ellipsis = True
1932 return
1934 def _append_word_with_breaks(self, word: str, word_width: int):
1935 while self.current_line_width + word_width > self.width:
1936 word_head_len = word_head_width = 0
1938 for c in word:
1939 c_width = line_width(c)
1940 if self.current_line_width + word_head_width + c_width > self.width:
1941 break
1942 word_head_len += 1
1943 word_head_width += c_width
1945 if self.at_line_start and not word_head_len:
1946 if self.overflow:
1947 return
1948 else:
1949 word_head_len = 1
1950 word_head_width += line_width(word[:1])
1952 self._append_word(word[:word_head_len], word_head_width)
1954 word = word[word_head_len:]
1955 word_width -= word_head_width
1957 self._flush_line()
1959 if word:
1960 self._append_word(word, word_width)
1962 def wrap(self, text: ColorizedString) -> list[ColorizedString]:
1963 nowrap = False
1965 for part in text:
1966 if isinstance(part, _Color):
1967 if (
1968 self.add_spaces_before_word
1969 and self.current_line_width + self.add_spaces_before_word
1970 < self.width
1971 ):
1972 # Make sure any whitespace that was added before color
1973 # is flushed. If it doesn't fit, we just forget it: the line
1974 # will be wrapped soon anyways.
1975 self._append_space()
1976 self.add_spaces_before_word = 0
1977 self.current_line.append_color(part)
1978 continue
1979 elif isinstance(part, LinkMarker):
1980 if (
1981 self.add_spaces_before_word
1982 and self.current_line_width + self.add_spaces_before_word
1983 < self.width
1984 ):
1985 # Make sure any whitespace that was added before color
1986 # is flushed. If it doesn't fit, we just forget it: the line
1987 # will be wrapped soon anyways.
1988 self._append_space()
1989 self.add_spaces_before_word = 0
1990 self.current_line.append_link(part.url)
1991 continue
1992 elif part is NO_WRAP_START:
1993 if nowrap: # pragma: no cover
1994 continue
1995 if (
1996 self.add_spaces_before_word
1997 and self.current_line_width + self.add_spaces_before_word
1998 < self.width
1999 ):
2000 # Make sure any whitespace that was added before no-wrap
2001 # is flushed. If it doesn't fit, we just forget it: the line
2002 # will be wrapped soon anyways.
2003 self._append_space()
2004 self.nowrap_start_added_space = True
2005 else:
2006 self.nowrap_start_added_space = False
2007 self.add_spaces_before_word = 0
2008 if self.at_line_start:
2009 self.nowrap_start_index = None
2010 self.nowrap_start_width = 0
2011 else:
2012 self.nowrap_start_index = len(self.current_line._parts)
2013 self.nowrap_start_width = self.current_line_width
2014 nowrap = True
2015 continue
2016 elif part is NO_WRAP_END:
2017 nowrap = False
2018 self.nowrap_start_index = None
2019 self.nowrap_start_width = 0
2020 self.nowrap_start_added_space = False
2021 continue
2023 esc = False
2024 if isinstance(part, Esc):
2025 words = [Esc(part.translate(_SPACE_TRANS))]
2026 esc = True
2027 elif nowrap:
2028 words = _WORDSEP_NL_RE.split(part)
2029 else:
2030 words = _WORDSEP_RE.split(part)
2032 for word in words:
2033 if not word:
2034 # `_WORDSEP_RE` produces empty strings, skip them.
2035 continue
2037 if word.startswith(("\v", "\r", "\n")):
2038 # `_WORDSEP_RE` yields one newline sequence at a time, we don't
2039 # need to split the word further.
2040 if nowrap or self.preserve_newlines or word.startswith("\v"):
2041 self._flush_line(explicit_newline=word)
2042 continue
2043 else:
2044 # Treat any newline sequence as a single space.
2045 word = " "
2047 isspace = not esc and word.isspace()
2048 if isspace:
2049 if (
2050 # Spaces are preserved in no-wrap sequences.
2051 nowrap
2052 # Spaces are explicitly preserved.
2053 or self.preserve_spaces
2054 # We preserve indentation even if `preserve_spaces` is `False`.
2055 # We need to check that the previous line ended with an
2056 # explicit newline, otherwise this is not an indent.
2057 or (
2058 self.at_line_start_or_indent
2059 and (not self.lines or self.lines[-1].explicit_newline)
2060 )
2061 ):
2062 word = word.translate(_SPACE_TRANS)
2063 else:
2064 self.add_spaces_before_word = len(word)
2065 continue
2067 word_width = line_width(word)
2069 if self._try_fit_word(word, word_width):
2070 # Word fits onto the current line.
2071 continue
2073 if self.nowrap_start_index is not None:
2074 # Move the entire no-wrap sequence onto the new line.
2075 self._flush_line_part()
2077 if self._try_fit_word(word, word_width):
2078 # Word fits onto the current line after we've moved
2079 # no-wrap sequence. Nothing more to do.
2080 continue
2082 if (
2083 not self.at_line_start
2084 and (
2085 # Spaces can be broken anywhere, so we don't break line
2086 # for them: `_append_word_with_breaks` will do it for us.
2087 # Note: `esc` implies `not isspace`, so all `esc` words
2088 # outside of no-wrap sequences are handled by this check.
2089 (not nowrap and not isspace)
2090 # No-wrap sequences are broken in the middle of any word,
2091 # so we don't need any special handling for them
2092 # (again, `_append_word_with_breaks` will do breaking for us).
2093 # An exception is `esc` words which can't be broken in the middle;
2094 # if the break is possible at all, it must happen here.
2095 or (nowrap and esc and self.break_long_nowrap_words)
2096 )
2097 and not (
2098 # This is an esc word which wouldn't fit onto this line, nor onto
2099 # the next line, and there's enough space for an ellipsis
2100 # on this line (or it already has one). We don't need to break
2101 # the line here: this word will be passed to `_append_word`,
2102 # which will handle ellipsis for us.
2103 self.overflow is not False
2104 and esc
2105 and self.continuation_indent.width + word_width > self.width
2106 and (
2107 self.has_ellipsis
2108 or self.current_line_width + self.add_spaces_before_word + 1
2109 <= self.width
2110 )
2111 )
2112 ):
2113 # Flush a non-empty line.
2114 self._flush_line()
2116 # Note: `need_space_before_word` is always `False` at this point.
2117 # `need_space_before_word` becomes `True` only when current line
2118 # is non-empty, we're not in no-wrap sequence, and `preserve_spaces`
2119 # is `False` (meaning `isspace` is also `False`). In such situation,
2120 # we flush the line in the condition above.
2121 if not esc and (
2122 (nowrap and self.break_long_nowrap_words)
2123 or (not nowrap and (self.break_long_words or isspace))
2124 ):
2125 # We will break the word in the middle if it doesn't fit.
2126 self._append_word_with_breaks(word, word_width)
2127 else:
2128 self._append_word(word, word_width)
2130 if self.current_line or not self.lines or self.lines[-1].explicit_newline:
2131 self._flush_line()
2133 return self.lines
2135 def _try_fit_word(self, word: str, word_width: int):
2136 if (
2137 self.current_line_width + word_width + self.add_spaces_before_word
2138 <= self.width
2139 ):
2140 self._append_space()
2141 self._append_word(word, word_width)
2142 return True
2143 else:
2144 return False
2147class _ReprContextState(Enum):
2148 START = 0
2149 """
2150 Initial state.
2152 """
2154 CONTAINER_START = 1
2155 """
2156 Right after a token starting a container was pushed.
2158 """
2160 ITEM_START = 2
2161 """
2162 Right after a token separating container items was pushed.
2164 """
2166 NORMAL = 3
2167 """
2168 In the middle of a container element.
2170 """
2173@_t.final
2174class ReprContext:
2175 """
2176 Context object that tracks repr settings and ensures that recursive objects
2177 are handled properly.
2179 .. warning::
2181 :class:`~yuio.string.ReprContext`\\ s are not thread safe. As such,
2182 you shouldn't create them for long term use.
2184 :param term:
2185 terminal that will be used to print formatted messages.
2186 :param theme:
2187 theme that will be used to format messages.
2188 :param multiline:
2189 indicates that values rendered via `rich repr protocol`_
2190 should be split into multiple lines. Default is :data:`False`.
2191 :param highlighted:
2192 indicates that values rendered via `rich repr protocol`_
2193 or via built-in :func:`repr` should be highlighted according to python syntax.
2194 Default is :data:`False`.
2195 :param max_depth:
2196 maximum depth of nested containers, after which container's contents
2197 are not rendered. Default is ``5``.
2198 :param width:
2199 maximum width of the content, used when wrapping text, rendering markdown,
2200 or rendering horizontal rulers. If not given, defaults
2201 to :attr:`Theme.fallback_width <yuio.theme.Theme.fallback_width>`.
2203 .. _rich repr protocol: https://rich.readthedocs.io/en/stable/pretty.html#rich-repr-protocol
2205 """
2207 def __init__(
2208 self,
2209 *,
2210 term: yuio.term.Term,
2211 theme: yuio.theme.Theme,
2212 multiline: bool | None = None,
2213 highlighted: bool | None = None,
2214 max_depth: int | None = None,
2215 width: int | None = None,
2216 ):
2217 self.term = term
2218 """
2219 Current term.
2221 """
2223 self.theme = theme
2224 """
2225 Current theme.
2227 """
2229 self.multiline: bool = multiline if multiline is not None else False
2230 """
2231 Whether values rendered with :meth:`~ReprContext.repr` are split into multiple lines.
2233 """
2235 self.highlighted: bool = highlighted if highlighted is not None else False
2236 """
2237 Whether values rendered with :meth:`~ReprContext.repr` are highlighted.
2239 """
2241 self.max_depth: int = max_depth if max_depth is not None else 5
2242 """
2243 Maximum depth of nested containers, after which container's contents
2244 are not rendered.
2246 """
2248 self.width: int = max(width or theme.fallback_width, 1)
2249 """
2250 Maximum width of the content, used when wrapping text or rendering markdown.
2252 """
2254 self._seen: set[int] = set()
2255 self._line = ColorizedString()
2256 self._indent = 0
2257 self._state = _ReprContextState.START
2258 self._pending_sep = None
2260 import yuio.hl
2262 self._hl, _ = yuio.hl.get_highlighter("repr")
2263 self._base_color = theme.get_color("msg/text:code/repr")
2265 @staticmethod
2266 def make_dummy(is_unicode: bool = True) -> ReprContext:
2267 """
2268 Make a dummy repr context with default settings.
2270 """
2272 return ReprContext(
2273 term=yuio.term.Term.make_dummy(is_unicode=is_unicode),
2274 theme=yuio.theme.Theme(),
2275 )
2277 def get_color(self, paths: str, /) -> yuio.color.Color:
2278 """
2279 Lookup a color by path.
2281 """
2283 return self.theme.get_color(paths)
2285 def to_color(
2286 self, color_or_path: yuio.color.Color | str | None, /
2287 ) -> yuio.color.Color:
2288 """
2289 Convert color or color path to color.
2291 """
2293 return self.theme.to_color(color_or_path)
2295 def get_msg_decoration(self, name: str, /) -> str:
2296 """
2297 Get message decoration by name.
2299 """
2301 return self.theme.get_msg_decoration(name, is_unicode=self.term.is_unicode)
2303 def _flush_sep(self, trim: bool = False):
2304 if self._pending_sep is not None:
2305 self._push_color("punct")
2306 if trim:
2307 self._pending_sep = self._pending_sep.rstrip()
2308 self._line.append_str(self._pending_sep)
2309 self._pending_sep = None
2311 def _flush_line(self):
2312 if self.multiline:
2313 self._line.append_color(self._base_color)
2314 self._line.append_str("\n")
2315 if self._indent:
2316 self._line.append_str(" " * self._indent)
2318 def _flush_sep_and_line(self):
2319 if self.multiline and self._state in [
2320 _ReprContextState.CONTAINER_START,
2321 _ReprContextState.ITEM_START,
2322 ]:
2323 self._flush_sep(trim=True)
2324 self._flush_line()
2325 else:
2326 self._flush_sep()
2328 def _push_color(self, tag: str):
2329 if self.highlighted:
2330 self._line.append_color(
2331 self._base_color | self.theme.to_color(f"hl/{tag}:repr")
2332 )
2334 def _push_token(self, content: str, tag: str):
2335 self._flush_sep_and_line()
2337 self._push_color(tag)
2338 self._line.append_str(content)
2340 self._state = _ReprContextState.NORMAL
2342 def _terminate_item(self, sep: str = ", "):
2343 self._flush_sep()
2344 self._pending_sep = sep
2345 self._state = _ReprContextState.ITEM_START
2347 def _start_container(self):
2348 self._state = _ReprContextState.CONTAINER_START
2349 self._indent += 1
2351 def _end_container(self):
2352 self._indent -= 1
2354 if self._state in [_ReprContextState.NORMAL, _ReprContextState.ITEM_START]:
2355 self._flush_line()
2357 self._state = _ReprContextState.NORMAL
2358 self._pending_sep = None
2360 def repr(
2361 self,
2362 value: _t.Any,
2363 /,
2364 *,
2365 multiline: bool | None = None,
2366 highlighted: bool | None = None,
2367 width: int | None = None,
2368 max_depth: int | None = None,
2369 ) -> ColorizedString:
2370 """
2371 Convert value to colorized string using repr methods.
2373 :param value:
2374 value to be rendered.
2375 :param multiline:
2376 if given, overrides settings passed to :class:`ReprContext` for this call.
2377 :param highlighted:
2378 if given, overrides settings passed to :class:`ReprContext` for this call.
2379 :param width:
2380 if given, overrides settings passed to :class:`ReprContext` for this call.
2381 :param max_depth:
2382 if given, overrides settings passed to :class:`ReprContext` for this call.
2383 :returns:
2384 a colorized string containing representation of the `value`.
2385 :raises:
2386 this method does not raise any errors. If any inner object raises an
2387 exception, this function returns a colorized string with
2388 an error description.
2390 """
2392 return self._print(
2393 value,
2394 multiline=multiline,
2395 highlighted=highlighted,
2396 use_str=False,
2397 width=width,
2398 max_depth=max_depth,
2399 )
2401 def str(
2402 self,
2403 value: _t.Any,
2404 /,
2405 *,
2406 multiline: bool | None = None,
2407 highlighted: bool | None = None,
2408 width: int | None = None,
2409 max_depth: int | None = None,
2410 ) -> ColorizedString:
2411 """
2412 Convert value to colorized string.
2414 :param value:
2415 value to be rendered.
2416 :param multiline:
2417 if given, overrides settings passed to :class:`ReprContext` for this call.
2418 :param highlighted:
2419 if given, overrides settings passed to :class:`ReprContext` for this call.
2420 :param width:
2421 if given, overrides settings passed to :class:`ReprContext` for this call.
2422 :param max_depth:
2423 if given, overrides settings passed to :class:`ReprContext` for this call.
2424 :returns:
2425 a colorized string containing string representation of the `value`.
2426 :raises:
2427 this method does not raise any errors. If any inner object raises an
2428 exception, this function returns a colorized string with
2429 an error description.
2431 """
2433 return self._print(
2434 value,
2435 multiline=multiline,
2436 highlighted=highlighted,
2437 use_str=True,
2438 width=width,
2439 max_depth=max_depth,
2440 )
2442 def convert(
2443 self,
2444 value: _t.Any,
2445 conversion: _t.Literal["a", "r", "s"] | None,
2446 format_spec: str | None = None,
2447 /,
2448 *,
2449 multiline: bool | None = None,
2450 highlighted: bool | None = None,
2451 width: int | None = None,
2452 max_depth: int | None = None,
2453 ):
2454 """
2455 Perform string conversion, similar to :func:`string.templatelib.convert`,
2456 and format the object with respect to the given `format_spec`.
2458 :param value:
2459 value to be converted.
2460 :param conversion:
2461 string conversion method:
2463 - ``'s'`` calls :meth:`~ReprContext.str`,
2464 - ``'r'`` calls :meth:`~ReprContext.repr`,
2465 - ``'a'`` calls :meth:`~ReprContext.repr` and escapes non-ascii
2466 characters.
2467 :param format_spec:
2468 formatting spec can override `multiline` and `highlighted`, and controls
2469 width, alignment, fill chars, etc. See its syntax below.
2470 :param multiline:
2471 if given, overrides settings passed to :class:`ReprContext` for this call.
2472 :param highlighted:
2473 if given, overrides settings passed to :class:`ReprContext` for this call.
2474 :param width:
2475 if given, overrides settings passed to :class:`ReprContext` for this call.
2476 :param max_depth:
2477 if given, overrides settings passed to :class:`ReprContext` for this call.
2478 :returns:
2479 a colorized string containing string representation of the `value`.
2480 :raises:
2481 :class:`ValueError` if `conversion` or `format_spec` are invalid.
2483 .. _t-string-spec:
2485 **Format specification**
2487 .. syntax:diagram::
2489 stack:
2490 - optional:
2491 - optional:
2492 - non_terminal: "fill"
2493 href: "#t-string-spec-fill"
2494 - non_terminal: "align"
2495 href: "#t-string-spec-align"
2496 - optional:
2497 - non_terminal: "flags"
2498 href: "#t-string-spec-flags"
2499 - optional:
2500 - comment: "width"
2501 href: "#t-string-spec-width"
2502 - "[0-9]+"
2503 - optional:
2504 - comment: "precision"
2505 href: "#t-string-spec-precision"
2506 - "'.'"
2507 - "[0-9]+"
2508 - optional:
2509 - comment: "conversion type"
2510 href: "#t-string-spec-conversion-type"
2511 - "'s'"
2512 skip_bottom: true
2513 skip: true
2515 .. _t-string-spec-fill:
2517 ``fill``
2518 Any character that will be used to extend string to the desired width.
2520 .. _t-string-spec-align:
2522 ``align``
2523 Controls alignment of a string when `width` is given: ``"<"`` for flushing
2524 string left, ``">"`` for flushing string right, ``"^"`` for centering.
2526 .. _t-string-spec-flags:
2528 ``flags``
2529 One or several flags: ``"#"`` to enable highlighting, ``"+"`` to enable
2530 multiline repr.
2532 .. _t-string-spec-width:
2534 ``width``
2535 If formatted string is narrower than this value, it will be extended and
2536 aligned using `fill` and `align` settings.
2538 .. _t-string-spec-precision:
2540 ``precision``
2541 If formatted string is wider that this value, it will be cropped to this
2542 width.
2544 .. _t-string-spec-conversion-type:
2546 ``conversion type``
2547 The only supported conversion type is ``"s"``.
2549 """
2551 if format_spec:
2552 match = _F_SYNTAX.match(format_spec)
2553 if not match:
2554 raise ValueError(f"invalid format specifier {format_spec!r}")
2555 fill = match.group("fill")
2556 align = match.group("align")
2557 if align == "=":
2558 raise ValueError("'=' alignment not allowed in string format specifier")
2559 flags = match.group("flags")
2560 if "#" in flags:
2561 highlighted = True
2562 if "+" in flags:
2563 multiline = True
2564 zero = match.group("zero")
2565 if zero and not fill:
2566 fill = zero
2567 format_width = match.group("width")
2568 if format_width:
2569 format_width = int(format_width)
2570 else:
2571 format_width = None
2572 format_width_grouping = match.group("width_grouping")
2573 if format_width_grouping:
2574 raise ValueError(f"cannot specify {format_width_grouping!r} with 's'")
2575 format_precision = match.group("precision")
2576 if format_precision:
2577 format_precision = int(format_precision)
2578 else:
2579 format_precision = None
2580 type = match.group("type")
2581 if type and type != "s":
2582 raise ValueError(f"unknown format code {type!r}")
2583 else:
2584 format_width = format_precision = align = fill = None
2586 if conversion == "r":
2587 res = self.repr(
2588 value,
2589 multiline=multiline,
2590 highlighted=highlighted,
2591 width=width,
2592 max_depth=max_depth,
2593 )
2594 elif conversion == "a":
2595 res = ColorizedString()
2596 for part in self.repr(
2597 value,
2598 multiline=multiline,
2599 highlighted=highlighted,
2600 width=width,
2601 max_depth=max_depth,
2602 ):
2603 if isinstance(part, _UserString):
2604 res += part._wrap(
2605 part.encode(encoding="unicode_escape").decode("ascii")
2606 )
2607 elif isinstance(part, str):
2608 res += part.encode(encoding="unicode_escape").decode("ascii")
2609 else:
2610 res += part
2611 elif not conversion or conversion == "s":
2612 res = self.str(
2613 value,
2614 multiline=multiline,
2615 highlighted=highlighted,
2616 width=width,
2617 max_depth=max_depth,
2618 )
2619 else:
2620 raise ValueError(
2621 f"unknown conversion {conversion!r}, should be 'a', 'r', or 's'"
2622 )
2624 return _apply_format(res, format_width, format_precision, align, fill)
2626 def hl(
2627 self,
2628 value: str,
2629 /,
2630 *,
2631 highlighted: bool | None = None,
2632 ) -> ColorizedString:
2633 """
2634 Highlight result of :func:`repr`.
2636 :meth:`ReprContext.repr` does this automatically, but sometimes you need
2637 to highlight a string without :func:`repr`-ing it one more time.
2639 :param value:
2640 result of :func:`repr` that needs highlighting.
2641 :returns:
2642 highlighted string.
2644 """
2646 highlighted = highlighted if highlighted is not None else self.highlighted
2648 if highlighted:
2649 return self._hl.highlight(
2650 value, theme=self.theme, syntax="repr", default_color=self._base_color
2651 )
2652 else:
2653 return ColorizedString(value)
2655 @contextlib.contextmanager
2656 def with_settings(
2657 self,
2658 *,
2659 multiline: bool | None = None,
2660 highlighted: bool | None = None,
2661 width: int | None = None,
2662 max_depth: int | None = None,
2663 ):
2664 """
2665 Temporarily replace settings of this context.
2667 :param multiline:
2668 if given, overrides settings passed to :class:`ReprContext` for this call.
2669 :param highlighted:
2670 if given, overrides settings passed to :class:`ReprContext` for this call.
2671 :param width:
2672 if given, overrides settings passed to :class:`ReprContext` for this call.
2673 :param max_depth:
2674 if given, overrides settings passed to :class:`ReprContext` for this call.
2675 :returns:
2676 a context manager that overrides settings.
2678 """
2680 old_multiline, self.multiline = (
2681 self.multiline,
2682 (self.multiline if multiline is None else multiline),
2683 )
2684 old_highlighted, self.highlighted = (
2685 self.highlighted,
2686 (self.highlighted if highlighted is None else highlighted),
2687 )
2688 old_width, self.width = (
2689 self.width,
2690 (self.width if width is None else max(width, 1)),
2691 )
2692 old_max_depth, self.max_depth = (
2693 self.max_depth,
2694 (self.max_depth if max_depth is None else max_depth),
2695 )
2697 try:
2698 yield
2699 finally:
2700 self.multiline = old_multiline
2701 self.highlighted = old_highlighted
2702 self.width = old_width
2703 self.max_depth = old_max_depth
2705 def _print(
2706 self,
2707 value: _t.Any,
2708 multiline: bool | None,
2709 highlighted: bool | None,
2710 width: int | None,
2711 max_depth: int | None,
2712 use_str: bool,
2713 ) -> ColorizedString:
2714 old_line, self._line = self._line, ColorizedString()
2715 old_state, self._state = self._state, _ReprContextState.START
2716 old_pending_sep, self._pending_sep = self._pending_sep, None
2718 try:
2719 with self.with_settings(
2720 multiline=multiline,
2721 highlighted=highlighted,
2722 width=width,
2723 max_depth=max_depth,
2724 ):
2725 self._print_nested(value, use_str)
2726 return self._line
2727 except Exception as e:
2728 yuio._logger.exception("error in repr context")
2729 res = ColorizedString()
2730 res.append_color(_Color.STYLE_INVERSE | _Color.FORE_RED)
2731 res.append_str(f"{_tx.type_repr(type(e))}: {e}")
2732 return res
2733 finally:
2734 self._line = old_line
2735 self._state = old_state
2736 self._pending_sep = old_pending_sep
2738 def _print_nested(self, value: _t.Any, use_str: bool = False):
2739 if id(value) in self._seen or self._indent > self.max_depth:
2740 self._push_token("...", "more")
2741 return
2742 self._seen.add(id(value))
2743 old_indent = self._indent
2744 try:
2745 if use_str:
2746 self._print_nested_as_str(value)
2747 else:
2748 self._print_nested_as_repr(value)
2749 finally:
2750 self._indent = old_indent
2751 self._seen.remove(id(value))
2753 def _print_nested_as_str(self, value):
2754 if isinstance(value, type):
2755 # This is a type.
2756 self._print_plain(value, convert=_tx.type_repr)
2757 elif hasattr(value, "__colorized_str__"):
2758 # Has `__colorized_str__`.
2759 self._print_colorized_str(value)
2760 elif getattr(type(value), "__str__", None) is not object.__str__:
2761 # Has custom `__str__`.
2762 self._print_plain(value, convert=str, hl=False)
2763 else:
2764 # Has default `__str__` which falls back to `__repr__`.
2765 self._print_nested_as_repr(value)
2767 def _print_nested_as_repr(self, value):
2768 if isinstance(value, type):
2769 # This is a type.
2770 self._print_plain(value, convert=_tx.type_repr)
2771 elif hasattr(value, "__colorized_repr__"):
2772 # Has `__colorized_repr__`.
2773 self._print_colorized_repr(value)
2774 elif hasattr(value, "__rich_repr__"):
2775 # Has `__rich_repr__`.
2776 self._print_rich_repr(value)
2777 elif isinstance(value, _CONTAINER_TYPES):
2778 # Is a known container.
2779 for ty, repr_fn in _CONTAINERS.items():
2780 if isinstance(value, ty):
2781 if getattr(type(value), "__repr__", None) is ty.__repr__:
2782 repr_fn(self, value) # type: ignore
2783 else:
2784 self._print_plain(value)
2785 break
2786 elif dataclasses.is_dataclass(value):
2787 # Is a dataclass.
2788 self._print_dataclass(value)
2789 else:
2790 # Fall back to regular `__repr__`.
2791 self._print_plain(value)
2793 def _print_plain(self, value, convert=None, hl=True):
2794 convert = convert or repr
2796 self._flush_sep_and_line()
2798 if hl and self.highlighted:
2799 self._line += self._hl.highlight(
2800 convert(value),
2801 theme=self.theme,
2802 syntax="repr",
2803 default_color=self._base_color,
2804 )
2805 else:
2806 self._line.append_str(convert(value))
2808 self._state = _ReprContextState.NORMAL
2810 def _print_list(self, name: str, obrace: str, cbrace: str, items):
2811 if name:
2812 self._push_token(name, "type")
2813 self._push_token(obrace, "punct")
2814 if self._indent >= self.max_depth:
2815 self._push_token("...", "more")
2816 else:
2817 self._start_container()
2818 for item in items:
2819 self._print_nested(item)
2820 self._terminate_item()
2821 self._end_container()
2822 self._push_token(cbrace, "punct")
2824 def _print_dict(self, name: str, obrace: str, cbrace: str, items):
2825 if name:
2826 self._push_token(name, "type")
2827 self._push_token(obrace, "punct")
2828 if self._indent >= self.max_depth:
2829 self._push_token("...", "more")
2830 else:
2831 self._start_container()
2832 for key, value in items:
2833 self._print_nested(key)
2834 self._push_token(": ", "punct")
2835 self._print_nested(value)
2836 self._terminate_item()
2837 self._end_container()
2838 self._push_token(cbrace, "punct")
2840 def _print_defaultdict(self, value: collections.defaultdict[_t.Any, _t.Any]):
2841 self._push_token("defaultdict", "type")
2842 self._push_token("(", "punct")
2843 if self._indent >= self.max_depth:
2844 self._push_token("...", "more")
2845 else:
2846 self._start_container()
2847 self._print_nested(value.default_factory)
2848 self._terminate_item()
2849 self._print_dict("", "{", "}", value.items())
2850 self._terminate_item()
2851 self._end_container()
2852 self._push_token(")", "punct")
2854 def _print_dequeue(self, value: collections.deque[_t.Any]):
2855 self._push_token("deque", "type")
2856 self._push_token("(", "punct")
2857 if self._indent >= self.max_depth:
2858 self._push_token("...", "more")
2859 else:
2860 self._start_container()
2861 self._print_list("", "[", "]", value)
2862 self._terminate_item()
2863 if value.maxlen is not None:
2864 self._push_token("maxlen", "param")
2865 self._push_token("=", "punct")
2866 self._print_nested(value.maxlen)
2867 self._terminate_item()
2868 self._end_container()
2869 self._push_token(")", "punct")
2871 def _print_dataclass(self, value):
2872 try:
2873 # If dataclass has a custom repr, fall back to it.
2874 # This code is copied from Rich, MIT License.
2875 # See https://github.com/Textualize/rich/blob/master/LICENSE
2876 has_custom_repr = value.__repr__.__code__.co_filename not in (
2877 dataclasses.__file__,
2878 reprlib.__file__,
2879 )
2880 except Exception: # pragma: no cover
2881 has_custom_repr = True
2883 if has_custom_repr:
2884 self._print_plain(value)
2885 return
2887 self._push_token(value.__class__.__name__, "type")
2888 self._push_token("(", "punct")
2890 if self._indent >= self.max_depth:
2891 self._push_token("...", "more")
2892 else:
2893 self._start_container()
2894 for field in dataclasses.fields(value):
2895 if not field.repr:
2896 continue
2897 self._push_token(field.name, "param")
2898 self._push_token("=", "punct")
2899 self._print_nested(getattr(value, field.name))
2900 self._terminate_item()
2901 self._end_container()
2903 self._push_token(")", "punct")
2905 def _print_colorized_repr(self, value):
2906 self._flush_sep_and_line()
2908 res = value.__colorized_repr__(self)
2909 if not isinstance(res, ColorizedString):
2910 raise TypeError(
2911 f"__colorized_repr__ returned non-colorized-string (type {_tx.type_repr(type(res))})"
2912 )
2913 self._line += res
2915 self._state = _ReprContextState.NORMAL
2917 def _print_colorized_str(self, value):
2918 self._flush_sep_and_line()
2920 res = value.__colorized_str__(self)
2921 if not isinstance(res, ColorizedString):
2922 raise TypeError(
2923 f"__colorized_str__ returned non-colorized-string (type {_tx.type_repr(type(res))})"
2924 )
2925 self._line += res
2926 self._state = _ReprContextState.NORMAL
2928 def _print_rich_repr(self, value):
2929 rich_repr = getattr(value, "__rich_repr__")
2930 angular = getattr(rich_repr, "angular", False)
2932 if angular:
2933 self._push_token("<", "punct")
2934 self._push_token(value.__class__.__name__, "type")
2935 if angular:
2936 self._push_token(" ", "space")
2937 else:
2938 self._push_token("(", "punct")
2940 if self._indent >= self.max_depth:
2941 self._push_token("...", "more")
2942 else:
2943 self._start_container()
2944 args = rich_repr()
2945 if args is None:
2946 args = [] # `rich_repr` didn't yield?
2947 for arg in args:
2948 if isinstance(arg, tuple):
2949 if len(arg) == 3:
2950 key, child, default = arg
2951 if default == child:
2952 continue
2953 elif len(arg) == 2:
2954 key, child = arg
2955 elif len(arg) == 1:
2956 key, child = None, arg[0]
2957 else:
2958 key, child = None, arg
2959 else:
2960 key, child = None, arg
2962 if key:
2963 self._push_token(str(key), "param")
2964 self._push_token("=", "punct")
2965 self._print_nested(child)
2966 self._terminate_item("" if angular else ", ")
2967 self._end_container()
2969 self._push_token(">" if angular else ")", "punct")
2972_CONTAINERS = {
2973 os._Environ: lambda c, o: c._print_dict("environ", "({", "})", o.items()),
2974 collections.defaultdict: ReprContext._print_defaultdict,
2975 collections.deque: ReprContext._print_dequeue,
2976 collections.Counter: lambda c, o: c._print_dict("Counter", "({", "})", o.items()),
2977 collections.UserList: lambda c, o: c._print_list("", "[", "]", o),
2978 collections.UserDict: lambda c, o: c._print_dict("", "{", "}", o.items()),
2979 list: lambda c, o: c._print_list("", "[", "]", o),
2980 set: lambda c, o: c._print_list("", "{", "}", o),
2981 frozenset: lambda c, o: c._print_list("frozenset", "({", "})", o),
2982 tuple: lambda c, o: c._print_list("", "(", ")", o),
2983 dict: lambda c, o: c._print_dict("", "{", "}", o.items()),
2984 types.MappingProxyType: lambda _: (
2985 lambda c, o: c._print_dict("mappingproxy", "({", "})", o.items())
2986 ),
2987}
2988_CONTAINER_TYPES = tuple(_CONTAINERS)
2991def _to_colorable(msg: _t.Any, args: tuple[_t.Any, ...] | None = None) -> Colorable:
2992 """
2993 Convert generic `msg`, `args` tuple to a colorable.
2995 If msg is a string, returns :class:`Format`. Otherwise, check that no arguments
2996 were given, and returns `msg` unchanged.
2998 """
3000 if isinstance(msg, (str, _Template)):
3001 return Format(_t.cast(_t.LiteralString, msg), *(args or ()))
3002 else:
3003 if args:
3004 raise TypeError(
3005 f"non-string type {_tx.type_repr(type(msg))} can't have format arguments"
3006 )
3007 return msg
3010class _StrBase(abc.ABC):
3011 def __str__(self) -> str:
3012 import yuio.io
3014 return str(yuio.io.make_repr_context().str(self))
3016 @abc.abstractmethod
3017 def __colorized_str__(self, ctx: ReprContext) -> ColorizedString:
3018 raise NotImplementedError()
3021@repr_from_rich
3022class Format(_StrBase):
3023 """Format(msg: typing.LiteralString, /, *args: typing.Any)
3024 Format(msg: ~string.templatelib.Template, /)
3026 Lazy wrapper that ``%``-formats the given message,
3027 or formats a :class:`~string.templatelib.Template`.
3029 This utility allows saving ``%``-formatted messages and templates and performing
3030 actual formatting lazily when requested. Color tags and backticks
3031 are handled as usual.
3033 :param msg:
3034 message to format.
3035 :param args:
3036 arguments for ``%``-formatting the message.
3037 :example:
3038 ::
3040 >>> message = Format("Hello, `%s`!", "world")
3041 >>> print(message)
3042 Hello, world!
3044 """
3046 @_t.overload
3047 def __init__(self, msg: _t.LiteralString, /, *args: _t.Any): ...
3048 @_t.overload
3049 def __init__(self, msg: _Template, /): ...
3050 def __init__(self, msg: str | _Template, /, *args: _t.Any):
3051 self._msg: str | _Template = msg
3052 self._args: tuple[_t.Any, ...] = args
3054 def __rich_repr__(self) -> RichReprResult:
3055 yield None, self._msg
3056 yield from ((None, arg) for arg in self._args)
3058 def __colorized_str__(self, ctx: ReprContext) -> ColorizedString:
3059 return colorize(self._msg, *self._args, ctx=ctx)
3062@_t.final
3063@repr_from_rich
3064class Repr(_StrBase):
3065 """
3066 Lazy wrapper that calls :meth:`~ReprContext.repr` on the given value.
3068 :param value:
3069 value to repr.
3070 :param multiline:
3071 if given, overrides settings passed to :class:`ReprContext` for this call.
3072 :param highlighted:
3073 if given, overrides settings passed to :class:`ReprContext` for this call.
3074 :example:
3075 .. code-block:: python
3077 config = ...
3078 yuio.io.info(
3079 "Loaded config:\\n`%#+s`", yuio.string.Indent(yuio.string.Repr(config))
3080 )
3082 """
3084 def __init__(
3085 self,
3086 value: _t.Any,
3087 /,
3088 *,
3089 multiline: bool | None = None,
3090 highlighted: bool | None = None,
3091 ):
3092 self.value = value
3093 self.multiline = multiline
3094 self.highlighted = highlighted
3096 def __rich_repr__(self) -> RichReprResult:
3097 yield None, self.value
3098 yield "multiline", self.multiline, None
3099 yield "highlighted", self.highlighted, None
3101 def __colorized_str__(self, ctx: ReprContext) -> ColorizedString:
3102 return ctx.repr(
3103 self.value, multiline=self.multiline, highlighted=self.highlighted
3104 )
3107@_t.final
3108@repr_from_rich
3109class TypeRepr(_StrBase):
3110 """
3111 Lazy wrapper that calls :func:`annotationlib.type_repr` on the given value
3112 and highlights the result.
3114 :param ty:
3115 type to format.
3117 If `ty` is a string, :func:`annotationlib.type_repr` is not called on it,
3118 allowing you to mix types and arbitrary descriptions.
3119 :param highlighted:
3120 if given, overrides settings passed to :class:`ReprContext` for this call.
3121 :example:
3122 .. invisible-code-block: python
3124 value = ...
3126 .. code-block:: python
3128 yuio.io.error("Expected `str`, got `%s`", yuio.string.TypeRepr(type(value)))
3130 """
3132 def __init__(self, ty: _t.Any, /, *, highlighted: bool | None = None):
3133 self._ty = ty
3134 self._highlighted = highlighted
3136 def __rich_repr__(self) -> RichReprResult:
3137 yield None, self._ty
3138 yield "highlighted", self._highlighted, None
3140 def __colorized_str__(self, ctx: ReprContext) -> ColorizedString:
3141 if not isinstance(self._ty, type) and isinstance(
3142 self._ty, (str, ColorizedString)
3143 ):
3144 return ColorizedString(self._ty)
3145 else:
3146 return ctx.hl(_tx.type_repr(self._ty), highlighted=self._highlighted)
3149@repr_from_rich
3150class _JoinBase(_StrBase):
3151 def __init__(
3152 self,
3153 collection: _t.Iterable[_t.Any],
3154 /,
3155 *,
3156 sep: str = ", ",
3157 sep_two: str | None = None,
3158 sep_last: str | None = None,
3159 fallback: AnyString = "",
3160 color: str | _Color | None = "code",
3161 limit: int = 0,
3162 limit_msg: str | None = None,
3163 ):
3164 self.__collection = collection
3165 self._sep = sep
3166 self._sep_two = sep_two
3167 self._sep_last = sep_last
3168 self._fallback: AnyString = fallback
3169 self._color = color
3170 self._limit = limit
3171 self._limit_msg = limit_msg if limit_msg is not None else ", +{n} more"
3173 @functools.cached_property
3174 def _collection(self):
3175 return list(self.__collection)
3177 @classmethod
3178 def or_(
3179 cls,
3180 collection: _t.Iterable[_t.Any],
3181 /,
3182 *,
3183 fallback: AnyString = "",
3184 color: str | _Color | None = "code",
3185 limit: int = 0,
3186 ) -> _t.Self:
3187 """
3188 Shortcut for joining arguments using word "or" as the last separator.
3190 :example:
3191 ::
3193 >>> print(yuio.string.JoinStr.or_([1, 2, 3]))
3194 1, 2, or 3
3196 """
3198 return cls(
3199 collection,
3200 sep_last=", or ",
3201 sep_two=" or ",
3202 fallback=fallback,
3203 color=color,
3204 limit=limit,
3205 limit_msg=", or {n} more",
3206 )
3208 @classmethod
3209 def and_(
3210 cls,
3211 collection: _t.Iterable[_t.Any],
3212 /,
3213 *,
3214 fallback: AnyString = "",
3215 color: str | _Color | None = "code",
3216 limit: int = 0,
3217 ) -> _t.Self:
3218 """
3219 Shortcut for joining arguments using word "and" as the last separator.
3221 :example:
3222 ::
3224 >>> print(yuio.string.JoinStr.and_([1, 2, 3]))
3225 1, 2, and 3
3227 """
3229 return cls(
3230 collection,
3231 sep_last=", and ",
3232 sep_two=" and ",
3233 fallback=fallback,
3234 color=color,
3235 limit=limit,
3236 limit_msg=", and {n} more",
3237 )
3239 def __rich_repr__(self) -> RichReprResult:
3240 yield None, self._collection
3241 yield "sep", self._sep, ", "
3242 yield "sep_two", self._sep_two, None
3243 yield "sep_last", self._sep_last, None
3244 yield "color", self._color, "code"
3246 def _render(
3247 self,
3248 theme: yuio.theme.Theme,
3249 to_str: _t.Callable[[_t.Any], ColorizedString],
3250 ) -> ColorizedString:
3251 res = ColorizedString()
3252 color = theme.to_color(self._color)
3254 size = len(self._collection)
3255 if not size:
3256 res += self._fallback
3257 return res
3258 elif size == 1:
3259 return to_str(self._collection[0]).with_base_color(color)
3260 elif size == 2:
3261 res.append_colorized_str(to_str(self._collection[0]).with_base_color(color))
3262 res.append_str(self._sep if self._sep_two is None else self._sep_two)
3263 res.append_colorized_str(to_str(self._collection[1]).with_base_color(color))
3264 return res
3266 if self._limit:
3267 limit = last_i = self._limit
3268 else:
3269 limit = size
3270 last_i = size - 1
3272 sep = self._sep
3273 sep_last = self._sep if self._sep_last is None else self._sep_last
3275 do_sep = False
3276 for i, value in enumerate(self._collection):
3277 if i == last_i and limit + 1 < size:
3278 res.append_str(self._limit_msg.format(n=size - limit))
3279 break
3280 if do_sep:
3281 if i == last_i:
3282 res.append_str(sep_last)
3283 else:
3284 res.append_str(sep)
3285 res.append_colorized_str(to_str(value).with_base_color(color))
3286 do_sep = True
3287 return res
3290@_t.final
3291class JoinStr(_JoinBase):
3292 """
3293 Lazy wrapper that calls :meth:`~ReprContext.str` on elements of the given collection,
3294 then joins the results using the given separator.
3296 :param collection:
3297 collection that will be printed.
3298 :param sep:
3299 separator that's printed between elements of the collection.
3300 :param sep_two:
3301 separator that's used when there are only two elements in the collection.
3302 Defaults to `sep`.
3303 :param sep_last:
3304 separator that's used between the last and prior-to-last element
3305 of the collection. Defaults to `sep`.
3306 :param fallback:
3307 printed if collection is empty.
3308 :param color:
3309 color applied to elements of the collection.
3310 :param limit:
3311 truncate number of entries to this limit.
3312 :param limit_msg:
3313 message that replaces truncated part. Will be :meth:`formatted <str.format>`
3314 with a single keyword argument `n` -- number of truncated entries. Default
3315 is ``"+{n} more"``.
3316 :example:
3317 .. code-block:: python
3319 values = ["foo", "bar"]
3320 yuio.io.info("Available values: %s", yuio.string.JoinStr(values))
3322 """
3324 def __colorized_str__(self, ctx: ReprContext) -> ColorizedString:
3325 return self._render(ctx.theme, ctx.str)
3328@_t.final
3329class JoinRepr(_JoinBase):
3330 """
3331 Lazy wrapper that calls :meth:`~ReprContext.repr` on elements of the given collection,
3332 then joins the results using the given separator.
3334 :param collection:
3335 collection that will be printed.
3336 :param sep:
3337 separator that's printed between elements of the collection.
3338 :param sep_two:
3339 separator that's used when there are only two elements in the collection.
3340 Defaults to `sep`.
3341 :param sep_last:
3342 separator that's used between the last and prior-to-last element
3343 of the collection. Defaults to `sep`.
3344 :param fallback:
3345 printed if collection is empty.
3346 :param color:
3347 color applied to elements of the collection.
3348 :param limit:
3349 truncate number of entries to this limit.
3350 :param limit_msg:
3351 message that replaces truncated part. Will be ``%``-formatted with a single
3352 keyword argument `n` -- number of truncated entries. Default
3353 is ``"+{n} more"``.
3354 :example:
3355 .. code-block:: python
3357 values = ["foo", "bar"]
3358 yuio.io.info("Available values: %s", yuio.string.JoinRepr(values))
3360 """
3362 def __colorized_str__(self, ctx: ReprContext) -> ColorizedString:
3363 return self._render(ctx.theme, ctx.repr)
3366And = JoinStr.and_
3367"""
3368Shortcut for :meth:`JoinStr.and_`.
3370"""
3373Or = JoinStr.or_
3374"""
3375Shortcut for :meth:`JoinStr.or_`.
3377"""
3380@_t.final
3381@repr_from_rich
3382class Stack(_StrBase):
3383 """
3384 Lazy wrapper that joins multiple :obj:`Colorable` objects with newlines,
3385 effectively stacking them one on top of another.
3387 :param args:
3388 colorables to stack.
3389 :example:
3390 ::
3392 >>> print(
3393 ... yuio.string.Stack(
3394 ... yuio.string.Format("<c bold magenta>Example:</c>"),
3395 ... yuio.string.Indent(
3396 ... yuio.string.Hl(
3397 ... \"""
3398 ... {
3399 ... "foo": "bar"
3400 ... }
3401 ... \""",
3402 ... syntax="json",
3403 ... ),
3404 ... indent="-> ",
3405 ... ),
3406 ... )
3407 ... )
3408 Example:
3409 -> {
3410 -> "foo": "bar"
3411 -> }
3413 """
3415 def __init__(self, *args: Colorable):
3416 self._args = args
3418 def __rich_repr__(self) -> RichReprResult:
3419 yield from ((None, arg) for arg in self._args)
3421 def __colorized_str__(self, ctx: ReprContext) -> ColorizedString:
3422 res = ColorizedString()
3423 sep = False
3424 for arg in self._args:
3425 if sep:
3426 res.append_color(_Color.NONE)
3427 res.append_str("\n")
3428 res += ctx.str(arg)
3429 sep = True
3430 return res
3433@_t.final
3434@repr_from_rich
3435class Link(_StrBase):
3436 """
3437 Lazy wrapper that adds a hyperlink to whatever is passed to it.
3439 :param msg:
3440 link body.
3441 :param url:
3442 link url, should be properly urlencoded.
3444 """
3446 def __init__(self, msg: Colorable, /, *, url: str):
3447 self._msg = msg
3448 self._url = url
3450 @classmethod
3451 def from_path(cls, msg: Colorable, /, *, path: str | pathlib.Path) -> _t.Self:
3452 """
3453 Create a link to a local file.
3455 Ensures that file path is absolute and properly formatted.
3457 :param msg:
3458 link body.
3459 :param path:
3460 path to a file.
3462 """
3464 url = pathlib.Path(path).expanduser().absolute().as_uri()
3465 return cls(msg, url=url)
3467 def __rich_repr__(self) -> RichReprResult:
3468 yield None, self._msg
3469 yield "url", self._url
3471 def __colorized_str__(self, ctx: ReprContext) -> ColorizedString:
3472 res = ColorizedString()
3473 if not ctx.term.supports_colors or yuio.term.detect_ci():
3474 res.append_colorized_str(ctx.str(self._msg))
3475 res.append_str(" ")
3476 res.start_no_wrap()
3477 res.append_str("[")
3478 res.append_str(self._url)
3479 res.append_str("]")
3480 res.end_no_wrap()
3481 else:
3482 res.start_link(self._url)
3483 res.append_colorized_str(ctx.str(self._msg))
3484 res.end_link()
3485 return res
3488@_t.final
3489@repr_from_rich
3490class Indent(_StrBase):
3491 """
3492 Lazy wrapper that indents the message during formatting.
3494 .. seealso::
3496 :meth:`ColorizedString.indent`.
3498 :param msg:
3499 message to indent.
3500 :param indent:
3501 this will be prepended to the first line in the string.
3502 Defaults to two spaces.
3503 :param continuation_indent:
3504 this will be prepended to subsequent lines in the string.
3505 Defaults to `indent`.
3506 :example:
3507 .. code-block:: python
3509 config = ...
3510 yuio.io.info(
3511 "Loaded config:\\n`%#+s`", yuio.string.Indent(yuio.string.Repr(config))
3512 )
3514 """
3516 def __init__(
3517 self,
3518 msg: Colorable,
3519 /,
3520 *,
3521 indent: AnyString | int = " ",
3522 continuation_indent: AnyString | int | None = None,
3523 ):
3524 self._msg = msg
3525 self._indent: AnyString | int = indent
3526 self._continuation_indent: AnyString | int | None = continuation_indent
3528 def __rich_repr__(self) -> RichReprResult:
3529 yield None, self._msg
3530 yield "indent", self._indent, " "
3531 yield "continuation_indent", self._continuation_indent, None
3533 def __colorized_str__(self, ctx: ReprContext) -> ColorizedString:
3534 if isinstance(self._indent, int):
3535 indent = ColorizedString(" " * self._indent)
3536 else:
3537 indent = ColorizedString(self._indent)
3538 if self._continuation_indent is None:
3539 continuation_indent = indent
3540 elif isinstance(self._continuation_indent, int):
3541 continuation_indent = ColorizedString(" " * self._continuation_indent)
3542 else:
3543 continuation_indent = ColorizedString(self._continuation_indent)
3545 indent_width = max(indent.width, continuation_indent.width)
3546 width = max(1, ctx.width - indent_width)
3548 return ctx.str(self._msg, width=width).indent(indent, continuation_indent)
3551@_t.final
3552@repr_from_rich
3553class Md(_StrBase):
3554 """Md(msg: typing.LiteralString, /, *args, width: int | None | yuio.Missing = yuio.MISSING, dedent: bool = True, allow_headings: bool = True)
3555 Md(msg: str, /, *, width: int | None | yuio.Missing = yuio.MISSING, dedent: bool = True, allow_headings: bool = True)
3557 Lazy wrapper that renders markdown during formatting.
3559 :param md:
3560 text to format.
3561 :param width:
3562 if given, overrides settings passed to :class:`ReprContext` for this call.
3563 :param dedent:
3564 whether to remove leading indent from text.
3565 :param allow_headings:
3566 whether to render headings as actual headings or as paragraphs.
3568 """
3570 def __init__(
3571 self,
3572 md: str,
3573 /,
3574 *,
3575 width: int | None = None,
3576 dedent: bool = True,
3577 allow_headings: bool = True,
3578 ):
3579 self._md: str = md
3580 self._width: int | None = width
3581 self._dedent: bool = dedent
3582 self._allow_headings: bool = allow_headings
3584 def __rich_repr__(self) -> RichReprResult:
3585 yield None, self._md
3586 yield "width", self._width, yuio.MISSING
3587 yield "dedent", self._dedent, True
3588 yield "allow_headings", self._allow_headings, True
3590 def __colorized_str__(self, ctx: ReprContext) -> ColorizedString:
3591 import yuio.doc
3592 import yuio.md
3594 width = self._width or ctx.width
3595 with ctx.with_settings(width=width):
3596 formatter = yuio.doc.Formatter(
3597 ctx,
3598 allow_headings=self._allow_headings,
3599 )
3601 res = ColorizedString()
3602 res.start_no_wrap()
3603 sep = False
3604 for line in formatter.format(yuio.md.parse(self._md, dedent=self._dedent)):
3605 if sep:
3606 res += "\n"
3607 res += line
3608 sep = True
3609 res.end_no_wrap()
3611 return res
3614@_t.final
3615@repr_from_rich
3616class Rst(_StrBase):
3617 """Rst(msg: typing.LiteralString, /, *args, width: int | None | yuio.Missing = yuio.MISSING, dedent: bool = True, allow_headings: bool = True)
3618 Rst(msg: str, /, *, width: int | None | yuio.Missing = yuio.MISSING, dedent: bool = True, allow_headings: bool = True)
3620 Lazy wrapper that renders ReStructuredText during formatting.
3622 :param rst:
3623 text to format.
3624 :param width:
3625 if given, overrides settings passed to :class:`ReprContext` for this call.
3626 :param dedent:
3627 whether to remove leading indent from text.
3628 :param allow_headings:
3629 whether to render headings as actual headings or as paragraphs.
3631 """
3633 def __init__(
3634 self,
3635 rst: str,
3636 /,
3637 *,
3638 width: int | None = None,
3639 dedent: bool = True,
3640 allow_headings: bool = True,
3641 ):
3642 self._rst: str = rst
3643 self._width: int | None = width
3644 self._dedent: bool = dedent
3645 self._allow_headings: bool = allow_headings
3647 def __rich_repr__(self) -> RichReprResult:
3648 yield None, self._rst
3649 yield "width", self._width, yuio.MISSING
3650 yield "dedent", self._dedent, True
3651 yield "allow_headings", self._allow_headings, True
3653 def __colorized_str__(self, ctx: ReprContext) -> ColorizedString:
3654 import yuio.doc
3655 import yuio.rst
3657 width = self._width or ctx.width
3658 with ctx.with_settings(width=width):
3659 formatter = yuio.doc.Formatter(
3660 ctx,
3661 allow_headings=self._allow_headings,
3662 )
3664 res = ColorizedString()
3665 res.start_no_wrap()
3666 sep = False
3667 for line in formatter.format(
3668 yuio.rst.parse(self._rst, dedent=self._dedent)
3669 ):
3670 if sep:
3671 res += "\n"
3672 res += line
3673 sep = True
3674 res.end_no_wrap()
3676 return res
3679@_t.final
3680@repr_from_rich
3681class Hl(_StrBase):
3682 """Hl(code: typing.LiteralString, /, *args, syntax: str, dedent: bool = True)
3683 Hl(code: str, /, *, syntax: str, dedent: bool = True)
3685 Lazy wrapper that highlights code during formatting.
3687 :param md:
3688 code to highlight.
3689 :param args:
3690 arguments for ``%``-formatting the highlighted code.
3691 :param syntax:
3692 name of syntax or a :class:`~yuio.hl.SyntaxHighlighter` instance.
3693 :param dedent:
3694 whether to remove leading indent from code.
3696 """
3698 @_t.overload
3699 def __init__(
3700 self,
3701 code: _t.LiteralString,
3702 /,
3703 *args: _t.Any,
3704 syntax: str,
3705 dedent: bool = True,
3706 ): ...
3707 @_t.overload
3708 def __init__(
3709 self,
3710 code: str,
3711 /,
3712 *,
3713 syntax: str,
3714 dedent: bool = True,
3715 ): ...
3716 def __init__(
3717 self,
3718 code: str,
3719 /,
3720 *args: _t.Any,
3721 syntax: str,
3722 dedent: bool = True,
3723 ):
3724 self._code: str = code
3725 self._args: tuple[_t.Any, ...] = args
3726 self._syntax: str = syntax
3727 self._dedent: bool = dedent
3729 def __rich_repr__(self) -> RichReprResult:
3730 yield None, self._code
3731 yield from ((None, arg) for arg in self._args)
3732 yield "syntax", self._syntax
3733 yield "dedent", self._dedent, True
3735 def __colorized_str__(self, ctx: ReprContext) -> ColorizedString:
3736 import yuio.hl
3738 highlighter, syntax_name = yuio.hl.get_highlighter(self._syntax)
3739 code = self._code
3740 if self._dedent:
3741 code = _dedent(code)
3742 code = code.rstrip()
3744 res = ColorizedString()
3745 res.start_no_wrap()
3746 res += highlighter.highlight(code, theme=ctx.theme, syntax=syntax_name)
3747 res.end_no_wrap()
3748 if self._args:
3749 res = res.percent_format(self._args, ctx)
3751 return res
3754@_t.final
3755@repr_from_rich
3756class Wrap(_StrBase):
3757 """
3758 Lazy wrapper that wraps the message during formatting.
3760 .. seealso::
3762 :meth:`ColorizedString.wrap`.
3764 :param msg:
3765 message to wrap.
3766 :param width:
3767 if given, overrides settings passed to :class:`ReprContext` for this call.
3768 :param preserve_spaces:
3769 if set to :data:`True`, all spaces are preserved.
3770 Otherwise, consecutive spaces are collapsed when newline break occurs.
3772 Note that tabs always treated as a single whitespace.
3773 :param preserve_newlines:
3774 if set to :data:`True` (default), text is additionally wrapped
3775 on newline sequences. When this happens, the newline sequence that wrapped
3776 the line will be placed into :attr:`~ColorizedString.explicit_newline`.
3778 If set to :data:`False`, newline sequences are treated as whitespaces.
3779 :param break_long_words:
3780 if set to :data:`True` (default), words that don't fit into a single line
3781 will be split into multiple lines.
3782 :param overflow:
3783 Pass :data:`True` to trim overflowing lines and replace them with ellipsis.
3784 :param break_long_nowrap_words:
3785 if set to :data:`True`, words in no-wrap regions that don't fit
3786 into a single line will be split into multiple lines.
3787 :param indent:
3788 this will be prepended to the first line in the string.
3789 Defaults to two spaces.
3790 :param continuation_indent:
3791 this will be prepended to subsequent lines in the string.
3792 Defaults to `indent`.
3794 """
3796 def __init__(
3797 self,
3798 msg: Colorable,
3799 /,
3800 *,
3801 width: int | None = None,
3802 preserve_spaces: bool = False,
3803 preserve_newlines: bool = True,
3804 break_long_words: bool = True,
3805 break_long_nowrap_words: bool = False,
3806 overflow: bool | str = False,
3807 indent: AnyString | int = "",
3808 continuation_indent: AnyString | int | None = None,
3809 ):
3810 self._msg = msg
3811 self._width: int | None = width
3812 self._preserve_spaces = preserve_spaces
3813 self._preserve_newlines = preserve_newlines
3814 self._break_long_words = break_long_words
3815 self._break_long_nowrap_words = break_long_nowrap_words
3816 self._overflow = overflow
3817 self._indent: AnyString | int = indent
3818 self._continuation_indent: AnyString | int | None = continuation_indent
3820 def __rich_repr__(self) -> RichReprResult:
3821 yield None, self._msg
3822 yield "width", self._width, None
3823 yield "indent", self._indent, ""
3824 yield "continuation_indent", self._continuation_indent, None
3825 yield "preserve_spaces", self._preserve_spaces, None
3826 yield "preserve_newlines", self._preserve_newlines, True
3827 yield "break_long_words", self._break_long_words, True
3828 yield "break_long_nowrap_words", self._break_long_nowrap_words, False
3830 def __colorized_str__(self, ctx: ReprContext) -> ColorizedString:
3831 if isinstance(self._indent, int):
3832 indent = ColorizedString(" " * self._indent)
3833 else:
3834 indent = ColorizedString(self._indent)
3835 if self._continuation_indent is None:
3836 continuation_indent = indent
3837 elif isinstance(self._continuation_indent, int):
3838 continuation_indent = ColorizedString(" " * self._continuation_indent)
3839 else:
3840 continuation_indent = ColorizedString(self._continuation_indent)
3842 width = self._width or ctx.width
3843 indent_width = max(indent.width, continuation_indent.width)
3844 inner_width = max(1, width - indent_width)
3846 overflow = self._overflow
3847 if overflow is True:
3848 overflow = ctx.get_msg_decoration("overflow")
3850 res = ColorizedString()
3851 res.start_no_wrap()
3852 sep = False
3853 for line in ctx.str(self._msg, width=inner_width).wrap(
3854 width,
3855 preserve_spaces=self._preserve_spaces,
3856 preserve_newlines=self._preserve_newlines,
3857 break_long_words=self._break_long_words,
3858 break_long_nowrap_words=self._break_long_nowrap_words,
3859 overflow=overflow,
3860 indent=indent,
3861 continuation_indent=continuation_indent,
3862 ):
3863 if sep:
3864 res.append_str("\n")
3865 res.append_colorized_str(line)
3866 sep = True
3867 res.end_no_wrap()
3869 return res
3872@_t.final
3873@repr_from_rich
3874class WithBaseColor(_StrBase):
3875 """
3876 Lazy wrapper that applies the given color "under" the given colorable.
3877 That is, all colors in the rendered colorable will be combined with this color
3878 on the left: ``base_color | color``.
3880 .. seealso::
3882 :meth:`ColorizedString.with_base_color`.
3884 :param msg:
3885 message to highlight.
3886 :param base_color:
3887 color that will be added under the message.
3889 """
3891 def __init__(
3892 self,
3893 msg: Colorable,
3894 /,
3895 *,
3896 base_color: str | _Color,
3897 ):
3898 self._msg = msg
3899 self._base_color = base_color
3901 def __rich_repr__(self) -> RichReprResult:
3902 yield None, self._msg
3903 yield "base_color", self._base_color
3905 def __colorized_str__(self, ctx: ReprContext) -> ColorizedString:
3906 return ctx.str(self._msg).with_base_color(ctx.to_color(self._base_color))
3909@repr_from_rich
3910class Hr(_StrBase):
3911 """Hr(msg: Colorable = "", /, *, weight: int | str = 1, overflow: bool | str = True, **kwargs)
3913 Produces horizontal ruler when converted to string.
3915 :param msg:
3916 any colorable that will be placed in the middle of the ruler.
3917 :param weight:
3918 weight or style of the ruler:
3920 - ``0`` prints no ruler (but still prints centered text),
3921 - ``1`` prints normal ruler,
3922 - ``2`` prints bold ruler.
3924 Additional styles can be added through
3925 :attr:`Theme.msg_decorations <yuio.theme.Theme.msg_decorations_unicode>`.
3926 :param width:
3927 if given, overrides settings passed to :class:`ReprContext` for this call.
3928 :param overflow:
3929 pass :data:`False` to disable trimming `msg` to terminal width.
3930 :param kwargs:
3931 Other keyword arguments override corresponding decorations from the theme:
3933 :`left_start`:
3934 start of the ruler to the left of the message.
3935 :`left_middle`:
3936 filler of the ruler to the left of the message.
3937 :`left_end`:
3938 end of the ruler to the left of the message.
3939 :`middle`:
3940 filler of the ruler that's used if `msg` is empty.
3941 :`right_start`:
3942 start of the ruler to the right of the message.
3943 :`right_middle`:
3944 filler of the ruler to the right of the message.
3945 :`right_end`:
3946 end of the ruler to the right of the message.
3948 """
3950 def __init__(
3951 self,
3952 msg: Colorable = "",
3953 /,
3954 *,
3955 width: int | None = None,
3956 overflow: bool | str = True,
3957 weight: int | str = 1,
3958 left_start: str | None = None,
3959 left_middle: str | None = None,
3960 left_end: str | None = None,
3961 middle: str | None = None,
3962 right_start: str | None = None,
3963 right_middle: str | None = None,
3964 right_end: str | None = None,
3965 ):
3966 self._msg = msg
3967 self._width = width
3968 self._overflow = overflow
3969 self._weight = weight
3970 self._left_start = left_start
3971 self._left_middle = left_middle
3972 self._left_end = left_end
3973 self._middle = middle
3974 self._right_start = right_start
3975 self._right_middle = right_middle
3976 self._right_end = right_end
3978 def __rich_repr__(self) -> RichReprResult:
3979 yield None, self._msg, None
3980 yield "weight", self._weight, None
3981 yield "width", self._width, None
3982 yield "overflow", self._overflow, None
3983 yield "left_start", self._left_start, None
3984 yield "left_middle", self._left_middle, None
3985 yield "left_end", self._left_end, None
3986 yield "middle", self._middle, None
3987 yield "right_start", self._right_start, None
3988 yield "right_middle", self._right_middle, None
3989 yield "right_end", self._right_end, None
3991 def __colorized_str__(self, ctx: ReprContext) -> ColorizedString:
3992 width = self._width or ctx.width
3994 color = ctx.get_color(f"msg/decoration:hr/{self._weight}")
3996 res = ColorizedString(color)
3997 res.start_no_wrap()
3999 msg = ctx.str(self._msg)
4000 if not msg:
4001 res.append_str(self._make_whole(width, ctx))
4002 return res
4004 overflow = self._overflow
4005 if overflow is True:
4006 overflow = ctx.get_msg_decoration("overflow")
4008 sep = False
4009 for line in msg.wrap(
4010 width, preserve_spaces=True, break_long_words=False, overflow=overflow
4011 ):
4012 if sep:
4013 res.append_color(yuio.color.Color.NONE)
4014 res.append_str("\n")
4015 res.append_color(color)
4017 line_w = line.width
4018 line_w_fill = max(0, width - line_w)
4019 line_w_fill_l = line_w_fill // 2
4020 line_w_fill_r = line_w_fill - line_w_fill_l
4021 if not line_w_fill_l and not line_w_fill_r:
4022 res.append_colorized_str(line)
4023 return res
4025 res.append_str(self._make_left(line_w_fill_l, ctx))
4026 res.append_colorized_str(line)
4027 res.append_str(self._make_right(line_w_fill_r, ctx))
4029 sep = True
4031 return res
4033 def _make_left(self, w: int, ctx: ReprContext):
4034 weight = self._weight
4035 start = (
4036 self._left_start
4037 if self._left_start is not None
4038 else ctx.get_msg_decoration(f"hr/{weight}/left_start")
4039 )
4040 middle = (
4041 self._left_middle
4042 if self._left_middle is not None
4043 else ctx.get_msg_decoration(f"hr/{weight}/left_middle")
4044 ) or " "
4045 end = (
4046 self._left_end
4047 if self._left_end is not None
4048 else ctx.get_msg_decoration(f"hr/{weight}/left_end")
4049 )
4051 return _make_left(w, start, middle, end)
4053 def _make_right(self, w: int, ctx: ReprContext):
4054 weight = self._weight
4055 start = (
4056 self._right_start
4057 if self._right_start is not None
4058 else ctx.get_msg_decoration(f"hr/{weight}/right_start")
4059 )
4060 middle = (
4061 self._right_middle
4062 if self._right_middle is not None
4063 else ctx.get_msg_decoration(f"hr/{weight}/right_middle")
4064 ) or " "
4065 end = (
4066 self._right_end
4067 if self._right_end is not None
4068 else ctx.get_msg_decoration(f"hr/{weight}/right_end")
4069 )
4071 return _make_right(w, start, middle, end)
4073 def _make_whole(self, w: int, ctx: ReprContext):
4074 weight = self._weight
4075 start = (
4076 self._left_start
4077 if self._left_start is not None
4078 else ctx.get_msg_decoration(f"hr/{weight}/left_start")
4079 )
4080 middle = (
4081 self._middle
4082 if self._middle is not None
4083 else ctx.get_msg_decoration(f"hr/{weight}/middle")
4084 ) or " "
4085 end = (
4086 self._right_end
4087 if self._right_end is not None
4088 else ctx.get_msg_decoration(f"hr/{weight}/right_end")
4089 )
4091 start_w = line_width(start)
4092 middle_w = line_width(middle)
4093 end_w = line_width(end)
4095 if w >= start_w:
4096 w -= start_w
4097 else:
4098 start = ""
4099 if w >= end_w:
4100 w -= end_w
4101 else:
4102 end = ""
4103 middle_times = w // middle_w
4104 w -= middle_times * middle_w
4105 middle *= middle_times
4106 return start + middle + end + " " * w
4109def _make_left(
4110 w: int,
4111 start: str,
4112 middle: str,
4113 end: str,
4114):
4115 start_w = line_width(start)
4116 middle_w = line_width(middle)
4117 end_w = line_width(end)
4119 if w >= end_w:
4120 w -= end_w
4121 else:
4122 end = ""
4123 if w >= start_w:
4124 w -= start_w
4125 else:
4126 start = ""
4127 middle_times = w // middle_w
4128 w -= middle_times * middle_w
4129 middle *= middle_times
4130 return start + middle + end + " " * w
4133def _make_right(
4134 w: int,
4135 start: str,
4136 middle: str,
4137 end: str,
4138):
4139 start_w = line_width(start)
4140 middle_w = line_width(middle)
4141 end_w = line_width(end)
4143 if w >= start_w:
4144 w -= start_w
4145 else:
4146 start = ""
4147 if w >= end_w:
4148 w -= end_w
4149 else:
4150 end = ""
4151 middle_times = w // middle_w
4152 w -= middle_times * middle_w
4153 middle *= middle_times
4154 return " " * w + start + middle + end
4157def _eng_key_cardinal(n: float):
4158 n = abs(n)
4159 if n == 1:
4160 return "one"
4161 else:
4162 return "other"
4165def _eng_key_ordinal(n: float):
4166 n = abs(n)
4168 if n % 10 == 1 and n % 100 != 11:
4169 return "one"
4170 elif n % 10 == 2 and n % 100 != 12:
4171 return "two"
4172 elif n % 10 == 3 and n % 100 != 13:
4173 return "few"
4174 else:
4175 return "other"
4178@_t.final
4179@repr_from_rich
4180class Plural(_StrBase):
4181 """
4182 Lazy wrapper that pluralizes the given string by adding ``s`` to its end.
4184 :param n:
4185 number to be used for pluralization.
4186 :param one:
4187 singular form of the word, i.e. "one thing".
4188 Will be :attr:`formatted <str.format>` with a single keyword argument `n` --
4189 the given number.
4190 :param other:
4191 plural form of the word, i.e. "other number of things";
4192 defaults to :samp:`"{one}s"`.
4193 :param forms:
4194 additional forms of the word, only used with custom `key`.
4195 :param key:
4196 can be used to provide pluralization for non-english words. This callable
4197 should take a number and return a string ``"one"``, ``"other"``, or a key
4198 of the `forms` dictionary.
4199 :example:
4200 .. code-block:: python
4202 n = 5
4203 yuio.io.info("Loaded %s", yuio.string.Plural(n, "a sample", "{n} samples"))
4205 With custom `key`:
4207 .. code-block:: python
4209 lt_plural_key = lambda n: (
4210 "one"
4211 if n % 10 == 1 and not 11 <= n % 100 <= 19
4212 else "few"
4213 if 2 <= n % 10 <= 9 and not 11 <= n % 100 <= 19
4214 else "many"
4215 if n != int(n)
4216 else "other"
4217 )
4219 for n in [1, 2, 0.1, 10]:
4220 yuio.io.info(
4221 "Rasta %s",
4222 yuio.string.Plural(
4223 n,
4224 one="{n} obuolys",
4225 few="{n} obuoliai",
4226 many="{n} obuolio",
4227 other="{n} obuolių",
4228 key=lt_plural_key,
4229 ),
4230 )
4231 """
4233 def __init__(
4234 self,
4235 n: float,
4236 /,
4237 one: str = "",
4238 other: str | None = None,
4239 *,
4240 key: _t.Callable[[float], str] | None = None,
4241 **forms,
4242 ):
4243 if other is None:
4244 other = one + "s"
4246 self._n = n
4247 self._forms = forms
4248 self._forms["one"] = one
4249 self._forms["other"] = other
4250 self._key = key or _eng_key_cardinal
4252 def __rich_repr__(self) -> RichReprResult:
4253 yield None, self._n
4254 yield from self._forms.items()
4255 yield "key", self._key
4257 def __colorized_str__(self, ctx: ReprContext) -> ColorizedString:
4258 key = self._key(self._n)
4259 if key in self._forms:
4260 return ColorizedString(self._forms[key].format(n=self._n))
4261 else:
4262 return ColorizedString(self._forms["other"].format(n=self._n))
4265def Ordinal(n: float, /):
4266 """
4267 Lazy wrapper that formats numbers as English ordinals (i.e. ``1st``, ``2nd``, etc.)
4269 :param n:
4270 number to format.
4271 :example:
4272 .. code-block:: python
4274 for n in range(5):
4275 print(yuio.string.Ordinal(n + 1))
4277 """
4279 return Plural(
4280 n,
4281 one="{n}st",
4282 two="{n}nd",
4283 few="{n}rd",
4284 other="{n}th",
4285 key=_eng_key_ordinal,
4286 )