Coverage for yuio / string.py: 98%
1447 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-05 11:41 +0000
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-05 11:41 +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:
16.. autoclass:: Link
17 :members:
20.. _pretty-protocol:
22Pretty printing protocol
23------------------------
25Complex message formatting requires knowing capabilities of the target terminal.
26This affects which message decorations are used (Unicode or ASCII), how lines are
27wrapped, and so on. This data is encapsulated in an instance of :class:`ReprContext`:
29.. autoclass:: ReprContext
30 :members:
32Repr context may not always be available when a message is created, though.
33For example, we may know that we will be printing some data, but we don't know
34whether we'll print it to a file or to a terminal.
36The solution is to defer formatting by creating a :type:`Colorable`, i.e. an object
37that defines one of the following special methods:
39``__colorized_str__``, ``__colorized_repr__``
40 This should be a method that accepts a single positional argument,
41 :class:`ReprContext`, and returns a :class:`ColorizedString`.
43 .. tip::
45 Prefer ``__rich_repr__`` for simpler use cases, and only use
46 ``__colorized_repr__`` when you need something advanced.
48 **Example:**
50 .. code-block:: python
52 class MyObject:
53 def __init__(self, value):
54 self.value = value
56 def __colorized_str__(self, ctx: yuio.string.ReprContext):
57 result = yuio.string.ColorizedString()
58 result += ctx.get_color("magenta")
59 result += "MyObject"
60 result += ctx.get_color("normal")
61 result += "("
62 result += ctx.repr(self.value)
63 result += ")"
64 return result
66``__rich_repr__``
67 This method doesn't have any arguments. It should return an iterable of tuples
68 describing object's arguments:
70 - ``yield name, value`` will generate a keyword argument,
71 - ``yield name, value, default`` will generate a keyword argument if value
72 is not equal to default,
73 - if `name` is :data:`None`, it will generate positional argument instead.
75 See the `Rich library documentation`__ for more info.
77 __ https://rich.readthedocs.io/en/stable/pretty.html#rich-repr-protocol
79 **Example:**
81 .. code-block:: python
83 class MyObject:
84 def __init__(self, value1, value2):
85 self.value1 = value1
86 self.value2 = value2
88 def __rich_repr__(self) -> yuio.string.RichReprResult:
89 yield "value1", self.value1
90 yield "value2", self.value2
92.. type:: RichReprResult
93 :canonical: typing.Iterable[tuple[typing.Any] | tuple[str | None, typing.Any] | tuple[str | None, typing.Any, typing.Any]]
95 This is an alias similar to ``rich.repr.Result``, but stricter: it only
96 allows tuples, not arbitrary values.
98 This is done to avoid bugs where you yield a single value which happens to contain
99 a tuple, and Yuio (or Rich) renders it as a named argument.
102.. type:: ColorizedStrProtocol
104 Protocol for objects that define ``__colorized_str__`` method.
106.. type:: ColorizedReprProtocol
108 Protocol for objects that define ``__colorized_repr__`` method.
110.. type:: RichReprProtocol
112 Protocol for objects that define ``__rich_repr__`` method.
114.. type:: Printable
116 Any object that supports printing.
118 Technically, any object supports colorized printing because we'll fall back
119 to ``__repr__`` or ``__str__`` if there are no special methods on it.
121 However, we don't use :class:`typing.Any` to avoid potential errors.
123.. type:: Colorable
124 :canonical: Printable | ColorizedStrProtocol | ColorizedReprProtocol | RichReprProtocol | ~typing.LiteralString | BaseException
126 An object that supports colorized printing.
128 This can be a string, and exception, or any object that follows
129 :class:`ColorableProtocol`. Additionally, you can pass any object that has
130 ``__repr__``, but you'll have to wrap it into :type:`Printable` to confirm
131 your intent to print it.
133.. type:: ToColorable
134 :canonical: Colorable | ~string.templatelib.Template
136 Any object that can be converted to a :type:`Colorable` by formatting it via
137 :class:`Format`.
139.. autofunction:: repr_from_rich
142.. _formatting-utilities:
144Formatting utilities
145--------------------
147.. autoclass:: Format
148 :members:
150.. autoclass:: Repr
151 :members:
153.. autoclass:: TypeRepr
154 :members:
156.. autoclass:: JoinStr
157 :members:
158 :inherited-members:
160.. autoclass:: JoinRepr
161 :members:
162 :inherited-members:
164.. autofunction:: And
166.. autofunction:: Or
168.. autoclass:: Stack
169 :members:
171.. autoclass:: Indent
172 :members:
174.. autoclass:: Md
175 :members:
177.. autoclass:: Hl
178 :members:
180.. autoclass:: Wrap
181 :members:
183.. autoclass:: WithBaseColor
184 :members:
186.. autoclass:: Hr
187 :members:
190Parsing color tags
191------------------
193.. autofunction:: colorize
195.. autofunction:: strip_color_tags
198Helpers
199-------
201.. autofunction:: line_width
203.. type:: AnyString
204 :canonical: str | ~yuio.color.Color | ColorizedString | NoWrapMarker | typing.Iterable[AnyString]
206 Any string (i.e. a :class:`str`, a raw colorized string,
207 or a normal colorized string).
209.. autodata:: NO_WRAP_START
211.. autodata:: NO_WRAP_END
213.. type:: NoWrapMarker
214 NoWrapStart
215 NoWrapEnd
217 Type of a no-wrap marker.
219"""
221from __future__ import annotations
223import abc
224import collections
225import contextlib
226import dataclasses
227import functools
228import os
229import pathlib
230import re
231import reprlib
232import string
233import sys
234import types
235import unicodedata
236from enum import Enum
238import yuio
239import yuio.color
240import yuio.term
241import yuio.theme
242from yuio.color import Color as _Color
243from yuio.util import UserString as _UserString
244from yuio.util import dedent as _dedent
246import yuio._typing_ext as _tx
247from typing import TYPE_CHECKING
249if TYPE_CHECKING:
250 import typing_extensions as _t
251else:
252 from yuio import _typing as _t
254if TYPE_CHECKING:
255 import yuio.md
257if sys.version_info >= (3, 14):
258 from string.templatelib import Interpolation as _Interpolation
259 from string.templatelib import Template as _Template
260else:
262 class _Interpolation: ...
264 class _Template: ...
266 _Interpolation.__module__ = "string.templatelib"
267 _Interpolation.__name__ = "Interpolation"
268 _Interpolation.__qualname__ = "Interpolation"
269 _Template.__module__ = "string.templatelib"
270 _Template.__name__ = "Template"
271 _Template.__qualname__ = "Template"
274__all__ = [
275 "NO_WRAP_END",
276 "NO_WRAP_START",
277 "And",
278 "AnyString",
279 "Colorable",
280 "ColorizedReprProtocol",
281 "ColorizedStrProtocol",
282 "ColorizedString",
283 "Esc",
284 "Format",
285 "Hl",
286 "Hr",
287 "Indent",
288 "JoinRepr",
289 "JoinStr",
290 "Link",
291 "Md",
292 "NoWrapEnd",
293 "NoWrapMarker",
294 "NoWrapStart",
295 "Or",
296 "Printable",
297 "Repr",
298 "ReprContext",
299 "RichReprProtocol",
300 "RichReprResult",
301 "Stack",
302 "ToColorable",
303 "TypeRepr",
304 "WithBaseColor",
305 "Wrap",
306 "colorize",
307 "line_width",
308 "repr_from_rich",
309 "strip_color_tags",
310]
313def line_width(s: str, /) -> int:
314 """
315 Calculates string width when the string is displayed
316 in a terminal.
318 This function makes effort to detect wide characters
319 such as emojis. If does not, however, work correctly
320 with extended grapheme clusters, and so it may fail
321 for emojis with modifiers, or other complex characters.
323 Example where it fails is ``👩🏽💻``. It consists
324 of four code points:
326 - Unicode Character `WOMAN` (``U+1F469``, ``👩``),
327 - Unicode Character `EMOJI MODIFIER FITZPATRICK TYPE-4` (``U+1F3FD``),
328 - Unicode Character `ZERO WIDTH JOINER` (``U+200D``),
329 - Unicode Character `PERSONAL COMPUTER` (``U+1F4BB``, ``💻``).
331 Since :func:`line_width` can't understand that these code points
332 are combined into a single emoji, it treats them separately,
333 resulting in answer `6` (`2` for every code point except `ZERO WIDTH JOINER`)::
335 >>> line_width("\U0001f469\U0001f3fd\U0000200d\U0001f4bb")
336 6
338 In all fairness, detecting how much space such an emoji will take
339 is not so straight forward, as that will depend on unicode capabilities
340 of a specific terminal. Since a lot of terminals will not handle such emojis
341 correctly, I've decided to go with this simplistic implementation.
343 """
345 # Note: it may be better to bundle `wcwidth` and use it instead of the code below.
346 # However, there is an issue that `wcwidth`'s results are not additive.
347 # In the above example, `wcswidth('👩🏽💻')` will see that it is two-spaces wide,
348 # while `sum(wcwidth(c) for c in '👩🏽💻')` will report that it is four-spaces wide.
349 # To render it properly, the widget will have to be aware of extended grapheme
350 # clusters, and generally this will be a lot of headache. Since most terminals
351 # won't handle these edge cases correctly, I don't want to bother.
353 if s.isascii():
354 # Fast path. Note that our renderer replaces unprintable characters
355 # with spaces, so ascii strings always have width equal to their length.
356 return len(s)
357 else:
358 # Long path. It kinda works, but not always, but most of the times...
359 return sum(
360 (unicodedata.east_asian_width(c) in "WF") + 1
361 for c in s
362 if unicodedata.category(c)[0] not in "MC"
363 )
366RichReprResult: _t.TypeAlias = _t.Iterable[
367 tuple[_t.Any] | tuple[str | None, _t.Any] | tuple[str | None, _t.Any, _t.Any]
368]
369"""
370Similar to ``rich.repr.Result``, but only allows tuples, not arbitrary values.
372"""
375@_t.runtime_checkable
376class ColorizedStrProtocol(_t.Protocol):
377 """
378 Protocol for objects that define ``__colorized_str__`` method.
380 """
382 @abc.abstractmethod
383 def __colorized_str__(self, ctx: ReprContext, /) -> ColorizedString: ...
386@_t.runtime_checkable
387class ColorizedReprProtocol(_t.Protocol):
388 """
389 Protocol for objects that define ``__colorized_repr__`` method.
391 """
393 @abc.abstractmethod
394 def __colorized_repr__(self, ctx: ReprContext, /) -> ColorizedString: ...
397@_t.runtime_checkable
398class RichReprProtocol(_t.Protocol):
399 """
400 Protocol for objects that define ``__rich_repr__`` method.
402 """
404 @abc.abstractmethod
405 def __rich_repr__(self) -> _t.Iterable[_t.Any]: ...
408Printable = _t.NewType("Printable", object)
409"""
410Any object that supports printing.
412Technically, any object supports colorized printing because we'll fall back
413to ``__repr__`` or ``__str__`` if there are no special methods on it.
415However, we don't use :class:`typing.Any` to avoid potential errors.
417"""
420Colorable: _t.TypeAlias = (
421 Printable
422 | ColorizedStrProtocol
423 | ColorizedReprProtocol
424 | RichReprProtocol
425 | str
426 | BaseException
427)
428"""
429Any object that supports colorized printing.
431This can be a string, and exception, or any object that follows
432:class:`ColorableProtocol`. Additionally, you can pass any object that has
433``__repr__``, but you'll have to wrap it into :type:`Printable` to confirm
434your intent to print it.
436"""
438ToColorable: _t.TypeAlias = Colorable | _Template
439"""
440Any object that can be converted to a :type:`Colorable` by formatting it via
441:class:`Format`.
443"""
446RichReprProtocolT = _t.TypeVar("RichReprProtocolT", bound=RichReprProtocol)
449def repr_from_rich(cls: type[RichReprProtocolT], /) -> type[RichReprProtocolT]:
450 """repr_from_rich(cls: RichReprProtocol) -> RichReprProtocol
452 A decorator that generates ``__repr__`` from ``__rich_repr__``.
454 :param cls:
455 class that needs ``__repr__``.
456 :returns:
457 always returns `cls`.
458 :example:
459 .. code-block:: python
461 @yuio.string.repr_from_rich
462 class MyClass:
463 def __init__(self, value):
464 self.value = value
466 def __rich_repr__(self) -> yuio.string.RichReprResult:
467 yield "value", self.value
469 ::
471 >>> print(repr(MyClass("plush!")))
472 MyClass(value='plush!')
475 """
477 setattr(cls, "__repr__", _repr_from_rich_impl)
478 return cls
481def _repr_from_rich_impl(self: RichReprProtocol):
482 if rich_repr := getattr(self, "__rich_repr__", None):
483 args = rich_repr()
484 angular = getattr(rich_repr, "angular", False)
485 else:
486 args = []
487 angular = False
489 if args is None:
490 args = [] # `rich_repr` didn't yield?
492 res = []
494 if angular:
495 res.append("<")
496 res.append(self.__class__.__name__)
497 if angular:
498 res.append(" ")
499 else:
500 res.append("(")
502 sep = False
503 for arg in args:
504 if isinstance(arg, tuple):
505 if len(arg) == 3:
506 key, child, default = arg
507 if default == child:
508 continue
509 elif len(arg) == 2:
510 key, child = arg
511 elif len(arg) == 1:
512 key, child = None, arg[0]
513 else:
514 key, child = None, arg
515 else:
516 key, child = None, arg
518 if sep:
519 res.append(" " if angular else ", ")
520 if key:
521 res.append(str(key))
522 res.append("=")
523 res.append(repr(child))
524 sep = True
526 res.append(">" if angular else ")")
528 return "".join(res)
531class _NoWrapMarker(Enum):
532 """
533 Type for a no-wrap marker.
535 """
537 NO_WRAP_START = "<no_wrap_start>"
538 NO_WRAP_END = "<no_wrap_end>"
540 def __repr__(self):
541 return f"yuio.string.{self.name}" # pragma: no cover
543 def __str__(self) -> str:
544 return self.value # pragma: no cover
547NoWrapStart: _t.TypeAlias = _t.Literal[_NoWrapMarker.NO_WRAP_START]
548"""
549Type of the :data:`NO_WRAP_START` placeholder.
551"""
553NO_WRAP_START: NoWrapStart = _NoWrapMarker.NO_WRAP_START
554"""
555Indicates start of a no-wrap region in a :class:`ColorizedString`.
557"""
560NoWrapEnd: _t.TypeAlias = _t.Literal[_NoWrapMarker.NO_WRAP_END]
561"""
562Type of the :data:`NO_WRAP_END` placeholder.
564"""
566NO_WRAP_END: NoWrapEnd = _NoWrapMarker.NO_WRAP_END
567"""
568Indicates end of a no-wrap region in a :class:`ColorizedString`.
570"""
572NoWrapMarker: _t.TypeAlias = NoWrapStart | NoWrapEnd
573"""
574Type of a no-wrap marker.
576"""
579@_t.final
580@repr_from_rich
581class ColorizedString:
582 """ColorizedString(content: AnyString = '', /)
584 A string with colors.
586 This class is a wrapper over a list of strings, colors, and no-wrap markers.
587 Each color applies to strings after it, right until the next color.
589 :class:`ColorizedString` supports some basic string operations.
590 Most notably, it supports wide-character-aware wrapping
591 (see :meth:`~ColorizedString.wrap`),
592 and ``%``-like formatting (see :meth:`~ColorizedString.percent_format`).
594 Unlike :class:`str`, :class:`ColorizedString` is mutable through
595 the ``+=`` operator and ``append``/``extend`` methods.
597 :param content:
598 initial content of the string. Can be :class:`str`, color, no-wrap marker,
599 or another colorized string.
602 **String combination semantics**
604 When you append a :class:`str`, it will take on color and no-wrap semantics
605 according to the last appended color and no-wrap marker.
607 When you append another :class:`ColorizedString`, it will not change its colors
608 based on the last appended color, nor will it affect colors of the consequent
609 strings. If appended :class:`ColorizedString` had an unterminated no-wrap region,
610 this region will be terminated after appending.
612 Thus, appending a colorized string does not change current color
613 or no-wrap setting::
615 >>> s1 = yuio.string.ColorizedString()
616 >>> s1 += yuio.color.Color.FORE_RED
617 >>> s1 += yuio.string.NO_WRAP_START
618 >>> s1 += "red nowrap text"
619 >>> s1 # doctest: +NORMALIZE_WHITESPACE
620 ColorizedString([yuio.string.NO_WRAP_START,
621 <Color fore=<RED>>,
622 'red nowrap text'])
624 >>> s2 = yuio.string.ColorizedString()
625 >>> s2 += yuio.color.Color.FORE_GREEN
626 >>> s2 += "green text "
627 >>> s2 += s1
628 >>> s2 += " green text continues"
629 >>> s2 # doctest: +NORMALIZE_WHITESPACE
630 ColorizedString([<Color fore=<GREEN>>,
631 'green text ',
632 yuio.string.NO_WRAP_START,
633 <Color fore=<RED>>,
634 'red nowrap text',
635 yuio.string.NO_WRAP_END,
636 <Color fore=<GREEN>>,
637 ' green text continues'])
639 """
641 # Invariants:
642 #
643 # - there is always a color before the first string in `_parts`.
644 # - there are no empty strings in `_parts`.
645 # - for every pair of colors in `_parts`, there is a string between them
646 # (i.e. there are no colors that don't highlight anything).
647 # - every color in `_parts` is different from the previous one
648 # (i.e. there are no redundant color markers).
649 # - `start-no-wrap` and `end-no-wrap` markers form a balanced bracket sequence,
650 # except for the last `start-no-wrap`, which may have no corresponding
651 # `end-no-wrap` yet.
652 # - no-wrap regions can't be nested.
653 # - fo every pair of (start-no-wrap, end-no-wrap) markers, there is a string
654 # between them (i.e. no empty no-wrap regions).
656 def __init__(
657 self,
658 content: AnyString = "",
659 /,
660 *,
661 _isolate_colors: bool = True,
662 ):
663 if isinstance(content, ColorizedString):
664 self._parts = content._parts.copy()
665 self._last_color = content._last_color
666 self._active_color = content._active_color
667 self._explicit_newline = content._explicit_newline
668 self._len = content._len
669 self._has_no_wrap = content._has_no_wrap
670 if (width := content.__dict__.get("width", None)) is not None:
671 self.__dict__["width"] = width
672 else:
673 self._parts: list[_Color | NoWrapMarker | str] = []
674 self._active_color = _Color.NONE
675 self._last_color: _Color | None = None
676 self._explicit_newline: str = ""
677 self._len = 0
678 self._has_no_wrap = False
680 if not _isolate_colors:
681 # Prevent adding `_Color.NONE` to the front of the string.
682 self._last_color = self._active_color
684 if content:
685 self += content
687 @property
688 def explicit_newline(self) -> str:
689 """
690 Explicit newline indicates that a line of a wrapped text
691 was broken because the original text contained a new line character.
693 See :meth:`~ColorizedString.wrap` for details.
695 """
697 return self._explicit_newline
699 @property
700 def active_color(self) -> _Color:
701 """
702 Last color appended to this string.
704 """
706 return self._active_color
708 @functools.cached_property
709 def width(self) -> int:
710 """
711 String width when the string is displayed in a terminal.
713 See :func:`line_width` for more information.
715 """
717 return sum(line_width(s) for s in self._parts if isinstance(s, str))
719 @property
720 def len(self) -> int:
721 """
722 Line length in bytes, ignoring all colors.
724 """
726 return self._len
728 def append_color(self, color: _Color, /):
729 """
730 Append new color to this string.
732 This operation is lazy, the color will be appended if a non-empty string
733 is appended after it.
735 :param color:
736 color to append.
738 """
740 self._active_color = color
742 def append_str(self, s: str, /):
743 """
744 Append new plain string to this string.
746 :param s:
747 plain string to append.
749 """
751 if not s:
752 return
753 if self._last_color != self._active_color:
754 self._parts.append(self._active_color)
755 self._last_color = self._active_color
756 self._parts.append(s)
757 self._len += len(s)
758 self.__dict__.pop("width", None)
760 def append_colorized_str(self, s: ColorizedString, /):
761 """
762 Append new colorized string to this string.
764 :param s:
765 colorized string to append.
767 """
768 if not s:
769 # Nothing to append.
770 return
772 parts = s._parts
774 # Cleanup color at the beginning of the string.
775 for i, part in enumerate(parts):
776 if part in (NO_WRAP_START, NO_WRAP_END):
777 continue
778 elif isinstance(part, str): # pragma: no cover
779 # We never hit this branch in normal conditions because colorized
780 # strings always start with a color. The only way to trigger this
781 # branch is to tamper with `_parts` and break colorized string
782 # invariants.
783 break
785 # First color in the appended string is the same as our last color.
786 # We can remove it without changing the outcome.
787 if part == self._last_color:
788 if i == 0:
789 parts = parts[i + 1 :]
790 else:
791 parts = parts[:i] + parts[i + 1 :]
793 break
795 if self._has_no_wrap:
796 # We're in a no-wrap sequence, we don't need any more markers.
797 self._parts.extend(
798 part for part in parts if part not in (NO_WRAP_START, NO_WRAP_END)
799 )
800 else:
801 # We're not in a no-wrap sequence. We preserve no-wrap regions from the
802 # appended string, but we make sure that they don't affect anything
803 # appended after.
804 self._parts.extend(parts)
805 if s._has_no_wrap:
806 self._has_no_wrap = True
807 self.end_no_wrap()
809 self._last_color = s._last_color
810 self._len += s._len
811 if (lw := self.__dict__.get("width")) and (rw := s.__dict__.get("width")):
812 self.__dict__["width"] = lw + rw
813 else:
814 self.__dict__.pop("width", None)
816 def append_no_wrap(self, m: NoWrapMarker, /):
817 """
818 Append a no-wrap marker.
820 :param m:
821 no-wrap marker, will be dispatched
822 to :meth:`~ColorizedString.start_no_wrap`
823 or :meth:`~ColorizedString.end_no_wrap`.
825 """
827 if m is NO_WRAP_START:
828 self.start_no_wrap()
829 else:
830 self.end_no_wrap()
832 def start_no_wrap(self):
833 """
834 Start a no-wrap region.
836 String parts within no-wrap regions are not wrapped on spaces; they can be
837 hard-wrapped if `break_long_nowrap_words` is :data:`True`. Whitespaces and
838 newlines in no-wrap regions are preserved regardless of `preserve_spaces`
839 and `preserve_newlines` settings.
841 """
843 if self._has_no_wrap:
844 return
846 self._has_no_wrap = True
847 self._parts.append(NO_WRAP_START)
849 def end_no_wrap(self):
850 """
851 End a no-wrap region.
853 """
855 if not self._has_no_wrap:
856 return
858 if self._parts and self._parts[-1] is NO_WRAP_START:
859 # Empty no-wrap sequence, just remove it.
860 self._parts.pop()
861 else:
862 self._parts.append(NO_WRAP_END)
864 self._has_no_wrap = False
866 def extend(
867 self,
868 parts: _t.Iterable[str | ColorizedString | _Color | NoWrapMarker],
869 /,
870 ):
871 """
872 Extend string from iterable of raw parts.
874 :param parts:
875 raw parts that will be appended to the string.
877 """
879 for part in parts:
880 self += part
882 def copy(self) -> ColorizedString:
883 """
884 Copy this string.
886 :returns:
887 copy of the string.
889 """
891 return ColorizedString(self)
893 def _split_at(self, i: int, /) -> tuple[ColorizedString, ColorizedString]:
894 l, r = ColorizedString(), ColorizedString()
895 l.extend(self._parts[:i])
896 r._active_color = l._active_color
897 r._has_no_wrap = l._has_no_wrap
898 r.extend(self._parts[i:])
899 r._active_color = self._active_color
900 return l, r
902 def with_base_color(self, base_color: _Color) -> ColorizedString:
903 """
904 Apply the given color "under" all parts of this string. That is, all colors
905 in this string will be combined with this color on the left:
906 ``base_color | color``.
908 :param base_color:
909 color that will be added under the string.
910 :returns:
911 new string with changed colors, or current string if base color
912 is :attr:`~yuio.color.Color.NONE`.
913 :example:
914 ::
916 >>> s1 = yuio.string.ColorizedString([
917 ... "part 1",
918 ... yuio.color.Color.FORE_GREEN,
919 ... "part 2",
920 ... ])
921 >>> s2 = s1.with_base_color(
922 ... yuio.color.Color.FORE_RED
923 ... | yuio.color.Color.STYLE_BOLD
924 ... )
925 >>> s2 # doctest: +NORMALIZE_WHITESPACE
926 ColorizedString([<Color fore=<RED> bold=True>,
927 'part 1',
928 <Color fore=<GREEN> bold=True>,
929 'part 2'])
931 """
933 if base_color == _Color.NONE:
934 return self
936 res = ColorizedString()
938 for part in self._parts:
939 if isinstance(part, _Color):
940 res.append_color(base_color | part)
941 else:
942 res += part
943 res._active_color = base_color | self._active_color
944 if self._last_color is not None:
945 res._last_color = base_color | self._last_color
947 return res
949 def as_code(self, color_support: yuio.color.ColorSupport, /) -> list[str]:
950 """
951 Convert colors in this string to ANSI escape sequences.
953 :param color_support:
954 desired level of color support.
955 :returns:
956 raw parts of colorized string with all colors converted to ANSI
957 escape sequences.
959 """
961 if color_support == yuio.color.ColorSupport.NONE:
962 return [part for part in self._parts if isinstance(part, str)]
963 else:
964 parts: list[str] = []
965 cur_link: Link | None = None
966 for part in self:
967 if isinstance(part, Link):
968 if not cur_link:
969 cur_link = part
970 elif cur_link.url == part.url:
971 cur_link += part
972 else:
973 parts.append(cur_link.as_code(color_support))
974 cur_link = part
975 continue
976 elif cur_link:
977 parts.append(cur_link.as_code(color_support))
978 cur_link = None
979 if isinstance(part, _Color):
980 parts.append(part.as_code(color_support))
981 elif isinstance(part, str):
982 parts.append(part)
983 if cur_link:
984 parts.append(cur_link)
985 if self._last_color != _Color.NONE:
986 parts.append(_Color.NONE.as_code(color_support))
987 return parts
989 def wrap(
990 self,
991 width: int,
992 /,
993 *,
994 preserve_spaces: bool = False,
995 preserve_newlines: bool = True,
996 break_long_words: bool = True,
997 break_long_nowrap_words: bool = False,
998 overflow: _t.Literal[False] | str = False,
999 indent: AnyString | int = "",
1000 continuation_indent: AnyString | int | None = None,
1001 ) -> list[ColorizedString]:
1002 """
1003 Wrap a long line of text into multiple lines.
1005 :param width:
1006 desired wrapping width.
1007 :param preserve_spaces:
1008 if set to :data:`True`, all spaces are preserved.
1009 Otherwise, consecutive spaces are collapsed into a single space.
1011 Note that tabs always treated as a single whitespace.
1012 :param preserve_newlines:
1013 if set to :data:`True` (default), text is additionally wrapped
1014 on newline sequences. When this happens, the newline sequence that wrapped
1015 the line will be placed into :attr:`~ColorizedString.explicit_newline`.
1017 If set to :data:`False`, newline sequences are treated as whitespaces.
1019 .. list-table:: Whitespace sequences
1020 :header-rows: 1
1021 :stub-columns: 1
1023 * - Sequence
1024 - `preserve_newlines`
1025 - Result
1026 * - ``\\n``, ``\\r\\n``, ``\\r``
1027 - ``False``
1028 - Treated as a single whitespace.
1029 * - ``\\n``, ``\\r\\n``, ``\\r``
1030 - ``True``
1031 - Creates a new line.
1032 * - ``\\v``, ``\\v\\n``, ``\\v\\r\\n``, ``\\v\\r``
1033 - Any
1034 - Always creates a new line.
1036 :param break_long_words:
1037 if set to :data:`True` (default), words that don't fit into a single line
1038 will be split into multiple lines.
1039 :param break_long_nowrap_words:
1040 if set to :data:`True`, words in no-wrap regions that don't fit
1041 into a single line will be split into multiple lines.
1042 :param overflow:
1043 a symbol that will be added to a line if it doesn't fit the given width.
1044 Pass :data:`False` to keep the overflowing lines without modification.
1045 :param indent:
1046 a string that will be prepended before the first line.
1047 :param continuation_indent:
1048 a string that will be prepended before all subsequent lines.
1049 Defaults to `indent`.
1050 :returns:
1051 a list of individual lines without newline characters at the end.
1053 """
1055 return _TextWrapper(
1056 width,
1057 preserve_spaces=preserve_spaces,
1058 preserve_newlines=preserve_newlines,
1059 break_long_words=break_long_words,
1060 break_long_nowrap_words=break_long_nowrap_words,
1061 overflow=overflow,
1062 indent=indent,
1063 continuation_indent=continuation_indent,
1064 ).wrap(self)
1066 def indent(
1067 self,
1068 indent: AnyString | int = " ",
1069 continuation_indent: AnyString | int | None = None,
1070 ) -> ColorizedString:
1071 """
1072 Indent this string.
1074 :param indent:
1075 this will be prepended to the first line in the string.
1076 Defaults to two spaces.
1077 :param continuation_indent:
1078 this will be prepended to subsequent lines in the string.
1079 Defaults to `indent`.
1080 :returns:
1081 indented string.
1083 """
1085 nowrap_indent = ColorizedString()
1086 nowrap_indent.start_no_wrap()
1087 nowrap_continuation_indent = ColorizedString()
1088 nowrap_continuation_indent.start_no_wrap()
1089 if isinstance(indent, int):
1090 nowrap_indent.append_str(" " * indent)
1091 else:
1092 nowrap_indent += indent
1093 if continuation_indent is None:
1094 nowrap_continuation_indent.append_colorized_str(nowrap_indent)
1095 elif isinstance(continuation_indent, int):
1096 nowrap_continuation_indent.append_str(" " * continuation_indent)
1097 else:
1098 nowrap_continuation_indent += continuation_indent
1100 if not nowrap_indent and not nowrap_continuation_indent:
1101 return self
1103 res = ColorizedString()
1105 needs_indent = True
1106 for part in self._parts:
1107 if not isinstance(part, str) or isinstance(part, Esc):
1108 res += part
1109 continue
1111 for line in _split_keep_link(part, _WORDSEP_NL_RE):
1112 if not line:
1113 continue
1114 if needs_indent:
1115 res.append_colorized_str(nowrap_indent)
1116 nowrap_indent = nowrap_continuation_indent
1117 res.append_str(line)
1118 needs_indent = line.endswith(("\n", "\r", "\v"))
1120 return res
1122 def percent_format(self, args: _t.Any, ctx: ReprContext) -> ColorizedString:
1123 """
1124 Format colorized string as if with ``%``-formatting
1125 (i.e. `printf-style formatting`__).
1127 __ https://docs.python.org/3/library/stdtypes.html#printf-style-string-formatting
1129 :param args:
1130 arguments for formatting. Can be either a tuple of a mapping. Any other
1131 value will be converted to a tuple of one element.
1132 :param ctx:
1133 :class:`ReprContext` that will be passed to ``__colorized_str__``
1134 and ``__colorized_repr__`` when formatting colorables.
1135 :returns:
1136 formatted string.
1137 :raises:
1138 :class:`TypeError`, :class:`ValueError`, :class:`KeyError` if formatting
1139 fails.
1141 """
1143 return _percent_format(self, args, ctx)
1145 def __len__(self) -> int:
1146 return self.len
1148 def __bool__(self) -> bool:
1149 return self.len > 0
1151 def __iter__(self) -> _t.Iterator[_Color | NoWrapMarker | str]:
1152 return self._parts.__iter__()
1154 def __add__(self, rhs: AnyString) -> ColorizedString:
1155 copy = self.copy()
1156 copy += rhs
1157 return copy
1159 def __radd__(self, lhs: AnyString) -> ColorizedString:
1160 copy = ColorizedString(lhs)
1161 copy += self
1162 return copy
1164 def __iadd__(self, rhs: AnyString) -> ColorizedString:
1165 if isinstance(rhs, str):
1166 self.append_str(rhs)
1167 elif isinstance(rhs, ColorizedString):
1168 self.append_colorized_str(rhs)
1169 elif isinstance(rhs, _Color):
1170 self.append_color(rhs)
1171 elif rhs in (NO_WRAP_START, NO_WRAP_END):
1172 self.append_no_wrap(rhs)
1173 else:
1174 self.extend(rhs)
1176 return self
1178 def __eq__(self, value: object) -> bool:
1179 if isinstance(value, ColorizedString):
1180 return self._parts == value._parts
1181 else:
1182 return NotImplemented
1184 def __ne__(self, value: object) -> bool:
1185 return not (self == value)
1187 def __rich_repr__(self) -> RichReprResult:
1188 yield None, self._parts
1189 yield "explicit_newline", self._explicit_newline, ""
1191 def __str__(self) -> str:
1192 return "".join(c for c in self._parts if isinstance(c, str))
1194 def __colorized_str__(self, ctx: ReprContext) -> ColorizedString:
1195 return self
1198AnyString: _t.TypeAlias = (
1199 str
1200 | ColorizedString
1201 | _Color
1202 | NoWrapMarker
1203 | _t.Iterable[str | ColorizedString | _Color | NoWrapMarker]
1204)
1205"""
1206Any string (i.e. a :class:`str`, a raw colorized string, or a normal colorized string).
1208"""
1211_S_SYNTAX = re.compile(
1212 r"""
1213 % # Percent
1214 (?:\((?P<mapping>[^)]*)\))? # Mapping key
1215 (?P<flag>[#0\-+ ]*) # Conversion Flag
1216 (?P<width>\*|\d+)? # Field width
1217 (?:\.(?P<precision>\*|\d*))? # Precision
1218 [hlL]? # Unused length modifier
1219 (?P<format>.) # Conversion type
1220 """,
1221 re.VERBOSE,
1222)
1224_F_SYNTAX = re.compile(
1225 r"""
1226 ^
1227 (?: # Options
1228 (?:
1229 (?P<fill>.)?
1230 (?P<align>[<>=^])
1231 )?
1232 (?P<flags>[+#]*)
1233 (?P<zero>0)?
1234 )
1235 (?: # Width
1236 (?P<width>\d+)?
1237 (?P<width_grouping>[,_])?
1238 )
1239 (?: # Precision
1240 \.
1241 (?P<precision>\d+)?
1242 (?P<precision_grouping>[,_])?
1243 )?
1244 (?: # Type
1245 (?P<type>.)
1246 )?
1247 $
1248 """,
1249 re.VERBOSE,
1250)
1253def _percent_format(
1254 s: ColorizedString, args: object, ctx: ReprContext
1255) -> ColorizedString:
1256 seen_mapping = False
1257 arg_index = 0
1258 res = ColorizedString()
1259 for part in s:
1260 if isinstance(part, str):
1261 pos = 0
1262 for match in _S_SYNTAX.finditer(part):
1263 if pos < match.start():
1264 res.append_str(part[pos : match.start()])
1265 seen_mapping = seen_mapping or bool(match.group("mapping"))
1266 last_color = res.active_color
1267 arg_index, replaced = _percent_format_repl(
1268 match, args, arg_index, last_color, ctx
1269 )
1270 res += replaced
1271 res.append_color(last_color)
1272 pos = match.end()
1273 if pos < len(part):
1274 res.append_str(part[pos:])
1275 else:
1276 res += part
1278 if (isinstance(args, tuple) and arg_index < len(args)) or (
1279 not isinstance(args, tuple)
1280 and (
1281 not hasattr(args, "__getitem__")
1282 or isinstance(args, (str, bytes, bytearray))
1283 )
1284 and not seen_mapping
1285 and not arg_index
1286 ):
1287 raise TypeError("not all arguments converted during string formatting")
1289 return res
1292def _percent_format_repl(
1293 match: _tx.StrReMatch,
1294 args: object,
1295 arg_index: int,
1296 base_color: _Color,
1297 ctx: ReprContext,
1298) -> tuple[int, str | ColorizedString]:
1299 if match.group("format") == "%":
1300 if match.group(0) != "%%":
1301 raise ValueError("unsupported format character '%'")
1302 return arg_index, "%"
1304 if match.group("format") in "rsa":
1305 return _percent_format_repl_str(match, args, arg_index, base_color, ctx)
1307 if mapping := match.group("mapping"):
1308 try:
1309 fmt_arg = args[mapping] # type: ignore
1310 except TypeError:
1311 raise TypeError("format requires a mapping") from None
1312 fmt_arg, added_color = _unwrap_base_color(fmt_arg, ctx.theme)
1313 if added_color:
1314 fmt_args = {mapping: fmt_arg}
1315 else:
1316 fmt_args = args
1317 elif isinstance(args, tuple):
1318 try:
1319 fmt_arg = args[arg_index]
1320 except IndexError:
1321 raise TypeError("not enough arguments for format string")
1322 fmt_arg, added_color = _unwrap_base_color(fmt_arg, ctx.theme)
1323 begin = arg_index + 1
1324 end = arg_index = (
1325 arg_index
1326 + 1
1327 + (match.group("width") == "*")
1328 + (match.group("precision") == "*")
1329 )
1330 fmt_args = (fmt_arg,) + args[begin:end]
1331 elif arg_index == 0:
1332 fmt_args, added_color = _unwrap_base_color(args, ctx.theme)
1333 arg_index += 1
1334 else:
1335 raise TypeError("not enough arguments for format string")
1337 fmt = match.group(0) % fmt_args
1338 if added_color:
1339 added_color = ctx.to_color(added_color)
1340 fmt = ColorizedString([base_color | added_color, fmt])
1341 return arg_index, fmt
1344def _unwrap_base_color(x, theme: yuio.theme.Theme):
1345 color = None
1346 while isinstance(x, WithBaseColor):
1347 x, base_color = x._msg, x._base_color
1348 base_color = theme.to_color(base_color)
1349 if color:
1350 color = color | base_color
1351 else:
1352 color = base_color
1353 else:
1354 return x, color
1357def _percent_format_repl_str(
1358 match: _tx.StrReMatch,
1359 args: object,
1360 arg_index: int,
1361 base_color: _Color,
1362 ctx: ReprContext,
1363) -> tuple[int, str | ColorizedString]:
1364 if width_s := match.group("width"):
1365 if width_s == "*":
1366 if not isinstance(args, tuple):
1367 raise TypeError("* wants int")
1368 try:
1369 width = args[arg_index]
1370 arg_index += 1
1371 except (KeyError, IndexError):
1372 raise TypeError("not enough arguments for format string")
1373 if not isinstance(width, int):
1374 raise TypeError("* wants int")
1375 else:
1376 width = int(width_s)
1377 else:
1378 width = None
1380 if precision_s := match.group("precision"):
1381 if precision_s == "*":
1382 if not isinstance(args, tuple):
1383 raise TypeError("* wants int")
1384 try:
1385 precision = args[arg_index]
1386 arg_index += 1
1387 except (KeyError, IndexError):
1388 raise TypeError("not enough arguments for format string")
1389 if not isinstance(precision, int):
1390 raise TypeError("* wants int")
1391 else:
1392 precision = int(precision_s)
1393 else:
1394 precision = None
1396 if mapping := match.group("mapping"):
1397 try:
1398 fmt_arg = args[mapping] # type: ignore
1399 except TypeError:
1400 raise TypeError("format requires a mapping") from None
1401 elif isinstance(args, tuple):
1402 try:
1403 fmt_arg = args[arg_index]
1404 arg_index += 1
1405 except IndexError:
1406 raise TypeError("not enough arguments for format string") from None
1407 elif arg_index == 0:
1408 fmt_arg = args
1409 arg_index += 1
1410 else:
1411 raise TypeError("not enough arguments for format string")
1413 flag = match.group("flag")
1414 multiline = "+" in flag
1415 highlighted = "#" in flag
1417 res = ctx.convert(
1418 fmt_arg,
1419 match.group("format"), # type: ignore
1420 multiline=multiline,
1421 highlighted=highlighted,
1422 )
1424 align = match.group("flag")
1425 if width is not None and width < 0:
1426 width = -width
1427 align = "<"
1428 elif align == "-":
1429 align = "<"
1430 else:
1431 align = ">"
1432 res = _apply_format(res, width, precision, align, " ")
1434 return arg_index, res.with_base_color(base_color)
1437def _format_interpolation(interp: _Interpolation, ctx: ReprContext) -> ColorizedString:
1438 value = interp.value
1439 if (
1440 interp.conversion is not None
1441 or getattr(type(value), "__format__", None) is object.__format__
1442 or isinstance(value, (str, ColorizedString))
1443 ):
1444 value = ctx.convert(value, interp.conversion, interp.format_spec)
1445 else:
1446 value = ColorizedString(format(value, interp.format_spec))
1448 return value
1451def _apply_format(
1452 value: ColorizedString,
1453 width: int | None,
1454 precision: int | None,
1455 align: str | None,
1456 fill: str | None,
1457):
1458 if precision is not None and value.width > precision:
1459 cut = ColorizedString()
1460 for part in value:
1461 if precision <= 0:
1462 break
1463 if isinstance(part, str):
1464 part_width = line_width(part)
1465 if part_width <= precision:
1466 cut.append_str(part)
1467 precision -= part_width
1468 elif part.isascii():
1469 cut.append_str(part[:precision])
1470 break
1471 else:
1472 for j, ch in enumerate(part):
1473 precision -= line_width(ch)
1474 if precision == 0:
1475 cut.append_str(part[: j + 1])
1476 break
1477 elif precision < 0:
1478 cut.append_str(part[:j])
1479 cut.append_str(" ")
1480 break
1481 break
1482 else:
1483 cut += part
1484 value = cut
1486 if width is not None and width > value.width:
1487 fill = fill or " "
1488 fill_width = line_width(fill)
1489 spacing = width - value.width
1490 spacing_fill = spacing // fill_width
1491 spacing_space = spacing - spacing_fill * fill_width
1492 value.append_color(_Color.NONE)
1493 if not align or align == "<":
1494 value = value + fill * spacing_fill + " " * spacing_space
1495 elif align == ">":
1496 value = fill * spacing_fill + " " * spacing_space + value
1497 else:
1498 left = spacing_fill // 2
1499 right = spacing_fill - left
1500 value = fill * left + value + fill * right + " " * spacing_space
1502 return value
1505__TAG_RE = re.compile(
1506 r"""
1507 <c (?P<tag_open>[a-z0-9 _/@:-]+)> # _Color tag open.
1508 | </c> # _Color tag close.
1509 | \\(?P<punct>[%(punct)s]) # Escape character.
1510 | (?<!`)(`+)(?!`)(?P<code>.*?)(?<!`)\3(?!`) # Inline code block (backticks).
1511 """
1512 % {"punct": re.escape(string.punctuation)},
1513 re.VERBOSE | re.MULTILINE,
1514)
1515__NEG_NUM_RE = re.compile(r"^-(0x[0-9a-fA-F]+|0b[01]+|\d+(e[+-]?\d+)?)$")
1516__FLAG_RE = re.compile(r"^-[-a-zA-Z0-9_]*$")
1519def colorize(
1520 template: str | _Template,
1521 /,
1522 *args: _t.Any,
1523 ctx: ReprContext,
1524 default_color: _Color | str = _Color.NONE,
1525 parse_cli_flags_in_backticks: bool = False,
1526) -> ColorizedString:
1527 """colorize(line: str, /, *args: typing.Any, ctx: ReprContext, default_color: ~yuio.color.Color | str = Color.NONE, parse_cli_flags_in_backticks: bool = False) -> ColorizedString
1528 colorize(line: ~string.templatelib.Template, /, *, ctx: ReprContext, default_color: ~yuio.color.Color | str = Color.NONE, parse_cli_flags_in_backticks: bool = False) -> ColorizedString
1530 Parse color tags and produce a colorized string.
1532 Apply ``default_color`` to the entire paragraph, and process color tags
1533 and backticks within it.
1535 :param line:
1536 text to colorize.
1537 :param args:
1538 if given, string will be ``%``-formatted after parsing.
1539 Can't be given if `line` is :class:`~string.templatelib.Template`.
1540 :param ctx:
1541 :class:`ReprContext` that will be used to look up color tags
1542 and format arguments.
1543 :param default_color:
1544 color or color tag to apply to the entire text.
1545 :returns:
1546 a colorized string.
1548 """
1550 interpolations: list[tuple[int, _Interpolation]] = []
1551 if isinstance(template, _Template):
1552 if args:
1553 raise TypeError("args can't be given with template")
1554 line = ""
1555 index = 0
1556 for part, interp in zip(template.strings, template.interpolations):
1557 line += part
1558 # Each interpolation is replaced by a zero byte so that our regex knows
1559 # there is something.
1560 line += "\0"
1561 index += len(part) + 1
1562 interpolations.append((index, interp))
1563 line += template.strings[-1]
1564 else:
1565 line = template
1567 default_color = ctx.to_color(default_color)
1569 res = ColorizedString(default_color)
1570 stack = [default_color]
1571 last_pos = 0
1572 last_interp = 0
1574 def append_to_res(s: str, start: int):
1575 nonlocal last_interp
1577 index = 0
1578 while (
1579 last_interp < len(interpolations)
1580 and start + len(s) >= interpolations[last_interp][0]
1581 ):
1582 interp_start, interp = interpolations[last_interp]
1583 res.append_str(
1584 s[
1585 index : interp_start
1586 - start
1587 - 1 # This compensates for that `\0` we added above.
1588 ]
1589 )
1590 res.append_colorized_str(
1591 _format_interpolation(interp, ctx).with_base_color(res.active_color)
1592 )
1593 index = interp_start - start
1594 last_interp += 1
1595 res.append_str(s[index:])
1597 for tag in __TAG_RE.finditer(line):
1598 append_to_res(line[last_pos : tag.start()], last_pos)
1599 last_pos = tag.end()
1601 if name := tag.group("tag_open"):
1602 color = stack[-1] | ctx.get_color(name)
1603 res.append_color(color)
1604 stack.append(color)
1605 elif code := tag.group("code"):
1606 code = code.replace("\n", " ")
1607 code_pos = tag.start("code")
1608 if code.startswith(" ") and code.endswith(" ") and not code.isspace():
1609 code = code[1:-1]
1610 code_pos += 1
1611 if (
1612 parse_cli_flags_in_backticks
1613 and __FLAG_RE.match(code)
1614 and not __NEG_NUM_RE.match(code)
1615 ):
1616 res.append_color(stack[-1] | ctx.get_color("flag"))
1617 else:
1618 res.append_color(stack[-1] | ctx.get_color("code"))
1619 res.start_no_wrap()
1620 append_to_res(code, code_pos)
1621 res.end_no_wrap()
1622 res.append_color(stack[-1])
1623 elif punct := tag.group("punct"):
1624 append_to_res(punct, tag.start("punct"))
1625 elif len(stack) > 1:
1626 stack.pop()
1627 res.append_color(stack[-1])
1629 append_to_res(line[last_pos:], last_pos)
1631 if args:
1632 return res.percent_format(args, ctx)
1633 else:
1634 return res
1637def strip_color_tags(s: str) -> str:
1638 """
1639 Remove all color tags from a string.
1641 """
1643 raw: list[str] = []
1645 last_pos = 0
1646 for tag in __TAG_RE.finditer(s):
1647 raw.append(s[last_pos : tag.start()])
1648 last_pos = tag.end()
1650 if code := tag.group("code"):
1651 code = code.replace("\n", " ")
1652 if code.startswith(" ") and code.endswith(" ") and not code.isspace():
1653 code = code[1:-1]
1654 raw.append(code)
1655 elif punct := tag.group("punct"):
1656 raw.append(punct)
1658 raw.append(s[last_pos:])
1660 return "".join(raw)
1663class Esc(_UserString):
1664 """
1665 A string that can't be broken during word wrapping even
1666 if `break_long_nowrap_words` is :data:`True`.
1668 """
1670 __slots__ = ()
1673class Link(_UserString):
1674 """
1675 A :class:`str` wrapper with an attached hyperlink.
1677 :param args:
1678 arguments for :class:`str` constructor.
1679 :param url:
1680 link, should be properly urlencoded.
1682 """
1684 __slots__ = ("__url",)
1686 def __new__(cls, *args, url: str, **kwargs):
1687 res = super().__new__(cls, *args, **kwargs)
1688 res.__url = url
1689 return res
1691 @classmethod
1692 def from_path(cls, *args, path: str | pathlib.Path) -> _t.Self:
1693 """
1694 Create a link to a local file.
1696 Ensures that file path is absolute and properly formatted.
1698 :param args:
1699 arguments for :class:`str` constructor.
1700 :param path:
1701 path to a file.
1703 """
1705 path = pathlib.Path(path).expanduser().absolute().as_uri()
1706 return cls(*args, url=path)
1708 @property
1709 def url(self):
1710 """
1711 Target link.
1713 """
1715 return self.__url
1717 def as_code(self, color_support: yuio.color.ColorSupport):
1718 """
1719 Convert this link into an ANSI escape code with respect to the given
1720 terminal capabilities.
1722 :param color_support:
1723 level of color support of a terminal.
1724 :returns:
1725 string text with ANSI codes that add a hyperlink to it.
1727 """
1729 if color_support < yuio.color.ColorSupport.ANSI_TRUE:
1730 return str(self)
1731 else:
1732 return f"\x1b]8;;{self.__url}\x1b\\{self}\x1b]8;;\x1b\\"
1734 def _wrap(self, data: str):
1735 return self.__class__(data, url=self.__url)
1737 def __repr__(self) -> str:
1738 return f"Link({super().__repr__()}, url={self.url!r})"
1740 def __colorized_str__(self, ctx: ReprContext) -> ColorizedString:
1741 return ColorizedString(self)
1744def _split_keep_link(s: str, r: _tx.StrRePattern):
1745 if isinstance(s, Link):
1746 url = s.url
1747 ctor = lambda x: Link(x, url=url)
1748 else:
1749 ctor = s.__class__
1750 return [ctor(part) for part in r.split(s)]
1753_SPACE_TRANS = str.maketrans("\r\n\t\v\b\f", " ")
1755_WORD_PUNCT = r'[\w!"\'&.,?]'
1756_LETTER = r"[^\d\W]"
1757_NOWHITESPACE = r"[^ \r\n\t\v\b\f]"
1759# Copied from textwrap with some modifications in newline handling
1760_WORDSEP_RE = re.compile(
1761 r"""
1762 ( # newlines and line feeds are matched one-by-one
1763 (?:\r\n|\r|\n|\v\r\n|\v\r|\v\n|\v)
1764 | # any whitespace
1765 [ \t\b\f]+
1766 | # em-dash between words
1767 (?<=%(wp)s) -{2,} (?=\w)
1768 | # word, possibly hyphenated
1769 %(nws)s+? (?:
1770 # hyphenated word
1771 -(?: (?<=%(lt)s{2}-) | (?<=%(lt)s-%(lt)s-))
1772 (?= %(lt)s -? %(lt)s)
1773 | # end of word
1774 (?=[ \r\n\t\v\b\f]|\Z)
1775 | # em-dash
1776 (?<=%(wp)s) (?=-{2,}\w)
1777 )
1778 )"""
1779 % {"wp": _WORD_PUNCT, "lt": _LETTER, "nws": _NOWHITESPACE},
1780 re.VERBOSE,
1781)
1782_WORDSEP_NL_RE = re.compile(r"(\r\n|\r|\n|\v\r\n|\v\r|\v\n|\v)")
1785class _TextWrapper:
1786 def __init__(
1787 self,
1788 width: int,
1789 /,
1790 *,
1791 preserve_spaces: bool,
1792 preserve_newlines: bool,
1793 break_long_words: bool,
1794 break_long_nowrap_words: bool,
1795 overflow: _t.Literal[False] | str,
1796 indent: AnyString | int,
1797 continuation_indent: AnyString | int | None,
1798 ):
1799 self.width = width
1800 self.preserve_spaces: bool = preserve_spaces
1801 self.preserve_newlines: bool = preserve_newlines
1802 self.break_long_words: bool = break_long_words
1803 self.break_long_nowrap_words: bool = break_long_nowrap_words
1804 self.overflow: _t.Literal[False] | str = overflow
1806 self.indent = ColorizedString()
1807 self.indent.start_no_wrap()
1808 self.continuation_indent = ColorizedString()
1809 self.continuation_indent.start_no_wrap()
1810 if isinstance(indent, int):
1811 self.indent.append_str(" " * indent)
1812 else:
1813 self.indent += indent
1814 if continuation_indent is None:
1815 self.continuation_indent.append_colorized_str(self.indent)
1816 elif isinstance(continuation_indent, int):
1817 self.continuation_indent.append_str(" " * continuation_indent)
1818 else:
1819 self.continuation_indent += continuation_indent
1821 self.lines: list[ColorizedString] = []
1823 self.current_line = ColorizedString()
1824 if self.indent:
1825 self.current_line += self.indent
1826 self.current_line_width: int = self.indent.width
1827 self.at_line_start: bool = True
1828 self.at_line_start_or_indent: bool = True
1829 self.has_ellipsis: bool = False
1830 self.add_spaces_before_word: int = 0
1831 self.space_before_word_url = None
1833 self.nowrap_start_index = None
1834 self.nowrap_start_width = 0
1835 self.nowrap_start_added_space = False
1837 def _flush_line(self, explicit_newline=""):
1838 self.current_line._explicit_newline = explicit_newline
1839 self.lines.append(self.current_line)
1841 self.current_line = ColorizedString(self.current_line.active_color)
1843 if self.continuation_indent:
1844 self.current_line += self.continuation_indent
1846 self.current_line_width: int = self.continuation_indent.width
1847 self.at_line_start = True
1848 self.at_line_start_or_indent = True
1849 self.has_ellipsis = False
1850 self.nowrap_start_index = None
1851 self.nowrap_start_width = 0
1852 self.nowrap_start_added_space = False
1853 self.add_spaces_before_word = 0
1854 self.space_before_word_url = None
1856 def _flush_line_part(self):
1857 assert self.nowrap_start_index is not None
1858 self.current_line, tail = self.current_line._split_at(self.nowrap_start_index)
1859 tail_width = self.current_line_width - self.nowrap_start_width
1860 if (
1861 self.nowrap_start_added_space
1862 and self.current_line._parts
1863 and self.current_line._parts[-1] == " "
1864 ):
1865 # Remove space that was added before no-wrap sequence.
1866 self.current_line._parts.pop()
1867 self._flush_line()
1868 self.current_line += tail
1869 self.current_line.append_color(tail.active_color)
1870 self.current_line_width += tail_width
1872 def _append_str(self, s: str):
1873 self.current_line.append_str(s)
1874 self.at_line_start = False
1875 self.at_line_start_or_indent = self.at_line_start_or_indent and s.isspace()
1877 def _append_word(self, word: str, word_width: int):
1878 if (
1879 self.overflow is not False
1880 and self.current_line_width + word_width > self.width
1881 ):
1882 if isinstance(word, Esc):
1883 if self.overflow:
1884 self._add_ellipsis()
1885 return
1887 word_head_len = word_head_width = 0
1889 for c in word:
1890 c_width = line_width(c)
1891 if self.current_line_width + word_head_width + c_width > self.width:
1892 break
1893 word_head_len += 1
1894 word_head_width += c_width
1896 if word_head_len:
1897 self._append_str(word[:word_head_len])
1898 self.has_ellipsis = False
1899 self.current_line_width += word_head_width
1901 if self.overflow:
1902 self._add_ellipsis()
1903 else:
1904 self._append_str(word)
1905 self.current_line_width += word_width
1906 self.has_ellipsis = False
1908 def _append_space(self):
1909 if self.add_spaces_before_word:
1910 word = " " * self.add_spaces_before_word
1911 if self.space_before_word_url:
1912 word = Link(word, url=self.space_before_word_url)
1913 self._append_word(word, 1)
1914 self.add_spaces_before_word = 0
1915 self.space_before_word_url = None
1917 def _add_ellipsis(self):
1918 if self.has_ellipsis:
1919 # Already has an ellipsis.
1920 return
1922 if self.current_line_width + 1 <= self.width:
1923 # There's enough space on this line to add new ellipsis.
1924 self._append_str(str(self.overflow))
1925 self.current_line_width += 1
1926 self.has_ellipsis = True
1927 elif not self.at_line_start:
1928 # Modify last word on this line, if there is any.
1929 parts = self.current_line._parts
1930 for i in range(len(parts) - 1, -1, -1):
1931 part = parts[i]
1932 if isinstance(part, str):
1933 if not isinstance(part, (Esc, Link)):
1934 parts[i] = f"{part[:-1]}{self.overflow}"
1935 self.has_ellipsis = True
1936 return
1938 def _append_word_with_breaks(self, word: str, word_width: int):
1939 while self.current_line_width + word_width > self.width:
1940 word_head_len = word_head_width = 0
1942 for c in word:
1943 c_width = line_width(c)
1944 if self.current_line_width + word_head_width + c_width > self.width:
1945 break
1946 word_head_len += 1
1947 word_head_width += c_width
1949 if self.at_line_start and not word_head_len:
1950 if self.overflow:
1951 return
1952 else:
1953 word_head_len = 1
1954 word_head_width += line_width(word[:1])
1956 self._append_word(word[:word_head_len], word_head_width)
1958 word = word[word_head_len:]
1959 word_width -= word_head_width
1961 self._flush_line()
1963 if word:
1964 self._append_word(word, word_width)
1966 def wrap(self, text: ColorizedString) -> list[ColorizedString]:
1967 nowrap = False
1969 for part in text:
1970 if isinstance(part, _Color):
1971 if (
1972 self.add_spaces_before_word
1973 and self.current_line_width + self.add_spaces_before_word
1974 < self.width
1975 ):
1976 # Make sure any whitespace that was added before color
1977 # is flushed. If it doesn't fit, we just forget it: the line
1978 # will be wrapped soon anyways.
1979 self._append_space()
1980 self.add_spaces_before_word = 0
1981 self.space_before_word_url = None
1982 self.current_line.append_color(part)
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 self.space_before_word_url = None
2001 if self.at_line_start:
2002 self.nowrap_start_index = None
2003 self.nowrap_start_width = 0
2004 else:
2005 self.nowrap_start_index = len(self.current_line._parts)
2006 self.nowrap_start_width = self.current_line_width
2007 nowrap = True
2008 continue
2009 elif part is NO_WRAP_END:
2010 nowrap = False
2011 self.nowrap_start_index = None
2012 self.nowrap_start_width = 0
2013 self.nowrap_start_added_space = False
2014 continue
2016 esc = False
2017 if isinstance(part, Esc):
2018 words = [Esc(part.translate(_SPACE_TRANS))]
2019 esc = True
2020 elif nowrap:
2021 words = _split_keep_link(part, _WORDSEP_NL_RE)
2022 else:
2023 words = _split_keep_link(part, _WORDSEP_RE)
2025 for word in words:
2026 if not word:
2027 # `_WORDSEP_RE` produces empty strings, skip them.
2028 continue
2030 if word.startswith(("\v", "\r", "\n")):
2031 # `_WORDSEP_RE` yields one newline sequence at a time, we don't
2032 # need to split the word further.
2033 if nowrap or self.preserve_newlines or word.startswith("\v"):
2034 self._flush_line(explicit_newline=word)
2035 continue
2036 else:
2037 # Treat any newline sequence as a single space.
2038 word = " "
2040 isspace = not esc and word.isspace()
2041 if isspace:
2042 if (
2043 # Spaces are preserved in no-wrap sequences.
2044 nowrap
2045 # Spaces are explicitly preserved.
2046 or self.preserve_spaces
2047 # We preserve indentation even if `preserve_spaces` is `False`.
2048 # We need to check that the previous line ended with an
2049 # explicit newline, otherwise this is not an indent.
2050 or (
2051 self.at_line_start_or_indent
2052 and (not self.lines or self.lines[-1].explicit_newline)
2053 )
2054 ):
2055 word = word.translate(_SPACE_TRANS)
2056 else:
2057 self.add_spaces_before_word = len(word)
2058 self.space_before_word_url = (
2059 word.url if isinstance(word, Link) else None
2060 )
2061 continue
2063 word_width = line_width(word)
2065 if self._try_fit_word(word, word_width):
2066 # Word fits onto the current line.
2067 continue
2069 if self.nowrap_start_index is not None:
2070 # Move the entire no-wrap sequence onto the new line.
2071 self._flush_line_part()
2073 if self._try_fit_word(word, word_width):
2074 # Word fits onto the current line after we've moved
2075 # no-wrap sequence. Nothing more to do.
2076 continue
2078 if (
2079 not self.at_line_start
2080 and (
2081 # Spaces can be broken anywhere, so we don't break line
2082 # for them: `_append_word_with_breaks` will do it for us.
2083 # Note: `esc` implies `not isspace`, so all `esc` words
2084 # outside of no-wrap sequences are handled by this check.
2085 (not nowrap and not isspace)
2086 # No-wrap sequences are broken in the middle of any word,
2087 # so we don't need any special handling for them
2088 # (again, `_append_word_with_breaks` will do breaking for us).
2089 # An exception is `esc` words which can't be broken in the middle;
2090 # if the break is possible at all, it must happen here.
2091 or (nowrap and esc and self.break_long_nowrap_words)
2092 )
2093 and not (
2094 # This is an esc word which wouldn't fit onto this line, nor onto
2095 # the next line, and there's enough space for an ellipsis
2096 # on this line (or it already has one). We don't need to break
2097 # the line here: this word will be passed to `_append_word`,
2098 # which will handle ellipsis for us.
2099 self.overflow is not False
2100 and esc
2101 and self.continuation_indent.width + word_width > self.width
2102 and (
2103 self.has_ellipsis
2104 or self.current_line_width + self.add_spaces_before_word + 1
2105 <= self.width
2106 )
2107 )
2108 ):
2109 # Flush a non-empty line.
2110 self._flush_line()
2112 # Note: `need_space_before_word` is always `False` at this point.
2113 # `need_space_before_word` becomes `True` only when current line
2114 # is non-empty, we're not in no-wrap sequence, and `preserve_spaces`
2115 # is `False` (meaning `isspace` is also `False`). In such situation,
2116 # we flush the line in the condition above.
2117 if not esc and (
2118 (nowrap and self.break_long_nowrap_words)
2119 or (not nowrap and (self.break_long_words or isspace))
2120 ):
2121 # We will break the word in the middle if it doesn't fit.
2122 self._append_word_with_breaks(word, word_width)
2123 else:
2124 self._append_word(word, word_width)
2126 if self.current_line or not self.lines or self.lines[-1].explicit_newline:
2127 self._flush_line()
2129 return self.lines
2131 def _try_fit_word(self, word: str, word_width: int):
2132 if (
2133 self.current_line_width + word_width + self.add_spaces_before_word
2134 <= self.width
2135 ):
2136 self._append_space()
2137 self._append_word(word, word_width)
2138 return True
2139 else:
2140 return False
2143class _ReprContextState(Enum):
2144 START = 0
2145 """
2146 Initial state.
2148 """
2150 CONTAINER_START = 1
2151 """
2152 Right after a token starting a container was pushed.
2154 """
2156 ITEM_START = 2
2157 """
2158 Right after a token separating container items was pushed.
2160 """
2162 NORMAL = 3
2163 """
2164 In the middle of a container element.
2166 """
2169@_t.final
2170class ReprContext:
2171 """
2172 Context object that tracks repr settings and ensures that recursive objects
2173 are handled properly.
2175 :param term:
2176 terminal that will be used to print formatted messages.
2177 :param theme:
2178 theme that will be used to format messages.
2179 :param multiline:
2180 indicates that values rendered via `rich repr protocol`_
2181 should be split into multiple lines. Default is :data:`False`.
2182 :param highlighted:
2183 indicates that values rendered via `rich repr protocol`_
2184 or via built-in :func:`repr` should be highlighted according to python syntax.
2185 Default is :data:`False`.
2186 :param max_depth:
2187 maximum depth of nested containers, after which container's contents
2188 are not rendered. Default is ``5``.
2189 :param width:
2190 maximum width of the content, used when wrapping text, rendering markdown,
2191 or rendering horizontal rulers. If not given, defaults
2192 to :attr:`Theme.fallback_width <yuio.theme.Theme.fallback_width>`.
2194 .. _rich repr protocol: https://rich.readthedocs.io/en/stable/pretty.html#rich-repr-protocol
2196 """
2198 def __init__(
2199 self,
2200 *,
2201 term: yuio.term.Term,
2202 theme: yuio.theme.Theme,
2203 multiline: bool | None = None,
2204 highlighted: bool | None = None,
2205 max_depth: int | None = None,
2206 width: int | None = None,
2207 ):
2208 self.term = term
2209 """
2210 Current term.
2212 """
2214 self.theme = theme
2215 """
2216 Current theme.
2218 """
2220 self.multiline: bool = multiline if multiline is not None else False
2221 """
2222 Whether values rendered with :meth:`~ReprContext.repr` are split into multiple lines.
2224 """
2226 self.highlighted: bool = highlighted if highlighted is not None else False
2227 """
2228 Whether values rendered with :meth:`~ReprContext.repr` are highlighted.
2230 """
2232 self.max_depth: int = max_depth if max_depth is not None else 5
2233 """
2234 Maximum depth of nested containers, after which container's contents
2235 are not rendered.
2237 """
2239 self.width: int = max(width or theme.fallback_width, 1)
2240 """
2241 Maximum width of the content, used when wrapping text or rendering markdown.
2243 """
2245 self._seen: set[int] = set()
2246 self._line = ColorizedString()
2247 self._indent = 0
2248 self._state = _ReprContextState.START
2249 self._pending_sep = None
2251 import yuio.md
2253 self._hl = yuio.md.SyntaxHighlighter.get_highlighter("repr")
2254 self._base_color = theme.get_color("msg/text:code/repr")
2256 def get_color(self, paths: str, /) -> yuio.color.Color:
2257 """
2258 Lookup a color by path.
2260 """
2262 return self.theme.get_color(paths)
2264 def to_color(
2265 self, color_or_path: yuio.color.Color | str | None, /
2266 ) -> yuio.color.Color:
2267 """
2268 Convert color or color path to color.
2270 """
2272 return self.theme.to_color(color_or_path)
2274 def get_msg_decoration(self, name: str, /) -> str:
2275 """
2276 Get message decoration by name.
2278 """
2280 return self.theme.get_msg_decoration(name, is_unicode=self.term.is_unicode)
2282 def _flush_sep(self, trim: bool = False):
2283 if self._pending_sep is not None:
2284 self._push_color("punct")
2285 if trim:
2286 self._pending_sep = self._pending_sep.rstrip()
2287 self._line.append_str(self._pending_sep)
2288 self._pending_sep = None
2290 def _flush_line(self):
2291 if self.multiline:
2292 self._line.append_color(self._base_color)
2293 self._line.append_str("\n")
2294 if self._indent:
2295 self._line.append_str(" " * self._indent)
2297 def _flush_sep_and_line(self):
2298 if self.multiline and self._state in [
2299 _ReprContextState.CONTAINER_START,
2300 _ReprContextState.ITEM_START,
2301 ]:
2302 self._flush_sep(trim=True)
2303 self._flush_line()
2304 else:
2305 self._flush_sep()
2307 def _push_color(self, tag: str):
2308 if self.highlighted:
2309 self._line.append_color(
2310 self._base_color | self.theme.to_color(f"hl/{tag}:repr")
2311 )
2313 def _push_token(self, content: str, tag: str):
2314 self._flush_sep_and_line()
2316 self._push_color(tag)
2317 self._line.append_str(content)
2319 self._state = _ReprContextState.NORMAL
2321 def _terminate_item(self, sep: str = ", "):
2322 self._flush_sep()
2323 self._pending_sep = sep
2324 self._state = _ReprContextState.ITEM_START
2326 def _start_container(self):
2327 self._state = _ReprContextState.CONTAINER_START
2328 self._indent += 1
2330 def _end_container(self):
2331 self._indent -= 1
2333 if self._state in [_ReprContextState.NORMAL, _ReprContextState.ITEM_START]:
2334 self._flush_line()
2336 self._state = _ReprContextState.NORMAL
2337 self._pending_sep = None
2339 def repr(
2340 self,
2341 value: _t.Any,
2342 /,
2343 *,
2344 multiline: bool | None = None,
2345 highlighted: bool | None = None,
2346 width: int | None = None,
2347 max_depth: int | None = None,
2348 ) -> ColorizedString:
2349 """
2350 Convert value to colorized string using repr methods.
2352 :param value:
2353 value to be rendered.
2354 :param multiline:
2355 if given, overrides settings passed to :class:`ReprContext` for this call.
2356 :param highlighted:
2357 if given, overrides settings passed to :class:`ReprContext` for this call.
2358 :param width:
2359 if given, overrides settings passed to :class:`ReprContext` for this call.
2360 :param max_depth:
2361 if given, overrides settings passed to :class:`ReprContext` for this call.
2362 :returns:
2363 a colorized string containing representation of the `value`.
2364 :raises:
2365 this method does not raise any errors. If any inner object raises an
2366 exception, this function returns a colorized string with
2367 an error description.
2369 """
2371 return self._print(
2372 value,
2373 multiline=multiline,
2374 highlighted=highlighted,
2375 use_str=False,
2376 width=width,
2377 max_depth=max_depth,
2378 )
2380 def str(
2381 self,
2382 value: _t.Any,
2383 /,
2384 *,
2385 multiline: bool | None = None,
2386 highlighted: bool | None = None,
2387 width: int | None = None,
2388 max_depth: int | None = None,
2389 ) -> ColorizedString:
2390 """
2391 Convert value to colorized string.
2393 :param value:
2394 value to be rendered.
2395 :param multiline:
2396 if given, overrides settings passed to :class:`ReprContext` for this call.
2397 :param highlighted:
2398 if given, overrides settings passed to :class:`ReprContext` for this call.
2399 :param width:
2400 if given, overrides settings passed to :class:`ReprContext` for this call.
2401 :param max_depth:
2402 if given, overrides settings passed to :class:`ReprContext` for this call.
2403 :returns:
2404 a colorized string containing string representation of the `value`.
2405 :raises:
2406 this method does not raise any errors. If any inner object raises an
2407 exception, this function returns a colorized string with
2408 an error description.
2410 """
2412 return self._print(
2413 value,
2414 multiline=multiline,
2415 highlighted=highlighted,
2416 use_str=True,
2417 width=width,
2418 max_depth=max_depth,
2419 )
2421 def convert(
2422 self,
2423 value: _t.Any,
2424 conversion: _t.Literal["a", "r", "s"] | None,
2425 format_spec: str | None = None,
2426 /,
2427 *,
2428 multiline: bool | None = None,
2429 highlighted: bool | None = None,
2430 width: int | None = None,
2431 max_depth: int | None = None,
2432 ):
2433 """
2434 Perform string conversion, similar to :func:`string.templatelib.convert`,
2435 and format the object with respect to the given `format_spec`.
2437 :param value:
2438 value to be converted.
2439 :param conversion:
2440 string conversion method:
2442 - ``'s'`` calls :meth:`~ReprContext.str`,
2443 - ``'r'`` calls :meth:`~ReprContext.repr`,
2444 - ``'a'`` calls :meth:`~ReprContext.repr` and escapes non-ascii
2445 characters.
2446 :param format_spec:
2447 formatting spec can override `multiline` and `highlighted`, and controls
2448 width, alignment, fill chars, etc. See its syntax below.
2449 :param multiline:
2450 if given, overrides settings passed to :class:`ReprContext` for this call.
2451 :param highlighted:
2452 if given, overrides settings passed to :class:`ReprContext` for this call.
2453 :param width:
2454 if given, overrides settings passed to :class:`ReprContext` for this call.
2455 :param max_depth:
2456 if given, overrides settings passed to :class:`ReprContext` for this call.
2457 :returns:
2458 a colorized string containing string representation of the `value`.
2459 :raises:
2460 :class:`ValueError` if `conversion` or `format_spec` are invalid.
2462 .. _t-string-spec:
2464 **Format specification**
2466 .. syntax:diagram::
2468 stack:
2469 - optional:
2470 - optional:
2471 - optional:
2472 - non_terminal: "fill"
2473 href: "#t-string-spec-fill"
2474 - non_terminal: "align"
2475 href: "#t-string-spec-align"
2476 - non_terminal: "flags"
2477 href: "#t-string-spec-flags"
2478 - optional:
2479 - comment: "width"
2480 href: "#t-string-spec-width"
2481 - "[0-9]+"
2482 - optional:
2483 - comment: "precision"
2484 href: "#t-string-spec-precision"
2485 - "'.'"
2486 - "[0-9]+"
2487 - optional:
2488 - comment: "conversion type"
2489 href: "#t-string-spec-conversion-type"
2490 - "'s'"
2491 skip_bottom: true
2492 skip: true
2494 .. _t-string-spec-fill:
2496 ``fill``
2497 Any character that will be used to extend string to the desired width.
2499 .. _t-string-spec-align:
2501 ``align``
2502 Controls alignment of a string when `width` is given: ``"<"`` for flushing
2503 string left, ``">"`` for flushing string right, ``"^"`` for centering.
2505 .. _t-string-spec-flags:
2507 ``flags``
2508 One or several flags: ``"#"`` to enable highlighting, ``"+"`` to enable
2509 multiline repr.
2511 .. _t-string-spec-width:
2513 ``width``
2514 If formatted string is narrower than this value, it will be extended and
2515 aligned using `fill` and `align` settings.
2517 .. _t-string-spec-precision:
2519 ``precision``
2520 If formatted string is wider that this value, it will be cropped to this
2521 width.
2523 .. _t-string-spec-conversion-type:
2525 ``conversion type``
2526 The only supported conversion type is ``"s"``.
2528 """
2530 if format_spec:
2531 match = _F_SYNTAX.match(format_spec)
2532 if not match:
2533 raise ValueError(f"invalid format specifier {format_spec!r}")
2534 fill = match.group("fill")
2535 align = match.group("align")
2536 if align == "=":
2537 raise ValueError("'=' alignment not allowed in string format specifier")
2538 flags = match.group("flags")
2539 if "#" in flags:
2540 highlighted = True
2541 if "+" in flags:
2542 multiline = True
2543 zero = match.group("zero")
2544 if zero and not fill:
2545 fill = zero
2546 format_width = match.group("width")
2547 if format_width:
2548 format_width = int(format_width)
2549 else:
2550 format_width = None
2551 format_width_grouping = match.group("width_grouping")
2552 if format_width_grouping:
2553 raise ValueError(f"cannot specify {format_width_grouping!r} with 's'")
2554 format_precision = match.group("precision")
2555 if format_precision:
2556 format_precision = int(format_precision)
2557 else:
2558 format_precision = None
2559 type = match.group("type")
2560 if type and type != "s":
2561 raise ValueError(f"unknown format code {type!r}")
2562 else:
2563 format_width = format_precision = align = fill = None
2565 if conversion == "r":
2566 res = self.repr(
2567 value,
2568 multiline=multiline,
2569 highlighted=highlighted,
2570 width=width,
2571 max_depth=max_depth,
2572 )
2573 elif conversion == "a":
2574 res = ColorizedString()
2575 for part in self.repr(
2576 value,
2577 multiline=multiline,
2578 highlighted=highlighted,
2579 width=width,
2580 max_depth=max_depth,
2581 ):
2582 if isinstance(part, _UserString):
2583 res += part._wrap(
2584 part.encode(encoding="unicode_escape").decode("ascii")
2585 )
2586 elif isinstance(part, str):
2587 res += part.encode(encoding="unicode_escape").decode("ascii")
2588 else:
2589 res += part
2590 elif not conversion or conversion == "s":
2591 res = self.str(
2592 value,
2593 multiline=multiline,
2594 highlighted=highlighted,
2595 width=width,
2596 max_depth=max_depth,
2597 )
2598 else:
2599 raise ValueError(
2600 f"unknown conversion {conversion!r}, should be 'a', 'r', or 's'"
2601 )
2603 return _apply_format(res, format_width, format_precision, align, fill)
2605 def hl(
2606 self,
2607 value: str,
2608 /,
2609 *,
2610 highlighted: bool | None = None,
2611 ) -> ColorizedString:
2612 """
2613 Highlight result of :func:`repr`.
2615 :meth:`ReprContext.repr` does this automatically, but sometimes you need
2616 to highlight a string without :func:`repr`-ing it one more time.
2618 :param value:
2619 result of :func:`repr` that needs highlighting.
2620 :returns:
2621 highlighted string.
2623 """
2625 highlighted = highlighted if highlighted is not None else self.highlighted
2627 if highlighted:
2628 return self._hl.highlight(self.theme, value, default_color=self._base_color)
2629 else:
2630 return ColorizedString(value)
2632 @contextlib.contextmanager
2633 def with_settings(
2634 self,
2635 *,
2636 multiline: bool | None = None,
2637 highlighted: bool | None = None,
2638 width: int | None = None,
2639 max_depth: int | None = None,
2640 ):
2641 """
2642 Temporarily replace settings of this context.
2644 :param multiline:
2645 if given, overrides settings passed to :class:`ReprContext` for this call.
2646 :param highlighted:
2647 if given, overrides settings passed to :class:`ReprContext` for this call.
2648 :param width:
2649 if given, overrides settings passed to :class:`ReprContext` for this call.
2650 :param max_depth:
2651 if given, overrides settings passed to :class:`ReprContext` for this call.
2652 :returns:
2653 a context manager that overrides settings.
2655 """
2657 old_multiline, self.multiline = (
2658 self.multiline,
2659 (self.multiline if multiline is None else multiline),
2660 )
2661 old_highlighted, self.highlighted = (
2662 self.highlighted,
2663 (self.highlighted if highlighted is None else highlighted),
2664 )
2665 old_width, self.width = (
2666 self.width,
2667 (self.width if width is None else max(width, 1)),
2668 )
2669 old_max_depth, self.max_depth = (
2670 self.max_depth,
2671 (self.max_depth if max_depth is None else max_depth),
2672 )
2674 try:
2675 yield
2676 finally:
2677 self.multiline = old_multiline
2678 self.highlighted = old_highlighted
2679 self.width = old_width
2680 self.max_depth = old_max_depth
2682 def _print(
2683 self,
2684 value: _t.Any,
2685 multiline: bool | None,
2686 highlighted: bool | None,
2687 width: int | None,
2688 max_depth: int | None,
2689 use_str: bool,
2690 ) -> ColorizedString:
2691 old_line, self._line = self._line, ColorizedString()
2692 old_state, self._state = self._state, _ReprContextState.START
2693 old_pending_sep, self._pending_sep = self._pending_sep, None
2695 try:
2696 with self.with_settings(
2697 multiline=multiline,
2698 highlighted=highlighted,
2699 width=width,
2700 max_depth=max_depth,
2701 ):
2702 self._print_nested(value, use_str)
2703 return self._line
2704 except Exception as e:
2705 yuio._logger.exception("error in repr context")
2706 res = ColorizedString()
2707 res.append_color(_Color.STYLE_INVERSE | _Color.FORE_RED)
2708 res.append_str(f"{_tx.type_repr(type(e))}: {e}")
2709 return res
2710 finally:
2711 self._line = old_line
2712 self._state = old_state
2713 self._pending_sep = old_pending_sep
2715 def _print_nested(self, value: _t.Any, use_str: bool = False):
2716 if id(value) in self._seen or self._indent > self.max_depth:
2717 self._push_token("...", "more")
2718 return
2719 self._seen.add(id(value))
2720 old_indent = self._indent
2721 try:
2722 if use_str:
2723 self._print_nested_as_str(value)
2724 else:
2725 self._print_nested_as_repr(value)
2726 finally:
2727 self._indent = old_indent
2728 self._seen.remove(id(value))
2730 def _print_nested_as_str(self, value):
2731 if isinstance(value, type):
2732 # This is a type.
2733 self._print_plain(value, convert=_tx.type_repr)
2734 elif hasattr(value, "__colorized_str__"):
2735 # Has `__colorized_str__`.
2736 self._print_colorized_str(value)
2737 elif getattr(type(value), "__str__", None) is not object.__str__:
2738 # Has custom `__str__`.
2739 self._print_plain(value, convert=str, hl=False)
2740 else:
2741 # Has default `__str__` which falls back to `__repr__`.
2742 self._print_nested_as_repr(value)
2744 def _print_nested_as_repr(self, value):
2745 if isinstance(value, type):
2746 # This is a type.
2747 self._print_plain(value, convert=_tx.type_repr)
2748 elif hasattr(value, "__colorized_repr__"):
2749 # Has `__colorized_repr__`.
2750 self._print_colorized_repr(value)
2751 elif hasattr(value, "__rich_repr__"):
2752 # Has `__rich_repr__`.
2753 self._print_rich_repr(value)
2754 elif isinstance(value, _CONTAINER_TYPES):
2755 # Is a known container.
2756 for ty, repr_fn in _CONTAINERS.items():
2757 if isinstance(value, ty):
2758 if getattr(type(value), "__repr__", None) is ty.__repr__:
2759 repr_fn(self, value) # type: ignore
2760 else:
2761 self._print_plain(value)
2762 break
2763 elif dataclasses.is_dataclass(value):
2764 # Is a dataclass.
2765 self._print_dataclass(value)
2766 else:
2767 # Fall back to regular `__repr__`.
2768 self._print_plain(value)
2770 def _print_plain(self, value, convert=None, hl=True):
2771 convert = convert or repr
2773 self._flush_sep_and_line()
2775 if hl and self.highlighted:
2776 self._line += self._hl.highlight(
2777 self.theme, convert(value), default_color=self._base_color
2778 )
2779 else:
2780 self._line.append_str(convert(value))
2782 self._state = _ReprContextState.NORMAL
2784 def _print_list(self, name: str, obrace: str, cbrace: str, items):
2785 if name:
2786 self._push_token(name, "type")
2787 self._push_token(obrace, "punct")
2788 if self._indent >= self.max_depth:
2789 self._push_token("...", "more")
2790 else:
2791 self._start_container()
2792 for item in items:
2793 self._print_nested(item)
2794 self._terminate_item()
2795 self._end_container()
2796 self._push_token(cbrace, "punct")
2798 def _print_dict(self, name: str, obrace: str, cbrace: str, items):
2799 if name:
2800 self._push_token(name, "type")
2801 self._push_token(obrace, "punct")
2802 if self._indent >= self.max_depth:
2803 self._push_token("...", "more")
2804 else:
2805 self._start_container()
2806 for key, value in items:
2807 self._print_nested(key)
2808 self._push_token(": ", "punct")
2809 self._print_nested(value)
2810 self._terminate_item()
2811 self._end_container()
2812 self._push_token(cbrace, "punct")
2814 def _print_defaultdict(self, value: collections.defaultdict[_t.Any, _t.Any]):
2815 self._push_token("defaultdict", "type")
2816 self._push_token("(", "punct")
2817 if self._indent >= self.max_depth:
2818 self._push_token("...", "more")
2819 else:
2820 self._start_container()
2821 self._print_nested(value.default_factory)
2822 self._terminate_item()
2823 self._print_dict("", "{", "}", value.items())
2824 self._terminate_item()
2825 self._end_container()
2826 self._push_token(")", "punct")
2828 def _print_dequeue(self, value: collections.deque[_t.Any]):
2829 self._push_token("deque", "type")
2830 self._push_token("(", "punct")
2831 if self._indent >= self.max_depth:
2832 self._push_token("...", "more")
2833 else:
2834 self._start_container()
2835 self._print_list("", "[", "]", value)
2836 self._terminate_item()
2837 if value.maxlen is not None:
2838 self._push_token("maxlen", "param")
2839 self._push_token("=", "punct")
2840 self._print_nested(value.maxlen)
2841 self._terminate_item()
2842 self._end_container()
2843 self._push_token(")", "punct")
2845 def _print_dataclass(self, value):
2846 try:
2847 # If dataclass has a custom repr, fall back to it.
2848 # This code is copied from Rich, MIT License.
2849 # See https://github.com/Textualize/rich/blob/master/LICENSE
2850 has_custom_repr = value.__repr__.__code__.co_filename not in (
2851 dataclasses.__file__,
2852 reprlib.__file__,
2853 )
2854 except Exception: # pragma: no cover
2855 has_custom_repr = True
2857 if has_custom_repr:
2858 self._print_plain(value)
2859 return
2861 self._push_token(value.__class__.__name__, "type")
2862 self._push_token("(", "punct")
2864 if self._indent >= self.max_depth:
2865 self._push_token("...", "more")
2866 else:
2867 self._start_container()
2868 for field in dataclasses.fields(value):
2869 if not field.repr:
2870 continue
2871 self._push_token(field.name, "param")
2872 self._push_token("=", "punct")
2873 self._print_nested(getattr(value, field.name))
2874 self._terminate_item()
2875 self._end_container()
2877 self._push_token(")", "punct")
2879 def _print_colorized_repr(self, value):
2880 self._flush_sep_and_line()
2882 res = value.__colorized_repr__(self)
2883 if not isinstance(res, ColorizedString):
2884 raise TypeError(
2885 f"__colorized_repr__ returned non-colorized-string (type {_tx.type_repr(type(res))})"
2886 )
2887 self._line += res
2889 self._state = _ReprContextState.NORMAL
2891 def _print_colorized_str(self, value):
2892 self._flush_sep_and_line()
2894 res = value.__colorized_str__(self)
2895 if not isinstance(res, ColorizedString):
2896 raise TypeError(
2897 f"__colorized_str__ returned non-colorized-string (type {_tx.type_repr(type(res))})"
2898 )
2899 self._line += res
2900 self._state = _ReprContextState.NORMAL
2902 def _print_rich_repr(self, value):
2903 rich_repr = getattr(value, "__rich_repr__")
2904 angular = getattr(rich_repr, "angular", False)
2906 if angular:
2907 self._push_token("<", "punct")
2908 self._push_token(value.__class__.__name__, "type")
2909 if angular:
2910 self._push_token(" ", "space")
2911 else:
2912 self._push_token("(", "punct")
2914 if self._indent >= self.max_depth:
2915 self._push_token("...", "more")
2916 else:
2917 self._start_container()
2918 args = rich_repr()
2919 if args is None:
2920 args = [] # `rich_repr` didn't yield?
2921 for arg in args:
2922 if isinstance(arg, tuple):
2923 if len(arg) == 3:
2924 key, child, default = arg
2925 if default == child:
2926 continue
2927 elif len(arg) == 2:
2928 key, child = arg
2929 elif len(arg) == 1:
2930 key, child = None, arg[0]
2931 else:
2932 key, child = None, arg
2933 else:
2934 key, child = None, arg
2936 if key:
2937 self._push_token(str(key), "param")
2938 self._push_token("=", "punct")
2939 self._print_nested(child)
2940 self._terminate_item("" if angular else ", ")
2941 self._end_container()
2943 self._push_token(">" if angular else ")", "punct")
2946_CONTAINERS = {
2947 os._Environ: lambda c, o: c._print_dict("environ", "({", "})", o.items()),
2948 collections.defaultdict: ReprContext._print_defaultdict,
2949 collections.deque: ReprContext._print_dequeue,
2950 collections.Counter: lambda c, o: c._print_dict("Counter", "({", "})", o.items()),
2951 collections.UserList: lambda c, o: c._print_list("", "[", "]", o),
2952 collections.UserDict: lambda c, o: c._print_dict("", "{", "}", o.items()),
2953 list: lambda c, o: c._print_list("", "[", "]", o),
2954 set: lambda c, o: c._print_list("", "{", "}", o),
2955 frozenset: lambda c, o: c._print_list("frozenset", "({", "})", o),
2956 tuple: lambda c, o: c._print_list("", "(", ")", o),
2957 dict: lambda c, o: c._print_dict("", "{", "}", o.items()),
2958 types.MappingProxyType: lambda _: lambda c, o: c._print_dict(
2959 "mappingproxy", "({", "})", o.items()
2960 ),
2961}
2962_CONTAINER_TYPES = tuple(_CONTAINERS)
2965def _to_colorable(msg: _t.Any, args: tuple[_t.Any, ...] | None = None) -> Colorable:
2966 """
2967 Convert generic `msg`, `args` tuple to a colorable.
2969 If msg is a string, returns :class:`Format`. Otherwise, check that no arguments
2970 were given, and returns `msg` unchanged.
2972 """
2974 if isinstance(msg, (str, _Template)):
2975 return Format(_t.cast(_t.LiteralString, msg), *(args or ()))
2976 else:
2977 if args:
2978 raise TypeError(
2979 f"non-string type {_tx.type_repr(type(msg))} can't have format arguments"
2980 )
2981 return msg
2984class _StrBase(abc.ABC):
2985 def __str__(self) -> str:
2986 import yuio.io
2988 return str(yuio.io.make_repr_context().str(self))
2990 @abc.abstractmethod
2991 def __colorized_str__(self, ctx: ReprContext) -> ColorizedString:
2992 raise NotImplementedError()
2995@repr_from_rich
2996class Format(_StrBase):
2997 """Format(msg: typing.LiteralString, /, *args: typing.Any)
2998 Format(msg: ~string.templatelib.Template, /)
3000 Lazy wrapper that ``%``-formats the given message,
3001 or formats a :class:`~string.templatelib.Template`.
3003 This utility allows saving ``%``-formatted messages and templates and performing
3004 actual formatting lazily when requested. Color tags and backticks
3005 are handled as usual.
3007 :param msg:
3008 message to format.
3009 :param args:
3010 arguments for ``%``-formatting the message.
3011 :example:
3012 ::
3014 >>> message = Format("Hello, `%s`!", "world")
3015 >>> print(message)
3016 Hello, world!
3018 """
3020 @_t.overload
3021 def __init__(self, msg: _t.LiteralString, /, *args: _t.Any): ...
3022 @_t.overload
3023 def __init__(self, msg: _Template, /): ...
3024 def __init__(self, msg: str | _Template, /, *args: _t.Any):
3025 self._msg: str | _Template = msg
3026 self._args: tuple[_t.Any, ...] = args
3028 def __rich_repr__(self) -> RichReprResult:
3029 yield None, self._msg
3030 yield from ((None, arg) for arg in self._args)
3032 def __colorized_str__(self, ctx: ReprContext) -> ColorizedString:
3033 return colorize(self._msg, *self._args, ctx=ctx)
3036@_t.final
3037@repr_from_rich
3038class Repr(_StrBase):
3039 """
3040 Lazy wrapper that calls :meth:`~ReprContext.repr` on the given value.
3042 :param value:
3043 value to repr.
3044 :param multiline:
3045 if given, overrides settings passed to :class:`ReprContext` for this call.
3046 :param highlighted:
3047 if given, overrides settings passed to :class:`ReprContext` for this call.
3048 :example:
3049 .. code-block:: python
3051 config = ...
3052 yuio.io.info(
3053 "Loaded config:\\n`%#+s`", yuio.string.Indent(yuio.string.Repr(config))
3054 )
3056 """
3058 def __init__(
3059 self,
3060 value: _t.Any,
3061 /,
3062 *,
3063 multiline: bool | None = None,
3064 highlighted: bool | None = None,
3065 ):
3066 self.value = value
3067 self.multiline = multiline
3068 self.highlighted = highlighted
3070 def __rich_repr__(self) -> RichReprResult:
3071 yield None, self.value
3072 yield "multiline", self.multiline, None
3073 yield "highlighted", self.highlighted, None
3075 def __colorized_str__(self, ctx: ReprContext) -> ColorizedString:
3076 return ctx.repr(
3077 self.value, multiline=self.multiline, highlighted=self.highlighted
3078 )
3081@_t.final
3082@repr_from_rich
3083class TypeRepr(_StrBase):
3084 """
3085 Lazy wrapper that calls :func:`annotationlib.type_repr` on the given value
3086 and highlights the result.
3088 :param ty:
3089 type to format.
3091 If `ty` is a string, :func:`annotationlib.type_repr` is not called on it,
3092 allowing you to mix types and arbitrary descriptions.
3093 :param highlighted:
3094 if given, overrides settings passed to :class:`ReprContext` for this call.
3095 :example:
3096 .. invisible-code-block: python
3098 value = ...
3100 .. code-block:: python
3102 yuio.io.error("Expected `str`, got `%s`", yuio.string.TypeRepr(type(value)))
3104 """
3106 def __init__(self, ty: _t.Any, /, *, highlighted: bool | None = None):
3107 self._ty = ty
3108 self._highlighted = highlighted
3110 def __rich_repr__(self) -> RichReprResult:
3111 yield None, self._ty
3112 yield "highlighted", self._highlighted, None
3114 def __colorized_str__(self, ctx: ReprContext) -> ColorizedString:
3115 if not isinstance(self._ty, type) and isinstance(
3116 self._ty, (str, ColorizedString)
3117 ):
3118 return ColorizedString(self._ty)
3119 else:
3120 return ctx.hl(_tx.type_repr(self._ty), highlighted=self._highlighted)
3123@repr_from_rich
3124class _JoinBase(_StrBase):
3125 def __init__(
3126 self,
3127 collection: _t.Iterable[_t.Any],
3128 /,
3129 *,
3130 sep: str = ", ",
3131 sep_two: str | None = None,
3132 sep_last: str | None = None,
3133 fallback: AnyString = "",
3134 color: str | _Color | None = "code",
3135 ):
3136 self.__collection = collection
3137 self._sep = sep
3138 self._sep_two = sep_two
3139 self._sep_last = sep_last
3140 self._fallback: AnyString = fallback
3141 self._color = color
3143 @functools.cached_property
3144 def _collection(self):
3145 return list(self.__collection)
3147 @classmethod
3148 def or_(
3149 cls,
3150 collection: _t.Iterable[_t.Any],
3151 /,
3152 *,
3153 fallback: AnyString = "",
3154 color: str | _Color | None = "code",
3155 ) -> _t.Self:
3156 """
3157 Shortcut for joining arguments using word "or" as the last separator.
3159 :example:
3160 ::
3162 >>> print(yuio.string.JoinStr.or_([1, 2, 3]))
3163 1, 2, or 3
3165 """
3167 return cls(
3168 collection, sep_last=", or ", sep_two=" or ", fallback=fallback, color=color
3169 )
3171 @classmethod
3172 def and_(
3173 cls,
3174 collection: _t.Iterable[_t.Any],
3175 /,
3176 *,
3177 fallback: AnyString = "",
3178 color: str | _Color | None = "code",
3179 ) -> _t.Self:
3180 """
3181 Shortcut for joining arguments using word "and" as the last separator.
3183 :example:
3184 ::
3186 >>> print(yuio.string.JoinStr.and_([1, 2, 3]))
3187 1, 2, and 3
3189 """
3191 return cls(
3192 collection,
3193 sep_last=", and ",
3194 sep_two=" and ",
3195 fallback=fallback,
3196 color=color,
3197 )
3199 def __rich_repr__(self) -> RichReprResult:
3200 yield None, self._collection
3201 yield "sep", self._sep, ", "
3202 yield "sep_two", self._sep_two, None
3203 yield "sep_last", self._sep_last, None
3204 yield "color", self._color, "code"
3206 def _render(
3207 self,
3208 theme: yuio.theme.Theme,
3209 to_str: _t.Callable[[_t.Any], ColorizedString],
3210 ) -> ColorizedString:
3211 res = ColorizedString()
3212 color = theme.to_color(self._color)
3214 size = len(self._collection)
3215 if not size:
3216 res += self._fallback
3217 return res
3218 elif size == 1:
3219 return to_str(self._collection[0]).with_base_color(color)
3220 elif size == 2:
3221 res.append_colorized_str(to_str(self._collection[0]).with_base_color(color))
3222 res.append_str(self._sep if self._sep_two is None else self._sep_two)
3223 res.append_colorized_str(to_str(self._collection[1]).with_base_color(color))
3224 return res
3226 last_i = size - 1
3228 sep = self._sep
3229 sep_last = self._sep if self._sep_last is None else self._sep_last
3231 do_sep = False
3232 for i, value in enumerate(self._collection):
3233 if do_sep:
3234 if i == last_i:
3235 res.append_str(sep_last)
3236 else:
3237 res.append_str(sep)
3238 res.append_colorized_str(to_str(value).with_base_color(color))
3239 do_sep = True
3240 return res
3243@_t.final
3244class JoinStr(_JoinBase):
3245 """
3246 Lazy wrapper that calls :meth:`~ReprContext.str` on elements of the given collection,
3247 then joins the results using the given separator.
3249 :param collection:
3250 collection that will be printed.
3251 :param sep:
3252 separator that's printed between elements of the collection.
3253 :param sep_two:
3254 separator that's used when there are only two elements in the collection.
3255 Defaults to `sep`.
3256 :param sep_last:
3257 separator that's used between the last and prior-to-last element
3258 of the collection. Defaults to `sep`.
3259 :param fallback:
3260 printed if collection is empty.
3261 :param color:
3262 color applied to elements of the collection.
3263 :example:
3264 .. code-block:: python
3266 values = ["foo", "bar"]
3267 yuio.io.info("Available values: %s", yuio.string.JoinStr(values))
3269 """
3271 def __colorized_str__(self, ctx: ReprContext) -> ColorizedString:
3272 return self._render(ctx.theme, ctx.str)
3275@_t.final
3276class JoinRepr(_JoinBase):
3277 """
3278 Lazy wrapper that calls :meth:`~ReprContext.repr` on elements of the given collection,
3279 then joins the results using the given separator.
3281 :param collection:
3282 collection that will be printed.
3283 :param sep:
3284 separator that's printed between elements of the collection.
3285 :param sep_two:
3286 separator that's used when there are only two elements in the collection.
3287 Defaults to `sep`.
3288 :param sep_last:
3289 separator that's used between the last and prior-to-last element
3290 of the collection. Defaults to `sep`.
3291 :param fallback:
3292 printed if collection is empty.
3293 :param color:
3294 color applied to elements of the collection.
3295 :example:
3296 .. code-block:: python
3298 values = ["foo", "bar"]
3299 yuio.io.info("Available values: %s", yuio.string.JoinRepr(values))
3301 """
3303 def __colorized_str__(self, ctx: ReprContext) -> ColorizedString:
3304 return self._render(ctx.theme, ctx.repr)
3307And = JoinStr.and_
3308"""
3309Shortcut for :meth:`JoinStr.and_`.
3311"""
3314Or = JoinStr.or_
3315"""
3316Shortcut for :meth:`JoinStr.or_`.
3318"""
3321@_t.final
3322@repr_from_rich
3323class Stack(_StrBase):
3324 """
3325 Lazy wrapper that joins multiple :obj:`Colorable` objects with newlines,
3326 effectively stacking them one on top of another.
3328 :param args:
3329 colorables to stack.
3330 :example:
3331 ::
3333 >>> print(
3334 ... yuio.string.Stack(
3335 ... yuio.string.Format("<c bold magenta>Example:</c>"),
3336 ... yuio.string.Indent(
3337 ... yuio.string.Hl(
3338 ... \"""
3339 ... {
3340 ... "foo": "bar"
3341 ... }
3342 ... \""",
3343 ... syntax="json",
3344 ... ),
3345 ... indent="-> ",
3346 ... ),
3347 ... )
3348 ... )
3349 Example:
3350 -> {
3351 -> "foo": "bar"
3352 -> }
3354 """
3356 def __init__(self, *args: Colorable):
3357 self._args = args
3359 def __rich_repr__(self) -> RichReprResult:
3360 yield from ((None, arg) for arg in self._args)
3362 def __colorized_str__(self, ctx: ReprContext) -> ColorizedString:
3363 res = ColorizedString()
3364 sep = False
3365 for arg in self._args:
3366 if sep:
3367 res.append_color(_Color.NONE)
3368 res.append_str("\n")
3369 res += ctx.str(arg)
3370 sep = True
3371 return res
3374@_t.final
3375@repr_from_rich
3376class Indent(_StrBase):
3377 """
3378 Lazy wrapper that indents the message during formatting.
3380 .. seealso::
3382 :meth:`ColorizedString.indent`.
3384 :param msg:
3385 message to indent.
3386 :param indent:
3387 this will be prepended to the first line in the string.
3388 Defaults to two spaces.
3389 :param continuation_indent:
3390 this will be prepended to subsequent lines in the string.
3391 Defaults to `indent`.
3392 :example:
3393 .. code-block:: python
3395 config = ...
3396 yuio.io.info(
3397 "Loaded config:\\n`%#+s`", yuio.string.Indent(yuio.string.Repr(config))
3398 )
3400 """
3402 def __init__(
3403 self,
3404 msg: Colorable,
3405 /,
3406 *,
3407 indent: AnyString | int = " ",
3408 continuation_indent: AnyString | int | None = None,
3409 ):
3410 self._msg = msg
3411 self._indent: AnyString | int = indent
3412 self._continuation_indent: AnyString | int | None = continuation_indent
3414 def __rich_repr__(self) -> RichReprResult:
3415 yield None, self._msg
3416 yield "indent", self._indent, " "
3417 yield "continuation_indent", self._continuation_indent, None
3419 def __colorized_str__(self, ctx: ReprContext) -> ColorizedString:
3420 if isinstance(self._indent, int):
3421 indent = ColorizedString(" " * self._indent)
3422 else:
3423 indent = ColorizedString(self._indent)
3424 if self._continuation_indent is None:
3425 continuation_indent = indent
3426 elif isinstance(self._continuation_indent, int):
3427 continuation_indent = ColorizedString(" " * self._continuation_indent)
3428 else:
3429 continuation_indent = ColorizedString(self._continuation_indent)
3431 indent_width = max(indent.width, continuation_indent.width)
3432 width = max(1, ctx.width - indent_width)
3434 return ctx.str(self._msg, width=width).indent(indent, continuation_indent)
3437@_t.final
3438@repr_from_rich
3439class Md(_StrBase):
3440 """Md(msg: typing.LiteralString, /, *args, width: int | None | yuio.Missing = yuio.MISSING, dedent: bool = True, allow_headings: bool = True)
3441 Md(msg: str, /, *, width: int | None | yuio.Missing = yuio.MISSING, dedent: bool = True, allow_headings: bool = True)
3443 Lazy wrapper that renders markdown during formatting.
3445 :param md:
3446 markdown to format.
3447 :param args:
3448 arguments for ``%``-formatting the rendered markdown.
3449 :param width:
3450 if given, overrides settings passed to :class:`ReprContext` for this call.
3451 :param dedent:
3452 whether to remove leading indent from markdown.
3453 :param allow_headings:
3454 whether to render headings as actual headings or as paragraphs.
3456 """
3458 @_t.overload
3459 def __init__(
3460 self,
3461 md: _t.LiteralString,
3462 /,
3463 *args: _t.Any,
3464 width: int | None = None,
3465 dedent: bool = True,
3466 allow_headings: bool = True,
3467 ): ...
3468 @_t.overload
3469 def __init__(
3470 self,
3471 md: str,
3472 /,
3473 *,
3474 width: int | None = None,
3475 dedent: bool = True,
3476 allow_headings: bool = True,
3477 ): ...
3478 def __init__(
3479 self,
3480 md: str,
3481 /,
3482 *args: _t.Any,
3483 width: int | None = None,
3484 dedent: bool = True,
3485 allow_headings: bool = True,
3486 ):
3487 self._md: str = md
3488 self._args: tuple[_t.Any, ...] = args
3489 self._width: int | None = width
3490 self._dedent: bool = dedent
3491 self._allow_headings: bool = allow_headings
3493 def __rich_repr__(self) -> RichReprResult:
3494 yield None, self._md
3495 yield from ((None, arg) for arg in self._args)
3496 yield "width", self._width, yuio.MISSING
3497 yield "dedent", self._dedent, True
3498 yield "allow_headings", self._allow_headings, True
3500 def __colorized_str__(self, ctx: ReprContext) -> ColorizedString:
3501 import yuio.md
3503 width = self._width or ctx.width
3504 with ctx.with_settings(width=width):
3505 formatter = yuio.md.MdFormatter(
3506 ctx,
3507 allow_headings=self._allow_headings,
3508 )
3510 res = ColorizedString()
3511 res.start_no_wrap()
3512 sep = False
3513 for line in formatter.format(self._md, dedent=self._dedent):
3514 if sep:
3515 res += "\n"
3516 res += line
3517 sep = True
3518 res.end_no_wrap()
3519 if self._args:
3520 res = res.percent_format(self._args, ctx)
3522 return res
3525@_t.final
3526@repr_from_rich
3527class Hl(_StrBase):
3528 """Hl(code: typing.LiteralString, /, *args, syntax: str | yuio.md.SyntaxHighlighter, dedent: bool = True)
3529 Hl(code: str, /, *, syntax: str | yuio.md.SyntaxHighlighter, dedent: bool = True)
3531 Lazy wrapper that highlights code during formatting.
3533 :param md:
3534 code to highlight.
3535 :param args:
3536 arguments for ``%``-formatting the highlighted code.
3537 :param syntax:
3538 name of syntax or a :class:`~yuio.md.SyntaxHighlighter` instance.
3539 :param dedent:
3540 whether to remove leading indent from code.
3542 """
3544 @_t.overload
3545 def __init__(
3546 self,
3547 code: _t.LiteralString,
3548 /,
3549 *args: _t.Any,
3550 syntax: str | yuio.md.SyntaxHighlighter,
3551 dedent: bool = True,
3552 ): ...
3553 @_t.overload
3554 def __init__(
3555 self,
3556 code: str,
3557 /,
3558 *,
3559 syntax: str | yuio.md.SyntaxHighlighter,
3560 dedent: bool = True,
3561 ): ...
3562 def __init__(
3563 self,
3564 code: str,
3565 /,
3566 *args: _t.Any,
3567 syntax: str | yuio.md.SyntaxHighlighter,
3568 dedent: bool = True,
3569 ):
3570 self._code: str = code
3571 self._args: tuple[_t.Any, ...] = args
3572 self._syntax: str | yuio.md.SyntaxHighlighter = syntax
3573 self._dedent: bool = dedent
3575 def __rich_repr__(self) -> RichReprResult:
3576 yield None, self._code
3577 yield from ((None, arg) for arg in self._args)
3578 yield "syntax", self._syntax
3579 yield "dedent", self._dedent, True
3581 def __colorized_str__(self, ctx: ReprContext) -> ColorizedString:
3582 import yuio.md
3584 syntax = (
3585 self._syntax
3586 if isinstance(self._syntax, yuio.md.SyntaxHighlighter)
3587 else yuio.md.SyntaxHighlighter.get_highlighter(self._syntax)
3588 )
3589 code = self._code
3590 if self._dedent:
3591 code = _dedent(code)
3592 code = code.rstrip()
3594 res = ColorizedString()
3595 res.start_no_wrap()
3596 res += syntax.highlight(ctx.theme, code)
3597 res.end_no_wrap()
3598 if self._args:
3599 res = res.percent_format(self._args, ctx)
3601 return res
3604@_t.final
3605@repr_from_rich
3606class Wrap(_StrBase):
3607 """
3608 Lazy wrapper that wraps the message during formatting.
3610 .. seealso::
3612 :meth:`ColorizedString.wrap`.
3614 :param msg:
3615 message to wrap.
3616 :param width:
3617 if given, overrides settings passed to :class:`ReprContext` for this call.
3618 :param preserve_spaces:
3619 if set to :data:`True`, all spaces are preserved.
3620 Otherwise, consecutive spaces are collapsed when newline break occurs.
3622 Note that tabs always treated as a single whitespace.
3623 :param preserve_newlines:
3624 if set to :data:`True` (default), text is additionally wrapped
3625 on newline sequences. When this happens, the newline sequence that wrapped
3626 the line will be placed into :attr:`~ColorizedString.explicit_newline`.
3628 If set to :data:`False`, newline sequences are treated as whitespaces.
3629 :param break_long_words:
3630 if set to :data:`True` (default), words that don't fit into a single line
3631 will be split into multiple lines.
3632 :param overflow:
3633 Pass :data:`True` to trim overflowing lines and replace them with ellipsis.
3634 :param break_long_nowrap_words:
3635 if set to :data:`True`, words in no-wrap regions that don't fit
3636 into a single line will be split into multiple lines.
3637 :param indent:
3638 this will be prepended to the first line in the string.
3639 Defaults to two spaces.
3640 :param continuation_indent:
3641 this will be prepended to subsequent lines in the string.
3642 Defaults to `indent`.
3644 """
3646 def __init__(
3647 self,
3648 msg: Colorable,
3649 /,
3650 *,
3651 width: int | None = None,
3652 preserve_spaces: bool = False,
3653 preserve_newlines: bool = True,
3654 break_long_words: bool = True,
3655 break_long_nowrap_words: bool = False,
3656 overflow: bool | str = False,
3657 indent: AnyString | int = "",
3658 continuation_indent: AnyString | int | None = None,
3659 ):
3660 self._msg = msg
3661 self._width: int | None = width
3662 self._preserve_spaces = preserve_spaces
3663 self._preserve_newlines = preserve_newlines
3664 self._break_long_words = break_long_words
3665 self._break_long_nowrap_words = break_long_nowrap_words
3666 self._overflow = overflow
3667 self._indent: AnyString | int = indent
3668 self._continuation_indent: AnyString | int | None = continuation_indent
3670 def __rich_repr__(self) -> RichReprResult:
3671 yield None, self._msg
3672 yield "width", self._width, None
3673 yield "indent", self._indent, ""
3674 yield "continuation_indent", self._continuation_indent, None
3675 yield "preserve_spaces", self._preserve_spaces, None
3676 yield "preserve_newlines", self._preserve_newlines, True
3677 yield "break_long_words", self._break_long_words, True
3678 yield "break_long_nowrap_words", self._break_long_nowrap_words, False
3680 def __colorized_str__(self, ctx: ReprContext) -> ColorizedString:
3681 if isinstance(self._indent, int):
3682 indent = ColorizedString(" " * self._indent)
3683 else:
3684 indent = ColorizedString(self._indent)
3685 if self._continuation_indent is None:
3686 continuation_indent = indent
3687 elif isinstance(self._continuation_indent, int):
3688 continuation_indent = ColorizedString(" " * self._continuation_indent)
3689 else:
3690 continuation_indent = ColorizedString(self._continuation_indent)
3692 width = self._width or ctx.width
3693 indent_width = max(indent.width, continuation_indent.width)
3694 inner_width = max(1, width - indent_width)
3696 overflow = self._overflow
3697 if overflow is True:
3698 overflow = ctx.get_msg_decoration("overflow")
3700 res = ColorizedString()
3701 res.start_no_wrap()
3702 sep = False
3703 for line in ctx.str(self._msg, width=inner_width).wrap(
3704 width,
3705 preserve_spaces=self._preserve_spaces,
3706 preserve_newlines=self._preserve_newlines,
3707 break_long_words=self._break_long_words,
3708 break_long_nowrap_words=self._break_long_nowrap_words,
3709 overflow=overflow,
3710 indent=indent,
3711 continuation_indent=continuation_indent,
3712 ):
3713 if sep:
3714 res.append_str("\n")
3715 res.append_colorized_str(line)
3716 sep = True
3717 res.end_no_wrap()
3719 return res
3722@_t.final
3723@repr_from_rich
3724class WithBaseColor(_StrBase):
3725 """
3726 Lazy wrapper that applies the given color "under" the given colorable.
3727 That is, all colors in the rendered colorable will be combined with this color
3728 on the left: ``base_color | color``.
3730 .. seealso::
3732 :meth:`ColorizedString.with_base_color`.
3734 :param msg:
3735 message to highlight.
3736 :param base_color:
3737 color that will be added under the message.
3739 """
3741 def __init__(
3742 self,
3743 msg: Colorable,
3744 /,
3745 *,
3746 base_color: str | _Color,
3747 ):
3748 self._msg = msg
3749 self._base_color = base_color
3751 def __rich_repr__(self) -> RichReprResult:
3752 yield None, self._msg
3753 yield "base_color", self._base_color
3755 def __colorized_str__(self, ctx: ReprContext) -> ColorizedString:
3756 return ctx.str(self._msg).with_base_color(ctx.to_color(self._base_color))
3759@repr_from_rich
3760class Hr(_StrBase):
3761 """Hr(msg: Colorable = "", /, *, weight: int | str = 1, overflow: bool | str = True, **kwargs)
3763 Produces horizontal ruler when converted to string.
3765 :param msg:
3766 any colorable that will be placed in the middle of the ruler.
3767 :param weight:
3768 weight or style of the ruler:
3770 - ``0`` prints no ruler (but still prints centered text),
3771 - ``1`` prints normal ruler,
3772 - ``2`` prints bold ruler.
3774 Additional styles can be added through
3775 :attr:`Theme.msg_decorations <yuio.theme.Theme.msg_decorations_unicode>`.
3776 :param width:
3777 if given, overrides settings passed to :class:`ReprContext` for this call.
3778 :param overflow:
3779 pass :data:`False` to disable trimming `msg` to terminal width.
3780 :param kwargs:
3781 Other keyword arguments override corresponding decorations from the theme:
3783 :`left_start`:
3784 start of the ruler to the left of the message.
3785 :`left_middle`:
3786 filler of the ruler to the left of the message.
3787 :`left_end`:
3788 end of the ruler to the left of the message.
3789 :`middle`:
3790 filler of the ruler that's used if `msg` is empty.
3791 :`right_start`:
3792 start of the ruler to the right of the message.
3793 :`right_middle`:
3794 filler of the ruler to the right of the message.
3795 :`right_end`:
3796 end of the ruler to the right of the message.
3798 """
3800 def __init__(
3801 self,
3802 msg: Colorable = "",
3803 /,
3804 *,
3805 width: int | None = None,
3806 overflow: bool | str = True,
3807 weight: int | str = 1,
3808 left_start: str | None = None,
3809 left_middle: str | None = None,
3810 left_end: str | None = None,
3811 middle: str | None = None,
3812 right_start: str | None = None,
3813 right_middle: str | None = None,
3814 right_end: str | None = None,
3815 ):
3816 self._msg = msg
3817 self._width = width
3818 self._overflow = overflow
3819 self._weight = weight
3820 self._left_start = left_start
3821 self._left_middle = left_middle
3822 self._left_end = left_end
3823 self._middle = middle
3824 self._right_start = right_start
3825 self._right_middle = right_middle
3826 self._right_end = right_end
3828 def __rich_repr__(self) -> RichReprResult:
3829 yield None, self._msg, None
3830 yield "weight", self._weight, None
3831 yield "width", self._width, None
3832 yield "overflow", self._overflow, None
3833 yield "left_start", self._left_start, None
3834 yield "left_middle", self._left_middle, None
3835 yield "left_end", self._left_end, None
3836 yield "middle", self._middle, None
3837 yield "right_start", self._right_start, None
3838 yield "right_middle", self._right_middle, None
3839 yield "right_end", self._right_end, None
3841 def __colorized_str__(self, ctx: ReprContext) -> ColorizedString:
3842 width = self._width or ctx.width
3844 color = ctx.get_color(f"msg/decoration:hr/{self._weight}")
3846 res = ColorizedString(color)
3847 res.start_no_wrap()
3849 msg = ctx.str(self._msg)
3850 if not msg:
3851 res.append_str(self._make_whole(width, ctx))
3852 return res
3854 overflow = self._overflow
3855 if overflow is True:
3856 overflow = ctx.get_msg_decoration("overflow")
3858 sep = False
3859 for line in msg.wrap(
3860 width, preserve_spaces=True, break_long_words=False, overflow=overflow
3861 ):
3862 if sep:
3863 res.append_color(yuio.color.Color.NONE)
3864 res.append_str("\n")
3865 res.append_color(color)
3867 line_w = line.width
3868 line_w_fill = max(0, width - line_w)
3869 line_w_fill_l = line_w_fill // 2
3870 line_w_fill_r = line_w_fill - line_w_fill_l
3871 if not line_w_fill_l and not line_w_fill_r:
3872 res.append_colorized_str(line)
3873 return res
3875 res.append_str(self._make_left(line_w_fill_l, ctx))
3876 res.append_colorized_str(line)
3877 res.append_str(self._make_right(line_w_fill_r, ctx))
3879 sep = True
3881 return res
3883 def _make_left(self, w: int, ctx: ReprContext):
3884 weight = self._weight
3885 start = (
3886 self._left_start
3887 if self._left_start is not None
3888 else ctx.get_msg_decoration(f"hr/{weight}/left_start")
3889 )
3890 middle = (
3891 self._left_middle
3892 if self._left_middle is not None
3893 else ctx.get_msg_decoration(f"hr/{weight}/left_middle")
3894 ) or " "
3895 end = (
3896 self._left_end
3897 if self._left_end is not None
3898 else ctx.get_msg_decoration(f"hr/{weight}/left_end")
3899 )
3901 return _make_left(w, start, middle, end)
3903 def _make_right(self, w: int, ctx: ReprContext):
3904 weight = self._weight
3905 start = (
3906 self._right_start
3907 if self._right_start is not None
3908 else ctx.get_msg_decoration(f"hr/{weight}/right_start")
3909 )
3910 middle = (
3911 self._right_middle
3912 if self._right_middle is not None
3913 else ctx.get_msg_decoration(f"hr/{weight}/right_middle")
3914 ) or " "
3915 end = (
3916 self._right_end
3917 if self._right_end is not None
3918 else ctx.get_msg_decoration(f"hr/{weight}/right_end")
3919 )
3921 return _make_right(w, start, middle, end)
3923 def _make_whole(self, w: int, ctx: ReprContext):
3924 weight = self._weight
3925 start = (
3926 self._left_start
3927 if self._left_start is not None
3928 else ctx.get_msg_decoration(f"hr/{weight}/left_start")
3929 )
3930 middle = (
3931 self._middle
3932 if self._middle is not None
3933 else ctx.get_msg_decoration(f"hr/{weight}/middle")
3934 ) or " "
3935 end = (
3936 self._right_end
3937 if self._right_end is not None
3938 else ctx.get_msg_decoration(f"hr/{weight}/right_end")
3939 )
3941 start_w = line_width(start)
3942 middle_w = line_width(middle)
3943 end_w = line_width(end)
3945 if w >= start_w:
3946 w -= start_w
3947 else:
3948 start = ""
3949 if w >= end_w:
3950 w -= end_w
3951 else:
3952 end = ""
3953 middle_times = w // middle_w
3954 w -= middle_times * middle_w
3955 middle *= middle_times
3956 return start + middle + end + " " * w
3959def _make_left(
3960 w: int,
3961 start: str,
3962 middle: str,
3963 end: str,
3964):
3965 start_w = line_width(start)
3966 middle_w = line_width(middle)
3967 end_w = line_width(end)
3969 if w >= end_w:
3970 w -= end_w
3971 else:
3972 end = ""
3973 if w >= start_w:
3974 w -= start_w
3975 else:
3976 start = ""
3977 middle_times = w // middle_w
3978 w -= middle_times * middle_w
3979 middle *= middle_times
3980 return start + middle + end + " " * w
3983def _make_right(
3984 w: int,
3985 start: str,
3986 middle: str,
3987 end: str,
3988):
3989 start_w = line_width(start)
3990 middle_w = line_width(middle)
3991 end_w = line_width(end)
3993 if w >= start_w:
3994 w -= start_w
3995 else:
3996 start = ""
3997 if w >= end_w:
3998 w -= end_w
3999 else:
4000 end = ""
4001 middle_times = w // middle_w
4002 w -= middle_times * middle_w
4003 middle *= middle_times
4004 return " " * w + start + middle + end