Coverage for yuio / string.py: 99%
1494 statements
« prev ^ index » next coverage.py v7.13.3, created at 2026-02-03 15:42 +0000
« prev ^ index » next coverage.py v7.13.3, created at 2026-02-03 15:42 +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:
193Parsing color tags
194------------------
196.. autofunction:: colorize
198.. autofunction:: strip_color_tags
201Helpers
202-------
204.. autofunction:: line_width
206.. type:: AnyString
207 :canonical: str | ~yuio.color.Color | ColorizedString | NoWrapMarker | typing.Iterable[AnyString]
209 Any string (i.e. a :class:`str`, a raw colorized string,
210 or a normal colorized string).
212.. autoclass:: LinkMarker
214.. autodata:: NO_WRAP_START
216.. autodata:: NO_WRAP_END
218.. type:: NoWrapMarker
219 NoWrapStart
220 NoWrapEnd
222 Type of a no-wrap marker.
224"""
226from __future__ import annotations
228import abc
229import collections
230import contextlib
231import dataclasses
232import functools
233import os
234import pathlib
235import re
236import reprlib
237import string
238import sys
239import types
240import unicodedata
241from dataclasses import dataclass
242from enum import Enum
244import yuio
245import yuio.color
246import yuio.term
247import yuio.theme
248from yuio.color import Color as _Color
249from yuio.util import UserString as _UserString
250from yuio.util import dedent as _dedent
252import yuio._typing_ext as _tx
253from typing import TYPE_CHECKING
255if TYPE_CHECKING:
256 import typing_extensions as _t
257else:
258 from yuio import _typing as _t
260if sys.version_info >= (3, 14):
261 from string.templatelib import Interpolation as _Interpolation
262 from string.templatelib import Template as _Template
263else:
265 class _Interpolation: ...
267 class _Template: ...
269 _Interpolation.__module__ = "string.templatelib"
270 _Interpolation.__name__ = "Interpolation"
271 _Interpolation.__qualname__ = "Interpolation"
272 _Template.__module__ = "string.templatelib"
273 _Template.__name__ = "Template"
274 _Template.__qualname__ = "Template"
277__all__ = [
278 "NO_WRAP_END",
279 "NO_WRAP_START",
280 "And",
281 "AnyString",
282 "Colorable",
283 "ColorizedReprProtocol",
284 "ColorizedStrProtocol",
285 "ColorizedString",
286 "Esc",
287 "Format",
288 "Hl",
289 "Hr",
290 "Indent",
291 "JoinRepr",
292 "JoinStr",
293 "Link",
294 "LinkMarker",
295 "Md",
296 "NoWrapEnd",
297 "NoWrapMarker",
298 "NoWrapStart",
299 "Or",
300 "Printable",
301 "Repr",
302 "ReprContext",
303 "RichReprProtocol",
304 "RichReprResult",
305 "Rst",
306 "Stack",
307 "ToColorable",
308 "TypeRepr",
309 "WithBaseColor",
310 "Wrap",
311 "colorize",
312 "line_width",
313 "repr_from_rich",
314 "strip_color_tags",
315]
318def line_width(s: str, /) -> int:
319 """
320 Calculates string width when the string is displayed
321 in a terminal.
323 This function makes effort to detect wide characters
324 such as emojis. If does not, however, work correctly
325 with extended grapheme clusters, and so it may fail
326 for emojis with modifiers, or other complex characters.
328 Example where it fails is ``👩🏽💻``. It consists
329 of four code points:
331 - Unicode Character `WOMAN` (``U+1F469``, ``👩``),
332 - Unicode Character `EMOJI MODIFIER FITZPATRICK TYPE-4` (``U+1F3FD``),
333 - Unicode Character `ZERO WIDTH JOINER` (``U+200D``),
334 - Unicode Character `PERSONAL COMPUTER` (``U+1F4BB``, ``💻``).
336 Since :func:`line_width` can't understand that these code points
337 are combined into a single emoji, it treats them separately,
338 resulting in answer `6` (`2` for every code point except `ZERO WIDTH JOINER`)::
340 >>> line_width("\U0001f469\U0001f3fd\U0000200d\U0001f4bb")
341 6
343 In all fairness, detecting how much space such an emoji will take
344 is not so straight forward, as that will depend on unicode capabilities
345 of a specific terminal. Since a lot of terminals will not handle such emojis
346 correctly, I've decided to go with this simplistic implementation.
348 """
350 # Note: it may be better to bundle `wcwidth` and use it instead of the code below.
351 # However, there is an issue that `wcwidth`'s results are not additive.
352 # In the above example, `wcswidth('👩🏽💻')` will see that it is two-spaces wide,
353 # while `sum(wcwidth(c) for c in '👩🏽💻')` will report that it is four-spaces wide.
354 # To render it properly, the widget will have to be aware of extended grapheme
355 # clusters, and generally this will be a lot of headache. Since most terminals
356 # won't handle these edge cases correctly, I don't want to bother.
358 if s.isascii():
359 # Fast path. Note that our renderer replaces unprintable characters
360 # with spaces, so ascii strings always have width equal to their length.
361 return len(s)
362 else:
363 # Long path. It kinda works, but not always, but most of the times...
364 return sum(
365 (unicodedata.east_asian_width(c) in "WF") + 1
366 for c in s
367 if unicodedata.category(c)[0] not in "MC"
368 )
371RichReprResult: _t.TypeAlias = _t.Iterable[
372 tuple[_t.Any] | tuple[str | None, _t.Any] | tuple[str | None, _t.Any, _t.Any]
373]
374"""
375Similar to ``rich.repr.Result``, but only allows tuples, not arbitrary values.
377"""
380@_t.runtime_checkable
381class ColorizedStrProtocol(_t.Protocol):
382 """
383 Protocol for objects that define ``__colorized_str__`` method.
385 """
387 @abc.abstractmethod
388 def __colorized_str__(self, ctx: ReprContext, /) -> ColorizedString: ...
391@_t.runtime_checkable
392class ColorizedReprProtocol(_t.Protocol):
393 """
394 Protocol for objects that define ``__colorized_repr__`` method.
396 """
398 @abc.abstractmethod
399 def __colorized_repr__(self, ctx: ReprContext, /) -> ColorizedString: ...
402@_t.runtime_checkable
403class RichReprProtocol(_t.Protocol):
404 """
405 Protocol for objects that define ``__rich_repr__`` method.
407 """
409 @abc.abstractmethod
410 def __rich_repr__(self) -> _t.Iterable[_t.Any]: ...
413Printable = _t.NewType("Printable", object)
414"""
415Any object that supports printing.
417Technically, any object supports colorized printing because we'll fall back
418to ``__repr__`` or ``__str__`` if there are no special methods on it.
420However, we don't use :class:`typing.Any` to avoid potential errors.
422"""
425Colorable: _t.TypeAlias = (
426 Printable
427 | ColorizedStrProtocol
428 | ColorizedReprProtocol
429 | RichReprProtocol
430 | str
431 | BaseException
432)
433"""
434Any object that supports colorized printing.
436This can be a string, and exception, or any object that follows
437:class:`ColorizedStrProtocol`. Additionally, you can pass any object that has
438``__repr__``, but you'll have to wrap it into :type:`Printable` to confirm
439your intent to print it.
441"""
443ToColorable: _t.TypeAlias = Colorable | _Template
444"""
445Any object that can be converted to a :type:`Colorable` by formatting it via
446:class:`Format`.
448"""
451RichReprProtocolT = _t.TypeVar("RichReprProtocolT", bound=RichReprProtocol)
454def repr_from_rich(cls: type[RichReprProtocolT], /) -> type[RichReprProtocolT]:
455 """repr_from_rich(cls: RichReprProtocol) -> RichReprProtocol
457 A decorator that generates ``__repr__`` from ``__rich_repr__``.
459 :param cls:
460 class that needs ``__repr__``.
461 :returns:
462 always returns `cls`.
463 :example:
464 .. code-block:: python
466 @yuio.string.repr_from_rich
467 class MyClass:
468 def __init__(self, value):
469 self.value = value
471 def __rich_repr__(self) -> yuio.string.RichReprResult:
472 yield "value", self.value
474 ::
476 >>> print(repr(MyClass("plush!")))
477 MyClass(value='plush!')
480 """
482 setattr(cls, "__repr__", _repr_from_rich_impl)
483 return cls
486def _repr_from_rich_impl(self: RichReprProtocol):
487 if rich_repr := getattr(self, "__rich_repr__", None):
488 args = rich_repr()
489 angular = getattr(rich_repr, "angular", False)
490 else:
491 args = []
492 angular = False
494 if args is None:
495 args = [] # `rich_repr` didn't yield?
497 res = []
499 if angular:
500 res.append("<")
501 res.append(self.__class__.__name__)
502 if angular:
503 res.append(" ")
504 else:
505 res.append("(")
507 sep = False
508 for arg in args:
509 if isinstance(arg, tuple):
510 if len(arg) == 3:
511 key, child, default = arg
512 if default == child:
513 continue
514 elif len(arg) == 2:
515 key, child = arg
516 elif len(arg) == 1:
517 key, child = None, arg[0]
518 else:
519 key, child = None, arg
520 else:
521 key, child = None, arg
523 if sep:
524 res.append(" " if angular else ", ")
525 if key:
526 res.append(str(key))
527 res.append("=")
528 res.append(repr(child))
529 sep = True
531 res.append(">" if angular else ")")
533 return "".join(res)
536class NoWrapMarker(Enum):
537 """
538 Type for a no-wrap marker.
540 """
542 NO_WRAP_START = "<no_wrap_start>"
543 NO_WRAP_END = "<no_wrap_end>"
545 def __repr__(self):
546 return f"yuio.string.{self.name}" # pragma: no cover
548 def __str__(self) -> str:
549 return self.value # pragma: no cover
552NoWrapStart: _t.TypeAlias = _t.Literal[NoWrapMarker.NO_WRAP_START]
553"""
554Type of the :data:`NO_WRAP_START` placeholder.
556"""
558NO_WRAP_START: NoWrapStart = NoWrapMarker.NO_WRAP_START
559"""
560Indicates start of a no-wrap region in a :class:`ColorizedString`.
562"""
565NoWrapEnd: _t.TypeAlias = _t.Literal[NoWrapMarker.NO_WRAP_END]
566"""
567Type of the :data:`NO_WRAP_END` placeholder.
569"""
571NO_WRAP_END: NoWrapEnd = NoWrapMarker.NO_WRAP_END
572"""
573Indicates end of a no-wrap region in a :class:`ColorizedString`.
575"""
578@dataclass(slots=True, frozen=True, unsafe_hash=True)
579class LinkMarker:
580 """
581 Indicates start or end of a hyperlink in a colorized string.
583 """
585 url: str | None
586 """
587 Hyperlink's url.
589 """
592@_t.final
593@repr_from_rich
594class ColorizedString:
595 """ColorizedString()
596 ColorizedString(rhs: ColorizedString, /)
597 ColorizedString(*args: AnyString, /)
599 A string with colors.
601 This class is a wrapper over a list of strings, colors, and no-wrap markers.
602 Each color applies to strings after it, right until the next color.
604 :class:`ColorizedString` supports some basic string operations.
605 Most notably, it supports wide-character-aware wrapping
606 (see :meth:`~ColorizedString.wrap`),
607 and ``%``-like formatting (see :meth:`~ColorizedString.percent_format`).
609 Unlike :class:`str`, :class:`ColorizedString` is mutable through
610 the ``+=`` operator and ``append``/``extend`` methods.
612 :param rhs:
613 when constructor gets a single :class:`ColorizedString`, it makes a copy.
614 :param args:
615 when constructor gets multiple arguments, it creates an empty string
616 and appends arguments to it.
619 **String combination semantics**
621 When you append a :class:`str`, it will take on color and no-wrap semantics
622 according to the last appended color and no-wrap marker.
624 When you append another :class:`ColorizedString`, it will not change its colors
625 based on the last appended color, nor will it affect colors of the consequent
626 strings. If appended :class:`ColorizedString` had an unterminated no-wrap region
627 or link region, this region will be terminated after appending.
629 Thus, appending a colorized string does not change current color, no-wrap
630 or link setting::
632 >>> s1 = yuio.string.ColorizedString()
633 >>> s1 += yuio.color.Color.FORE_RED
634 >>> s1 += yuio.string.NO_WRAP_START
635 >>> s1 += "red nowrap text"
636 >>> s1 # doctest: +NORMALIZE_WHITESPACE
637 ColorizedString([yuio.string.NO_WRAP_START,
638 <Color fore=<RED>>,
639 'red nowrap text'])
641 >>> s2 = yuio.string.ColorizedString()
642 >>> s2 += yuio.color.Color.FORE_GREEN
643 >>> s2 += "green text "
644 >>> s2 += s1
645 >>> s2 += " green text continues"
646 >>> s2 # doctest: +NORMALIZE_WHITESPACE
647 ColorizedString([<Color fore=<GREEN>>,
648 'green text ',
649 yuio.string.NO_WRAP_START,
650 <Color fore=<RED>>,
651 'red nowrap text',
652 yuio.string.NO_WRAP_END,
653 <Color fore=<GREEN>>,
654 ' green text continues'])
656 """
658 # Invariants:
659 #
660 # - there is always a color before the first string in `_parts`.
661 # - there are no empty strings in `_parts`.
662 # - for every pair of colors in `_parts`, there is a string between them
663 # (i.e. there are no colors that don't highlight anything).
664 # - every color in `_parts` is different from the previous one
665 # (i.e. there are no redundant color markers).
666 # - `start-no-wrap` and `end-no-wrap` markers form a balanced bracket sequence,
667 # except for the last `start-no-wrap`, which may have no corresponding
668 # `end-no-wrap` yet.
669 # - no-wrap regions can't be nested.
670 # - for every pair of (start-no-wrap, end-no-wrap) markers, there is a string
671 # between them (i.e. no empty no-wrap regions).
673 def __init__(
674 self,
675 /,
676 *args: AnyString,
677 _isolate_colors: bool = True,
678 ):
679 if len(args) == 1 and isinstance(args[0], ColorizedString):
680 content = args[0]
681 self._parts = content._parts.copy()
682 self._last_color = content._last_color
683 self._active_color = content._active_color
684 self._last_url = content._last_url
685 self._active_url = content._active_url
686 self._explicit_newline = content._explicit_newline
687 self._len = content._len
688 self._has_no_wrap = content._has_no_wrap
689 if (width := content.__dict__.get("width", None)) is not None:
690 self.__dict__["width"] = width
691 else:
692 self._parts: list[_Color | NoWrapMarker | LinkMarker | str] = []
693 self._active_color = _Color.NONE
694 self._last_color: _Color | None = None
695 self._last_url: str | None = None
696 self._active_url: str | None = None
697 self._explicit_newline: str = ""
698 self._len = 0
699 self._has_no_wrap = False
701 if not _isolate_colors:
702 # Prevent adding `_Color.NONE` to the front of the string.
703 self._last_color = self._active_color
705 for arg in args:
706 self += arg
708 @property
709 def explicit_newline(self) -> str:
710 """
711 Explicit newline indicates that a line of a wrapped text
712 was broken because the original text contained a new line character.
714 See :meth:`~ColorizedString.wrap` for details.
716 """
718 return self._explicit_newline
720 @property
721 def active_color(self) -> _Color:
722 """
723 Last color appended to this string.
725 """
727 return self._active_color
729 @property
730 def active_url(self) -> str | None:
731 """
732 Last url appended to this string.
734 """
736 return self._active_url
738 @functools.cached_property
739 def width(self) -> int:
740 """
741 String width when the string is displayed in a terminal.
743 See :func:`line_width` for more information.
745 """
747 return sum(line_width(s) for s in self._parts if isinstance(s, str))
749 @property
750 def len(self) -> int:
751 """
752 Line length in bytes, ignoring all colors.
754 """
756 return self._len
758 def append_color(self, color: _Color, /):
759 """
760 Append new color to this string.
762 This operation is lazy, the color will be appended if a non-empty string
763 is appended after it.
765 :param color:
766 color to append.
768 """
770 self._active_color = color
772 def append_link(self, url: str | None, /):
773 """
774 Append new link marker to this string.
776 This operation is lazy, the link marker will be appended if a non-empty string
777 is appended after it.s
779 :param url:
780 link url.
782 """
784 self._active_url = url
786 def start_link(self, url: str, /):
787 """
788 Start hyperlink with the given url.
790 :param url:
791 link url.
793 """
795 self._active_url = url
797 def end_link(self):
798 """
799 End hyperlink.
801 """
803 self._active_url = None
805 def append_str(self, s: str, /):
806 """
807 Append new plain string to this string.
809 :param s:
810 plain string to append.
812 """
814 if not s:
815 return
816 if self._last_url != self._active_url:
817 self._parts.append(LinkMarker(self._active_url))
818 self._last_url = self._active_url
819 if self._last_color != self._active_color:
820 self._parts.append(self._active_color)
821 self._last_color = self._active_color
822 self._parts.append(s)
823 self._len += len(s)
824 self.__dict__.pop("width", None)
826 def append_colorized_str(self, s: ColorizedString, /):
827 """
828 Append new colorized string to this string.
830 :param s:
831 colorized string to append.
833 """
834 if not s:
835 # Nothing to append.
836 return
838 parts = s._parts
840 # Cleanup color at the beginning of the string.
841 for i, part in enumerate(parts):
842 if part in (NO_WRAP_START, NO_WRAP_END) or isinstance(part, LinkMarker):
843 continue
844 elif isinstance(part, str): # pragma: no cover
845 # We never hit this branch in normal conditions because colorized
846 # strings always start with a color. The only way to trigger this
847 # branch is to tamper with `_parts` and break colorized string
848 # invariants.
849 break
851 # First color in the appended string is the same as our last color.
852 # We can remove it without changing the outcome.
853 if part == self._last_color:
854 if i == 0:
855 parts = parts[i + 1 :]
856 else:
857 parts = parts[:i] + parts[i + 1 :]
859 break
861 if self._has_no_wrap:
862 # We're in a no-wrap sequence, we don't need any more markers.
863 parts = filter(lambda part: part not in (NO_WRAP_START, NO_WRAP_END), parts)
865 if self._active_url:
866 # Current url overrides appended urls.
867 parts = filter(lambda part: not isinstance(part, LinkMarker), parts)
869 # Ensure that current url marker is added to the string.
870 # We don't need to do this with colors because `parts` already starts with
871 # a correct color.
872 if self._last_url != self._active_url:
873 self._parts.append(LinkMarker(self._active_url))
874 self._last_url = self._active_url
876 self._parts.extend(parts)
878 if not self._has_no_wrap and s._has_no_wrap:
879 self._has_no_wrap = True
880 self.end_no_wrap()
881 if not self._active_url and s._last_url:
882 self._last_url = s._last_url
884 self._last_color = s._last_color
885 self._len += s._len
886 if (lw := self.__dict__.get("width")) and (rw := s.__dict__.get("width")):
887 self.__dict__["width"] = lw + rw
888 else:
889 self.__dict__.pop("width", None)
891 def append_no_wrap(self, m: NoWrapMarker, /):
892 """
893 Append a no-wrap marker.
895 :param m:
896 no-wrap marker, will be dispatched
897 to :meth:`~ColorizedString.start_no_wrap`
898 or :meth:`~ColorizedString.end_no_wrap`.
900 """
902 if m is NO_WRAP_START:
903 self.start_no_wrap()
904 else:
905 self.end_no_wrap()
907 def start_no_wrap(self):
908 """
909 Start a no-wrap region.
911 String parts within no-wrap regions are not wrapped on spaces; they can be
912 hard-wrapped if `break_long_nowrap_words` is :data:`True`. Whitespaces and
913 newlines in no-wrap regions are preserved regardless of `preserve_spaces`
914 and `preserve_newlines` settings.
916 """
918 if self._has_no_wrap:
919 return
921 self._has_no_wrap = True
922 self._parts.append(NO_WRAP_START)
924 def end_no_wrap(self):
925 """
926 End a no-wrap region.
928 """
930 if not self._has_no_wrap:
931 return
933 if self._parts and self._parts[-1] is NO_WRAP_START:
934 # Empty no-wrap sequence, just remove it.
935 self._parts.pop()
936 else:
937 self._parts.append(NO_WRAP_END)
939 self._has_no_wrap = False
941 def extend(
942 self,
943 parts: _t.Iterable[str | ColorizedString | _Color | NoWrapMarker | LinkMarker],
944 /,
945 ):
946 """
947 Extend string from iterable of raw parts.
949 :param parts:
950 raw parts that will be appended to the string.
952 """
954 for part in parts:
955 self += part
957 def copy(self) -> ColorizedString:
958 """
959 Copy this string.
961 :returns:
962 copy of the string.
964 """
966 return ColorizedString(self)
968 def _split_at(self, i: int, /) -> tuple[ColorizedString, ColorizedString]:
969 l, r = ColorizedString(), ColorizedString()
970 l.extend(self._parts[:i])
971 r._active_color = l._active_color
972 r._active_url = l._active_url
973 r._has_no_wrap = l._has_no_wrap # TODO: waat???
974 r.extend(self._parts[i:])
975 r._active_color = self._active_color
976 return l, r
978 def with_base_color(self, base_color: _Color) -> ColorizedString:
979 """
980 Apply the given color "under" all parts of this string. That is, all colors
981 in this string will be combined with this color on the left:
982 ``base_color | color``.
984 :param base_color:
985 color that will be added under the string.
986 :returns:
987 new string with changed colors, or current string if base color
988 is :attr:`~yuio.color.Color.NONE`.
989 :example:
990 ::
992 >>> s1 = yuio.string.ColorizedString([
993 ... "part 1",
994 ... yuio.color.Color.FORE_GREEN,
995 ... "part 2",
996 ... ])
997 >>> s2 = s1.with_base_color(
998 ... yuio.color.Color.FORE_RED
999 ... | yuio.color.Color.STYLE_BOLD
1000 ... )
1001 >>> s2 # doctest: +NORMALIZE_WHITESPACE
1002 ColorizedString([<Color fore=<RED> bold=True>,
1003 'part 1',
1004 <Color fore=<GREEN> bold=True>,
1005 'part 2'])
1007 """
1009 if base_color == _Color.NONE:
1010 return self
1012 res = ColorizedString()
1014 for part in self._parts:
1015 if isinstance(part, _Color):
1016 res.append_color(base_color | part)
1017 else:
1018 res += part
1019 res._active_color = base_color | self._active_color
1020 if self._last_color is not None:
1021 res._last_color = base_color | self._last_color
1023 return res
1025 def as_code(self, color_support: yuio.color.ColorSupport, /) -> list[str]:
1026 """
1027 Convert colors in this string to ANSI escape sequences.
1029 :param color_support:
1030 desired level of color support.
1031 :returns:
1032 raw parts of colorized string with all colors converted to ANSI
1033 escape sequences.
1035 """
1037 if color_support == yuio.color.ColorSupport.NONE:
1038 return [part for part in self._parts if isinstance(part, str)]
1039 else:
1040 parts: list[str] = []
1041 for part in self:
1042 if isinstance(part, LinkMarker):
1043 parts.append("\x1b]8;;")
1044 parts.append(part.url or "")
1045 parts.append("\x1b\\")
1046 elif isinstance(part, str):
1047 parts.append(part)
1048 elif isinstance(part, _Color):
1049 parts.append(part.as_code(color_support))
1050 if self._last_color != _Color.NONE:
1051 parts.append(_Color.NONE.as_code(color_support))
1052 if self._last_url is not None:
1053 parts.append("\x1b]8;;\x1b\\")
1054 return parts
1056 def wrap(
1057 self,
1058 width: int,
1059 /,
1060 *,
1061 preserve_spaces: bool = False,
1062 preserve_newlines: bool = True,
1063 break_long_words: bool = True,
1064 break_long_nowrap_words: bool = False,
1065 overflow: _t.Literal[False] | str = False,
1066 indent: AnyString | int = "",
1067 continuation_indent: AnyString | int | None = None,
1068 ) -> list[ColorizedString]:
1069 """
1070 Wrap a long line of text into multiple lines.
1072 :param width:
1073 desired wrapping width.
1074 :param preserve_spaces:
1075 if set to :data:`True`, all spaces are preserved.
1076 Otherwise, consecutive spaces are collapsed into a single space.
1078 Note that tabs always treated as a single whitespace.
1079 :param preserve_newlines:
1080 if set to :data:`True` (default), text is additionally wrapped
1081 on newline sequences. When this happens, the newline sequence that wrapped
1082 the line will be placed into :attr:`~ColorizedString.explicit_newline`.
1084 If set to :data:`False`, newline sequences are treated as whitespaces.
1086 .. list-table:: Whitespace sequences
1087 :header-rows: 1
1088 :stub-columns: 1
1090 * - Sequence
1091 - `preserve_newlines`
1092 - Result
1093 * - ``\\n``, ``\\r\\n``, ``\\r``
1094 - ``False``
1095 - Treated as a single whitespace.
1096 * - ``\\n``, ``\\r\\n``, ``\\r``
1097 - ``True``
1098 - Creates a new line.
1099 * - ``\\v``, ``\\v\\n``, ``\\v\\r\\n``, ``\\v\\r``
1100 - Any
1101 - Always creates a new line.
1103 :param break_long_words:
1104 if set to :data:`True` (default), words that don't fit into a single line
1105 will be split into multiple lines.
1106 :param break_long_nowrap_words:
1107 if set to :data:`True`, words in no-wrap regions that don't fit
1108 into a single line will be split into multiple lines.
1109 :param overflow:
1110 a symbol that will be added to a line if it doesn't fit the given width.
1111 Pass :data:`False` to keep the overflowing lines without modification.
1112 :param indent:
1113 a string that will be prepended before the first line.
1114 :param continuation_indent:
1115 a string that will be prepended before all subsequent lines.
1116 Defaults to `indent`.
1117 :returns:
1118 a list of individual lines without newline characters at the end.
1120 """
1122 return _TextWrapper(
1123 width,
1124 preserve_spaces=preserve_spaces,
1125 preserve_newlines=preserve_newlines,
1126 break_long_words=break_long_words,
1127 break_long_nowrap_words=break_long_nowrap_words,
1128 overflow=overflow,
1129 indent=indent,
1130 continuation_indent=continuation_indent,
1131 ).wrap(self)
1133 def indent(
1134 self,
1135 indent: AnyString | int = " ",
1136 continuation_indent: AnyString | int | None = None,
1137 ) -> ColorizedString:
1138 """
1139 Indent this string.
1141 :param indent:
1142 this will be prepended to the first line in the string.
1143 Defaults to two spaces.
1144 :param continuation_indent:
1145 this will be prepended to subsequent lines in the string.
1146 Defaults to `indent`.
1147 :returns:
1148 indented string.
1150 """
1152 nowrap_indent = ColorizedString()
1153 nowrap_indent.start_no_wrap()
1154 nowrap_continuation_indent = ColorizedString()
1155 nowrap_continuation_indent.start_no_wrap()
1156 if isinstance(indent, int):
1157 nowrap_indent.append_str(" " * indent)
1158 else:
1159 nowrap_indent += indent
1160 if continuation_indent is None:
1161 nowrap_continuation_indent.append_colorized_str(nowrap_indent)
1162 elif isinstance(continuation_indent, int):
1163 nowrap_continuation_indent.append_str(" " * continuation_indent)
1164 else:
1165 nowrap_continuation_indent += continuation_indent
1167 if not nowrap_indent and not nowrap_continuation_indent:
1168 return self
1170 res = ColorizedString()
1172 needs_indent = True
1173 for part in self._parts:
1174 if not isinstance(part, str) or isinstance(part, Esc):
1175 res += part
1176 continue
1178 for line in _WORDSEP_NL_RE.split(part):
1179 if not line:
1180 continue
1181 if needs_indent:
1182 url = res.active_url
1183 res.end_link()
1184 res.append_colorized_str(nowrap_indent)
1185 res.append_link(url)
1186 nowrap_indent = nowrap_continuation_indent
1187 res.append_str(line)
1188 needs_indent = line.endswith(("\n", "\r", "\v"))
1190 return res
1192 def percent_format(self, args: _t.Any, ctx: ReprContext) -> ColorizedString:
1193 """
1194 Format colorized string as if with ``%``-formatting
1195 (i.e. `printf-style formatting`__).
1197 __ https://docs.python.org/3/library/stdtypes.html#printf-style-string-formatting
1199 :param args:
1200 arguments for formatting. Can be either a tuple of a mapping. Any other
1201 value will be converted to a tuple of one element.
1202 :param ctx:
1203 :class:`ReprContext` that will be passed to ``__colorized_str__``
1204 and ``__colorized_repr__`` when formatting colorables.
1205 :returns:
1206 formatted string.
1207 :raises:
1208 :class:`TypeError`, :class:`ValueError`, :class:`KeyError` if formatting
1209 fails.
1211 """
1213 return _percent_format(self, args, ctx)
1215 def __len__(self) -> int:
1216 return self.len
1218 def __bool__(self) -> bool:
1219 return self.len > 0
1221 def __iter__(self) -> _t.Iterator[_Color | NoWrapMarker | LinkMarker | str]:
1222 return self._parts.__iter__()
1224 def __add__(self, rhs: AnyString) -> ColorizedString:
1225 copy = self.copy()
1226 copy += rhs
1227 return copy
1229 def __radd__(self, lhs: AnyString) -> ColorizedString:
1230 copy = ColorizedString(lhs)
1231 copy += self
1232 return copy
1234 def __iadd__(self, rhs: AnyString) -> ColorizedString:
1235 if isinstance(rhs, str):
1236 self.append_str(rhs)
1237 elif isinstance(rhs, ColorizedString):
1238 self.append_colorized_str(rhs)
1239 elif isinstance(rhs, _Color):
1240 self.append_color(rhs)
1241 elif rhs in (NO_WRAP_START, NO_WRAP_END):
1242 self.append_no_wrap(rhs)
1243 elif isinstance(rhs, LinkMarker):
1244 self.append_link(rhs.url)
1245 else:
1246 self.extend(rhs)
1248 return self
1250 def __eq__(self, value: object) -> bool:
1251 if isinstance(value, ColorizedString):
1252 return self._parts == value._parts
1253 else:
1254 return NotImplemented
1256 def __ne__(self, value: object) -> bool:
1257 return not (self == value)
1259 def __rich_repr__(self) -> RichReprResult:
1260 yield None, self._parts
1261 yield "explicit_newline", self._explicit_newline, ""
1263 def __str__(self) -> str:
1264 return "".join(c for c in self._parts if isinstance(c, str))
1266 def __colorized_str__(self, ctx: ReprContext) -> ColorizedString:
1267 return self
1270AnyString: _t.TypeAlias = (
1271 str
1272 | ColorizedString
1273 | _Color
1274 | NoWrapMarker
1275 | LinkMarker
1276 | _t.Iterable[str | ColorizedString | _Color | NoWrapMarker | LinkMarker]
1277)
1278"""
1279Any string (i.e. a :class:`str`, a raw colorized string, or a normal colorized string).
1281"""
1284_S_SYNTAX = re.compile(
1285 r"""
1286 % # Percent
1287 (?:\((?P<mapping>[^)]*)\))? # Mapping key
1288 (?P<flag>[#0\-+ ]*) # Conversion Flag
1289 (?P<width>\*|\d+)? # Field width
1290 (?:\.(?P<precision>\*|\d*))? # Precision
1291 [hlL]? # Unused length modifier
1292 (?P<format>.) # Conversion type
1293 """,
1294 re.VERBOSE,
1295)
1297_F_SYNTAX = re.compile(
1298 r"""
1299 ^
1300 (?: # Options
1301 (?:
1302 (?P<fill>.)?
1303 (?P<align>[<>=^])
1304 )?
1305 (?P<flags>[+#]*)
1306 (?P<zero>0)?
1307 )
1308 (?: # Width
1309 (?P<width>\d+)?
1310 (?P<width_grouping>[,_])?
1311 )
1312 (?: # Precision
1313 \.
1314 (?P<precision>\d+)?
1315 (?P<precision_grouping>[,_])?
1316 )?
1317 (?: # Type
1318 (?P<type>.)
1319 )?
1320 $
1321 """,
1322 re.VERBOSE,
1323)
1326def _percent_format(
1327 s: ColorizedString, args: object, ctx: ReprContext
1328) -> ColorizedString:
1329 seen_mapping = False
1330 arg_index = 0
1331 res = ColorizedString()
1332 for part in s:
1333 if isinstance(part, str):
1334 pos = 0
1335 for match in _S_SYNTAX.finditer(part):
1336 if pos < match.start():
1337 res.append_str(part[pos : match.start()])
1338 seen_mapping = seen_mapping or bool(match.group("mapping"))
1339 last_color = res.active_color
1340 arg_index, replaced = _percent_format_repl(
1341 match, args, arg_index, last_color, ctx
1342 )
1343 res += replaced
1344 res.append_color(last_color)
1345 pos = match.end()
1346 if pos < len(part):
1347 res.append_str(part[pos:])
1348 else:
1349 res += part
1351 if (isinstance(args, tuple) and arg_index < len(args)) or (
1352 not isinstance(args, tuple)
1353 and (
1354 not hasattr(args, "__getitem__")
1355 or isinstance(args, (str, bytes, bytearray))
1356 )
1357 and not seen_mapping
1358 and not arg_index
1359 ):
1360 raise TypeError("not all arguments converted during string formatting")
1362 return res
1365def _percent_format_repl(
1366 match: _tx.StrReMatch,
1367 args: object,
1368 arg_index: int,
1369 base_color: _Color,
1370 ctx: ReprContext,
1371) -> tuple[int, str | ColorizedString]:
1372 if match.group("format") == "%":
1373 if match.group(0) != "%%":
1374 raise ValueError("unsupported format character '%'")
1375 return arg_index, "%"
1377 if match.group("format") in "rsa":
1378 return _percent_format_repl_str(match, args, arg_index, base_color, ctx)
1380 if mapping := match.group("mapping"):
1381 try:
1382 fmt_arg = args[mapping] # type: ignore
1383 except TypeError:
1384 raise TypeError("format requires a mapping") from None
1385 fmt_arg, added_color = _unwrap_base_color(fmt_arg, ctx.theme)
1386 if added_color:
1387 fmt_args = {mapping: fmt_arg}
1388 else:
1389 fmt_args = args
1390 elif isinstance(args, tuple):
1391 try:
1392 fmt_arg = args[arg_index]
1393 except IndexError:
1394 raise TypeError("not enough arguments for format string")
1395 fmt_arg, added_color = _unwrap_base_color(fmt_arg, ctx.theme)
1396 begin = arg_index + 1
1397 end = arg_index = (
1398 arg_index
1399 + 1
1400 + (match.group("width") == "*")
1401 + (match.group("precision") == "*")
1402 )
1403 fmt_args = (fmt_arg,) + args[begin:end]
1404 elif arg_index == 0:
1405 fmt_args, added_color = _unwrap_base_color(args, ctx.theme)
1406 arg_index += 1
1407 else:
1408 raise TypeError("not enough arguments for format string")
1410 fmt = match.group(0) % fmt_args
1411 if added_color:
1412 added_color = ctx.to_color(added_color)
1413 fmt = ColorizedString([base_color | added_color, fmt])
1414 return arg_index, fmt
1417def _unwrap_base_color(x, theme: yuio.theme.Theme):
1418 color = None
1419 while isinstance(x, WithBaseColor):
1420 x, base_color = x._msg, x._base_color
1421 base_color = theme.to_color(base_color)
1422 if color:
1423 color = color | base_color
1424 else:
1425 color = base_color
1426 else:
1427 return x, color
1430def _percent_format_repl_str(
1431 match: _tx.StrReMatch,
1432 args: object,
1433 arg_index: int,
1434 base_color: _Color,
1435 ctx: ReprContext,
1436) -> tuple[int, str | ColorizedString]:
1437 if width_s := match.group("width"):
1438 if width_s == "*":
1439 if not isinstance(args, tuple):
1440 raise TypeError("* wants int")
1441 try:
1442 width = args[arg_index]
1443 arg_index += 1
1444 except (KeyError, IndexError):
1445 raise TypeError("not enough arguments for format string")
1446 if not isinstance(width, int):
1447 raise TypeError("* wants int")
1448 else:
1449 width = int(width_s)
1450 else:
1451 width = None
1453 if precision_s := match.group("precision"):
1454 if precision_s == "*":
1455 if not isinstance(args, tuple):
1456 raise TypeError("* wants int")
1457 try:
1458 precision = args[arg_index]
1459 arg_index += 1
1460 except (KeyError, IndexError):
1461 raise TypeError("not enough arguments for format string")
1462 if not isinstance(precision, int):
1463 raise TypeError("* wants int")
1464 else:
1465 precision = int(precision_s)
1466 else:
1467 precision = None
1469 if mapping := match.group("mapping"):
1470 try:
1471 fmt_arg = args[mapping] # type: ignore
1472 except TypeError:
1473 raise TypeError("format requires a mapping") from None
1474 elif isinstance(args, tuple):
1475 try:
1476 fmt_arg = args[arg_index]
1477 arg_index += 1
1478 except IndexError:
1479 raise TypeError("not enough arguments for format string") from None
1480 elif arg_index == 0:
1481 fmt_arg = args
1482 arg_index += 1
1483 else:
1484 raise TypeError("not enough arguments for format string")
1486 flag = match.group("flag")
1487 multiline = "+" in flag
1488 highlighted = "#" in flag
1490 res = ctx.convert(
1491 fmt_arg,
1492 match.group("format"), # type: ignore
1493 multiline=multiline,
1494 highlighted=highlighted,
1495 )
1497 align = match.group("flag")
1498 if width is not None and width < 0:
1499 width = -width
1500 align = "<"
1501 elif align == "-":
1502 align = "<"
1503 else:
1504 align = ">"
1505 res = _apply_format(res, width, precision, align, " ")
1507 return arg_index, res.with_base_color(base_color)
1510def _format_interpolation(interp: _Interpolation, ctx: ReprContext) -> ColorizedString:
1511 value = interp.value
1512 if (
1513 interp.conversion is not None
1514 or getattr(type(value), "__format__", None) is object.__format__
1515 or isinstance(value, (str, ColorizedString))
1516 ):
1517 value = ctx.convert(value, interp.conversion, interp.format_spec)
1518 else:
1519 value = ColorizedString(format(value, interp.format_spec))
1521 return value
1524def _apply_format(
1525 value: ColorizedString,
1526 width: int | None,
1527 precision: int | None,
1528 align: str | None,
1529 fill: str | None,
1530):
1531 if precision is not None and value.width > precision:
1532 cut = ColorizedString()
1533 for part in value:
1534 if precision <= 0:
1535 break
1536 if isinstance(part, str):
1537 part_width = line_width(part)
1538 if part_width <= precision:
1539 cut.append_str(part)
1540 precision -= part_width
1541 elif part.isascii():
1542 cut.append_str(part[:precision])
1543 break
1544 else:
1545 for j, ch in enumerate(part):
1546 precision -= line_width(ch)
1547 if precision == 0:
1548 cut.append_str(part[: j + 1])
1549 break
1550 elif precision < 0:
1551 cut.append_str(part[:j])
1552 cut.append_str(" ")
1553 break
1554 break
1555 else:
1556 cut += part
1557 value = cut
1559 if width is not None and width > value.width:
1560 fill = fill or " "
1561 fill_width = line_width(fill)
1562 spacing = width - value.width
1563 spacing_fill = spacing // fill_width
1564 spacing_space = spacing - spacing_fill * fill_width
1565 value.append_color(_Color.NONE)
1566 if not align or align == "<":
1567 value = value + fill * spacing_fill + " " * spacing_space
1568 elif align == ">":
1569 value = fill * spacing_fill + " " * spacing_space + value
1570 else:
1571 left = spacing_fill // 2
1572 right = spacing_fill - left
1573 value = fill * left + value + fill * right + " " * spacing_space
1575 return value
1578__TAG_RE = re.compile(
1579 r"""
1580 <c (?P<tag_open>[a-z0-9 _/@:-]+)> # _Color tag open.
1581 | </c> # _Color tag close.
1582 | \\(?P<punct>[%(punct)s]) # Escape character.
1583 | (?<!`)(`+)(?!`)(?P<code>.*?)(?<!`)\3(?!`) # Inline code block (backticks).
1584 """
1585 % {"punct": re.escape(string.punctuation)},
1586 re.VERBOSE | re.MULTILINE,
1587)
1588__NEG_NUM_RE = re.compile(r"^-(0x[0-9a-fA-F]+|0b[01]+|\d+(e[+-]?\d+)?)$")
1589__FLAG_RE = re.compile(r"^-[-a-zA-Z0-9_]*$")
1592def colorize(
1593 template: str | _Template,
1594 /,
1595 *args: _t.Any,
1596 ctx: ReprContext,
1597 default_color: _Color | str = _Color.NONE,
1598) -> ColorizedString:
1599 """colorize(line: str, /, *args: typing.Any, ctx: ReprContext, default_color: ~yuio.color.Color | str = Color.NONE, parse_cli_flags_in_backticks: bool = False) -> ColorizedString
1600 colorize(line: ~string.templatelib.Template, /, *, ctx: ReprContext, default_color: ~yuio.color.Color | str = Color.NONE, parse_cli_flags_in_backticks: bool = False) -> ColorizedString
1602 Parse color tags and produce a colorized string.
1604 Apply ``default_color`` to the entire paragraph, and process color tags
1605 and backticks within it.
1607 :param line:
1608 text to colorize.
1609 :param args:
1610 if given, string will be ``%``-formatted after parsing.
1611 Can't be given if `line` is :class:`~string.templatelib.Template`.
1612 :param ctx:
1613 :class:`ReprContext` that will be used to look up color tags
1614 and format arguments.
1615 :param default_color:
1616 color or color tag to apply to the entire text.
1617 :returns:
1618 a colorized string.
1620 """
1622 interpolations: list[tuple[int, _Interpolation]] = []
1623 if isinstance(template, _Template):
1624 if args:
1625 raise TypeError("args can't be given with template")
1626 line = ""
1627 index = 0
1628 for part, interp in zip(template.strings, template.interpolations):
1629 line += part
1630 # Each interpolation is replaced by a zero byte so that our regex knows
1631 # there is something.
1632 line += "\0"
1633 index += len(part) + 1
1634 interpolations.append((index, interp))
1635 line += template.strings[-1]
1636 else:
1637 line = template
1639 default_color = ctx.to_color(default_color)
1641 res = ColorizedString(default_color)
1642 stack = [default_color]
1643 last_pos = 0
1644 last_interp = 0
1646 def append_to_res(s: str, start: int):
1647 nonlocal last_interp
1649 index = 0
1650 while (
1651 last_interp < len(interpolations)
1652 and start + len(s) >= interpolations[last_interp][0]
1653 ):
1654 interp_start, interp = interpolations[last_interp]
1655 res.append_str(
1656 s[
1657 index : interp_start
1658 - start
1659 - 1 # This compensates for that `\0` we added above.
1660 ]
1661 )
1662 res.append_colorized_str(
1663 _format_interpolation(interp, ctx).with_base_color(res.active_color)
1664 )
1665 index = interp_start - start
1666 last_interp += 1
1667 res.append_str(s[index:])
1669 for tag in __TAG_RE.finditer(line):
1670 append_to_res(line[last_pos : tag.start()], last_pos)
1671 last_pos = tag.end()
1673 if name := tag.group("tag_open"):
1674 color = stack[-1] | ctx.get_color(name)
1675 res.append_color(color)
1676 stack.append(color)
1677 elif code := tag.group("code"):
1678 code = code.replace("\n", " ")
1679 code_pos = tag.start("code")
1680 if code.startswith(" ") and code.endswith(" ") and not code.isspace():
1681 code = code[1:-1]
1682 code_pos += 1
1683 if __FLAG_RE.match(code) and not __NEG_NUM_RE.match(code):
1684 res.append_color(stack[-1] | ctx.get_color("flag"))
1685 else:
1686 res.append_color(stack[-1] | ctx.get_color("code"))
1687 res.start_no_wrap()
1688 append_to_res(code, code_pos)
1689 res.end_no_wrap()
1690 res.append_color(stack[-1])
1691 elif punct := tag.group("punct"):
1692 append_to_res(punct, tag.start("punct"))
1693 elif len(stack) > 1:
1694 stack.pop()
1695 res.append_color(stack[-1])
1697 append_to_res(line[last_pos:], last_pos)
1699 if args:
1700 return res.percent_format(args, ctx)
1701 else:
1702 return res
1705def strip_color_tags(s: str) -> str:
1706 """
1707 Remove all color tags from a string.
1709 """
1711 raw: list[str] = []
1713 last_pos = 0
1714 for tag in __TAG_RE.finditer(s):
1715 raw.append(s[last_pos : tag.start()])
1716 last_pos = tag.end()
1718 if code := tag.group("code"):
1719 code = code.replace("\n", " ")
1720 if code.startswith(" ") and code.endswith(" ") and not code.isspace():
1721 code = code[1:-1]
1722 raw.append(code)
1723 elif punct := tag.group("punct"):
1724 raw.append(punct)
1726 raw.append(s[last_pos:])
1728 return "".join(raw)
1731class Esc(_UserString):
1732 """
1733 A string that can't be broken during word wrapping even
1734 if `break_long_nowrap_words` is :data:`True`.
1736 """
1738 __slots__ = ()
1741_SPACE_TRANS = str.maketrans("\r\n\t\v\b\f", " ")
1743_WORD_PUNCT = r'[\w!"\'&.,?]'
1744_LETTER = r"[^\d\W]"
1745_NOWHITESPACE = r"[^ \r\n\t\v\b\f]"
1747# Copied from textwrap with some modifications in newline handling
1748_WORDSEP_RE = re.compile(
1749 r"""
1750 ( # newlines and line feeds are matched one-by-one
1751 (?:\r\n|\r|\n|\v\r\n|\v\r|\v\n|\v)
1752 | # any whitespace
1753 [ \t\b\f]+
1754 | # em-dash between words
1755 (?<=%(wp)s) -{2,} (?=\w)
1756 | # word, possibly hyphenated
1757 %(nws)s+? (?:
1758 # hyphenated word
1759 -(?: (?<=%(lt)s{2}-) | (?<=%(lt)s-%(lt)s-))
1760 (?= %(lt)s -? %(lt)s)
1761 | # end of word
1762 (?=[ \r\n\t\v\b\f]|\Z)
1763 | # em-dash
1764 (?<=%(wp)s) (?=-{2,}\w)
1765 )
1766 )"""
1767 % {"wp": _WORD_PUNCT, "lt": _LETTER, "nws": _NOWHITESPACE},
1768 re.VERBOSE,
1769)
1770_WORDSEP_NL_RE = re.compile(r"(\r\n|\r|\n|\v\r\n|\v\r|\v\n|\v)")
1773class _TextWrapper:
1774 def __init__(
1775 self,
1776 width: int,
1777 /,
1778 *,
1779 preserve_spaces: bool,
1780 preserve_newlines: bool,
1781 break_long_words: bool,
1782 break_long_nowrap_words: bool,
1783 overflow: _t.Literal[False] | str,
1784 indent: AnyString | int,
1785 continuation_indent: AnyString | int | None,
1786 ):
1787 self.width = width
1788 self.preserve_spaces: bool = preserve_spaces
1789 self.preserve_newlines: bool = preserve_newlines
1790 self.break_long_words: bool = break_long_words
1791 self.break_long_nowrap_words: bool = break_long_nowrap_words
1792 self.overflow: _t.Literal[False] | str = overflow
1794 self.indent = ColorizedString()
1795 self.indent.start_no_wrap()
1796 self.continuation_indent = ColorizedString()
1797 self.continuation_indent.start_no_wrap()
1798 if isinstance(indent, int):
1799 self.indent.append_str(" " * indent)
1800 else:
1801 self.indent += indent
1802 if continuation_indent is None:
1803 self.continuation_indent.append_colorized_str(self.indent)
1804 elif isinstance(continuation_indent, int):
1805 self.continuation_indent.append_str(" " * continuation_indent)
1806 else:
1807 self.continuation_indent += continuation_indent
1809 self.lines: list[ColorizedString] = []
1811 self.current_line = ColorizedString()
1812 if self.indent:
1813 self.current_line += self.indent
1814 self.current_line_width: int = self.indent.width
1815 self.at_line_start: bool = True
1816 self.at_line_start_or_indent: bool = True
1817 self.has_ellipsis: bool = False
1818 self.add_spaces_before_word: int = 0
1820 self.nowrap_start_index = None
1821 self.nowrap_start_width = 0
1822 self.nowrap_start_added_space = False
1824 def _flush_line(self, explicit_newline=""):
1825 self.current_line._explicit_newline = explicit_newline
1826 self.lines.append(self.current_line)
1828 next_line = ColorizedString()
1830 if self.continuation_indent:
1831 next_line += self.continuation_indent
1833 next_line.append_color(self.current_line.active_color)
1834 next_line.append_link(self.current_line.active_url)
1836 self.current_line = next_line
1837 self.current_line_width: int = self.continuation_indent.width
1838 self.at_line_start = True
1839 self.at_line_start_or_indent = True
1840 self.has_ellipsis = False
1841 self.nowrap_start_index = None
1842 self.nowrap_start_width = 0
1843 self.nowrap_start_added_space = False
1844 self.add_spaces_before_word = 0
1846 def _flush_line_part(self):
1847 assert self.nowrap_start_index is not None
1848 self.current_line, tail = self.current_line._split_at(self.nowrap_start_index)
1849 tail_width = self.current_line_width - self.nowrap_start_width
1850 if (
1851 self.nowrap_start_added_space
1852 and self.current_line._parts
1853 and self.current_line._parts[-1] == " "
1854 ):
1855 # Remove space that was added before no-wrap sequence.
1856 self.current_line._parts.pop()
1857 self._flush_line()
1858 self.current_line += tail
1859 self.current_line.append_color(tail.active_color)
1860 self.current_line.append_link(tail.active_url)
1861 self.current_line_width += tail_width
1863 def _append_str(self, s: str):
1864 self.current_line.append_str(s)
1865 self.at_line_start = False
1866 self.at_line_start_or_indent = self.at_line_start_or_indent and s.isspace()
1868 def _append_word(self, word: str, word_width: int):
1869 if (
1870 self.overflow is not False
1871 and self.current_line_width + word_width > self.width
1872 ):
1873 if isinstance(word, Esc):
1874 if self.overflow:
1875 self._add_ellipsis()
1876 return
1878 word_head_len = word_head_width = 0
1880 for c in word:
1881 c_width = line_width(c)
1882 if self.current_line_width + word_head_width + c_width > self.width:
1883 break
1884 word_head_len += 1
1885 word_head_width += c_width
1887 if word_head_len:
1888 self._append_str(word[:word_head_len])
1889 self.has_ellipsis = False
1890 self.current_line_width += word_head_width
1892 if self.overflow:
1893 self._add_ellipsis()
1894 else:
1895 self._append_str(word)
1896 self.current_line_width += word_width
1897 self.has_ellipsis = False
1899 def _append_space(self):
1900 if self.add_spaces_before_word:
1901 word = " " * self.add_spaces_before_word
1902 self._append_word(word, 1)
1903 self.add_spaces_before_word = 0
1905 def _add_ellipsis(self):
1906 if self.has_ellipsis:
1907 # Already has an ellipsis.
1908 return
1910 if self.current_line_width + 1 <= self.width:
1911 # There's enough space on this line to add new ellipsis.
1912 self._append_str(str(self.overflow))
1913 self.current_line_width += 1
1914 self.has_ellipsis = True
1915 elif not self.at_line_start:
1916 # Modify last word on this line, if there is any.
1917 parts = self.current_line._parts
1918 for i in range(len(parts) - 1, -1, -1):
1919 part = parts[i]
1920 if isinstance(part, str):
1921 if not isinstance(part, Esc):
1922 parts[i] = f"{part[:-1]}{self.overflow}"
1923 self.has_ellipsis = True
1924 return
1926 def _append_word_with_breaks(self, word: str, word_width: int):
1927 while self.current_line_width + word_width > self.width:
1928 word_head_len = word_head_width = 0
1930 for c in word:
1931 c_width = line_width(c)
1932 if self.current_line_width + word_head_width + c_width > self.width:
1933 break
1934 word_head_len += 1
1935 word_head_width += c_width
1937 if self.at_line_start and not word_head_len:
1938 if self.overflow:
1939 return
1940 else:
1941 word_head_len = 1
1942 word_head_width += line_width(word[:1])
1944 self._append_word(word[:word_head_len], word_head_width)
1946 word = word[word_head_len:]
1947 word_width -= word_head_width
1949 self._flush_line()
1951 if word:
1952 self._append_word(word, word_width)
1954 def wrap(self, text: ColorizedString) -> list[ColorizedString]:
1955 nowrap = False
1957 for part in text:
1958 if isinstance(part, _Color):
1959 if (
1960 self.add_spaces_before_word
1961 and self.current_line_width + self.add_spaces_before_word
1962 < self.width
1963 ):
1964 # Make sure any whitespace that was added before color
1965 # is flushed. If it doesn't fit, we just forget it: the line
1966 # will be wrapped soon anyways.
1967 self._append_space()
1968 self.add_spaces_before_word = 0
1969 self.current_line.append_color(part)
1970 continue
1971 elif isinstance(part, LinkMarker):
1972 if (
1973 self.add_spaces_before_word
1974 and self.current_line_width + self.add_spaces_before_word
1975 < self.width
1976 ):
1977 # Make sure any whitespace that was added before color
1978 # is flushed. If it doesn't fit, we just forget it: the line
1979 # will be wrapped soon anyways.
1980 self._append_space()
1981 self.add_spaces_before_word = 0
1982 self.current_line.append_link(part.url)
1983 continue
1984 elif part is NO_WRAP_START:
1985 if nowrap: # pragma: no cover
1986 continue
1987 if (
1988 self.add_spaces_before_word
1989 and self.current_line_width + self.add_spaces_before_word
1990 < self.width
1991 ):
1992 # Make sure any whitespace that was added before no-wrap
1993 # is flushed. If it doesn't fit, we just forget it: the line
1994 # will be wrapped soon anyways.
1995 self._append_space()
1996 self.nowrap_start_added_space = True
1997 else:
1998 self.nowrap_start_added_space = False
1999 self.add_spaces_before_word = 0
2000 if self.at_line_start:
2001 self.nowrap_start_index = None
2002 self.nowrap_start_width = 0
2003 else:
2004 self.nowrap_start_index = len(self.current_line._parts)
2005 self.nowrap_start_width = self.current_line_width
2006 nowrap = True
2007 continue
2008 elif part is NO_WRAP_END:
2009 nowrap = False
2010 self.nowrap_start_index = None
2011 self.nowrap_start_width = 0
2012 self.nowrap_start_added_space = False
2013 continue
2015 esc = False
2016 if isinstance(part, Esc):
2017 words = [Esc(part.translate(_SPACE_TRANS))]
2018 esc = True
2019 elif nowrap:
2020 words = _WORDSEP_NL_RE.split(part)
2021 else:
2022 words = _WORDSEP_RE.split(part)
2024 for word in words:
2025 if not word:
2026 # `_WORDSEP_RE` produces empty strings, skip them.
2027 continue
2029 if word.startswith(("\v", "\r", "\n")):
2030 # `_WORDSEP_RE` yields one newline sequence at a time, we don't
2031 # need to split the word further.
2032 if nowrap or self.preserve_newlines or word.startswith("\v"):
2033 self._flush_line(explicit_newline=word)
2034 continue
2035 else:
2036 # Treat any newline sequence as a single space.
2037 word = " "
2039 isspace = not esc and word.isspace()
2040 if isspace:
2041 if (
2042 # Spaces are preserved in no-wrap sequences.
2043 nowrap
2044 # Spaces are explicitly preserved.
2045 or self.preserve_spaces
2046 # We preserve indentation even if `preserve_spaces` is `False`.
2047 # We need to check that the previous line ended with an
2048 # explicit newline, otherwise this is not an indent.
2049 or (
2050 self.at_line_start_or_indent
2051 and (not self.lines or self.lines[-1].explicit_newline)
2052 )
2053 ):
2054 word = word.translate(_SPACE_TRANS)
2055 else:
2056 self.add_spaces_before_word = len(word)
2057 continue
2059 word_width = line_width(word)
2061 if self._try_fit_word(word, word_width):
2062 # Word fits onto the current line.
2063 continue
2065 if self.nowrap_start_index is not None:
2066 # Move the entire no-wrap sequence onto the new line.
2067 self._flush_line_part()
2069 if self._try_fit_word(word, word_width):
2070 # Word fits onto the current line after we've moved
2071 # no-wrap sequence. Nothing more to do.
2072 continue
2074 if (
2075 not self.at_line_start
2076 and (
2077 # Spaces can be broken anywhere, so we don't break line
2078 # for them: `_append_word_with_breaks` will do it for us.
2079 # Note: `esc` implies `not isspace`, so all `esc` words
2080 # outside of no-wrap sequences are handled by this check.
2081 (not nowrap and not isspace)
2082 # No-wrap sequences are broken in the middle of any word,
2083 # so we don't need any special handling for them
2084 # (again, `_append_word_with_breaks` will do breaking for us).
2085 # An exception is `esc` words which can't be broken in the middle;
2086 # if the break is possible at all, it must happen here.
2087 or (nowrap and esc and self.break_long_nowrap_words)
2088 )
2089 and not (
2090 # This is an esc word which wouldn't fit onto this line, nor onto
2091 # the next line, and there's enough space for an ellipsis
2092 # on this line (or it already has one). We don't need to break
2093 # the line here: this word will be passed to `_append_word`,
2094 # which will handle ellipsis for us.
2095 self.overflow is not False
2096 and esc
2097 and self.continuation_indent.width + word_width > self.width
2098 and (
2099 self.has_ellipsis
2100 or self.current_line_width + self.add_spaces_before_word + 1
2101 <= self.width
2102 )
2103 )
2104 ):
2105 # Flush a non-empty line.
2106 self._flush_line()
2108 # Note: `need_space_before_word` is always `False` at this point.
2109 # `need_space_before_word` becomes `True` only when current line
2110 # is non-empty, we're not in no-wrap sequence, and `preserve_spaces`
2111 # is `False` (meaning `isspace` is also `False`). In such situation,
2112 # we flush the line in the condition above.
2113 if not esc and (
2114 (nowrap and self.break_long_nowrap_words)
2115 or (not nowrap and (self.break_long_words or isspace))
2116 ):
2117 # We will break the word in the middle if it doesn't fit.
2118 self._append_word_with_breaks(word, word_width)
2119 else:
2120 self._append_word(word, word_width)
2122 if self.current_line or not self.lines or self.lines[-1].explicit_newline:
2123 self._flush_line()
2125 return self.lines
2127 def _try_fit_word(self, word: str, word_width: int):
2128 if (
2129 self.current_line_width + word_width + self.add_spaces_before_word
2130 <= self.width
2131 ):
2132 self._append_space()
2133 self._append_word(word, word_width)
2134 return True
2135 else:
2136 return False
2139class _ReprContextState(Enum):
2140 START = 0
2141 """
2142 Initial state.
2144 """
2146 CONTAINER_START = 1
2147 """
2148 Right after a token starting a container was pushed.
2150 """
2152 ITEM_START = 2
2153 """
2154 Right after a token separating container items was pushed.
2156 """
2158 NORMAL = 3
2159 """
2160 In the middle of a container element.
2162 """
2165@_t.final
2166class ReprContext:
2167 """
2168 Context object that tracks repr settings and ensures that recursive objects
2169 are handled properly.
2171 .. warning::
2173 :class:`~yuio.string.ReprContext`\\ s are not thread safe. As such,
2174 you shouldn't create them for long term use.
2176 :param term:
2177 terminal that will be used to print formatted messages.
2178 :param theme:
2179 theme that will be used to format messages.
2180 :param multiline:
2181 indicates that values rendered via `rich repr protocol`_
2182 should be split into multiple lines. Default is :data:`False`.
2183 :param highlighted:
2184 indicates that values rendered via `rich repr protocol`_
2185 or via built-in :func:`repr` should be highlighted according to python syntax.
2186 Default is :data:`False`.
2187 :param max_depth:
2188 maximum depth of nested containers, after which container's contents
2189 are not rendered. Default is ``5``.
2190 :param width:
2191 maximum width of the content, used when wrapping text, rendering markdown,
2192 or rendering horizontal rulers. If not given, defaults
2193 to :attr:`Theme.fallback_width <yuio.theme.Theme.fallback_width>`.
2195 .. _rich repr protocol: https://rich.readthedocs.io/en/stable/pretty.html#rich-repr-protocol
2197 """
2199 def __init__(
2200 self,
2201 *,
2202 term: yuio.term.Term,
2203 theme: yuio.theme.Theme,
2204 multiline: bool | None = None,
2205 highlighted: bool | None = None,
2206 max_depth: int | None = None,
2207 width: int | None = None,
2208 ):
2209 self.term = term
2210 """
2211 Current term.
2213 """
2215 self.theme = theme
2216 """
2217 Current theme.
2219 """
2221 self.multiline: bool = multiline if multiline is not None else False
2222 """
2223 Whether values rendered with :meth:`~ReprContext.repr` are split into multiple lines.
2225 """
2227 self.highlighted: bool = highlighted if highlighted is not None else False
2228 """
2229 Whether values rendered with :meth:`~ReprContext.repr` are highlighted.
2231 """
2233 self.max_depth: int = max_depth if max_depth is not None else 5
2234 """
2235 Maximum depth of nested containers, after which container's contents
2236 are not rendered.
2238 """
2240 self.width: int = max(width or theme.fallback_width, 1)
2241 """
2242 Maximum width of the content, used when wrapping text or rendering markdown.
2244 """
2246 self._seen: set[int] = set()
2247 self._line = ColorizedString()
2248 self._indent = 0
2249 self._state = _ReprContextState.START
2250 self._pending_sep = None
2252 import yuio.hl
2254 self._hl, _ = yuio.hl.get_highlighter("repr")
2255 self._base_color = theme.get_color("msg/text:code/repr")
2257 @staticmethod
2258 def make_dummy(is_unicode: bool = True) -> ReprContext:
2259 """
2260 Make a dummy repr context with default settings.
2262 """
2264 return ReprContext(
2265 term=yuio.term.Term.make_dummy(is_unicode=is_unicode),
2266 theme=yuio.theme.Theme(),
2267 )
2269 def get_color(self, paths: str, /) -> yuio.color.Color:
2270 """
2271 Lookup a color by path.
2273 """
2275 return self.theme.get_color(paths)
2277 def to_color(
2278 self, color_or_path: yuio.color.Color | str | None, /
2279 ) -> yuio.color.Color:
2280 """
2281 Convert color or color path to color.
2283 """
2285 return self.theme.to_color(color_or_path)
2287 def get_msg_decoration(self, name: str, /) -> str:
2288 """
2289 Get message decoration by name.
2291 """
2293 return self.theme.get_msg_decoration(name, is_unicode=self.term.is_unicode)
2295 def _flush_sep(self, trim: bool = False):
2296 if self._pending_sep is not None:
2297 self._push_color("punct")
2298 if trim:
2299 self._pending_sep = self._pending_sep.rstrip()
2300 self._line.append_str(self._pending_sep)
2301 self._pending_sep = None
2303 def _flush_line(self):
2304 if self.multiline:
2305 self._line.append_color(self._base_color)
2306 self._line.append_str("\n")
2307 if self._indent:
2308 self._line.append_str(" " * self._indent)
2310 def _flush_sep_and_line(self):
2311 if self.multiline and self._state in [
2312 _ReprContextState.CONTAINER_START,
2313 _ReprContextState.ITEM_START,
2314 ]:
2315 self._flush_sep(trim=True)
2316 self._flush_line()
2317 else:
2318 self._flush_sep()
2320 def _push_color(self, tag: str):
2321 if self.highlighted:
2322 self._line.append_color(
2323 self._base_color | self.theme.to_color(f"hl/{tag}:repr")
2324 )
2326 def _push_token(self, content: str, tag: str):
2327 self._flush_sep_and_line()
2329 self._push_color(tag)
2330 self._line.append_str(content)
2332 self._state = _ReprContextState.NORMAL
2334 def _terminate_item(self, sep: str = ", "):
2335 self._flush_sep()
2336 self._pending_sep = sep
2337 self._state = _ReprContextState.ITEM_START
2339 def _start_container(self):
2340 self._state = _ReprContextState.CONTAINER_START
2341 self._indent += 1
2343 def _end_container(self):
2344 self._indent -= 1
2346 if self._state in [_ReprContextState.NORMAL, _ReprContextState.ITEM_START]:
2347 self._flush_line()
2349 self._state = _ReprContextState.NORMAL
2350 self._pending_sep = None
2352 def repr(
2353 self,
2354 value: _t.Any,
2355 /,
2356 *,
2357 multiline: bool | None = None,
2358 highlighted: bool | None = None,
2359 width: int | None = None,
2360 max_depth: int | None = None,
2361 ) -> ColorizedString:
2362 """
2363 Convert value to colorized string using repr methods.
2365 :param value:
2366 value to be rendered.
2367 :param multiline:
2368 if given, overrides settings passed to :class:`ReprContext` for this call.
2369 :param highlighted:
2370 if given, overrides settings passed to :class:`ReprContext` for this call.
2371 :param width:
2372 if given, overrides settings passed to :class:`ReprContext` for this call.
2373 :param max_depth:
2374 if given, overrides settings passed to :class:`ReprContext` for this call.
2375 :returns:
2376 a colorized string containing representation of the `value`.
2377 :raises:
2378 this method does not raise any errors. If any inner object raises an
2379 exception, this function returns a colorized string with
2380 an error description.
2382 """
2384 return self._print(
2385 value,
2386 multiline=multiline,
2387 highlighted=highlighted,
2388 use_str=False,
2389 width=width,
2390 max_depth=max_depth,
2391 )
2393 def str(
2394 self,
2395 value: _t.Any,
2396 /,
2397 *,
2398 multiline: bool | None = None,
2399 highlighted: bool | None = None,
2400 width: int | None = None,
2401 max_depth: int | None = None,
2402 ) -> ColorizedString:
2403 """
2404 Convert value to colorized string.
2406 :param value:
2407 value to be rendered.
2408 :param multiline:
2409 if given, overrides settings passed to :class:`ReprContext` for this call.
2410 :param highlighted:
2411 if given, overrides settings passed to :class:`ReprContext` for this call.
2412 :param width:
2413 if given, overrides settings passed to :class:`ReprContext` for this call.
2414 :param max_depth:
2415 if given, overrides settings passed to :class:`ReprContext` for this call.
2416 :returns:
2417 a colorized string containing string representation of the `value`.
2418 :raises:
2419 this method does not raise any errors. If any inner object raises an
2420 exception, this function returns a colorized string with
2421 an error description.
2423 """
2425 return self._print(
2426 value,
2427 multiline=multiline,
2428 highlighted=highlighted,
2429 use_str=True,
2430 width=width,
2431 max_depth=max_depth,
2432 )
2434 def convert(
2435 self,
2436 value: _t.Any,
2437 conversion: _t.Literal["a", "r", "s"] | None,
2438 format_spec: str | None = None,
2439 /,
2440 *,
2441 multiline: bool | None = None,
2442 highlighted: bool | None = None,
2443 width: int | None = None,
2444 max_depth: int | None = None,
2445 ):
2446 """
2447 Perform string conversion, similar to :func:`string.templatelib.convert`,
2448 and format the object with respect to the given `format_spec`.
2450 :param value:
2451 value to be converted.
2452 :param conversion:
2453 string conversion method:
2455 - ``'s'`` calls :meth:`~ReprContext.str`,
2456 - ``'r'`` calls :meth:`~ReprContext.repr`,
2457 - ``'a'`` calls :meth:`~ReprContext.repr` and escapes non-ascii
2458 characters.
2459 :param format_spec:
2460 formatting spec can override `multiline` and `highlighted`, and controls
2461 width, alignment, fill chars, etc. See its syntax below.
2462 :param multiline:
2463 if given, overrides settings passed to :class:`ReprContext` for this call.
2464 :param highlighted:
2465 if given, overrides settings passed to :class:`ReprContext` for this call.
2466 :param width:
2467 if given, overrides settings passed to :class:`ReprContext` for this call.
2468 :param max_depth:
2469 if given, overrides settings passed to :class:`ReprContext` for this call.
2470 :returns:
2471 a colorized string containing string representation of the `value`.
2472 :raises:
2473 :class:`ValueError` if `conversion` or `format_spec` are invalid.
2475 .. _t-string-spec:
2477 **Format specification**
2479 .. syntax:diagram::
2481 stack:
2482 - optional:
2483 - optional:
2484 - optional:
2485 - non_terminal: "fill"
2486 href: "#t-string-spec-fill"
2487 - non_terminal: "align"
2488 href: "#t-string-spec-align"
2489 - non_terminal: "flags"
2490 href: "#t-string-spec-flags"
2491 - optional:
2492 - comment: "width"
2493 href: "#t-string-spec-width"
2494 - "[0-9]+"
2495 - optional:
2496 - comment: "precision"
2497 href: "#t-string-spec-precision"
2498 - "'.'"
2499 - "[0-9]+"
2500 - optional:
2501 - comment: "conversion type"
2502 href: "#t-string-spec-conversion-type"
2503 - "'s'"
2504 skip_bottom: true
2505 skip: true
2507 .. _t-string-spec-fill:
2509 ``fill``
2510 Any character that will be used to extend string to the desired width.
2512 .. _t-string-spec-align:
2514 ``align``
2515 Controls alignment of a string when `width` is given: ``"<"`` for flushing
2516 string left, ``">"`` for flushing string right, ``"^"`` for centering.
2518 .. _t-string-spec-flags:
2520 ``flags``
2521 One or several flags: ``"#"`` to enable highlighting, ``"+"`` to enable
2522 multiline repr.
2524 .. _t-string-spec-width:
2526 ``width``
2527 If formatted string is narrower than this value, it will be extended and
2528 aligned using `fill` and `align` settings.
2530 .. _t-string-spec-precision:
2532 ``precision``
2533 If formatted string is wider that this value, it will be cropped to this
2534 width.
2536 .. _t-string-spec-conversion-type:
2538 ``conversion type``
2539 The only supported conversion type is ``"s"``.
2541 """
2543 if format_spec:
2544 match = _F_SYNTAX.match(format_spec)
2545 if not match:
2546 raise ValueError(f"invalid format specifier {format_spec!r}")
2547 fill = match.group("fill")
2548 align = match.group("align")
2549 if align == "=":
2550 raise ValueError("'=' alignment not allowed in string format specifier")
2551 flags = match.group("flags")
2552 if "#" in flags:
2553 highlighted = True
2554 if "+" in flags:
2555 multiline = True
2556 zero = match.group("zero")
2557 if zero and not fill:
2558 fill = zero
2559 format_width = match.group("width")
2560 if format_width:
2561 format_width = int(format_width)
2562 else:
2563 format_width = None
2564 format_width_grouping = match.group("width_grouping")
2565 if format_width_grouping:
2566 raise ValueError(f"cannot specify {format_width_grouping!r} with 's'")
2567 format_precision = match.group("precision")
2568 if format_precision:
2569 format_precision = int(format_precision)
2570 else:
2571 format_precision = None
2572 type = match.group("type")
2573 if type and type != "s":
2574 raise ValueError(f"unknown format code {type!r}")
2575 else:
2576 format_width = format_precision = align = fill = None
2578 if conversion == "r":
2579 res = self.repr(
2580 value,
2581 multiline=multiline,
2582 highlighted=highlighted,
2583 width=width,
2584 max_depth=max_depth,
2585 )
2586 elif conversion == "a":
2587 res = ColorizedString()
2588 for part in self.repr(
2589 value,
2590 multiline=multiline,
2591 highlighted=highlighted,
2592 width=width,
2593 max_depth=max_depth,
2594 ):
2595 if isinstance(part, _UserString):
2596 res += part._wrap(
2597 part.encode(encoding="unicode_escape").decode("ascii")
2598 )
2599 elif isinstance(part, str):
2600 res += part.encode(encoding="unicode_escape").decode("ascii")
2601 else:
2602 res += part
2603 elif not conversion or conversion == "s":
2604 res = self.str(
2605 value,
2606 multiline=multiline,
2607 highlighted=highlighted,
2608 width=width,
2609 max_depth=max_depth,
2610 )
2611 else:
2612 raise ValueError(
2613 f"unknown conversion {conversion!r}, should be 'a', 'r', or 's'"
2614 )
2616 return _apply_format(res, format_width, format_precision, align, fill)
2618 def hl(
2619 self,
2620 value: str,
2621 /,
2622 *,
2623 highlighted: bool | None = None,
2624 ) -> ColorizedString:
2625 """
2626 Highlight result of :func:`repr`.
2628 :meth:`ReprContext.repr` does this automatically, but sometimes you need
2629 to highlight a string without :func:`repr`-ing it one more time.
2631 :param value:
2632 result of :func:`repr` that needs highlighting.
2633 :returns:
2634 highlighted string.
2636 """
2638 highlighted = highlighted if highlighted is not None else self.highlighted
2640 if highlighted:
2641 return self._hl.highlight(
2642 value, theme=self.theme, syntax="repr", default_color=self._base_color
2643 )
2644 else:
2645 return ColorizedString(value)
2647 @contextlib.contextmanager
2648 def with_settings(
2649 self,
2650 *,
2651 multiline: bool | None = None,
2652 highlighted: bool | None = None,
2653 width: int | None = None,
2654 max_depth: int | None = None,
2655 ):
2656 """
2657 Temporarily replace settings of this context.
2659 :param multiline:
2660 if given, overrides settings passed to :class:`ReprContext` for this call.
2661 :param highlighted:
2662 if given, overrides settings passed to :class:`ReprContext` for this call.
2663 :param width:
2664 if given, overrides settings passed to :class:`ReprContext` for this call.
2665 :param max_depth:
2666 if given, overrides settings passed to :class:`ReprContext` for this call.
2667 :returns:
2668 a context manager that overrides settings.
2670 """
2672 old_multiline, self.multiline = (
2673 self.multiline,
2674 (self.multiline if multiline is None else multiline),
2675 )
2676 old_highlighted, self.highlighted = (
2677 self.highlighted,
2678 (self.highlighted if highlighted is None else highlighted),
2679 )
2680 old_width, self.width = (
2681 self.width,
2682 (self.width if width is None else max(width, 1)),
2683 )
2684 old_max_depth, self.max_depth = (
2685 self.max_depth,
2686 (self.max_depth if max_depth is None else max_depth),
2687 )
2689 try:
2690 yield
2691 finally:
2692 self.multiline = old_multiline
2693 self.highlighted = old_highlighted
2694 self.width = old_width
2695 self.max_depth = old_max_depth
2697 def _print(
2698 self,
2699 value: _t.Any,
2700 multiline: bool | None,
2701 highlighted: bool | None,
2702 width: int | None,
2703 max_depth: int | None,
2704 use_str: bool,
2705 ) -> ColorizedString:
2706 old_line, self._line = self._line, ColorizedString()
2707 old_state, self._state = self._state, _ReprContextState.START
2708 old_pending_sep, self._pending_sep = self._pending_sep, None
2710 try:
2711 with self.with_settings(
2712 multiline=multiline,
2713 highlighted=highlighted,
2714 width=width,
2715 max_depth=max_depth,
2716 ):
2717 self._print_nested(value, use_str)
2718 return self._line
2719 except Exception as e:
2720 yuio._logger.exception("error in repr context")
2721 res = ColorizedString()
2722 res.append_color(_Color.STYLE_INVERSE | _Color.FORE_RED)
2723 res.append_str(f"{_tx.type_repr(type(e))}: {e}")
2724 return res
2725 finally:
2726 self._line = old_line
2727 self._state = old_state
2728 self._pending_sep = old_pending_sep
2730 def _print_nested(self, value: _t.Any, use_str: bool = False):
2731 if id(value) in self._seen or self._indent > self.max_depth:
2732 self._push_token("...", "more")
2733 return
2734 self._seen.add(id(value))
2735 old_indent = self._indent
2736 try:
2737 if use_str:
2738 self._print_nested_as_str(value)
2739 else:
2740 self._print_nested_as_repr(value)
2741 finally:
2742 self._indent = old_indent
2743 self._seen.remove(id(value))
2745 def _print_nested_as_str(self, value):
2746 if isinstance(value, type):
2747 # This is a type.
2748 self._print_plain(value, convert=_tx.type_repr)
2749 elif hasattr(value, "__colorized_str__"):
2750 # Has `__colorized_str__`.
2751 self._print_colorized_str(value)
2752 elif getattr(type(value), "__str__", None) is not object.__str__:
2753 # Has custom `__str__`.
2754 self._print_plain(value, convert=str, hl=False)
2755 else:
2756 # Has default `__str__` which falls back to `__repr__`.
2757 self._print_nested_as_repr(value)
2759 def _print_nested_as_repr(self, value):
2760 if isinstance(value, type):
2761 # This is a type.
2762 self._print_plain(value, convert=_tx.type_repr)
2763 elif hasattr(value, "__colorized_repr__"):
2764 # Has `__colorized_repr__`.
2765 self._print_colorized_repr(value)
2766 elif hasattr(value, "__rich_repr__"):
2767 # Has `__rich_repr__`.
2768 self._print_rich_repr(value)
2769 elif isinstance(value, _CONTAINER_TYPES):
2770 # Is a known container.
2771 for ty, repr_fn in _CONTAINERS.items():
2772 if isinstance(value, ty):
2773 if getattr(type(value), "__repr__", None) is ty.__repr__:
2774 repr_fn(self, value) # type: ignore
2775 else:
2776 self._print_plain(value)
2777 break
2778 elif dataclasses.is_dataclass(value):
2779 # Is a dataclass.
2780 self._print_dataclass(value)
2781 else:
2782 # Fall back to regular `__repr__`.
2783 self._print_plain(value)
2785 def _print_plain(self, value, convert=None, hl=True):
2786 convert = convert or repr
2788 self._flush_sep_and_line()
2790 if hl and self.highlighted:
2791 self._line += self._hl.highlight(
2792 convert(value),
2793 theme=self.theme,
2794 syntax="repr",
2795 default_color=self._base_color,
2796 )
2797 else:
2798 self._line.append_str(convert(value))
2800 self._state = _ReprContextState.NORMAL
2802 def _print_list(self, name: str, obrace: str, cbrace: str, items):
2803 if name:
2804 self._push_token(name, "type")
2805 self._push_token(obrace, "punct")
2806 if self._indent >= self.max_depth:
2807 self._push_token("...", "more")
2808 else:
2809 self._start_container()
2810 for item in items:
2811 self._print_nested(item)
2812 self._terminate_item()
2813 self._end_container()
2814 self._push_token(cbrace, "punct")
2816 def _print_dict(self, name: str, obrace: str, cbrace: str, items):
2817 if name:
2818 self._push_token(name, "type")
2819 self._push_token(obrace, "punct")
2820 if self._indent >= self.max_depth:
2821 self._push_token("...", "more")
2822 else:
2823 self._start_container()
2824 for key, value in items:
2825 self._print_nested(key)
2826 self._push_token(": ", "punct")
2827 self._print_nested(value)
2828 self._terminate_item()
2829 self._end_container()
2830 self._push_token(cbrace, "punct")
2832 def _print_defaultdict(self, value: collections.defaultdict[_t.Any, _t.Any]):
2833 self._push_token("defaultdict", "type")
2834 self._push_token("(", "punct")
2835 if self._indent >= self.max_depth:
2836 self._push_token("...", "more")
2837 else:
2838 self._start_container()
2839 self._print_nested(value.default_factory)
2840 self._terminate_item()
2841 self._print_dict("", "{", "}", value.items())
2842 self._terminate_item()
2843 self._end_container()
2844 self._push_token(")", "punct")
2846 def _print_dequeue(self, value: collections.deque[_t.Any]):
2847 self._push_token("deque", "type")
2848 self._push_token("(", "punct")
2849 if self._indent >= self.max_depth:
2850 self._push_token("...", "more")
2851 else:
2852 self._start_container()
2853 self._print_list("", "[", "]", value)
2854 self._terminate_item()
2855 if value.maxlen is not None:
2856 self._push_token("maxlen", "param")
2857 self._push_token("=", "punct")
2858 self._print_nested(value.maxlen)
2859 self._terminate_item()
2860 self._end_container()
2861 self._push_token(")", "punct")
2863 def _print_dataclass(self, value):
2864 try:
2865 # If dataclass has a custom repr, fall back to it.
2866 # This code is copied from Rich, MIT License.
2867 # See https://github.com/Textualize/rich/blob/master/LICENSE
2868 has_custom_repr = value.__repr__.__code__.co_filename not in (
2869 dataclasses.__file__,
2870 reprlib.__file__,
2871 )
2872 except Exception: # pragma: no cover
2873 has_custom_repr = True
2875 if has_custom_repr:
2876 self._print_plain(value)
2877 return
2879 self._push_token(value.__class__.__name__, "type")
2880 self._push_token("(", "punct")
2882 if self._indent >= self.max_depth:
2883 self._push_token("...", "more")
2884 else:
2885 self._start_container()
2886 for field in dataclasses.fields(value):
2887 if not field.repr:
2888 continue
2889 self._push_token(field.name, "param")
2890 self._push_token("=", "punct")
2891 self._print_nested(getattr(value, field.name))
2892 self._terminate_item()
2893 self._end_container()
2895 self._push_token(")", "punct")
2897 def _print_colorized_repr(self, value):
2898 self._flush_sep_and_line()
2900 res = value.__colorized_repr__(self)
2901 if not isinstance(res, ColorizedString):
2902 raise TypeError(
2903 f"__colorized_repr__ returned non-colorized-string (type {_tx.type_repr(type(res))})"
2904 )
2905 self._line += res
2907 self._state = _ReprContextState.NORMAL
2909 def _print_colorized_str(self, value):
2910 self._flush_sep_and_line()
2912 res = value.__colorized_str__(self)
2913 if not isinstance(res, ColorizedString):
2914 raise TypeError(
2915 f"__colorized_str__ returned non-colorized-string (type {_tx.type_repr(type(res))})"
2916 )
2917 self._line += res
2918 self._state = _ReprContextState.NORMAL
2920 def _print_rich_repr(self, value):
2921 rich_repr = getattr(value, "__rich_repr__")
2922 angular = getattr(rich_repr, "angular", False)
2924 if angular:
2925 self._push_token("<", "punct")
2926 self._push_token(value.__class__.__name__, "type")
2927 if angular:
2928 self._push_token(" ", "space")
2929 else:
2930 self._push_token("(", "punct")
2932 if self._indent >= self.max_depth:
2933 self._push_token("...", "more")
2934 else:
2935 self._start_container()
2936 args = rich_repr()
2937 if args is None:
2938 args = [] # `rich_repr` didn't yield?
2939 for arg in args:
2940 if isinstance(arg, tuple):
2941 if len(arg) == 3:
2942 key, child, default = arg
2943 if default == child:
2944 continue
2945 elif len(arg) == 2:
2946 key, child = arg
2947 elif len(arg) == 1:
2948 key, child = None, arg[0]
2949 else:
2950 key, child = None, arg
2951 else:
2952 key, child = None, arg
2954 if key:
2955 self._push_token(str(key), "param")
2956 self._push_token("=", "punct")
2957 self._print_nested(child)
2958 self._terminate_item("" if angular else ", ")
2959 self._end_container()
2961 self._push_token(">" if angular else ")", "punct")
2964_CONTAINERS = {
2965 os._Environ: lambda c, o: c._print_dict("environ", "({", "})", o.items()),
2966 collections.defaultdict: ReprContext._print_defaultdict,
2967 collections.deque: ReprContext._print_dequeue,
2968 collections.Counter: lambda c, o: c._print_dict("Counter", "({", "})", o.items()),
2969 collections.UserList: lambda c, o: c._print_list("", "[", "]", o),
2970 collections.UserDict: lambda c, o: c._print_dict("", "{", "}", o.items()),
2971 list: lambda c, o: c._print_list("", "[", "]", o),
2972 set: lambda c, o: c._print_list("", "{", "}", o),
2973 frozenset: lambda c, o: c._print_list("frozenset", "({", "})", o),
2974 tuple: lambda c, o: c._print_list("", "(", ")", o),
2975 dict: lambda c, o: c._print_dict("", "{", "}", o.items()),
2976 types.MappingProxyType: lambda _: lambda c, o: c._print_dict(
2977 "mappingproxy", "({", "})", o.items()
2978 ),
2979}
2980_CONTAINER_TYPES = tuple(_CONTAINERS)
2983def _to_colorable(msg: _t.Any, args: tuple[_t.Any, ...] | None = None) -> Colorable:
2984 """
2985 Convert generic `msg`, `args` tuple to a colorable.
2987 If msg is a string, returns :class:`Format`. Otherwise, check that no arguments
2988 were given, and returns `msg` unchanged.
2990 """
2992 if isinstance(msg, (str, _Template)):
2993 return Format(_t.cast(_t.LiteralString, msg), *(args or ()))
2994 else:
2995 if args:
2996 raise TypeError(
2997 f"non-string type {_tx.type_repr(type(msg))} can't have format arguments"
2998 )
2999 return msg
3002class _StrBase(abc.ABC):
3003 def __str__(self) -> str:
3004 import yuio.io
3006 return str(yuio.io.make_repr_context().str(self))
3008 @abc.abstractmethod
3009 def __colorized_str__(self, ctx: ReprContext) -> ColorizedString:
3010 raise NotImplementedError()
3013@repr_from_rich
3014class Format(_StrBase):
3015 """Format(msg: typing.LiteralString, /, *args: typing.Any)
3016 Format(msg: ~string.templatelib.Template, /)
3018 Lazy wrapper that ``%``-formats the given message,
3019 or formats a :class:`~string.templatelib.Template`.
3021 This utility allows saving ``%``-formatted messages and templates and performing
3022 actual formatting lazily when requested. Color tags and backticks
3023 are handled as usual.
3025 :param msg:
3026 message to format.
3027 :param args:
3028 arguments for ``%``-formatting the message.
3029 :example:
3030 ::
3032 >>> message = Format("Hello, `%s`!", "world")
3033 >>> print(message)
3034 Hello, world!
3036 """
3038 @_t.overload
3039 def __init__(self, msg: _t.LiteralString, /, *args: _t.Any): ...
3040 @_t.overload
3041 def __init__(self, msg: _Template, /): ...
3042 def __init__(self, msg: str | _Template, /, *args: _t.Any):
3043 self._msg: str | _Template = msg
3044 self._args: tuple[_t.Any, ...] = args
3046 def __rich_repr__(self) -> RichReprResult:
3047 yield None, self._msg
3048 yield from ((None, arg) for arg in self._args)
3050 def __colorized_str__(self, ctx: ReprContext) -> ColorizedString:
3051 return colorize(self._msg, *self._args, ctx=ctx)
3054@_t.final
3055@repr_from_rich
3056class Repr(_StrBase):
3057 """
3058 Lazy wrapper that calls :meth:`~ReprContext.repr` on the given value.
3060 :param value:
3061 value to repr.
3062 :param multiline:
3063 if given, overrides settings passed to :class:`ReprContext` for this call.
3064 :param highlighted:
3065 if given, overrides settings passed to :class:`ReprContext` for this call.
3066 :example:
3067 .. code-block:: python
3069 config = ...
3070 yuio.io.info(
3071 "Loaded config:\\n`%#+s`", yuio.string.Indent(yuio.string.Repr(config))
3072 )
3074 """
3076 def __init__(
3077 self,
3078 value: _t.Any,
3079 /,
3080 *,
3081 multiline: bool | None = None,
3082 highlighted: bool | None = None,
3083 ):
3084 self.value = value
3085 self.multiline = multiline
3086 self.highlighted = highlighted
3088 def __rich_repr__(self) -> RichReprResult:
3089 yield None, self.value
3090 yield "multiline", self.multiline, None
3091 yield "highlighted", self.highlighted, None
3093 def __colorized_str__(self, ctx: ReprContext) -> ColorizedString:
3094 return ctx.repr(
3095 self.value, multiline=self.multiline, highlighted=self.highlighted
3096 )
3099@_t.final
3100@repr_from_rich
3101class TypeRepr(_StrBase):
3102 """
3103 Lazy wrapper that calls :func:`annotationlib.type_repr` on the given value
3104 and highlights the result.
3106 :param ty:
3107 type to format.
3109 If `ty` is a string, :func:`annotationlib.type_repr` is not called on it,
3110 allowing you to mix types and arbitrary descriptions.
3111 :param highlighted:
3112 if given, overrides settings passed to :class:`ReprContext` for this call.
3113 :example:
3114 .. invisible-code-block: python
3116 value = ...
3118 .. code-block:: python
3120 yuio.io.error("Expected `str`, got `%s`", yuio.string.TypeRepr(type(value)))
3122 """
3124 def __init__(self, ty: _t.Any, /, *, highlighted: bool | None = None):
3125 self._ty = ty
3126 self._highlighted = highlighted
3128 def __rich_repr__(self) -> RichReprResult:
3129 yield None, self._ty
3130 yield "highlighted", self._highlighted, None
3132 def __colorized_str__(self, ctx: ReprContext) -> ColorizedString:
3133 if not isinstance(self._ty, type) and isinstance(
3134 self._ty, (str, ColorizedString)
3135 ):
3136 return ColorizedString(self._ty)
3137 else:
3138 return ctx.hl(_tx.type_repr(self._ty), highlighted=self._highlighted)
3141@repr_from_rich
3142class _JoinBase(_StrBase):
3143 def __init__(
3144 self,
3145 collection: _t.Iterable[_t.Any],
3146 /,
3147 *,
3148 sep: str = ", ",
3149 sep_two: str | None = None,
3150 sep_last: str | None = None,
3151 fallback: AnyString = "",
3152 color: str | _Color | None = "code",
3153 ):
3154 self.__collection = collection
3155 self._sep = sep
3156 self._sep_two = sep_two
3157 self._sep_last = sep_last
3158 self._fallback: AnyString = fallback
3159 self._color = color
3161 @functools.cached_property
3162 def _collection(self):
3163 return list(self.__collection)
3165 @classmethod
3166 def or_(
3167 cls,
3168 collection: _t.Iterable[_t.Any],
3169 /,
3170 *,
3171 fallback: AnyString = "",
3172 color: str | _Color | None = "code",
3173 ) -> _t.Self:
3174 """
3175 Shortcut for joining arguments using word "or" as the last separator.
3177 :example:
3178 ::
3180 >>> print(yuio.string.JoinStr.or_([1, 2, 3]))
3181 1, 2, or 3
3183 """
3185 return cls(
3186 collection, sep_last=", or ", sep_two=" or ", fallback=fallback, color=color
3187 )
3189 @classmethod
3190 def and_(
3191 cls,
3192 collection: _t.Iterable[_t.Any],
3193 /,
3194 *,
3195 fallback: AnyString = "",
3196 color: str | _Color | None = "code",
3197 ) -> _t.Self:
3198 """
3199 Shortcut for joining arguments using word "and" as the last separator.
3201 :example:
3202 ::
3204 >>> print(yuio.string.JoinStr.and_([1, 2, 3]))
3205 1, 2, and 3
3207 """
3209 return cls(
3210 collection,
3211 sep_last=", and ",
3212 sep_two=" and ",
3213 fallback=fallback,
3214 color=color,
3215 )
3217 def __rich_repr__(self) -> RichReprResult:
3218 yield None, self._collection
3219 yield "sep", self._sep, ", "
3220 yield "sep_two", self._sep_two, None
3221 yield "sep_last", self._sep_last, None
3222 yield "color", self._color, "code"
3224 def _render(
3225 self,
3226 theme: yuio.theme.Theme,
3227 to_str: _t.Callable[[_t.Any], ColorizedString],
3228 ) -> ColorizedString:
3229 res = ColorizedString()
3230 color = theme.to_color(self._color)
3232 size = len(self._collection)
3233 if not size:
3234 res += self._fallback
3235 return res
3236 elif size == 1:
3237 return to_str(self._collection[0]).with_base_color(color)
3238 elif size == 2:
3239 res.append_colorized_str(to_str(self._collection[0]).with_base_color(color))
3240 res.append_str(self._sep if self._sep_two is None else self._sep_two)
3241 res.append_colorized_str(to_str(self._collection[1]).with_base_color(color))
3242 return res
3244 last_i = size - 1
3246 sep = self._sep
3247 sep_last = self._sep if self._sep_last is None else self._sep_last
3249 do_sep = False
3250 for i, value in enumerate(self._collection):
3251 if do_sep:
3252 if i == last_i:
3253 res.append_str(sep_last)
3254 else:
3255 res.append_str(sep)
3256 res.append_colorized_str(to_str(value).with_base_color(color))
3257 do_sep = True
3258 return res
3261@_t.final
3262class JoinStr(_JoinBase):
3263 """
3264 Lazy wrapper that calls :meth:`~ReprContext.str` on elements of the given collection,
3265 then joins the results using the given separator.
3267 :param collection:
3268 collection that will be printed.
3269 :param sep:
3270 separator that's printed between elements of the collection.
3271 :param sep_two:
3272 separator that's used when there are only two elements in the collection.
3273 Defaults to `sep`.
3274 :param sep_last:
3275 separator that's used between the last and prior-to-last element
3276 of the collection. Defaults to `sep`.
3277 :param fallback:
3278 printed if collection is empty.
3279 :param color:
3280 color applied to elements of the collection.
3281 :example:
3282 .. code-block:: python
3284 values = ["foo", "bar"]
3285 yuio.io.info("Available values: %s", yuio.string.JoinStr(values))
3287 """
3289 def __colorized_str__(self, ctx: ReprContext) -> ColorizedString:
3290 return self._render(ctx.theme, ctx.str)
3293@_t.final
3294class JoinRepr(_JoinBase):
3295 """
3296 Lazy wrapper that calls :meth:`~ReprContext.repr` on elements of the given collection,
3297 then joins the results using the given separator.
3299 :param collection:
3300 collection that will be printed.
3301 :param sep:
3302 separator that's printed between elements of the collection.
3303 :param sep_two:
3304 separator that's used when there are only two elements in the collection.
3305 Defaults to `sep`.
3306 :param sep_last:
3307 separator that's used between the last and prior-to-last element
3308 of the collection. Defaults to `sep`.
3309 :param fallback:
3310 printed if collection is empty.
3311 :param color:
3312 color applied to elements of the collection.
3313 :example:
3314 .. code-block:: python
3316 values = ["foo", "bar"]
3317 yuio.io.info("Available values: %s", yuio.string.JoinRepr(values))
3319 """
3321 def __colorized_str__(self, ctx: ReprContext) -> ColorizedString:
3322 return self._render(ctx.theme, ctx.repr)
3325And = JoinStr.and_
3326"""
3327Shortcut for :meth:`JoinStr.and_`.
3329"""
3332Or = JoinStr.or_
3333"""
3334Shortcut for :meth:`JoinStr.or_`.
3336"""
3339@_t.final
3340@repr_from_rich
3341class Stack(_StrBase):
3342 """
3343 Lazy wrapper that joins multiple :obj:`Colorable` objects with newlines,
3344 effectively stacking them one on top of another.
3346 :param args:
3347 colorables to stack.
3348 :example:
3349 ::
3351 >>> print(
3352 ... yuio.string.Stack(
3353 ... yuio.string.Format("<c bold magenta>Example:</c>"),
3354 ... yuio.string.Indent(
3355 ... yuio.string.Hl(
3356 ... \"""
3357 ... {
3358 ... "foo": "bar"
3359 ... }
3360 ... \""",
3361 ... syntax="json",
3362 ... ),
3363 ... indent="-> ",
3364 ... ),
3365 ... )
3366 ... )
3367 Example:
3368 -> {
3369 -> "foo": "bar"
3370 -> }
3372 """
3374 def __init__(self, *args: Colorable):
3375 self._args = args
3377 def __rich_repr__(self) -> RichReprResult:
3378 yield from ((None, arg) for arg in self._args)
3380 def __colorized_str__(self, ctx: ReprContext) -> ColorizedString:
3381 res = ColorizedString()
3382 sep = False
3383 for arg in self._args:
3384 if sep:
3385 res.append_color(_Color.NONE)
3386 res.append_str("\n")
3387 res += ctx.str(arg)
3388 sep = True
3389 return res
3392@_t.final
3393@repr_from_rich
3394class Link(_StrBase):
3395 """
3396 Lazy wrapper that adds a hyperlink to whatever is passed to it.
3398 :param msg:
3399 link body.
3400 :param url:
3401 link url, should be properly urlencoded.
3403 """
3405 def __init__(self, msg: Colorable, /, *, url: str):
3406 self._msg = msg
3407 self._url = url
3409 @classmethod
3410 def from_path(cls, msg: Colorable, /, *, path: str | pathlib.Path) -> _t.Self:
3411 """
3412 Create a link to a local file.
3414 Ensures that file path is absolute and properly formatted.
3416 :param msg:
3417 link body.
3418 :param path:
3419 path to a file.
3421 """
3423 url = pathlib.Path(path).expanduser().absolute().as_uri()
3424 return cls(msg, url=url)
3426 def __rich_repr__(self) -> RichReprResult:
3427 yield None, self._msg
3428 yield "url", self._url
3430 def __colorized_str__(self, ctx: ReprContext) -> ColorizedString:
3431 res = ColorizedString()
3432 res.start_link(self._url)
3433 res.append_colorized_str(ctx.str(self._msg))
3434 if not ctx.term.supports_colors:
3435 res.start_no_wrap()
3436 res.append_str(" [")
3437 res.append_str(self._url)
3438 res.append_str("]")
3439 res.end_no_wrap()
3440 res.end_link()
3441 return res
3444@_t.final
3445@repr_from_rich
3446class Indent(_StrBase):
3447 """
3448 Lazy wrapper that indents the message during formatting.
3450 .. seealso::
3452 :meth:`ColorizedString.indent`.
3454 :param msg:
3455 message to indent.
3456 :param indent:
3457 this will be prepended to the first line in the string.
3458 Defaults to two spaces.
3459 :param continuation_indent:
3460 this will be prepended to subsequent lines in the string.
3461 Defaults to `indent`.
3462 :example:
3463 .. code-block:: python
3465 config = ...
3466 yuio.io.info(
3467 "Loaded config:\\n`%#+s`", yuio.string.Indent(yuio.string.Repr(config))
3468 )
3470 """
3472 def __init__(
3473 self,
3474 msg: Colorable,
3475 /,
3476 *,
3477 indent: AnyString | int = " ",
3478 continuation_indent: AnyString | int | None = None,
3479 ):
3480 self._msg = msg
3481 self._indent: AnyString | int = indent
3482 self._continuation_indent: AnyString | int | None = continuation_indent
3484 def __rich_repr__(self) -> RichReprResult:
3485 yield None, self._msg
3486 yield "indent", self._indent, " "
3487 yield "continuation_indent", self._continuation_indent, None
3489 def __colorized_str__(self, ctx: ReprContext) -> ColorizedString:
3490 if isinstance(self._indent, int):
3491 indent = ColorizedString(" " * self._indent)
3492 else:
3493 indent = ColorizedString(self._indent)
3494 if self._continuation_indent is None:
3495 continuation_indent = indent
3496 elif isinstance(self._continuation_indent, int):
3497 continuation_indent = ColorizedString(" " * self._continuation_indent)
3498 else:
3499 continuation_indent = ColorizedString(self._continuation_indent)
3501 indent_width = max(indent.width, continuation_indent.width)
3502 width = max(1, ctx.width - indent_width)
3504 return ctx.str(self._msg, width=width).indent(indent, continuation_indent)
3507@_t.final
3508@repr_from_rich
3509class Md(_StrBase):
3510 """Md(msg: typing.LiteralString, /, *args, width: int | None | yuio.Missing = yuio.MISSING, dedent: bool = True, allow_headings: bool = True)
3511 Md(msg: str, /, *, width: int | None | yuio.Missing = yuio.MISSING, dedent: bool = True, allow_headings: bool = True)
3513 Lazy wrapper that renders markdown during formatting.
3515 :param md:
3516 text to format.
3517 :param width:
3518 if given, overrides settings passed to :class:`ReprContext` for this call.
3519 :param dedent:
3520 whether to remove leading indent from text.
3521 :param allow_headings:
3522 whether to render headings as actual headings or as paragraphs.
3524 """
3526 def __init__(
3527 self,
3528 md: str,
3529 /,
3530 *,
3531 width: int | None = None,
3532 dedent: bool = True,
3533 allow_headings: bool = True,
3534 ):
3535 self._md: str = md
3536 self._width: int | None = width
3537 self._dedent: bool = dedent
3538 self._allow_headings: bool = allow_headings
3540 def __rich_repr__(self) -> RichReprResult:
3541 yield None, self._md
3542 yield "width", self._width, yuio.MISSING
3543 yield "dedent", self._dedent, True
3544 yield "allow_headings", self._allow_headings, True
3546 def __colorized_str__(self, ctx: ReprContext) -> ColorizedString:
3547 import yuio.doc
3548 import yuio.md
3550 width = self._width or ctx.width
3551 with ctx.with_settings(width=width):
3552 formatter = yuio.doc.Formatter(
3553 ctx,
3554 allow_headings=self._allow_headings,
3555 )
3557 res = ColorizedString()
3558 res.start_no_wrap()
3559 sep = False
3560 for line in formatter.format(yuio.md.parse(self._md, dedent=self._dedent)):
3561 if sep:
3562 res += "\n"
3563 res += line
3564 sep = True
3565 res.end_no_wrap()
3567 return res
3570@_t.final
3571@repr_from_rich
3572class Rst(_StrBase):
3573 """Rst(msg: typing.LiteralString, /, *args, width: int | None | yuio.Missing = yuio.MISSING, dedent: bool = True, allow_headings: bool = True)
3574 Rst(msg: str, /, *, width: int | None | yuio.Missing = yuio.MISSING, dedent: bool = True, allow_headings: bool = True)
3576 Lazy wrapper that renders ReStructuredText during formatting.
3578 :param rst:
3579 text to format.
3580 :param width:
3581 if given, overrides settings passed to :class:`ReprContext` for this call.
3582 :param dedent:
3583 whether to remove leading indent from text.
3584 :param allow_headings:
3585 whether to render headings as actual headings or as paragraphs.
3587 """
3589 def __init__(
3590 self,
3591 rst: str,
3592 /,
3593 *,
3594 width: int | None = None,
3595 dedent: bool = True,
3596 allow_headings: bool = True,
3597 ):
3598 self._rst: str = rst
3599 self._width: int | None = width
3600 self._dedent: bool = dedent
3601 self._allow_headings: bool = allow_headings
3603 def __rich_repr__(self) -> RichReprResult:
3604 yield None, self._rst
3605 yield "width", self._width, yuio.MISSING
3606 yield "dedent", self._dedent, True
3607 yield "allow_headings", self._allow_headings, True
3609 def __colorized_str__(self, ctx: ReprContext) -> ColorizedString:
3610 import yuio.doc
3611 import yuio.rst
3613 width = self._width or ctx.width
3614 with ctx.with_settings(width=width):
3615 formatter = yuio.doc.Formatter(
3616 ctx,
3617 allow_headings=self._allow_headings,
3618 )
3620 res = ColorizedString()
3621 res.start_no_wrap()
3622 sep = False
3623 for line in formatter.format(
3624 yuio.rst.parse(self._rst, dedent=self._dedent)
3625 ):
3626 if sep:
3627 res += "\n"
3628 res += line
3629 sep = True
3630 res.end_no_wrap()
3632 return res
3635@_t.final
3636@repr_from_rich
3637class Hl(_StrBase):
3638 """Hl(code: typing.LiteralString, /, *args, syntax: str, dedent: bool = True)
3639 Hl(code: str, /, *, syntax: str, dedent: bool = True)
3641 Lazy wrapper that highlights code during formatting.
3643 :param md:
3644 code to highlight.
3645 :param args:
3646 arguments for ``%``-formatting the highlighted code.
3647 :param syntax:
3648 name of syntax or a :class:`~yuio.hl.SyntaxHighlighter` instance.
3649 :param dedent:
3650 whether to remove leading indent from code.
3652 """
3654 @_t.overload
3655 def __init__(
3656 self,
3657 code: _t.LiteralString,
3658 /,
3659 *args: _t.Any,
3660 syntax: str,
3661 dedent: bool = True,
3662 ): ...
3663 @_t.overload
3664 def __init__(
3665 self,
3666 code: str,
3667 /,
3668 *,
3669 syntax: str,
3670 dedent: bool = True,
3671 ): ...
3672 def __init__(
3673 self,
3674 code: str,
3675 /,
3676 *args: _t.Any,
3677 syntax: str,
3678 dedent: bool = True,
3679 ):
3680 self._code: str = code
3681 self._args: tuple[_t.Any, ...] = args
3682 self._syntax: str = syntax
3683 self._dedent: bool = dedent
3685 def __rich_repr__(self) -> RichReprResult:
3686 yield None, self._code
3687 yield from ((None, arg) for arg in self._args)
3688 yield "syntax", self._syntax
3689 yield "dedent", self._dedent, True
3691 def __colorized_str__(self, ctx: ReprContext) -> ColorizedString:
3692 import yuio.hl
3694 highlighter, syntax_name = yuio.hl.get_highlighter(self._syntax)
3695 code = self._code
3696 if self._dedent:
3697 code = _dedent(code)
3698 code = code.rstrip()
3700 res = ColorizedString()
3701 res.start_no_wrap()
3702 res += highlighter.highlight(code, theme=ctx.theme, syntax=syntax_name)
3703 res.end_no_wrap()
3704 if self._args:
3705 res = res.percent_format(self._args, ctx)
3707 return res
3710@_t.final
3711@repr_from_rich
3712class Wrap(_StrBase):
3713 """
3714 Lazy wrapper that wraps the message during formatting.
3716 .. seealso::
3718 :meth:`ColorizedString.wrap`.
3720 :param msg:
3721 message to wrap.
3722 :param width:
3723 if given, overrides settings passed to :class:`ReprContext` for this call.
3724 :param preserve_spaces:
3725 if set to :data:`True`, all spaces are preserved.
3726 Otherwise, consecutive spaces are collapsed when newline break occurs.
3728 Note that tabs always treated as a single whitespace.
3729 :param preserve_newlines:
3730 if set to :data:`True` (default), text is additionally wrapped
3731 on newline sequences. When this happens, the newline sequence that wrapped
3732 the line will be placed into :attr:`~ColorizedString.explicit_newline`.
3734 If set to :data:`False`, newline sequences are treated as whitespaces.
3735 :param break_long_words:
3736 if set to :data:`True` (default), words that don't fit into a single line
3737 will be split into multiple lines.
3738 :param overflow:
3739 Pass :data:`True` to trim overflowing lines and replace them with ellipsis.
3740 :param break_long_nowrap_words:
3741 if set to :data:`True`, words in no-wrap regions that don't fit
3742 into a single line will be split into multiple lines.
3743 :param indent:
3744 this will be prepended to the first line in the string.
3745 Defaults to two spaces.
3746 :param continuation_indent:
3747 this will be prepended to subsequent lines in the string.
3748 Defaults to `indent`.
3750 """
3752 def __init__(
3753 self,
3754 msg: Colorable,
3755 /,
3756 *,
3757 width: int | None = None,
3758 preserve_spaces: bool = False,
3759 preserve_newlines: bool = True,
3760 break_long_words: bool = True,
3761 break_long_nowrap_words: bool = False,
3762 overflow: bool | str = False,
3763 indent: AnyString | int = "",
3764 continuation_indent: AnyString | int | None = None,
3765 ):
3766 self._msg = msg
3767 self._width: int | None = width
3768 self._preserve_spaces = preserve_spaces
3769 self._preserve_newlines = preserve_newlines
3770 self._break_long_words = break_long_words
3771 self._break_long_nowrap_words = break_long_nowrap_words
3772 self._overflow = overflow
3773 self._indent: AnyString | int = indent
3774 self._continuation_indent: AnyString | int | None = continuation_indent
3776 def __rich_repr__(self) -> RichReprResult:
3777 yield None, self._msg
3778 yield "width", self._width, None
3779 yield "indent", self._indent, ""
3780 yield "continuation_indent", self._continuation_indent, None
3781 yield "preserve_spaces", self._preserve_spaces, None
3782 yield "preserve_newlines", self._preserve_newlines, True
3783 yield "break_long_words", self._break_long_words, True
3784 yield "break_long_nowrap_words", self._break_long_nowrap_words, False
3786 def __colorized_str__(self, ctx: ReprContext) -> ColorizedString:
3787 if isinstance(self._indent, int):
3788 indent = ColorizedString(" " * self._indent)
3789 else:
3790 indent = ColorizedString(self._indent)
3791 if self._continuation_indent is None:
3792 continuation_indent = indent
3793 elif isinstance(self._continuation_indent, int):
3794 continuation_indent = ColorizedString(" " * self._continuation_indent)
3795 else:
3796 continuation_indent = ColorizedString(self._continuation_indent)
3798 width = self._width or ctx.width
3799 indent_width = max(indent.width, continuation_indent.width)
3800 inner_width = max(1, width - indent_width)
3802 overflow = self._overflow
3803 if overflow is True:
3804 overflow = ctx.get_msg_decoration("overflow")
3806 res = ColorizedString()
3807 res.start_no_wrap()
3808 sep = False
3809 for line in ctx.str(self._msg, width=inner_width).wrap(
3810 width,
3811 preserve_spaces=self._preserve_spaces,
3812 preserve_newlines=self._preserve_newlines,
3813 break_long_words=self._break_long_words,
3814 break_long_nowrap_words=self._break_long_nowrap_words,
3815 overflow=overflow,
3816 indent=indent,
3817 continuation_indent=continuation_indent,
3818 ):
3819 if sep:
3820 res.append_str("\n")
3821 res.append_colorized_str(line)
3822 sep = True
3823 res.end_no_wrap()
3825 return res
3828@_t.final
3829@repr_from_rich
3830class WithBaseColor(_StrBase):
3831 """
3832 Lazy wrapper that applies the given color "under" the given colorable.
3833 That is, all colors in the rendered colorable will be combined with this color
3834 on the left: ``base_color | color``.
3836 .. seealso::
3838 :meth:`ColorizedString.with_base_color`.
3840 :param msg:
3841 message to highlight.
3842 :param base_color:
3843 color that will be added under the message.
3845 """
3847 def __init__(
3848 self,
3849 msg: Colorable,
3850 /,
3851 *,
3852 base_color: str | _Color,
3853 ):
3854 self._msg = msg
3855 self._base_color = base_color
3857 def __rich_repr__(self) -> RichReprResult:
3858 yield None, self._msg
3859 yield "base_color", self._base_color
3861 def __colorized_str__(self, ctx: ReprContext) -> ColorizedString:
3862 return ctx.str(self._msg).with_base_color(ctx.to_color(self._base_color))
3865@repr_from_rich
3866class Hr(_StrBase):
3867 """Hr(msg: Colorable = "", /, *, weight: int | str = 1, overflow: bool | str = True, **kwargs)
3869 Produces horizontal ruler when converted to string.
3871 :param msg:
3872 any colorable that will be placed in the middle of the ruler.
3873 :param weight:
3874 weight or style of the ruler:
3876 - ``0`` prints no ruler (but still prints centered text),
3877 - ``1`` prints normal ruler,
3878 - ``2`` prints bold ruler.
3880 Additional styles can be added through
3881 :attr:`Theme.msg_decorations <yuio.theme.Theme.msg_decorations_unicode>`.
3882 :param width:
3883 if given, overrides settings passed to :class:`ReprContext` for this call.
3884 :param overflow:
3885 pass :data:`False` to disable trimming `msg` to terminal width.
3886 :param kwargs:
3887 Other keyword arguments override corresponding decorations from the theme:
3889 :`left_start`:
3890 start of the ruler to the left of the message.
3891 :`left_middle`:
3892 filler of the ruler to the left of the message.
3893 :`left_end`:
3894 end of the ruler to the left of the message.
3895 :`middle`:
3896 filler of the ruler that's used if `msg` is empty.
3897 :`right_start`:
3898 start of the ruler to the right of the message.
3899 :`right_middle`:
3900 filler of the ruler to the right of the message.
3901 :`right_end`:
3902 end of the ruler to the right of the message.
3904 """
3906 def __init__(
3907 self,
3908 msg: Colorable = "",
3909 /,
3910 *,
3911 width: int | None = None,
3912 overflow: bool | str = True,
3913 weight: int | str = 1,
3914 left_start: str | None = None,
3915 left_middle: str | None = None,
3916 left_end: str | None = None,
3917 middle: str | None = None,
3918 right_start: str | None = None,
3919 right_middle: str | None = None,
3920 right_end: str | None = None,
3921 ):
3922 self._msg = msg
3923 self._width = width
3924 self._overflow = overflow
3925 self._weight = weight
3926 self._left_start = left_start
3927 self._left_middle = left_middle
3928 self._left_end = left_end
3929 self._middle = middle
3930 self._right_start = right_start
3931 self._right_middle = right_middle
3932 self._right_end = right_end
3934 def __rich_repr__(self) -> RichReprResult:
3935 yield None, self._msg, None
3936 yield "weight", self._weight, None
3937 yield "width", self._width, None
3938 yield "overflow", self._overflow, None
3939 yield "left_start", self._left_start, None
3940 yield "left_middle", self._left_middle, None
3941 yield "left_end", self._left_end, None
3942 yield "middle", self._middle, None
3943 yield "right_start", self._right_start, None
3944 yield "right_middle", self._right_middle, None
3945 yield "right_end", self._right_end, None
3947 def __colorized_str__(self, ctx: ReprContext) -> ColorizedString:
3948 width = self._width or ctx.width
3950 color = ctx.get_color(f"msg/decoration:hr/{self._weight}")
3952 res = ColorizedString(color)
3953 res.start_no_wrap()
3955 msg = ctx.str(self._msg)
3956 if not msg:
3957 res.append_str(self._make_whole(width, ctx))
3958 return res
3960 overflow = self._overflow
3961 if overflow is True:
3962 overflow = ctx.get_msg_decoration("overflow")
3964 sep = False
3965 for line in msg.wrap(
3966 width, preserve_spaces=True, break_long_words=False, overflow=overflow
3967 ):
3968 if sep:
3969 res.append_color(yuio.color.Color.NONE)
3970 res.append_str("\n")
3971 res.append_color(color)
3973 line_w = line.width
3974 line_w_fill = max(0, width - line_w)
3975 line_w_fill_l = line_w_fill // 2
3976 line_w_fill_r = line_w_fill - line_w_fill_l
3977 if not line_w_fill_l and not line_w_fill_r:
3978 res.append_colorized_str(line)
3979 return res
3981 res.append_str(self._make_left(line_w_fill_l, ctx))
3982 res.append_colorized_str(line)
3983 res.append_str(self._make_right(line_w_fill_r, ctx))
3985 sep = True
3987 return res
3989 def _make_left(self, w: int, ctx: ReprContext):
3990 weight = self._weight
3991 start = (
3992 self._left_start
3993 if self._left_start is not None
3994 else ctx.get_msg_decoration(f"hr/{weight}/left_start")
3995 )
3996 middle = (
3997 self._left_middle
3998 if self._left_middle is not None
3999 else ctx.get_msg_decoration(f"hr/{weight}/left_middle")
4000 ) or " "
4001 end = (
4002 self._left_end
4003 if self._left_end is not None
4004 else ctx.get_msg_decoration(f"hr/{weight}/left_end")
4005 )
4007 return _make_left(w, start, middle, end)
4009 def _make_right(self, w: int, ctx: ReprContext):
4010 weight = self._weight
4011 start = (
4012 self._right_start
4013 if self._right_start is not None
4014 else ctx.get_msg_decoration(f"hr/{weight}/right_start")
4015 )
4016 middle = (
4017 self._right_middle
4018 if self._right_middle is not None
4019 else ctx.get_msg_decoration(f"hr/{weight}/right_middle")
4020 ) or " "
4021 end = (
4022 self._right_end
4023 if self._right_end is not None
4024 else ctx.get_msg_decoration(f"hr/{weight}/right_end")
4025 )
4027 return _make_right(w, start, middle, end)
4029 def _make_whole(self, w: int, ctx: ReprContext):
4030 weight = self._weight
4031 start = (
4032 self._left_start
4033 if self._left_start is not None
4034 else ctx.get_msg_decoration(f"hr/{weight}/left_start")
4035 )
4036 middle = (
4037 self._middle
4038 if self._middle is not None
4039 else ctx.get_msg_decoration(f"hr/{weight}/middle")
4040 ) or " "
4041 end = (
4042 self._right_end
4043 if self._right_end is not None
4044 else ctx.get_msg_decoration(f"hr/{weight}/right_end")
4045 )
4047 start_w = line_width(start)
4048 middle_w = line_width(middle)
4049 end_w = line_width(end)
4051 if w >= start_w:
4052 w -= start_w
4053 else:
4054 start = ""
4055 if w >= end_w:
4056 w -= end_w
4057 else:
4058 end = ""
4059 middle_times = w // middle_w
4060 w -= middle_times * middle_w
4061 middle *= middle_times
4062 return start + middle + end + " " * w
4065def _make_left(
4066 w: int,
4067 start: str,
4068 middle: str,
4069 end: str,
4070):
4071 start_w = line_width(start)
4072 middle_w = line_width(middle)
4073 end_w = line_width(end)
4075 if w >= end_w:
4076 w -= end_w
4077 else:
4078 end = ""
4079 if w >= start_w:
4080 w -= start_w
4081 else:
4082 start = ""
4083 middle_times = w // middle_w
4084 w -= middle_times * middle_w
4085 middle *= middle_times
4086 return start + middle + end + " " * w
4089def _make_right(
4090 w: int,
4091 start: str,
4092 middle: str,
4093 end: str,
4094):
4095 start_w = line_width(start)
4096 middle_w = line_width(middle)
4097 end_w = line_width(end)
4099 if w >= start_w:
4100 w -= start_w
4101 else:
4102 start = ""
4103 if w >= end_w:
4104 w -= end_w
4105 else:
4106 end = ""
4107 middle_times = w // middle_w
4108 w -= middle_times * middle_w
4109 middle *= middle_times
4110 return " " * w + start + middle + end