Coverage for yuio / string.py: 99%

1536 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-03-29 19:55 +0000

1# Yuio project, MIT license. 

2# 

3# https://github.com/taminomara/yuio/ 

4# 

5# You're free to copy this file to your project and edit it for your needs, 

6# just keep this copyright line please :3 

7 

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. 

12 

13.. autoclass:: ColorizedString 

14 :members: 

15 

16 

17.. _pretty-protocol: 

18 

19Pretty printing protocol 

20------------------------ 

21 

22Complex message formatting requires knowing capabilities of the target terminal. 

23This affects which message decorations are used (Unicode or ASCII), how lines are 

24wrapped, and so on. This data is encapsulated in an instance of :class:`ReprContext`: 

25 

26.. autoclass:: ReprContext 

27 :members: 

28 

29Repr context may not always be available when a message is created, though. 

30For example, we may know that we will be printing some data, but we don't know 

31whether we'll print it to a file or to a terminal. 

32 

33The solution is to defer formatting by creating a :type:`Colorable`, i.e. an object 

34that defines one of the following special methods: 

35 

36``__colorized_str__``, ``__colorized_repr__`` 

37 This should be a method that accepts a single positional argument, 

38 :class:`ReprContext`, and returns a :class:`ColorizedString`. 

39 

40 .. tip:: 

41 

42 Prefer ``__rich_repr__`` for simpler use cases, and only use 

43 ``__colorized_repr__`` when you need something advanced. 

44 

45 **Example:** 

46 

47 .. code-block:: python 

48 

49 class MyObject: 

50 def __init__(self, value): 

51 self.value = value 

52 

53 def __colorized_str__(self, ctx: yuio.string.ReprContext): 

54 result = yuio.string.ColorizedString() 

55 result += ctx.get_color("magenta") 

56 result += "MyObject" 

57 result += ctx.get_color("normal") 

58 result += "(" 

59 result += ctx.repr(self.value) 

60 result += ")" 

61 return result 

62 

63``__rich_repr__`` 

64 This method doesn't have any arguments. It should return an iterable of tuples 

65 describing object's arguments: 

66 

67 - ``yield name, value`` will generate a keyword argument, 

68 - ``yield name, value, default`` will generate a keyword argument if value 

69 is not equal to default, 

70 - if `name` is :data:`None`, it will generate positional argument instead. 

71 

72 See the `Rich library documentation`__ for more info. 

73 

74 __ https://rich.readthedocs.io/en/stable/pretty.html#rich-repr-protocol 

75 

76 **Example:** 

77 

78 .. code-block:: python 

79 

80 class MyObject: 

81 def __init__(self, value1, value2): 

82 self.value1 = value1 

83 self.value2 = value2 

84 

85 def __rich_repr__(self) -> yuio.string.RichReprResult: 

86 yield "value1", self.value1 

87 yield "value2", self.value2 

88 

89.. type:: RichReprResult 

90 :canonical: typing.Iterable[tuple[typing.Any] | tuple[str | None, typing.Any] | tuple[str | None, typing.Any, typing.Any]] 

91 

92 This is an alias similar to ``rich.repr.Result``, but stricter: it only 

93 allows tuples, not arbitrary values. 

94 

95 This is done to avoid bugs where you yield a single value which happens to contain 

96 a tuple, and Yuio (or Rich) renders it as a named argument. 

97 

98 

99.. type:: ColorizedStrProtocol 

100 

101 Protocol for objects that define ``__colorized_str__`` method. 

102 

103.. type:: ColorizedReprProtocol 

104 

105 Protocol for objects that define ``__colorized_repr__`` method. 

106 

107.. type:: RichReprProtocol 

108 

109 Protocol for objects that define ``__rich_repr__`` method. 

110 

111.. type:: Printable 

112 

113 Any object that supports printing. 

114 

115 Technically, any object supports colorized printing because we'll fall back 

116 to ``__repr__`` or ``__str__`` if there are no special methods on it. 

117 

118 However, we don't use :class:`typing.Any` to avoid potential errors. 

119 

120.. type:: Colorable 

121 :canonical: Printable | ColorizedStrProtocol | ColorizedReprProtocol | RichReprProtocol | ~typing.LiteralString | BaseException 

122 

123 An object that supports colorized printing. 

124 

125 This can be a string, and exception, or any object that follows 

126 :class:`ColorizedStrProtocol`. Additionally, you can pass any object that has 

127 ``__repr__``, but you'll have to wrap it into :type:`Printable` to confirm 

128 your intent to print it. 

129 

130.. type:: ToColorable 

131 :canonical: Colorable | ~string.templatelib.Template 

132 

133 Any object that can be converted to a :type:`Colorable` by formatting it via 

134 :class:`Format`. 

135 

136.. autofunction:: repr_from_rich 

137 

138 

139.. _formatting-utilities: 

140 

141Formatting utilities 

142-------------------- 

143 

144.. autoclass:: Format 

145 :members: 

146 

147.. autoclass:: Repr 

148 :members: 

149 

150.. autoclass:: TypeRepr 

151 :members: 

152 

153.. autoclass:: JoinStr 

154 :members: 

155 :inherited-members: 

156 

157.. autoclass:: JoinRepr 

158 :members: 

159 :inherited-members: 

160 

161.. autofunction:: And 

162 

163.. autofunction:: Or 

164 

165.. autoclass:: Stack 

166 :members: 

167 

168.. autoclass:: Link 

169 :members: 

170 

171.. autoclass:: Indent 

172 :members: 

173 

174.. autoclass:: Md 

175 :members: 

176 

177.. autoclass:: Rst 

178 :members: 

179 

180.. autoclass:: Hl 

181 :members: 

182 

183.. autoclass:: Wrap 

184 :members: 

185 

186.. autoclass:: WithBaseColor 

187 :members: 

188 

189.. autoclass:: Hr 

190 :members: 

191 

192.. autoclass:: Plural 

193 :members: 

194 

195.. autoclass:: Ordinal 

196 :members: 

197 

198 

199Parsing color tags 

200------------------ 

201 

202.. autofunction:: colorize 

203 

204.. autofunction:: strip_color_tags 

205 

206 

207Helpers 

208------- 

209 

210.. autofunction:: line_width 

211 

212.. type:: AnyString 

213 :canonical: str | ~yuio.color.Color | ColorizedString | NoWrapMarker | typing.Iterable[AnyString] 

214 

215 Any string (i.e. a :class:`str`, a raw colorized string, 

216 or a normal colorized string). 

217 

218.. autoclass:: LinkMarker 

219 

220.. autodata:: NO_WRAP_START 

221 

222.. autodata:: NO_WRAP_END 

223 

224.. type:: NoWrapMarker 

225 NoWrapStart 

226 NoWrapEnd 

227 

228 Type of a no-wrap marker. 

229 

230""" 

231 

232from __future__ import annotations 

233 

234import abc 

235import collections 

236import contextlib 

237import dataclasses 

238import functools 

239import os 

240import pathlib 

241import re 

242import reprlib 

243import string 

244import sys 

245import types 

246import unicodedata 

247from dataclasses import dataclass 

248from enum import Enum 

249 

250import yuio 

251import yuio.color 

252import yuio.term 

253import yuio.theme 

254from yuio.color import Color as _Color 

255from yuio.util import UserString as _UserString 

256from yuio.util import dedent as _dedent 

257 

258import yuio._typing_ext as _tx 

259from typing import TYPE_CHECKING 

260 

261if TYPE_CHECKING: 

262 import typing_extensions as _t 

263else: 

264 from yuio import _typing as _t 

265 

266if sys.version_info >= (3, 14): 

267 from string.templatelib import Interpolation as _Interpolation 

268 from string.templatelib import Template as _Template 

269else: 

270 

271 class _Interpolation: ... 

272 

273 class _Template: ... 

274 

275 _Interpolation.__module__ = "string.templatelib" 

276 _Interpolation.__name__ = "Interpolation" 

277 _Interpolation.__qualname__ = "Interpolation" 

278 _Template.__module__ = "string.templatelib" 

279 _Template.__name__ = "Template" 

280 _Template.__qualname__ = "Template" 

281 

282 

283__all__ = [ 

284 "NO_WRAP_END", 

285 "NO_WRAP_START", 

286 "And", 

287 "AnyString", 

288 "Colorable", 

289 "ColorizedReprProtocol", 

290 "ColorizedStrProtocol", 

291 "ColorizedString", 

292 "Esc", 

293 "Format", 

294 "Hl", 

295 "Hr", 

296 "Indent", 

297 "JoinRepr", 

298 "JoinStr", 

299 "Link", 

300 "LinkMarker", 

301 "Md", 

302 "NoWrapEnd", 

303 "NoWrapMarker", 

304 "NoWrapStart", 

305 "Or", 

306 "Ordinal", 

307 "Plural", 

308 "Printable", 

309 "Repr", 

310 "ReprContext", 

311 "RichReprProtocol", 

312 "RichReprResult", 

313 "Rst", 

314 "Stack", 

315 "ToColorable", 

316 "TypeRepr", 

317 "WithBaseColor", 

318 "Wrap", 

319 "colorize", 

320 "line_width", 

321 "repr_from_rich", 

322 "strip_color_tags", 

323] 

324 

325 

326def line_width(s: str, /) -> int: 

327 """ 

328 Calculates string width when the string is displayed 

329 in a terminal. 

330 

331 This function makes effort to detect wide characters 

332 such as emojis. If does not, however, work correctly 

333 with extended grapheme clusters, and so it may fail 

334 for emojis with modifiers, or other complex characters. 

335 

336 Example where it fails is ``👩🏽‍💻``. It consists 

337 of four code points: 

338 

339 - Unicode Character `WOMAN` (``U+1F469``, ``👩``), 

340 - Unicode Character `EMOJI MODIFIER FITZPATRICK TYPE-4` (``U+1F3FD``), 

341 - Unicode Character `ZERO WIDTH JOINER` (``U+200D``), 

342 - Unicode Character `PERSONAL COMPUTER` (``U+1F4BB``, ``💻``). 

343 

344 Since :func:`line_width` can't understand that these code points 

345 are combined into a single emoji, it treats them separately, 

346 resulting in answer `6` (`2` for every code point except `ZERO WIDTH JOINER`):: 

347 

348 >>> line_width("\U0001f469\U0001f3fd\U0000200d\U0001f4bb") 

349 6 

350 

351 In all fairness, detecting how much space such an emoji will take 

352 is not so straight forward, as that will depend on unicode capabilities 

353 of a specific terminal. Since a lot of terminals will not handle such emojis 

354 correctly, I've decided to go with this simplistic implementation. 

355 

356 """ 

357 

358 # Note: it may be better to bundle `wcwidth` and use it instead of the code below. 

359 # However, there is an issue that `wcwidth`'s results are not additive. 

360 # In the above example, `wcswidth('👩🏽‍💻')` will see that it is two-spaces wide, 

361 # while `sum(wcwidth(c) for c in '👩🏽‍💻')` will report that it is four-spaces wide. 

362 # To render it properly, the widget will have to be aware of extended grapheme 

363 # clusters, and generally this will be a lot of headache. Since most terminals 

364 # won't handle these edge cases correctly, I don't want to bother. 

365 

366 if s.isascii(): 

367 # Fast path. Note that our renderer replaces unprintable characters 

368 # with spaces, so ascii strings always have width equal to their length. 

369 return len(s) 

370 else: 

371 # Long path. It kinda works, but not always, but most of the times... 

372 return sum( 

373 (unicodedata.east_asian_width(c) in "WF") + 1 

374 for c in s 

375 if unicodedata.category(c)[0] not in "MC" 

376 ) 

377 

378 

379RichReprResult: _t.TypeAlias = _t.Iterable[ 

380 tuple[_t.Any] | tuple[str | None, _t.Any] | tuple[str | None, _t.Any, _t.Any] 

381] 

382""" 

383Similar to ``rich.repr.Result``, but only allows tuples, not arbitrary values. 

384 

385""" 

386 

387 

388@_t.runtime_checkable 

389class ColorizedStrProtocol(_t.Protocol): 

390 """ 

391 Protocol for objects that define ``__colorized_str__`` method. 

392 

393 """ 

394 

395 @abc.abstractmethod 

396 def __colorized_str__(self, ctx: ReprContext, /) -> ColorizedString: ... 

397 

398 

399@_t.runtime_checkable 

400class ColorizedReprProtocol(_t.Protocol): 

401 """ 

402 Protocol for objects that define ``__colorized_repr__`` method. 

403 

404 """ 

405 

406 @abc.abstractmethod 

407 def __colorized_repr__(self, ctx: ReprContext, /) -> ColorizedString: ... 

408 

409 

410@_t.runtime_checkable 

411class RichReprProtocol(_t.Protocol): 

412 """ 

413 Protocol for objects that define ``__rich_repr__`` method. 

414 

415 """ 

416 

417 @abc.abstractmethod 

418 def __rich_repr__(self) -> _t.Iterable[_t.Any]: ... 

419 

420 

421Printable = _t.NewType("Printable", object) 

422""" 

423Any object that supports printing. 

424 

425Technically, any object supports colorized printing because we'll fall back 

426to ``__repr__`` or ``__str__`` if there are no special methods on it. 

427 

428However, we don't use :class:`typing.Any` to avoid potential errors. 

429 

430""" 

431 

432 

433Colorable: _t.TypeAlias = ( 

434 Printable 

435 | ColorizedStrProtocol 

436 | ColorizedReprProtocol 

437 | RichReprProtocol 

438 | str 

439 | BaseException 

440) 

441""" 

442Any object that supports colorized printing. 

443 

444This can be a string, and exception, or any object that follows 

445:class:`ColorizedStrProtocol`. Additionally, you can pass any object that has 

446``__repr__``, but you'll have to wrap it into :type:`Printable` to confirm 

447your intent to print it. 

448 

449""" 

450 

451ToColorable: _t.TypeAlias = Colorable | _Template 

452""" 

453Any object that can be converted to a :type:`Colorable` by formatting it via 

454:class:`Format`. 

455 

456""" 

457 

458 

459RichReprProtocolT = _t.TypeVar("RichReprProtocolT", bound=RichReprProtocol) 

460 

461 

462def repr_from_rich(cls: type[RichReprProtocolT], /) -> type[RichReprProtocolT]: 

463 """repr_from_rich(cls: RichReprProtocol) -> RichReprProtocol 

464 

465 A decorator that generates ``__repr__`` from ``__rich_repr__``. 

466 

467 :param cls: 

468 class that needs ``__repr__``. 

469 :returns: 

470 always returns `cls`. 

471 :example: 

472 .. code-block:: python 

473 

474 @yuio.string.repr_from_rich 

475 class MyClass: 

476 def __init__(self, value): 

477 self.value = value 

478 

479 def __rich_repr__(self) -> yuio.string.RichReprResult: 

480 yield "value", self.value 

481 

482 :: 

483 

484 >>> print(repr(MyClass("plush!"))) 

485 MyClass(value='plush!') 

486 

487 

488 """ 

489 

490 setattr(cls, "__repr__", _repr_from_rich_impl) 

491 return cls 

492 

493 

494def _repr_from_rich_impl(self: RichReprProtocol): 

495 if rich_repr := getattr(self, "__rich_repr__", None): 

496 args = rich_repr() 

497 angular = getattr(rich_repr, "angular", False) 

498 else: 

499 args = [] 

500 angular = False 

501 

502 if args is None: 

503 args = [] # `rich_repr` didn't yield? 

504 

505 res = [] 

506 

507 if angular: 

508 res.append("<") 

509 res.append(self.__class__.__name__) 

510 if angular: 

511 res.append(" ") 

512 else: 

513 res.append("(") 

514 

515 sep = False 

516 for arg in args: 

517 if isinstance(arg, tuple): 

518 if len(arg) == 3: 

519 key, child, default = arg 

520 if default == child: 

521 continue 

522 elif len(arg) == 2: 

523 key, child = arg 

524 elif len(arg) == 1: 

525 key, child = None, arg[0] 

526 else: 

527 key, child = None, arg 

528 else: 

529 key, child = None, arg 

530 

531 if sep: 

532 res.append(" " if angular else ", ") 

533 if key: 

534 res.append(str(key)) 

535 res.append("=") 

536 res.append(repr(child)) 

537 sep = True 

538 

539 res.append(">" if angular else ")") 

540 

541 return "".join(res) 

542 

543 

544class NoWrapMarker(Enum): 

545 """ 

546 Type for a no-wrap marker. 

547 

548 """ 

549 

550 NO_WRAP_START = "<no_wrap_start>" 

551 NO_WRAP_END = "<no_wrap_end>" 

552 

553 def __repr__(self): 

554 return f"yuio.string.{self.name}" # pragma: no cover 

555 

556 def __str__(self) -> str: 

557 return self.value # pragma: no cover 

558 

559 

560NoWrapStart: _t.TypeAlias = _t.Literal[NoWrapMarker.NO_WRAP_START] 

561""" 

562Type of the :data:`NO_WRAP_START` placeholder. 

563 

564""" 

565 

566NO_WRAP_START: NoWrapStart = NoWrapMarker.NO_WRAP_START 

567""" 

568Indicates start of a no-wrap region in a :class:`ColorizedString`. 

569 

570""" 

571 

572 

573NoWrapEnd: _t.TypeAlias = _t.Literal[NoWrapMarker.NO_WRAP_END] 

574""" 

575Type of the :data:`NO_WRAP_END` placeholder. 

576 

577""" 

578 

579NO_WRAP_END: NoWrapEnd = NoWrapMarker.NO_WRAP_END 

580""" 

581Indicates end of a no-wrap region in a :class:`ColorizedString`. 

582 

583""" 

584 

585 

586@dataclass(slots=True, frozen=True, unsafe_hash=True) 

587class LinkMarker: 

588 """ 

589 Indicates start or end of a hyperlink in a colorized string. 

590 

591 """ 

592 

593 url: str | None 

594 """ 

595 Hyperlink's url. 

596 

597 """ 

598 

599 

600@_t.final 

601@repr_from_rich 

602class ColorizedString: 

603 """ColorizedString() 

604 ColorizedString(rhs: ColorizedString, /) 

605 ColorizedString(*args: AnyString, /) 

606 

607 A string with colors. 

608 

609 This class is a wrapper over a list of strings, colors, and no-wrap markers. 

610 Each color applies to strings after it, right until the next color. 

611 

612 :class:`ColorizedString` supports some basic string operations. 

613 Most notably, it supports wide-character-aware wrapping 

614 (see :meth:`~ColorizedString.wrap`), 

615 and ``%``-like formatting (see :meth:`~ColorizedString.percent_format`). 

616 

617 Unlike :class:`str`, :class:`ColorizedString` is mutable through 

618 the ``+=`` operator and ``append``/``extend`` methods. 

619 

620 :param rhs: 

621 when constructor gets a single :class:`ColorizedString`, it makes a copy. 

622 :param args: 

623 when constructor gets multiple arguments, it creates an empty string 

624 and appends arguments to it. 

625 

626 

627 **String combination semantics** 

628 

629 When you append a :class:`str`, it will take on color and no-wrap semantics 

630 according to the last appended color and no-wrap marker. 

631 

632 When you append another :class:`ColorizedString`, it will not change its colors 

633 based on the last appended color, nor will it affect colors of the consequent 

634 strings. If appended :class:`ColorizedString` had an unterminated no-wrap region 

635 or link region, this region will be terminated after appending. 

636 

637 Thus, appending a colorized string does not change current color, no-wrap 

638 or link setting:: 

639 

640 >>> s1 = yuio.string.ColorizedString() 

641 >>> s1 += yuio.color.Color.FORE_RED 

642 >>> s1 += yuio.string.NO_WRAP_START 

643 >>> s1 += "red nowrap text" 

644 >>> s1 # doctest: +NORMALIZE_WHITESPACE 

645 ColorizedString([yuio.string.NO_WRAP_START, 

646 <Color fore=<RED>>, 

647 'red nowrap text']) 

648 

649 >>> s2 = yuio.string.ColorizedString() 

650 >>> s2 += yuio.color.Color.FORE_GREEN 

651 >>> s2 += "green text " 

652 >>> s2 += s1 

653 >>> s2 += " green text continues" 

654 >>> s2 # doctest: +NORMALIZE_WHITESPACE 

655 ColorizedString([<Color fore=<GREEN>>, 

656 'green text ', 

657 yuio.string.NO_WRAP_START, 

658 <Color fore=<RED>>, 

659 'red nowrap text', 

660 yuio.string.NO_WRAP_END, 

661 <Color fore=<GREEN>>, 

662 ' green text continues']) 

663 

664 """ 

665 

666 # Invariants: 

667 # 

668 # - there is always a color before the first string in `_parts`. 

669 # - there are no empty strings in `_parts`. 

670 # - for every pair of colors in `_parts`, there is a string between them 

671 # (i.e. there are no colors that don't highlight anything). 

672 # - every color in `_parts` is different from the previous one 

673 # (i.e. there are no redundant color markers). 

674 # - `start-no-wrap` and `end-no-wrap` markers form a balanced bracket sequence, 

675 # except for the last `start-no-wrap`, which may have no corresponding 

676 # `end-no-wrap` yet. 

677 # - no-wrap regions can't be nested. 

678 # - for every pair of (start-no-wrap, end-no-wrap) markers, there is a string 

679 # between them (i.e. no empty no-wrap regions). 

680 

681 def __init__( 

682 self, 

683 /, 

684 *args: AnyString, 

685 _isolate_colors: bool = True, 

686 ): 

687 if len(args) == 1 and isinstance(args[0], ColorizedString): 

688 content = args[0] 

689 self._parts = content._parts.copy() 

690 self._last_color = content._last_color 

691 self._active_color = content._active_color 

692 self._last_url = content._last_url 

693 self._active_url = content._active_url 

694 self._explicit_newline = content._explicit_newline 

695 self._len = content._len 

696 self._has_no_wrap = content._has_no_wrap 

697 if (width := content.__dict__.get("width", None)) is not None: 

698 self.__dict__["width"] = width 

699 else: 

700 self._parts: list[_Color | NoWrapMarker | LinkMarker | str] = [] 

701 self._active_color = _Color.NONE 

702 self._last_color: _Color | None = None 

703 self._last_url: str | None = None 

704 self._active_url: str | None = None 

705 self._explicit_newline: str = "" 

706 self._len = 0 

707 self._has_no_wrap = False 

708 

709 if not _isolate_colors: 

710 # Prevent adding `_Color.NONE` to the front of the string. 

711 self._last_color = self._active_color 

712 

713 for arg in args: 

714 self += arg 

715 

716 @property 

717 def explicit_newline(self) -> str: 

718 """ 

719 Explicit newline indicates that a line of a wrapped text 

720 was broken because the original text contained a new line character. 

721 

722 See :meth:`~ColorizedString.wrap` for details. 

723 

724 """ 

725 

726 return self._explicit_newline 

727 

728 @property 

729 def active_color(self) -> _Color: 

730 """ 

731 Last color appended to this string. 

732 

733 """ 

734 

735 return self._active_color 

736 

737 @property 

738 def active_url(self) -> str | None: 

739 """ 

740 Last url appended to this string. 

741 

742 """ 

743 

744 return self._active_url 

745 

746 @functools.cached_property 

747 def width(self) -> int: 

748 """ 

749 String width when the string is displayed in a terminal. 

750 

751 See :func:`line_width` for more information. 

752 

753 """ 

754 

755 return sum(line_width(s) for s in self._parts if isinstance(s, str)) 

756 

757 @property 

758 def len(self) -> int: 

759 """ 

760 Line length in bytes, ignoring all colors. 

761 

762 """ 

763 

764 return self._len 

765 

766 def append_color(self, color: _Color, /): 

767 """ 

768 Append new color to this string. 

769 

770 This operation is lazy, the color will be appended if a non-empty string 

771 is appended after it. 

772 

773 :param color: 

774 color to append. 

775 

776 """ 

777 

778 self._active_color = color 

779 

780 def append_link(self, url: str | None, /): 

781 """ 

782 Append new link marker to this string. 

783 

784 This operation is lazy, the link marker will be appended if a non-empty string 

785 is appended after it.s 

786 

787 :param url: 

788 link url. 

789 

790 """ 

791 

792 self._active_url = url 

793 

794 def start_link(self, url: str, /): 

795 """ 

796 Start hyperlink with the given url. 

797 

798 :param url: 

799 link url. 

800 

801 """ 

802 

803 self._active_url = url 

804 

805 def end_link(self): 

806 """ 

807 End hyperlink. 

808 

809 """ 

810 

811 self._active_url = None 

812 

813 def append_str(self, s: str, /): 

814 """ 

815 Append new plain string to this string. 

816 

817 :param s: 

818 plain string to append. 

819 

820 """ 

821 

822 if not s: 

823 return 

824 if self._last_url != self._active_url: 

825 self._parts.append(LinkMarker(self._active_url)) 

826 self._last_url = self._active_url 

827 if self._last_color != self._active_color: 

828 self._parts.append(self._active_color) 

829 self._last_color = self._active_color 

830 self._parts.append(s) 

831 self._len += len(s) 

832 self.__dict__.pop("width", None) 

833 

834 def append_colorized_str(self, s: ColorizedString, /): 

835 """ 

836 Append new colorized string to this string. 

837 

838 :param s: 

839 colorized string to append. 

840 

841 """ 

842 if not s: 

843 # Nothing to append. 

844 return 

845 

846 parts = s._parts 

847 

848 # Cleanup color at the beginning of the string. 

849 for i, part in enumerate(parts): 

850 if part in (NO_WRAP_START, NO_WRAP_END) or isinstance(part, LinkMarker): 

851 continue 

852 elif isinstance(part, str): # pragma: no cover 

853 # We never hit this branch in normal conditions because colorized 

854 # strings always start with a color. The only way to trigger this 

855 # branch is to tamper with `_parts` and break colorized string 

856 # invariants. 

857 break 

858 

859 # First color in the appended string is the same as our last color. 

860 # We can remove it without changing the outcome. 

861 if part == self._last_color: 

862 if i == 0: 

863 parts = parts[i + 1 :] 

864 else: 

865 parts = parts[:i] + parts[i + 1 :] 

866 

867 break 

868 

869 if self._has_no_wrap: 

870 # We're in a no-wrap sequence, we don't need any more markers. 

871 parts = filter(lambda part: part not in (NO_WRAP_START, NO_WRAP_END), parts) 

872 

873 if self._active_url: 

874 # Current url overrides appended urls. 

875 parts = filter(lambda part: not isinstance(part, LinkMarker), parts) 

876 

877 # Ensure that current url marker is added to the string. 

878 # We don't need to do this with colors because `parts` already starts with 

879 # a correct color. 

880 if self._last_url != self._active_url: 

881 self._parts.append(LinkMarker(self._active_url)) 

882 self._last_url = self._active_url 

883 

884 self._parts.extend(parts) 

885 

886 if not self._has_no_wrap and s._has_no_wrap: 

887 self._has_no_wrap = True 

888 self.end_no_wrap() 

889 if not self._active_url and s._last_url: 

890 self._last_url = s._last_url 

891 

892 self._last_color = s._last_color 

893 self._len += s._len 

894 if (lw := self.__dict__.get("width")) and (rw := s.__dict__.get("width")): 

895 self.__dict__["width"] = lw + rw 

896 else: 

897 self.__dict__.pop("width", None) 

898 

899 def append_no_wrap(self, m: NoWrapMarker, /): 

900 """ 

901 Append a no-wrap marker. 

902 

903 :param m: 

904 no-wrap marker, will be dispatched 

905 to :meth:`~ColorizedString.start_no_wrap` 

906 or :meth:`~ColorizedString.end_no_wrap`. 

907 

908 """ 

909 

910 if m is NO_WRAP_START: 

911 self.start_no_wrap() 

912 else: 

913 self.end_no_wrap() 

914 

915 def start_no_wrap(self): 

916 """ 

917 Start a no-wrap region. 

918 

919 String parts within no-wrap regions are not wrapped on spaces; they can be 

920 hard-wrapped if `break_long_nowrap_words` is :data:`True`. Whitespaces and 

921 newlines in no-wrap regions are preserved regardless of `preserve_spaces` 

922 and `preserve_newlines` settings. 

923 

924 """ 

925 

926 if self._has_no_wrap: 

927 return 

928 

929 self._has_no_wrap = True 

930 self._parts.append(NO_WRAP_START) 

931 

932 def end_no_wrap(self): 

933 """ 

934 End a no-wrap region. 

935 

936 """ 

937 

938 if not self._has_no_wrap: 

939 return 

940 

941 if self._parts and self._parts[-1] is NO_WRAP_START: 

942 # Empty no-wrap sequence, just remove it. 

943 self._parts.pop() 

944 else: 

945 self._parts.append(NO_WRAP_END) 

946 

947 self._has_no_wrap = False 

948 

949 def extend( 

950 self, 

951 parts: _t.Iterable[str | ColorizedString | _Color | NoWrapMarker | LinkMarker], 

952 /, 

953 ): 

954 """ 

955 Extend string from iterable of raw parts. 

956 

957 :param parts: 

958 raw parts that will be appended to the string. 

959 

960 """ 

961 

962 for part in parts: 

963 self += part 

964 

965 def copy(self) -> ColorizedString: 

966 """ 

967 Copy this string. 

968 

969 :returns: 

970 copy of the string. 

971 

972 """ 

973 

974 return ColorizedString(self) 

975 

976 def _split_at(self, i: int, /) -> tuple[ColorizedString, ColorizedString]: 

977 l, r = ColorizedString(), ColorizedString() 

978 l.extend(self._parts[:i]) 

979 r._active_color = l._active_color 

980 r._active_url = l._active_url 

981 r._has_no_wrap = l._has_no_wrap # TODO: waat??? 

982 r.extend(self._parts[i:]) 

983 r._active_color = self._active_color 

984 return l, r 

985 

986 def with_base_color(self, base_color: _Color) -> ColorizedString: 

987 """ 

988 Apply the given color "under" all parts of this string. That is, all colors 

989 in this string will be combined with this color on the left: 

990 ``base_color | color``. 

991 

992 :param base_color: 

993 color that will be added under the string. 

994 :returns: 

995 new string with changed colors, or current string if base color 

996 is :attr:`~yuio.color.Color.NONE`. 

997 :example: 

998 :: 

999 

1000 >>> s1 = yuio.string.ColorizedString([ 

1001 ... "part 1", 

1002 ... yuio.color.Color.FORE_GREEN, 

1003 ... "part 2", 

1004 ... ]) 

1005 >>> s2 = s1.with_base_color( 

1006 ... yuio.color.Color.FORE_RED 

1007 ... | yuio.color.Color.STYLE_BOLD 

1008 ... ) 

1009 >>> s2 # doctest: +NORMALIZE_WHITESPACE 

1010 ColorizedString([<Color fore=<RED> bold=True>, 

1011 'part 1', 

1012 <Color fore=<GREEN> bold=True>, 

1013 'part 2']) 

1014 

1015 """ 

1016 

1017 if base_color == _Color.NONE: 

1018 return self 

1019 

1020 res = ColorizedString() 

1021 

1022 for part in self._parts: 

1023 if isinstance(part, _Color): 

1024 res.append_color(base_color | part) 

1025 else: 

1026 res += part 

1027 res._active_color = base_color | self._active_color 

1028 if self._last_color is not None: 

1029 res._last_color = base_color | self._last_color 

1030 

1031 return res 

1032 

1033 def as_code(self, color_support: yuio.color.ColorSupport, /) -> list[str]: 

1034 """ 

1035 Convert colors in this string to ANSI escape sequences. 

1036 

1037 :param color_support: 

1038 desired level of color support. 

1039 :returns: 

1040 raw parts of colorized string with all colors converted to ANSI 

1041 escape sequences. 

1042 

1043 """ 

1044 

1045 if color_support == yuio.color.ColorSupport.NONE: 

1046 return [part for part in self._parts if isinstance(part, str)] 

1047 else: 

1048 parts: list[str] = [] 

1049 for part in self: 

1050 if isinstance(part, LinkMarker): 

1051 parts.append("\x1b]8;;") 

1052 parts.append(part.url or "") 

1053 parts.append("\x1b\\") 

1054 elif isinstance(part, str): 

1055 parts.append(part) 

1056 elif isinstance(part, _Color): 

1057 parts.append(part.as_code(color_support)) 

1058 if self._last_color != _Color.NONE: 

1059 parts.append(_Color.NONE.as_code(color_support)) 

1060 if self._last_url is not None: 

1061 parts.append("\x1b]8;;\x1b\\") 

1062 return parts 

1063 

1064 def wrap( 

1065 self, 

1066 width: int, 

1067 /, 

1068 *, 

1069 preserve_spaces: bool = False, 

1070 preserve_newlines: bool = True, 

1071 break_long_words: bool = True, 

1072 break_long_nowrap_words: bool = False, 

1073 overflow: _t.Literal[False] | str = False, 

1074 indent: AnyString | int = "", 

1075 continuation_indent: AnyString | int | None = None, 

1076 ) -> list[ColorizedString]: 

1077 """ 

1078 Wrap a long line of text into multiple lines. 

1079 

1080 :param width: 

1081 desired wrapping width. 

1082 :param preserve_spaces: 

1083 if set to :data:`True`, all spaces are preserved. 

1084 Otherwise, consecutive spaces are collapsed into a single space. 

1085 

1086 Note that tabs always treated as a single whitespace. 

1087 :param preserve_newlines: 

1088 if set to :data:`True` (default), text is additionally wrapped 

1089 on newline sequences. When this happens, the newline sequence that wrapped 

1090 the line will be placed into :attr:`~ColorizedString.explicit_newline`. 

1091 

1092 If set to :data:`False`, newline sequences are treated as whitespaces. 

1093 

1094 .. list-table:: Whitespace sequences 

1095 :header-rows: 1 

1096 :stub-columns: 1 

1097 

1098 * - Sequence 

1099 - `preserve_newlines` 

1100 - Result 

1101 * - ``\\n``, ``\\r\\n``, ``\\r`` 

1102 - ``False`` 

1103 - Treated as a single whitespace. 

1104 * - ``\\n``, ``\\r\\n``, ``\\r`` 

1105 - ``True`` 

1106 - Creates a new line. 

1107 * - ``\\v``, ``\\v\\n``, ``\\v\\r\\n``, ``\\v\\r`` 

1108 - Any 

1109 - Always creates a new line. 

1110 

1111 :param break_long_words: 

1112 if set to :data:`True` (default), words that don't fit into a single line 

1113 will be split into multiple lines. 

1114 :param break_long_nowrap_words: 

1115 if set to :data:`True`, words in no-wrap regions that don't fit 

1116 into a single line will be split into multiple lines. 

1117 :param overflow: 

1118 a symbol that will be added to a line if it doesn't fit the given width. 

1119 Pass :data:`False` to keep the overflowing lines without modification. 

1120 :param indent: 

1121 a string that will be prepended before the first line. 

1122 :param continuation_indent: 

1123 a string that will be prepended before all subsequent lines. 

1124 Defaults to `indent`. 

1125 :returns: 

1126 a list of individual lines without newline characters at the end. 

1127 

1128 """ 

1129 

1130 return _TextWrapper( 

1131 width, 

1132 preserve_spaces=preserve_spaces, 

1133 preserve_newlines=preserve_newlines, 

1134 break_long_words=break_long_words, 

1135 break_long_nowrap_words=break_long_nowrap_words, 

1136 overflow=overflow, 

1137 indent=indent, 

1138 continuation_indent=continuation_indent, 

1139 ).wrap(self) 

1140 

1141 def indent( 

1142 self, 

1143 indent: AnyString | int = " ", 

1144 continuation_indent: AnyString | int | None = None, 

1145 ) -> ColorizedString: 

1146 """ 

1147 Indent this string. 

1148 

1149 :param indent: 

1150 this will be prepended to the first line in the string. 

1151 Defaults to two spaces. 

1152 :param continuation_indent: 

1153 this will be prepended to subsequent lines in the string. 

1154 Defaults to `indent`. 

1155 :returns: 

1156 indented string. 

1157 

1158 """ 

1159 

1160 nowrap_indent = ColorizedString() 

1161 nowrap_indent.start_no_wrap() 

1162 nowrap_continuation_indent = ColorizedString() 

1163 nowrap_continuation_indent.start_no_wrap() 

1164 if isinstance(indent, int): 

1165 nowrap_indent.append_str(" " * indent) 

1166 else: 

1167 nowrap_indent += indent 

1168 if continuation_indent is None: 

1169 nowrap_continuation_indent.append_colorized_str(nowrap_indent) 

1170 elif isinstance(continuation_indent, int): 

1171 nowrap_continuation_indent.append_str(" " * continuation_indent) 

1172 else: 

1173 nowrap_continuation_indent += continuation_indent 

1174 

1175 if not nowrap_indent and not nowrap_continuation_indent: 

1176 return self 

1177 

1178 res = ColorizedString() 

1179 

1180 needs_indent = True 

1181 for part in self._parts: 

1182 if not isinstance(part, str) or isinstance(part, Esc): 

1183 res += part 

1184 continue 

1185 

1186 for line in _WORDSEP_NL_RE.split(part): 

1187 if not line: 

1188 continue 

1189 if needs_indent: 

1190 url = res.active_url 

1191 res.end_link() 

1192 res.append_colorized_str(nowrap_indent) 

1193 res.append_link(url) 

1194 nowrap_indent = nowrap_continuation_indent 

1195 res.append_str(line) 

1196 needs_indent = line.endswith(("\n", "\r", "\v")) 

1197 

1198 return res 

1199 

1200 def percent_format(self, args: _t.Any, ctx: ReprContext) -> ColorizedString: 

1201 """ 

1202 Format colorized string as if with ``%``-formatting 

1203 (i.e. `printf-style formatting`__). 

1204 

1205 __ https://docs.python.org/3/library/stdtypes.html#printf-style-string-formatting 

1206 

1207 :param args: 

1208 arguments for formatting. Can be either a tuple of a mapping. Any other 

1209 value will be converted to a tuple of one element. 

1210 :param ctx: 

1211 :class:`ReprContext` that will be passed to ``__colorized_str__`` 

1212 and ``__colorized_repr__`` when formatting colorables. 

1213 :returns: 

1214 formatted string. 

1215 :raises: 

1216 :class:`TypeError`, :class:`ValueError`, :class:`KeyError` if formatting 

1217 fails. 

1218 

1219 """ 

1220 

1221 return _percent_format(self, args, ctx) 

1222 

1223 def __len__(self) -> int: 

1224 return self.len 

1225 

1226 def __bool__(self) -> bool: 

1227 return self.len > 0 

1228 

1229 def __iter__(self) -> _t.Iterator[_Color | NoWrapMarker | LinkMarker | str]: 

1230 return self._parts.__iter__() 

1231 

1232 def __add__(self, rhs: AnyString) -> ColorizedString: 

1233 copy = self.copy() 

1234 copy += rhs 

1235 return copy 

1236 

1237 def __radd__(self, lhs: AnyString) -> ColorizedString: 

1238 copy = ColorizedString(lhs) 

1239 copy += self 

1240 return copy 

1241 

1242 def __iadd__(self, rhs: AnyString) -> ColorizedString: 

1243 if isinstance(rhs, str): 

1244 self.append_str(rhs) 

1245 elif isinstance(rhs, ColorizedString): 

1246 self.append_colorized_str(rhs) 

1247 elif isinstance(rhs, _Color): 

1248 self.append_color(rhs) 

1249 elif rhs in (NO_WRAP_START, NO_WRAP_END): 

1250 self.append_no_wrap(rhs) 

1251 elif isinstance(rhs, LinkMarker): 

1252 self.append_link(rhs.url) 

1253 else: 

1254 self.extend(rhs) 

1255 

1256 return self 

1257 

1258 def __eq__(self, value: object) -> bool: 

1259 if isinstance(value, ColorizedString): 

1260 return self._parts == value._parts 

1261 else: 

1262 return NotImplemented 

1263 

1264 def __ne__(self, value: object) -> bool: 

1265 return not (self == value) 

1266 

1267 def __rich_repr__(self) -> RichReprResult: 

1268 yield None, self._parts 

1269 yield "explicit_newline", self._explicit_newline, "" 

1270 

1271 def __str__(self) -> str: 

1272 return "".join(c for c in self._parts if isinstance(c, str)) 

1273 

1274 def __colorized_str__(self, ctx: ReprContext) -> ColorizedString: 

1275 return self 

1276 

1277 

1278AnyString: _t.TypeAlias = ( 

1279 str 

1280 | ColorizedString 

1281 | _Color 

1282 | NoWrapMarker 

1283 | LinkMarker 

1284 | _t.Iterable[str | ColorizedString | _Color | NoWrapMarker | LinkMarker] 

1285) 

1286""" 

1287Any string (i.e. a :class:`str`, a raw colorized string, or a normal colorized string). 

1288 

1289""" 

1290 

1291 

1292_S_SYNTAX = re.compile( 

1293 r""" 

1294 % # Percent 

1295 (?:\((?P<mapping>[^)]*)\))? # Mapping key 

1296 (?P<flag>[#0\-+ ]*) # Conversion Flag 

1297 (?P<width>\*|\d+)? # Field width 

1298 (?:\.(?P<precision>\*|\d*))? # Precision 

1299 [hlL]? # Unused length modifier 

1300 (?P<format>.) # Conversion type 

1301 """, 

1302 re.VERBOSE, 

1303) 

1304 

1305_F_SYNTAX = re.compile( 

1306 r""" 

1307 ^ 

1308 (?: # Options 

1309 (?: 

1310 (?P<fill>.)? 

1311 (?P<align>[<>=^]) 

1312 )? 

1313 (?P<flags>[+#]*) 

1314 (?P<zero>0)? 

1315 ) 

1316 (?: # Width 

1317 (?P<width>\d+)? 

1318 (?P<width_grouping>[,_])? 

1319 ) 

1320 (?: # Precision 

1321 \. 

1322 (?P<precision>\d+)? 

1323 (?P<precision_grouping>[,_])? 

1324 )? 

1325 (?: # Type 

1326 (?P<type>.) 

1327 )? 

1328 $ 

1329 """, 

1330 re.VERBOSE, 

1331) 

1332 

1333 

1334def _percent_format( 

1335 s: ColorizedString, args: object, ctx: ReprContext 

1336) -> ColorizedString: 

1337 seen_mapping = False 

1338 arg_index = 0 

1339 res = ColorizedString() 

1340 for part in s: 

1341 if isinstance(part, str): 

1342 pos = 0 

1343 for match in _S_SYNTAX.finditer(part): 

1344 if pos < match.start(): 

1345 res.append_str(part[pos : match.start()]) 

1346 seen_mapping = seen_mapping or bool(match.group("mapping")) 

1347 last_color = res.active_color 

1348 arg_index, replaced = _percent_format_repl( 

1349 match, args, arg_index, last_color, ctx 

1350 ) 

1351 res += replaced 

1352 res.append_color(last_color) 

1353 pos = match.end() 

1354 if pos < len(part): 

1355 res.append_str(part[pos:]) 

1356 else: 

1357 res += part 

1358 

1359 if (isinstance(args, tuple) and arg_index < len(args)) or ( 

1360 not isinstance(args, tuple) 

1361 and ( 

1362 not hasattr(args, "__getitem__") 

1363 or isinstance(args, (str, bytes, bytearray)) 

1364 ) 

1365 and not seen_mapping 

1366 and not arg_index 

1367 ): 

1368 raise TypeError("not all arguments converted during string formatting") 

1369 

1370 return res 

1371 

1372 

1373def _percent_format_repl( 

1374 match: _tx.StrReMatch, 

1375 args: object, 

1376 arg_index: int, 

1377 base_color: _Color, 

1378 ctx: ReprContext, 

1379) -> tuple[int, str | ColorizedString]: 

1380 if match.group("format") == "%": 

1381 if match.group(0) != "%%": 

1382 raise ValueError("unsupported format character '%'") 

1383 return arg_index, "%" 

1384 

1385 if match.group("format") in "rsa": 

1386 return _percent_format_repl_str(match, args, arg_index, base_color, ctx) 

1387 

1388 if mapping := match.group("mapping"): 

1389 try: 

1390 fmt_arg = args[mapping] # type: ignore 

1391 except TypeError: 

1392 raise TypeError("format requires a mapping") from None 

1393 fmt_arg, added_color = _unwrap_base_color(fmt_arg, ctx.theme) 

1394 if added_color: 

1395 fmt_args = {mapping: fmt_arg} 

1396 else: 

1397 fmt_args = args 

1398 elif isinstance(args, tuple): 

1399 try: 

1400 fmt_arg = args[arg_index] 

1401 except IndexError: 

1402 raise TypeError("not enough arguments for format string") 

1403 fmt_arg, added_color = _unwrap_base_color(fmt_arg, ctx.theme) 

1404 begin = arg_index + 1 

1405 end = arg_index = ( 

1406 arg_index 

1407 + 1 

1408 + (match.group("width") == "*") 

1409 + (match.group("precision") == "*") 

1410 ) 

1411 fmt_args = (fmt_arg,) + args[begin:end] 

1412 elif arg_index == 0: 

1413 fmt_args, added_color = _unwrap_base_color(args, ctx.theme) 

1414 arg_index += 1 

1415 else: 

1416 raise TypeError("not enough arguments for format string") 

1417 

1418 fmt = match.group(0) % fmt_args 

1419 if added_color: 

1420 added_color = ctx.to_color(added_color) 

1421 fmt = ColorizedString([base_color | added_color, fmt]) 

1422 return arg_index, fmt 

1423 

1424 

1425def _unwrap_base_color(x, theme: yuio.theme.Theme): 

1426 color = None 

1427 while isinstance(x, WithBaseColor): 

1428 x, base_color = x._msg, x._base_color 

1429 base_color = theme.to_color(base_color) 

1430 if color: 

1431 color = color | base_color 

1432 else: 

1433 color = base_color 

1434 else: 

1435 return x, color 

1436 

1437 

1438def _percent_format_repl_str( 

1439 match: _tx.StrReMatch, 

1440 args: object, 

1441 arg_index: int, 

1442 base_color: _Color, 

1443 ctx: ReprContext, 

1444) -> tuple[int, str | ColorizedString]: 

1445 if width_s := match.group("width"): 

1446 if width_s == "*": 

1447 if not isinstance(args, tuple): 

1448 raise TypeError("* wants int") 

1449 try: 

1450 width = args[arg_index] 

1451 arg_index += 1 

1452 except (KeyError, IndexError): 

1453 raise TypeError("not enough arguments for format string") 

1454 if not isinstance(width, int): 

1455 raise TypeError("* wants int") 

1456 else: 

1457 width = int(width_s) 

1458 else: 

1459 width = None 

1460 

1461 if precision_s := match.group("precision"): 

1462 if precision_s == "*": 

1463 if not isinstance(args, tuple): 

1464 raise TypeError("* wants int") 

1465 try: 

1466 precision = args[arg_index] 

1467 arg_index += 1 

1468 except (KeyError, IndexError): 

1469 raise TypeError("not enough arguments for format string") 

1470 if not isinstance(precision, int): 

1471 raise TypeError("* wants int") 

1472 else: 

1473 precision = int(precision_s) 

1474 else: 

1475 precision = None 

1476 

1477 if mapping := match.group("mapping"): 

1478 try: 

1479 fmt_arg = args[mapping] # type: ignore 

1480 except TypeError: 

1481 raise TypeError("format requires a mapping") from None 

1482 elif isinstance(args, tuple): 

1483 try: 

1484 fmt_arg = args[arg_index] 

1485 arg_index += 1 

1486 except IndexError: 

1487 raise TypeError("not enough arguments for format string") from None 

1488 elif arg_index == 0: 

1489 fmt_arg = args 

1490 arg_index += 1 

1491 else: 

1492 raise TypeError("not enough arguments for format string") 

1493 

1494 flag = match.group("flag") 

1495 multiline = "+" in flag 

1496 highlighted = "#" in flag 

1497 

1498 res = ctx.convert( 

1499 fmt_arg, 

1500 match.group("format"), # type: ignore 

1501 multiline=multiline, 

1502 highlighted=highlighted, 

1503 ) 

1504 

1505 align = match.group("flag") 

1506 if width is not None and width < 0: 

1507 width = -width 

1508 align = "<" 

1509 elif align == "-": 

1510 align = "<" 

1511 else: 

1512 align = ">" 

1513 res = _apply_format(res, width, precision, align, " ") 

1514 

1515 return arg_index, res.with_base_color(base_color) 

1516 

1517 

1518def _format_interpolation(interp: _Interpolation, ctx: ReprContext) -> ColorizedString: 

1519 value = interp.value 

1520 if ( 

1521 interp.conversion is not None 

1522 or getattr(type(value), "__format__", None) is object.__format__ 

1523 or isinstance(value, (str, ColorizedString)) 

1524 ): 

1525 value = ctx.convert(value, interp.conversion, interp.format_spec) 

1526 else: 

1527 value = ColorizedString(format(value, interp.format_spec)) 

1528 

1529 return value 

1530 

1531 

1532def _apply_format( 

1533 value: ColorizedString, 

1534 width: int | None, 

1535 precision: int | None, 

1536 align: str | None, 

1537 fill: str | None, 

1538): 

1539 if precision is not None and value.width > precision: 

1540 cut = ColorizedString() 

1541 for part in value: 

1542 if precision <= 0: 

1543 break 

1544 if isinstance(part, str): 

1545 part_width = line_width(part) 

1546 if part_width <= precision: 

1547 cut.append_str(part) 

1548 precision -= part_width 

1549 elif part.isascii(): 

1550 cut.append_str(part[:precision]) 

1551 break 

1552 else: 

1553 for j, ch in enumerate(part): 

1554 precision -= line_width(ch) 

1555 if precision == 0: 

1556 cut.append_str(part[: j + 1]) 

1557 break 

1558 elif precision < 0: 

1559 cut.append_str(part[:j]) 

1560 cut.append_str(" ") 

1561 break 

1562 break 

1563 else: 

1564 cut += part 

1565 value = cut 

1566 

1567 if width is not None and width > value.width: 

1568 fill = fill or " " 

1569 fill_width = line_width(fill) 

1570 spacing = width - value.width 

1571 spacing_fill = spacing // fill_width 

1572 spacing_space = spacing - spacing_fill * fill_width 

1573 value.append_color(_Color.NONE) 

1574 if not align or align == "<": 

1575 value = value + fill * spacing_fill + " " * spacing_space 

1576 elif align == ">": 

1577 value = fill * spacing_fill + " " * spacing_space + value 

1578 else: 

1579 left = spacing_fill // 2 

1580 right = spacing_fill - left 

1581 value = fill * left + value + fill * right + " " * spacing_space 

1582 

1583 return value 

1584 

1585 

1586__TAG_RE = re.compile( 

1587 r""" 

1588 <c (?P<tag_open>[a-z0-9 _/@:-]+)> # _Color tag open. 

1589 | </c> # _Color tag close. 

1590 | \\(?P<punct>[%(punct)s]) # Escape character. 

1591 | (?<!`)(`+)(?!`)(?P<code>.*?)(?<!`)\3(?!`) # Inline code block (backticks). 

1592 """ 

1593 % {"punct": re.escape(string.punctuation)}, 

1594 re.VERBOSE | re.MULTILINE, 

1595) 

1596__NEG_NUM_RE = re.compile(r"^-(0x[0-9a-fA-F]+|0b[01]+|\d+(e[+-]?\d+)?)$") 

1597__FLAG_RE = re.compile(r"^-[-a-zA-Z0-9_]*$") 

1598 

1599 

1600def colorize( 

1601 template: str | _Template, 

1602 /, 

1603 *args: _t.Any, 

1604 ctx: ReprContext, 

1605 default_color: _Color | str = _Color.NONE, 

1606) -> ColorizedString: 

1607 """colorize(line: str, /, *args: typing.Any, ctx: ReprContext, default_color: ~yuio.color.Color | str = Color.NONE, parse_cli_flags_in_backticks: bool = False) -> ColorizedString 

1608 colorize(line: ~string.templatelib.Template, /, *, ctx: ReprContext, default_color: ~yuio.color.Color | str = Color.NONE, parse_cli_flags_in_backticks: bool = False) -> ColorizedString 

1609 

1610 Parse color tags and produce a colorized string. 

1611 

1612 Apply ``default_color`` to the entire paragraph, and process color tags 

1613 and backticks within it. 

1614 

1615 :param line: 

1616 text to colorize. 

1617 :param args: 

1618 if given, string will be ``%``-formatted after parsing. 

1619 Can't be given if `line` is :class:`~string.templatelib.Template`. 

1620 :param ctx: 

1621 :class:`ReprContext` that will be used to look up color tags 

1622 and format arguments. 

1623 :param default_color: 

1624 color or color tag to apply to the entire text. 

1625 :returns: 

1626 a colorized string. 

1627 

1628 """ 

1629 

1630 interpolations: list[tuple[int, _Interpolation]] = [] 

1631 if isinstance(template, _Template): 

1632 if args: 

1633 raise TypeError("args can't be given with template") 

1634 line = "" 

1635 index = 0 

1636 for part, interp in zip(template.strings, template.interpolations): 

1637 line += part 

1638 # Each interpolation is replaced by a zero byte so that our regex knows 

1639 # there is something. 

1640 line += "\0" 

1641 index += len(part) + 1 

1642 interpolations.append((index, interp)) 

1643 line += template.strings[-1] 

1644 else: 

1645 line = template 

1646 

1647 default_color = ctx.to_color(default_color) 

1648 

1649 res = ColorizedString(default_color) 

1650 stack = [default_color] 

1651 last_pos = 0 

1652 last_interp = 0 

1653 

1654 def append_to_res(s: str, start: int): 

1655 nonlocal last_interp 

1656 

1657 index = 0 

1658 while ( 

1659 last_interp < len(interpolations) 

1660 and start + len(s) >= interpolations[last_interp][0] 

1661 ): 

1662 interp_start, interp = interpolations[last_interp] 

1663 res.append_str( 

1664 s[ 

1665 index : interp_start 

1666 - start 

1667 - 1 # This compensates for that `\0` we added above. 

1668 ] 

1669 ) 

1670 res.append_colorized_str( 

1671 _format_interpolation(interp, ctx).with_base_color(res.active_color) 

1672 ) 

1673 index = interp_start - start 

1674 last_interp += 1 

1675 res.append_str(s[index:]) 

1676 

1677 for tag in __TAG_RE.finditer(line): 

1678 append_to_res(line[last_pos : tag.start()], last_pos) 

1679 last_pos = tag.end() 

1680 

1681 if name := tag.group("tag_open"): 

1682 color = stack[-1] | ctx.get_color(name) 

1683 res.append_color(color) 

1684 stack.append(color) 

1685 elif code := tag.group("code"): 

1686 code = code.replace("\n", " ") 

1687 code_pos = tag.start("code") 

1688 if code.startswith(" ") and code.endswith(" ") and not code.isspace(): 

1689 code = code[1:-1] 

1690 code_pos += 1 

1691 if __FLAG_RE.match(code) and not __NEG_NUM_RE.match(code): 

1692 res.append_color(stack[-1] | ctx.get_color("flag")) 

1693 else: 

1694 res.append_color(stack[-1] | ctx.get_color("code")) 

1695 res.start_no_wrap() 

1696 append_to_res(code, code_pos) 

1697 res.end_no_wrap() 

1698 res.append_color(stack[-1]) 

1699 elif punct := tag.group("punct"): 

1700 append_to_res(punct, tag.start("punct")) 

1701 elif len(stack) > 1: 

1702 stack.pop() 

1703 res.append_color(stack[-1]) 

1704 

1705 append_to_res(line[last_pos:], last_pos) 

1706 

1707 if args: 

1708 return res.percent_format(args, ctx) 

1709 else: 

1710 return res 

1711 

1712 

1713def strip_color_tags(s: str) -> str: 

1714 """ 

1715 Remove all color tags from a string. 

1716 

1717 """ 

1718 

1719 raw: list[str] = [] 

1720 

1721 last_pos = 0 

1722 for tag in __TAG_RE.finditer(s): 

1723 raw.append(s[last_pos : tag.start()]) 

1724 last_pos = tag.end() 

1725 

1726 if code := tag.group("code"): 

1727 code = code.replace("\n", " ") 

1728 if code.startswith(" ") and code.endswith(" ") and not code.isspace(): 

1729 code = code[1:-1] 

1730 raw.append(code) 

1731 elif punct := tag.group("punct"): 

1732 raw.append(punct) 

1733 

1734 raw.append(s[last_pos:]) 

1735 

1736 return "".join(raw) 

1737 

1738 

1739class Esc(_UserString): 

1740 """ 

1741 A string that can't be broken during word wrapping even 

1742 if `break_long_nowrap_words` is :data:`True`. 

1743 

1744 """ 

1745 

1746 __slots__ = () 

1747 

1748 

1749_SPACE_TRANS = str.maketrans("\r\n\t\v\b\f", " ") 

1750 

1751_WORD_PUNCT = r'[\w!"\'&.,?]' 

1752_LETTER = r"[^\d\W]" 

1753_NOWHITESPACE = r"[^ \r\n\t\v\b\f]" 

1754 

1755# Copied from textwrap with some modifications in newline handling 

1756_WORDSEP_RE = re.compile( 

1757 r""" 

1758 ( # newlines and line feeds are matched one-by-one 

1759 (?:\r\n|\r|\n|\v\r\n|\v\r|\v\n|\v) 

1760 | # any whitespace 

1761 [ \t\b\f]+ 

1762 | # em-dash between words 

1763 (?<=%(wp)s) -{2,} (?=\w) 

1764 | # word, possibly hyphenated 

1765 %(nws)s+? (?: 

1766 # hyphenated word 

1767 -(?: (?<=%(lt)s{2}-) | (?<=%(lt)s-%(lt)s-)) 

1768 (?= %(lt)s -? %(lt)s) 

1769 | # end of word 

1770 (?=[ \r\n\t\v\b\f]|\Z) 

1771 | # em-dash 

1772 (?<=%(wp)s) (?=-{2,}\w) 

1773 ) 

1774 )""" 

1775 % {"wp": _WORD_PUNCT, "lt": _LETTER, "nws": _NOWHITESPACE}, 

1776 re.VERBOSE, 

1777) 

1778_WORDSEP_NL_RE = re.compile(r"(\r\n|\r|\n|\v\r\n|\v\r|\v\n|\v)") 

1779 

1780 

1781class _TextWrapper: 

1782 def __init__( 

1783 self, 

1784 width: int, 

1785 /, 

1786 *, 

1787 preserve_spaces: bool, 

1788 preserve_newlines: bool, 

1789 break_long_words: bool, 

1790 break_long_nowrap_words: bool, 

1791 overflow: _t.Literal[False] | str, 

1792 indent: AnyString | int, 

1793 continuation_indent: AnyString | int | None, 

1794 ): 

1795 self.width = width 

1796 self.preserve_spaces: bool = preserve_spaces 

1797 self.preserve_newlines: bool = preserve_newlines 

1798 self.break_long_words: bool = break_long_words 

1799 self.break_long_nowrap_words: bool = break_long_nowrap_words 

1800 self.overflow: _t.Literal[False] | str = overflow 

1801 

1802 self.indent = ColorizedString() 

1803 self.indent.start_no_wrap() 

1804 self.continuation_indent = ColorizedString() 

1805 self.continuation_indent.start_no_wrap() 

1806 if isinstance(indent, int): 

1807 self.indent.append_str(" " * indent) 

1808 else: 

1809 self.indent += indent 

1810 if continuation_indent is None: 

1811 self.continuation_indent.append_colorized_str(self.indent) 

1812 elif isinstance(continuation_indent, int): 

1813 self.continuation_indent.append_str(" " * continuation_indent) 

1814 else: 

1815 self.continuation_indent += continuation_indent 

1816 

1817 self.lines: list[ColorizedString] = [] 

1818 

1819 self.current_line = ColorizedString() 

1820 if self.indent: 

1821 self.current_line += self.indent 

1822 self.current_line_width: int = self.indent.width 

1823 self.at_line_start: bool = True 

1824 self.at_line_start_or_indent: bool = True 

1825 self.has_ellipsis: bool = False 

1826 self.add_spaces_before_word: int = 0 

1827 

1828 self.nowrap_start_index = None 

1829 self.nowrap_start_width = 0 

1830 self.nowrap_start_added_space = False 

1831 

1832 def _flush_line(self, explicit_newline=""): 

1833 self.current_line._explicit_newline = explicit_newline 

1834 self.lines.append(self.current_line) 

1835 

1836 next_line = ColorizedString() 

1837 

1838 if self.continuation_indent: 

1839 next_line += self.continuation_indent 

1840 

1841 next_line.append_color(self.current_line.active_color) 

1842 next_line.append_link(self.current_line.active_url) 

1843 

1844 self.current_line = next_line 

1845 self.current_line_width: int = self.continuation_indent.width 

1846 self.at_line_start = True 

1847 self.at_line_start_or_indent = True 

1848 self.has_ellipsis = False 

1849 self.nowrap_start_index = None 

1850 self.nowrap_start_width = 0 

1851 self.nowrap_start_added_space = False 

1852 self.add_spaces_before_word = 0 

1853 

1854 def _flush_line_part(self): 

1855 assert self.nowrap_start_index is not None 

1856 self.current_line, tail = self.current_line._split_at(self.nowrap_start_index) 

1857 tail_width = self.current_line_width - self.nowrap_start_width 

1858 if ( 

1859 self.nowrap_start_added_space 

1860 and self.current_line._parts 

1861 and self.current_line._parts[-1] == " " 

1862 ): 

1863 # Remove space that was added before no-wrap sequence. 

1864 self.current_line._parts.pop() 

1865 self._flush_line() 

1866 self.current_line += tail 

1867 self.current_line.append_color(tail.active_color) 

1868 self.current_line.append_link(tail.active_url) 

1869 self.current_line_width += tail_width 

1870 

1871 def _append_str(self, s: str): 

1872 self.current_line.append_str(s) 

1873 self.at_line_start = False 

1874 self.at_line_start_or_indent = self.at_line_start_or_indent and s.isspace() 

1875 

1876 def _append_word(self, word: str, word_width: int): 

1877 if ( 

1878 self.overflow is not False 

1879 and self.current_line_width + word_width > self.width 

1880 ): 

1881 if isinstance(word, Esc): 

1882 if self.overflow: 

1883 self._add_ellipsis() 

1884 return 

1885 

1886 word_head_len = word_head_width = 0 

1887 

1888 for c in word: 

1889 c_width = line_width(c) 

1890 if self.current_line_width + word_head_width + c_width > self.width: 

1891 break 

1892 word_head_len += 1 

1893 word_head_width += c_width 

1894 

1895 if word_head_len: 

1896 self._append_str(word[:word_head_len]) 

1897 self.has_ellipsis = False 

1898 self.current_line_width += word_head_width 

1899 

1900 if self.overflow: 

1901 self._add_ellipsis() 

1902 else: 

1903 self._append_str(word) 

1904 self.current_line_width += word_width 

1905 self.has_ellipsis = False 

1906 

1907 def _append_space(self): 

1908 if self.add_spaces_before_word: 

1909 word = " " * self.add_spaces_before_word 

1910 self._append_word(word, 1) 

1911 self.add_spaces_before_word = 0 

1912 

1913 def _add_ellipsis(self): 

1914 if self.has_ellipsis: 

1915 # Already has an ellipsis. 

1916 return 

1917 

1918 if self.current_line_width + 1 <= self.width: 

1919 # There's enough space on this line to add new ellipsis. 

1920 self._append_str(str(self.overflow)) 

1921 self.current_line_width += 1 

1922 self.has_ellipsis = True 

1923 elif not self.at_line_start: 

1924 # Modify last word on this line, if there is any. 

1925 parts = self.current_line._parts 

1926 for i in range(len(parts) - 1, -1, -1): 

1927 part = parts[i] 

1928 if isinstance(part, str): 

1929 if not isinstance(part, Esc): 

1930 parts[i] = f"{part[:-1]}{self.overflow}" 

1931 self.has_ellipsis = True 

1932 return 

1933 

1934 def _append_word_with_breaks(self, word: str, word_width: int): 

1935 while self.current_line_width + word_width > self.width: 

1936 word_head_len = word_head_width = 0 

1937 

1938 for c in word: 

1939 c_width = line_width(c) 

1940 if self.current_line_width + word_head_width + c_width > self.width: 

1941 break 

1942 word_head_len += 1 

1943 word_head_width += c_width 

1944 

1945 if self.at_line_start and not word_head_len: 

1946 if self.overflow: 

1947 return 

1948 else: 

1949 word_head_len = 1 

1950 word_head_width += line_width(word[:1]) 

1951 

1952 self._append_word(word[:word_head_len], word_head_width) 

1953 

1954 word = word[word_head_len:] 

1955 word_width -= word_head_width 

1956 

1957 self._flush_line() 

1958 

1959 if word: 

1960 self._append_word(word, word_width) 

1961 

1962 def wrap(self, text: ColorizedString) -> list[ColorizedString]: 

1963 nowrap = False 

1964 

1965 for part in text: 

1966 if isinstance(part, _Color): 

1967 if ( 

1968 self.add_spaces_before_word 

1969 and self.current_line_width + self.add_spaces_before_word 

1970 < self.width 

1971 ): 

1972 # Make sure any whitespace that was added before color 

1973 # is flushed. If it doesn't fit, we just forget it: the line 

1974 # will be wrapped soon anyways. 

1975 self._append_space() 

1976 self.add_spaces_before_word = 0 

1977 self.current_line.append_color(part) 

1978 continue 

1979 elif isinstance(part, LinkMarker): 

1980 if ( 

1981 self.add_spaces_before_word 

1982 and self.current_line_width + self.add_spaces_before_word 

1983 < self.width 

1984 ): 

1985 # Make sure any whitespace that was added before color 

1986 # is flushed. If it doesn't fit, we just forget it: the line 

1987 # will be wrapped soon anyways. 

1988 self._append_space() 

1989 self.add_spaces_before_word = 0 

1990 self.current_line.append_link(part.url) 

1991 continue 

1992 elif part is NO_WRAP_START: 

1993 if nowrap: # pragma: no cover 

1994 continue 

1995 if ( 

1996 self.add_spaces_before_word 

1997 and self.current_line_width + self.add_spaces_before_word 

1998 < self.width 

1999 ): 

2000 # Make sure any whitespace that was added before no-wrap 

2001 # is flushed. If it doesn't fit, we just forget it: the line 

2002 # will be wrapped soon anyways. 

2003 self._append_space() 

2004 self.nowrap_start_added_space = True 

2005 else: 

2006 self.nowrap_start_added_space = False 

2007 self.add_spaces_before_word = 0 

2008 if self.at_line_start: 

2009 self.nowrap_start_index = None 

2010 self.nowrap_start_width = 0 

2011 else: 

2012 self.nowrap_start_index = len(self.current_line._parts) 

2013 self.nowrap_start_width = self.current_line_width 

2014 nowrap = True 

2015 continue 

2016 elif part is NO_WRAP_END: 

2017 nowrap = False 

2018 self.nowrap_start_index = None 

2019 self.nowrap_start_width = 0 

2020 self.nowrap_start_added_space = False 

2021 continue 

2022 

2023 esc = False 

2024 if isinstance(part, Esc): 

2025 words = [Esc(part.translate(_SPACE_TRANS))] 

2026 esc = True 

2027 elif nowrap: 

2028 words = _WORDSEP_NL_RE.split(part) 

2029 else: 

2030 words = _WORDSEP_RE.split(part) 

2031 

2032 for word in words: 

2033 if not word: 

2034 # `_WORDSEP_RE` produces empty strings, skip them. 

2035 continue 

2036 

2037 if word.startswith(("\v", "\r", "\n")): 

2038 # `_WORDSEP_RE` yields one newline sequence at a time, we don't 

2039 # need to split the word further. 

2040 if nowrap or self.preserve_newlines or word.startswith("\v"): 

2041 self._flush_line(explicit_newline=word) 

2042 continue 

2043 else: 

2044 # Treat any newline sequence as a single space. 

2045 word = " " 

2046 

2047 isspace = not esc and word.isspace() 

2048 if isspace: 

2049 if ( 

2050 # Spaces are preserved in no-wrap sequences. 

2051 nowrap 

2052 # Spaces are explicitly preserved. 

2053 or self.preserve_spaces 

2054 # We preserve indentation even if `preserve_spaces` is `False`. 

2055 # We need to check that the previous line ended with an 

2056 # explicit newline, otherwise this is not an indent. 

2057 or ( 

2058 self.at_line_start_or_indent 

2059 and (not self.lines or self.lines[-1].explicit_newline) 

2060 ) 

2061 ): 

2062 word = word.translate(_SPACE_TRANS) 

2063 else: 

2064 self.add_spaces_before_word = len(word) 

2065 continue 

2066 

2067 word_width = line_width(word) 

2068 

2069 if self._try_fit_word(word, word_width): 

2070 # Word fits onto the current line. 

2071 continue 

2072 

2073 if self.nowrap_start_index is not None: 

2074 # Move the entire no-wrap sequence onto the new line. 

2075 self._flush_line_part() 

2076 

2077 if self._try_fit_word(word, word_width): 

2078 # Word fits onto the current line after we've moved 

2079 # no-wrap sequence. Nothing more to do. 

2080 continue 

2081 

2082 if ( 

2083 not self.at_line_start 

2084 and ( 

2085 # Spaces can be broken anywhere, so we don't break line 

2086 # for them: `_append_word_with_breaks` will do it for us. 

2087 # Note: `esc` implies `not isspace`, so all `esc` words 

2088 # outside of no-wrap sequences are handled by this check. 

2089 (not nowrap and not isspace) 

2090 # No-wrap sequences are broken in the middle of any word, 

2091 # so we don't need any special handling for them 

2092 # (again, `_append_word_with_breaks` will do breaking for us). 

2093 # An exception is `esc` words which can't be broken in the middle; 

2094 # if the break is possible at all, it must happen here. 

2095 or (nowrap and esc and self.break_long_nowrap_words) 

2096 ) 

2097 and not ( 

2098 # This is an esc word which wouldn't fit onto this line, nor onto 

2099 # the next line, and there's enough space for an ellipsis 

2100 # on this line (or it already has one). We don't need to break 

2101 # the line here: this word will be passed to `_append_word`, 

2102 # which will handle ellipsis for us. 

2103 self.overflow is not False 

2104 and esc 

2105 and self.continuation_indent.width + word_width > self.width 

2106 and ( 

2107 self.has_ellipsis 

2108 or self.current_line_width + self.add_spaces_before_word + 1 

2109 <= self.width 

2110 ) 

2111 ) 

2112 ): 

2113 # Flush a non-empty line. 

2114 self._flush_line() 

2115 

2116 # Note: `need_space_before_word` is always `False` at this point. 

2117 # `need_space_before_word` becomes `True` only when current line 

2118 # is non-empty, we're not in no-wrap sequence, and `preserve_spaces` 

2119 # is `False` (meaning `isspace` is also `False`). In such situation, 

2120 # we flush the line in the condition above. 

2121 if not esc and ( 

2122 (nowrap and self.break_long_nowrap_words) 

2123 or (not nowrap and (self.break_long_words or isspace)) 

2124 ): 

2125 # We will break the word in the middle if it doesn't fit. 

2126 self._append_word_with_breaks(word, word_width) 

2127 else: 

2128 self._append_word(word, word_width) 

2129 

2130 if self.current_line or not self.lines or self.lines[-1].explicit_newline: 

2131 self._flush_line() 

2132 

2133 return self.lines 

2134 

2135 def _try_fit_word(self, word: str, word_width: int): 

2136 if ( 

2137 self.current_line_width + word_width + self.add_spaces_before_word 

2138 <= self.width 

2139 ): 

2140 self._append_space() 

2141 self._append_word(word, word_width) 

2142 return True 

2143 else: 

2144 return False 

2145 

2146 

2147class _ReprContextState(Enum): 

2148 START = 0 

2149 """ 

2150 Initial state. 

2151 

2152 """ 

2153 

2154 CONTAINER_START = 1 

2155 """ 

2156 Right after a token starting a container was pushed. 

2157 

2158 """ 

2159 

2160 ITEM_START = 2 

2161 """ 

2162 Right after a token separating container items was pushed. 

2163 

2164 """ 

2165 

2166 NORMAL = 3 

2167 """ 

2168 In the middle of a container element. 

2169 

2170 """ 

2171 

2172 

2173@_t.final 

2174class ReprContext: 

2175 """ 

2176 Context object that tracks repr settings and ensures that recursive objects 

2177 are handled properly. 

2178 

2179 .. warning:: 

2180 

2181 :class:`~yuio.string.ReprContext`\\ s are not thread safe. As such, 

2182 you shouldn't create them for long term use. 

2183 

2184 :param term: 

2185 terminal that will be used to print formatted messages. 

2186 :param theme: 

2187 theme that will be used to format messages. 

2188 :param multiline: 

2189 indicates that values rendered via `rich repr protocol`_ 

2190 should be split into multiple lines. Default is :data:`False`. 

2191 :param highlighted: 

2192 indicates that values rendered via `rich repr protocol`_ 

2193 or via built-in :func:`repr` should be highlighted according to python syntax. 

2194 Default is :data:`False`. 

2195 :param max_depth: 

2196 maximum depth of nested containers, after which container's contents 

2197 are not rendered. Default is ``5``. 

2198 :param width: 

2199 maximum width of the content, used when wrapping text, rendering markdown, 

2200 or rendering horizontal rulers. If not given, defaults 

2201 to :attr:`Theme.fallback_width <yuio.theme.Theme.fallback_width>`. 

2202 

2203 .. _rich repr protocol: https://rich.readthedocs.io/en/stable/pretty.html#rich-repr-protocol 

2204 

2205 """ 

2206 

2207 def __init__( 

2208 self, 

2209 *, 

2210 term: yuio.term.Term, 

2211 theme: yuio.theme.Theme, 

2212 multiline: bool | None = None, 

2213 highlighted: bool | None = None, 

2214 max_depth: int | None = None, 

2215 width: int | None = None, 

2216 ): 

2217 self.term = term 

2218 """ 

2219 Current term. 

2220 

2221 """ 

2222 

2223 self.theme = theme 

2224 """ 

2225 Current theme. 

2226 

2227 """ 

2228 

2229 self.multiline: bool = multiline if multiline is not None else False 

2230 """ 

2231 Whether values rendered with :meth:`~ReprContext.repr` are split into multiple lines. 

2232 

2233 """ 

2234 

2235 self.highlighted: bool = highlighted if highlighted is not None else False 

2236 """ 

2237 Whether values rendered with :meth:`~ReprContext.repr` are highlighted. 

2238 

2239 """ 

2240 

2241 self.max_depth: int = max_depth if max_depth is not None else 5 

2242 """ 

2243 Maximum depth of nested containers, after which container's contents 

2244 are not rendered. 

2245 

2246 """ 

2247 

2248 self.width: int = max(width or theme.fallback_width, 1) 

2249 """ 

2250 Maximum width of the content, used when wrapping text or rendering markdown. 

2251 

2252 """ 

2253 

2254 self._seen: set[int] = set() 

2255 self._line = ColorizedString() 

2256 self._indent = 0 

2257 self._state = _ReprContextState.START 

2258 self._pending_sep = None 

2259 

2260 import yuio.hl 

2261 

2262 self._hl, _ = yuio.hl.get_highlighter("repr") 

2263 self._base_color = theme.get_color("msg/text:code/repr") 

2264 

2265 @staticmethod 

2266 def make_dummy(is_unicode: bool = True) -> ReprContext: 

2267 """ 

2268 Make a dummy repr context with default settings. 

2269 

2270 """ 

2271 

2272 return ReprContext( 

2273 term=yuio.term.Term.make_dummy(is_unicode=is_unicode), 

2274 theme=yuio.theme.Theme(), 

2275 ) 

2276 

2277 def get_color(self, paths: str, /) -> yuio.color.Color: 

2278 """ 

2279 Lookup a color by path. 

2280 

2281 """ 

2282 

2283 return self.theme.get_color(paths) 

2284 

2285 def to_color( 

2286 self, color_or_path: yuio.color.Color | str | None, / 

2287 ) -> yuio.color.Color: 

2288 """ 

2289 Convert color or color path to color. 

2290 

2291 """ 

2292 

2293 return self.theme.to_color(color_or_path) 

2294 

2295 def get_msg_decoration(self, name: str, /) -> str: 

2296 """ 

2297 Get message decoration by name. 

2298 

2299 """ 

2300 

2301 return self.theme.get_msg_decoration(name, is_unicode=self.term.is_unicode) 

2302 

2303 def _flush_sep(self, trim: bool = False): 

2304 if self._pending_sep is not None: 

2305 self._push_color("punct") 

2306 if trim: 

2307 self._pending_sep = self._pending_sep.rstrip() 

2308 self._line.append_str(self._pending_sep) 

2309 self._pending_sep = None 

2310 

2311 def _flush_line(self): 

2312 if self.multiline: 

2313 self._line.append_color(self._base_color) 

2314 self._line.append_str("\n") 

2315 if self._indent: 

2316 self._line.append_str(" " * self._indent) 

2317 

2318 def _flush_sep_and_line(self): 

2319 if self.multiline and self._state in [ 

2320 _ReprContextState.CONTAINER_START, 

2321 _ReprContextState.ITEM_START, 

2322 ]: 

2323 self._flush_sep(trim=True) 

2324 self._flush_line() 

2325 else: 

2326 self._flush_sep() 

2327 

2328 def _push_color(self, tag: str): 

2329 if self.highlighted: 

2330 self._line.append_color( 

2331 self._base_color | self.theme.to_color(f"hl/{tag}:repr") 

2332 ) 

2333 

2334 def _push_token(self, content: str, tag: str): 

2335 self._flush_sep_and_line() 

2336 

2337 self._push_color(tag) 

2338 self._line.append_str(content) 

2339 

2340 self._state = _ReprContextState.NORMAL 

2341 

2342 def _terminate_item(self, sep: str = ", "): 

2343 self._flush_sep() 

2344 self._pending_sep = sep 

2345 self._state = _ReprContextState.ITEM_START 

2346 

2347 def _start_container(self): 

2348 self._state = _ReprContextState.CONTAINER_START 

2349 self._indent += 1 

2350 

2351 def _end_container(self): 

2352 self._indent -= 1 

2353 

2354 if self._state in [_ReprContextState.NORMAL, _ReprContextState.ITEM_START]: 

2355 self._flush_line() 

2356 

2357 self._state = _ReprContextState.NORMAL 

2358 self._pending_sep = None 

2359 

2360 def repr( 

2361 self, 

2362 value: _t.Any, 

2363 /, 

2364 *, 

2365 multiline: bool | None = None, 

2366 highlighted: bool | None = None, 

2367 width: int | None = None, 

2368 max_depth: int | None = None, 

2369 ) -> ColorizedString: 

2370 """ 

2371 Convert value to colorized string using repr methods. 

2372 

2373 :param value: 

2374 value to be rendered. 

2375 :param multiline: 

2376 if given, overrides settings passed to :class:`ReprContext` for this call. 

2377 :param highlighted: 

2378 if given, overrides settings passed to :class:`ReprContext` for this call. 

2379 :param width: 

2380 if given, overrides settings passed to :class:`ReprContext` for this call. 

2381 :param max_depth: 

2382 if given, overrides settings passed to :class:`ReprContext` for this call. 

2383 :returns: 

2384 a colorized string containing representation of the `value`. 

2385 :raises: 

2386 this method does not raise any errors. If any inner object raises an 

2387 exception, this function returns a colorized string with 

2388 an error description. 

2389 

2390 """ 

2391 

2392 return self._print( 

2393 value, 

2394 multiline=multiline, 

2395 highlighted=highlighted, 

2396 use_str=False, 

2397 width=width, 

2398 max_depth=max_depth, 

2399 ) 

2400 

2401 def str( 

2402 self, 

2403 value: _t.Any, 

2404 /, 

2405 *, 

2406 multiline: bool | None = None, 

2407 highlighted: bool | None = None, 

2408 width: int | None = None, 

2409 max_depth: int | None = None, 

2410 ) -> ColorizedString: 

2411 """ 

2412 Convert value to colorized string. 

2413 

2414 :param value: 

2415 value to be rendered. 

2416 :param multiline: 

2417 if given, overrides settings passed to :class:`ReprContext` for this call. 

2418 :param highlighted: 

2419 if given, overrides settings passed to :class:`ReprContext` for this call. 

2420 :param width: 

2421 if given, overrides settings passed to :class:`ReprContext` for this call. 

2422 :param max_depth: 

2423 if given, overrides settings passed to :class:`ReprContext` for this call. 

2424 :returns: 

2425 a colorized string containing string representation of the `value`. 

2426 :raises: 

2427 this method does not raise any errors. If any inner object raises an 

2428 exception, this function returns a colorized string with 

2429 an error description. 

2430 

2431 """ 

2432 

2433 return self._print( 

2434 value, 

2435 multiline=multiline, 

2436 highlighted=highlighted, 

2437 use_str=True, 

2438 width=width, 

2439 max_depth=max_depth, 

2440 ) 

2441 

2442 def convert( 

2443 self, 

2444 value: _t.Any, 

2445 conversion: _t.Literal["a", "r", "s"] | None, 

2446 format_spec: str | None = None, 

2447 /, 

2448 *, 

2449 multiline: bool | None = None, 

2450 highlighted: bool | None = None, 

2451 width: int | None = None, 

2452 max_depth: int | None = None, 

2453 ): 

2454 """ 

2455 Perform string conversion, similar to :func:`string.templatelib.convert`, 

2456 and format the object with respect to the given `format_spec`. 

2457 

2458 :param value: 

2459 value to be converted. 

2460 :param conversion: 

2461 string conversion method: 

2462 

2463 - ``'s'`` calls :meth:`~ReprContext.str`, 

2464 - ``'r'`` calls :meth:`~ReprContext.repr`, 

2465 - ``'a'`` calls :meth:`~ReprContext.repr` and escapes non-ascii 

2466 characters. 

2467 :param format_spec: 

2468 formatting spec can override `multiline` and `highlighted`, and controls 

2469 width, alignment, fill chars, etc. See its syntax below. 

2470 :param multiline: 

2471 if given, overrides settings passed to :class:`ReprContext` for this call. 

2472 :param highlighted: 

2473 if given, overrides settings passed to :class:`ReprContext` for this call. 

2474 :param width: 

2475 if given, overrides settings passed to :class:`ReprContext` for this call. 

2476 :param max_depth: 

2477 if given, overrides settings passed to :class:`ReprContext` for this call. 

2478 :returns: 

2479 a colorized string containing string representation of the `value`. 

2480 :raises: 

2481 :class:`ValueError` if `conversion` or `format_spec` are invalid. 

2482 

2483 .. _t-string-spec: 

2484 

2485 **Format specification** 

2486 

2487 .. syntax:diagram:: 

2488 

2489 stack: 

2490 - optional: 

2491 - optional: 

2492 - non_terminal: "fill" 

2493 href: "#t-string-spec-fill" 

2494 - non_terminal: "align" 

2495 href: "#t-string-spec-align" 

2496 - optional: 

2497 - non_terminal: "flags" 

2498 href: "#t-string-spec-flags" 

2499 - optional: 

2500 - comment: "width" 

2501 href: "#t-string-spec-width" 

2502 - "[0-9]+" 

2503 - optional: 

2504 - comment: "precision" 

2505 href: "#t-string-spec-precision" 

2506 - "'.'" 

2507 - "[0-9]+" 

2508 - optional: 

2509 - comment: "conversion type" 

2510 href: "#t-string-spec-conversion-type" 

2511 - "'s'" 

2512 skip_bottom: true 

2513 skip: true 

2514 

2515 .. _t-string-spec-fill: 

2516 

2517 ``fill`` 

2518 Any character that will be used to extend string to the desired width. 

2519 

2520 .. _t-string-spec-align: 

2521 

2522 ``align`` 

2523 Controls alignment of a string when `width` is given: ``"<"`` for flushing 

2524 string left, ``">"`` for flushing string right, ``"^"`` for centering. 

2525 

2526 .. _t-string-spec-flags: 

2527 

2528 ``flags`` 

2529 One or several flags: ``"#"`` to enable highlighting, ``"+"`` to enable 

2530 multiline repr. 

2531 

2532 .. _t-string-spec-width: 

2533 

2534 ``width`` 

2535 If formatted string is narrower than this value, it will be extended and 

2536 aligned using `fill` and `align` settings. 

2537 

2538 .. _t-string-spec-precision: 

2539 

2540 ``precision`` 

2541 If formatted string is wider that this value, it will be cropped to this 

2542 width. 

2543 

2544 .. _t-string-spec-conversion-type: 

2545 

2546 ``conversion type`` 

2547 The only supported conversion type is ``"s"``. 

2548 

2549 """ 

2550 

2551 if format_spec: 

2552 match = _F_SYNTAX.match(format_spec) 

2553 if not match: 

2554 raise ValueError(f"invalid format specifier {format_spec!r}") 

2555 fill = match.group("fill") 

2556 align = match.group("align") 

2557 if align == "=": 

2558 raise ValueError("'=' alignment not allowed in string format specifier") 

2559 flags = match.group("flags") 

2560 if "#" in flags: 

2561 highlighted = True 

2562 if "+" in flags: 

2563 multiline = True 

2564 zero = match.group("zero") 

2565 if zero and not fill: 

2566 fill = zero 

2567 format_width = match.group("width") 

2568 if format_width: 

2569 format_width = int(format_width) 

2570 else: 

2571 format_width = None 

2572 format_width_grouping = match.group("width_grouping") 

2573 if format_width_grouping: 

2574 raise ValueError(f"cannot specify {format_width_grouping!r} with 's'") 

2575 format_precision = match.group("precision") 

2576 if format_precision: 

2577 format_precision = int(format_precision) 

2578 else: 

2579 format_precision = None 

2580 type = match.group("type") 

2581 if type and type != "s": 

2582 raise ValueError(f"unknown format code {type!r}") 

2583 else: 

2584 format_width = format_precision = align = fill = None 

2585 

2586 if conversion == "r": 

2587 res = self.repr( 

2588 value, 

2589 multiline=multiline, 

2590 highlighted=highlighted, 

2591 width=width, 

2592 max_depth=max_depth, 

2593 ) 

2594 elif conversion == "a": 

2595 res = ColorizedString() 

2596 for part in self.repr( 

2597 value, 

2598 multiline=multiline, 

2599 highlighted=highlighted, 

2600 width=width, 

2601 max_depth=max_depth, 

2602 ): 

2603 if isinstance(part, _UserString): 

2604 res += part._wrap( 

2605 part.encode(encoding="unicode_escape").decode("ascii") 

2606 ) 

2607 elif isinstance(part, str): 

2608 res += part.encode(encoding="unicode_escape").decode("ascii") 

2609 else: 

2610 res += part 

2611 elif not conversion or conversion == "s": 

2612 res = self.str( 

2613 value, 

2614 multiline=multiline, 

2615 highlighted=highlighted, 

2616 width=width, 

2617 max_depth=max_depth, 

2618 ) 

2619 else: 

2620 raise ValueError( 

2621 f"unknown conversion {conversion!r}, should be 'a', 'r', or 's'" 

2622 ) 

2623 

2624 return _apply_format(res, format_width, format_precision, align, fill) 

2625 

2626 def hl( 

2627 self, 

2628 value: str, 

2629 /, 

2630 *, 

2631 highlighted: bool | None = None, 

2632 ) -> ColorizedString: 

2633 """ 

2634 Highlight result of :func:`repr`. 

2635 

2636 :meth:`ReprContext.repr` does this automatically, but sometimes you need 

2637 to highlight a string without :func:`repr`-ing it one more time. 

2638 

2639 :param value: 

2640 result of :func:`repr` that needs highlighting. 

2641 :returns: 

2642 highlighted string. 

2643 

2644 """ 

2645 

2646 highlighted = highlighted if highlighted is not None else self.highlighted 

2647 

2648 if highlighted: 

2649 return self._hl.highlight( 

2650 value, theme=self.theme, syntax="repr", default_color=self._base_color 

2651 ) 

2652 else: 

2653 return ColorizedString(value) 

2654 

2655 @contextlib.contextmanager 

2656 def with_settings( 

2657 self, 

2658 *, 

2659 multiline: bool | None = None, 

2660 highlighted: bool | None = None, 

2661 width: int | None = None, 

2662 max_depth: int | None = None, 

2663 ): 

2664 """ 

2665 Temporarily replace settings of this context. 

2666 

2667 :param multiline: 

2668 if given, overrides settings passed to :class:`ReprContext` for this call. 

2669 :param highlighted: 

2670 if given, overrides settings passed to :class:`ReprContext` for this call. 

2671 :param width: 

2672 if given, overrides settings passed to :class:`ReprContext` for this call. 

2673 :param max_depth: 

2674 if given, overrides settings passed to :class:`ReprContext` for this call. 

2675 :returns: 

2676 a context manager that overrides settings. 

2677 

2678 """ 

2679 

2680 old_multiline, self.multiline = ( 

2681 self.multiline, 

2682 (self.multiline if multiline is None else multiline), 

2683 ) 

2684 old_highlighted, self.highlighted = ( 

2685 self.highlighted, 

2686 (self.highlighted if highlighted is None else highlighted), 

2687 ) 

2688 old_width, self.width = ( 

2689 self.width, 

2690 (self.width if width is None else max(width, 1)), 

2691 ) 

2692 old_max_depth, self.max_depth = ( 

2693 self.max_depth, 

2694 (self.max_depth if max_depth is None else max_depth), 

2695 ) 

2696 

2697 try: 

2698 yield 

2699 finally: 

2700 self.multiline = old_multiline 

2701 self.highlighted = old_highlighted 

2702 self.width = old_width 

2703 self.max_depth = old_max_depth 

2704 

2705 def _print( 

2706 self, 

2707 value: _t.Any, 

2708 multiline: bool | None, 

2709 highlighted: bool | None, 

2710 width: int | None, 

2711 max_depth: int | None, 

2712 use_str: bool, 

2713 ) -> ColorizedString: 

2714 old_line, self._line = self._line, ColorizedString() 

2715 old_state, self._state = self._state, _ReprContextState.START 

2716 old_pending_sep, self._pending_sep = self._pending_sep, None 

2717 

2718 try: 

2719 with self.with_settings( 

2720 multiline=multiline, 

2721 highlighted=highlighted, 

2722 width=width, 

2723 max_depth=max_depth, 

2724 ): 

2725 self._print_nested(value, use_str) 

2726 return self._line 

2727 except Exception as e: 

2728 yuio._logger.exception("error in repr context") 

2729 res = ColorizedString() 

2730 res.append_color(_Color.STYLE_INVERSE | _Color.FORE_RED) 

2731 res.append_str(f"{_tx.type_repr(type(e))}: {e}") 

2732 return res 

2733 finally: 

2734 self._line = old_line 

2735 self._state = old_state 

2736 self._pending_sep = old_pending_sep 

2737 

2738 def _print_nested(self, value: _t.Any, use_str: bool = False): 

2739 if id(value) in self._seen or self._indent > self.max_depth: 

2740 self._push_token("...", "more") 

2741 return 

2742 self._seen.add(id(value)) 

2743 old_indent = self._indent 

2744 try: 

2745 if use_str: 

2746 self._print_nested_as_str(value) 

2747 else: 

2748 self._print_nested_as_repr(value) 

2749 finally: 

2750 self._indent = old_indent 

2751 self._seen.remove(id(value)) 

2752 

2753 def _print_nested_as_str(self, value): 

2754 if isinstance(value, type): 

2755 # This is a type. 

2756 self._print_plain(value, convert=_tx.type_repr) 

2757 elif hasattr(value, "__colorized_str__"): 

2758 # Has `__colorized_str__`. 

2759 self._print_colorized_str(value) 

2760 elif getattr(type(value), "__str__", None) is not object.__str__: 

2761 # Has custom `__str__`. 

2762 self._print_plain(value, convert=str, hl=False) 

2763 else: 

2764 # Has default `__str__` which falls back to `__repr__`. 

2765 self._print_nested_as_repr(value) 

2766 

2767 def _print_nested_as_repr(self, value): 

2768 if isinstance(value, type): 

2769 # This is a type. 

2770 self._print_plain(value, convert=_tx.type_repr) 

2771 elif hasattr(value, "__colorized_repr__"): 

2772 # Has `__colorized_repr__`. 

2773 self._print_colorized_repr(value) 

2774 elif hasattr(value, "__rich_repr__"): 

2775 # Has `__rich_repr__`. 

2776 self._print_rich_repr(value) 

2777 elif isinstance(value, _CONTAINER_TYPES): 

2778 # Is a known container. 

2779 for ty, repr_fn in _CONTAINERS.items(): 

2780 if isinstance(value, ty): 

2781 if getattr(type(value), "__repr__", None) is ty.__repr__: 

2782 repr_fn(self, value) # type: ignore 

2783 else: 

2784 self._print_plain(value) 

2785 break 

2786 elif dataclasses.is_dataclass(value): 

2787 # Is a dataclass. 

2788 self._print_dataclass(value) 

2789 else: 

2790 # Fall back to regular `__repr__`. 

2791 self._print_plain(value) 

2792 

2793 def _print_plain(self, value, convert=None, hl=True): 

2794 convert = convert or repr 

2795 

2796 self._flush_sep_and_line() 

2797 

2798 if hl and self.highlighted: 

2799 self._line += self._hl.highlight( 

2800 convert(value), 

2801 theme=self.theme, 

2802 syntax="repr", 

2803 default_color=self._base_color, 

2804 ) 

2805 else: 

2806 self._line.append_str(convert(value)) 

2807 

2808 self._state = _ReprContextState.NORMAL 

2809 

2810 def _print_list(self, name: str, obrace: str, cbrace: str, items): 

2811 if name: 

2812 self._push_token(name, "type") 

2813 self._push_token(obrace, "punct") 

2814 if self._indent >= self.max_depth: 

2815 self._push_token("...", "more") 

2816 else: 

2817 self._start_container() 

2818 for item in items: 

2819 self._print_nested(item) 

2820 self._terminate_item() 

2821 self._end_container() 

2822 self._push_token(cbrace, "punct") 

2823 

2824 def _print_dict(self, name: str, obrace: str, cbrace: str, items): 

2825 if name: 

2826 self._push_token(name, "type") 

2827 self._push_token(obrace, "punct") 

2828 if self._indent >= self.max_depth: 

2829 self._push_token("...", "more") 

2830 else: 

2831 self._start_container() 

2832 for key, value in items: 

2833 self._print_nested(key) 

2834 self._push_token(": ", "punct") 

2835 self._print_nested(value) 

2836 self._terminate_item() 

2837 self._end_container() 

2838 self._push_token(cbrace, "punct") 

2839 

2840 def _print_defaultdict(self, value: collections.defaultdict[_t.Any, _t.Any]): 

2841 self._push_token("defaultdict", "type") 

2842 self._push_token("(", "punct") 

2843 if self._indent >= self.max_depth: 

2844 self._push_token("...", "more") 

2845 else: 

2846 self._start_container() 

2847 self._print_nested(value.default_factory) 

2848 self._terminate_item() 

2849 self._print_dict("", "{", "}", value.items()) 

2850 self._terminate_item() 

2851 self._end_container() 

2852 self._push_token(")", "punct") 

2853 

2854 def _print_dequeue(self, value: collections.deque[_t.Any]): 

2855 self._push_token("deque", "type") 

2856 self._push_token("(", "punct") 

2857 if self._indent >= self.max_depth: 

2858 self._push_token("...", "more") 

2859 else: 

2860 self._start_container() 

2861 self._print_list("", "[", "]", value) 

2862 self._terminate_item() 

2863 if value.maxlen is not None: 

2864 self._push_token("maxlen", "param") 

2865 self._push_token("=", "punct") 

2866 self._print_nested(value.maxlen) 

2867 self._terminate_item() 

2868 self._end_container() 

2869 self._push_token(")", "punct") 

2870 

2871 def _print_dataclass(self, value): 

2872 try: 

2873 # If dataclass has a custom repr, fall back to it. 

2874 # This code is copied from Rich, MIT License. 

2875 # See https://github.com/Textualize/rich/blob/master/LICENSE 

2876 has_custom_repr = value.__repr__.__code__.co_filename not in ( 

2877 dataclasses.__file__, 

2878 reprlib.__file__, 

2879 ) 

2880 except Exception: # pragma: no cover 

2881 has_custom_repr = True 

2882 

2883 if has_custom_repr: 

2884 self._print_plain(value) 

2885 return 

2886 

2887 self._push_token(value.__class__.__name__, "type") 

2888 self._push_token("(", "punct") 

2889 

2890 if self._indent >= self.max_depth: 

2891 self._push_token("...", "more") 

2892 else: 

2893 self._start_container() 

2894 for field in dataclasses.fields(value): 

2895 if not field.repr: 

2896 continue 

2897 self._push_token(field.name, "param") 

2898 self._push_token("=", "punct") 

2899 self._print_nested(getattr(value, field.name)) 

2900 self._terminate_item() 

2901 self._end_container() 

2902 

2903 self._push_token(")", "punct") 

2904 

2905 def _print_colorized_repr(self, value): 

2906 self._flush_sep_and_line() 

2907 

2908 res = value.__colorized_repr__(self) 

2909 if not isinstance(res, ColorizedString): 

2910 raise TypeError( 

2911 f"__colorized_repr__ returned non-colorized-string (type {_tx.type_repr(type(res))})" 

2912 ) 

2913 self._line += res 

2914 

2915 self._state = _ReprContextState.NORMAL 

2916 

2917 def _print_colorized_str(self, value): 

2918 self._flush_sep_and_line() 

2919 

2920 res = value.__colorized_str__(self) 

2921 if not isinstance(res, ColorizedString): 

2922 raise TypeError( 

2923 f"__colorized_str__ returned non-colorized-string (type {_tx.type_repr(type(res))})" 

2924 ) 

2925 self._line += res 

2926 self._state = _ReprContextState.NORMAL 

2927 

2928 def _print_rich_repr(self, value): 

2929 rich_repr = getattr(value, "__rich_repr__") 

2930 angular = getattr(rich_repr, "angular", False) 

2931 

2932 if angular: 

2933 self._push_token("<", "punct") 

2934 self._push_token(value.__class__.__name__, "type") 

2935 if angular: 

2936 self._push_token(" ", "space") 

2937 else: 

2938 self._push_token("(", "punct") 

2939 

2940 if self._indent >= self.max_depth: 

2941 self._push_token("...", "more") 

2942 else: 

2943 self._start_container() 

2944 args = rich_repr() 

2945 if args is None: 

2946 args = [] # `rich_repr` didn't yield? 

2947 for arg in args: 

2948 if isinstance(arg, tuple): 

2949 if len(arg) == 3: 

2950 key, child, default = arg 

2951 if default == child: 

2952 continue 

2953 elif len(arg) == 2: 

2954 key, child = arg 

2955 elif len(arg) == 1: 

2956 key, child = None, arg[0] 

2957 else: 

2958 key, child = None, arg 

2959 else: 

2960 key, child = None, arg 

2961 

2962 if key: 

2963 self._push_token(str(key), "param") 

2964 self._push_token("=", "punct") 

2965 self._print_nested(child) 

2966 self._terminate_item("" if angular else ", ") 

2967 self._end_container() 

2968 

2969 self._push_token(">" if angular else ")", "punct") 

2970 

2971 

2972_CONTAINERS = { 

2973 os._Environ: lambda c, o: c._print_dict("environ", "({", "})", o.items()), 

2974 collections.defaultdict: ReprContext._print_defaultdict, 

2975 collections.deque: ReprContext._print_dequeue, 

2976 collections.Counter: lambda c, o: c._print_dict("Counter", "({", "})", o.items()), 

2977 collections.UserList: lambda c, o: c._print_list("", "[", "]", o), 

2978 collections.UserDict: lambda c, o: c._print_dict("", "{", "}", o.items()), 

2979 list: lambda c, o: c._print_list("", "[", "]", o), 

2980 set: lambda c, o: c._print_list("", "{", "}", o), 

2981 frozenset: lambda c, o: c._print_list("frozenset", "({", "})", o), 

2982 tuple: lambda c, o: c._print_list("", "(", ")", o), 

2983 dict: lambda c, o: c._print_dict("", "{", "}", o.items()), 

2984 types.MappingProxyType: lambda _: ( 

2985 lambda c, o: c._print_dict("mappingproxy", "({", "})", o.items()) 

2986 ), 

2987} 

2988_CONTAINER_TYPES = tuple(_CONTAINERS) 

2989 

2990 

2991def _to_colorable(msg: _t.Any, args: tuple[_t.Any, ...] | None = None) -> Colorable: 

2992 """ 

2993 Convert generic `msg`, `args` tuple to a colorable. 

2994 

2995 If msg is a string, returns :class:`Format`. Otherwise, check that no arguments 

2996 were given, and returns `msg` unchanged. 

2997 

2998 """ 

2999 

3000 if isinstance(msg, (str, _Template)): 

3001 return Format(_t.cast(_t.LiteralString, msg), *(args or ())) 

3002 else: 

3003 if args: 

3004 raise TypeError( 

3005 f"non-string type {_tx.type_repr(type(msg))} can't have format arguments" 

3006 ) 

3007 return msg 

3008 

3009 

3010class _StrBase(abc.ABC): 

3011 def __str__(self) -> str: 

3012 import yuio.io 

3013 

3014 return str(yuio.io.make_repr_context().str(self)) 

3015 

3016 @abc.abstractmethod 

3017 def __colorized_str__(self, ctx: ReprContext) -> ColorizedString: 

3018 raise NotImplementedError() 

3019 

3020 

3021@repr_from_rich 

3022class Format(_StrBase): 

3023 """Format(msg: typing.LiteralString, /, *args: typing.Any) 

3024 Format(msg: ~string.templatelib.Template, /) 

3025 

3026 Lazy wrapper that ``%``-formats the given message, 

3027 or formats a :class:`~string.templatelib.Template`. 

3028 

3029 This utility allows saving ``%``-formatted messages and templates and performing 

3030 actual formatting lazily when requested. Color tags and backticks 

3031 are handled as usual. 

3032 

3033 :param msg: 

3034 message to format. 

3035 :param args: 

3036 arguments for ``%``-formatting the message. 

3037 :example: 

3038 :: 

3039 

3040 >>> message = Format("Hello, `%s`!", "world") 

3041 >>> print(message) 

3042 Hello, world! 

3043 

3044 """ 

3045 

3046 @_t.overload 

3047 def __init__(self, msg: _t.LiteralString, /, *args: _t.Any): ... 

3048 @_t.overload 

3049 def __init__(self, msg: _Template, /): ... 

3050 def __init__(self, msg: str | _Template, /, *args: _t.Any): 

3051 self._msg: str | _Template = msg 

3052 self._args: tuple[_t.Any, ...] = args 

3053 

3054 def __rich_repr__(self) -> RichReprResult: 

3055 yield None, self._msg 

3056 yield from ((None, arg) for arg in self._args) 

3057 

3058 def __colorized_str__(self, ctx: ReprContext) -> ColorizedString: 

3059 return colorize(self._msg, *self._args, ctx=ctx) 

3060 

3061 

3062@_t.final 

3063@repr_from_rich 

3064class Repr(_StrBase): 

3065 """ 

3066 Lazy wrapper that calls :meth:`~ReprContext.repr` on the given value. 

3067 

3068 :param value: 

3069 value to repr. 

3070 :param multiline: 

3071 if given, overrides settings passed to :class:`ReprContext` for this call. 

3072 :param highlighted: 

3073 if given, overrides settings passed to :class:`ReprContext` for this call. 

3074 :example: 

3075 .. code-block:: python 

3076 

3077 config = ... 

3078 yuio.io.info( 

3079 "Loaded config:\\n`%#+s`", yuio.string.Indent(yuio.string.Repr(config)) 

3080 ) 

3081 

3082 """ 

3083 

3084 def __init__( 

3085 self, 

3086 value: _t.Any, 

3087 /, 

3088 *, 

3089 multiline: bool | None = None, 

3090 highlighted: bool | None = None, 

3091 ): 

3092 self.value = value 

3093 self.multiline = multiline 

3094 self.highlighted = highlighted 

3095 

3096 def __rich_repr__(self) -> RichReprResult: 

3097 yield None, self.value 

3098 yield "multiline", self.multiline, None 

3099 yield "highlighted", self.highlighted, None 

3100 

3101 def __colorized_str__(self, ctx: ReprContext) -> ColorizedString: 

3102 return ctx.repr( 

3103 self.value, multiline=self.multiline, highlighted=self.highlighted 

3104 ) 

3105 

3106 

3107@_t.final 

3108@repr_from_rich 

3109class TypeRepr(_StrBase): 

3110 """ 

3111 Lazy wrapper that calls :func:`annotationlib.type_repr` on the given value 

3112 and highlights the result. 

3113 

3114 :param ty: 

3115 type to format. 

3116 

3117 If `ty` is a string, :func:`annotationlib.type_repr` is not called on it, 

3118 allowing you to mix types and arbitrary descriptions. 

3119 :param highlighted: 

3120 if given, overrides settings passed to :class:`ReprContext` for this call. 

3121 :example: 

3122 .. invisible-code-block: python 

3123 

3124 value = ... 

3125 

3126 .. code-block:: python 

3127 

3128 yuio.io.error("Expected `str`, got `%s`", yuio.string.TypeRepr(type(value))) 

3129 

3130 """ 

3131 

3132 def __init__(self, ty: _t.Any, /, *, highlighted: bool | None = None): 

3133 self._ty = ty 

3134 self._highlighted = highlighted 

3135 

3136 def __rich_repr__(self) -> RichReprResult: 

3137 yield None, self._ty 

3138 yield "highlighted", self._highlighted, None 

3139 

3140 def __colorized_str__(self, ctx: ReprContext) -> ColorizedString: 

3141 if not isinstance(self._ty, type) and isinstance( 

3142 self._ty, (str, ColorizedString) 

3143 ): 

3144 return ColorizedString(self._ty) 

3145 else: 

3146 return ctx.hl(_tx.type_repr(self._ty), highlighted=self._highlighted) 

3147 

3148 

3149@repr_from_rich 

3150class _JoinBase(_StrBase): 

3151 def __init__( 

3152 self, 

3153 collection: _t.Iterable[_t.Any], 

3154 /, 

3155 *, 

3156 sep: str = ", ", 

3157 sep_two: str | None = None, 

3158 sep_last: str | None = None, 

3159 fallback: AnyString = "", 

3160 color: str | _Color | None = "code", 

3161 limit: int = 0, 

3162 limit_msg: str | None = None, 

3163 ): 

3164 self.__collection = collection 

3165 self._sep = sep 

3166 self._sep_two = sep_two 

3167 self._sep_last = sep_last 

3168 self._fallback: AnyString = fallback 

3169 self._color = color 

3170 self._limit = limit 

3171 self._limit_msg = limit_msg if limit_msg is not None else ", +{n} more" 

3172 

3173 @functools.cached_property 

3174 def _collection(self): 

3175 return list(self.__collection) 

3176 

3177 @classmethod 

3178 def or_( 

3179 cls, 

3180 collection: _t.Iterable[_t.Any], 

3181 /, 

3182 *, 

3183 fallback: AnyString = "", 

3184 color: str | _Color | None = "code", 

3185 limit: int = 0, 

3186 ) -> _t.Self: 

3187 """ 

3188 Shortcut for joining arguments using word "or" as the last separator. 

3189 

3190 :example: 

3191 :: 

3192 

3193 >>> print(yuio.string.JoinStr.or_([1, 2, 3])) 

3194 1, 2, or 3 

3195 

3196 """ 

3197 

3198 return cls( 

3199 collection, 

3200 sep_last=", or ", 

3201 sep_two=" or ", 

3202 fallback=fallback, 

3203 color=color, 

3204 limit=limit, 

3205 limit_msg=", or {n} more", 

3206 ) 

3207 

3208 @classmethod 

3209 def and_( 

3210 cls, 

3211 collection: _t.Iterable[_t.Any], 

3212 /, 

3213 *, 

3214 fallback: AnyString = "", 

3215 color: str | _Color | None = "code", 

3216 limit: int = 0, 

3217 ) -> _t.Self: 

3218 """ 

3219 Shortcut for joining arguments using word "and" as the last separator. 

3220 

3221 :example: 

3222 :: 

3223 

3224 >>> print(yuio.string.JoinStr.and_([1, 2, 3])) 

3225 1, 2, and 3 

3226 

3227 """ 

3228 

3229 return cls( 

3230 collection, 

3231 sep_last=", and ", 

3232 sep_two=" and ", 

3233 fallback=fallback, 

3234 color=color, 

3235 limit=limit, 

3236 limit_msg=", and {n} more", 

3237 ) 

3238 

3239 def __rich_repr__(self) -> RichReprResult: 

3240 yield None, self._collection 

3241 yield "sep", self._sep, ", " 

3242 yield "sep_two", self._sep_two, None 

3243 yield "sep_last", self._sep_last, None 

3244 yield "color", self._color, "code" 

3245 

3246 def _render( 

3247 self, 

3248 theme: yuio.theme.Theme, 

3249 to_str: _t.Callable[[_t.Any], ColorizedString], 

3250 ) -> ColorizedString: 

3251 res = ColorizedString() 

3252 color = theme.to_color(self._color) 

3253 

3254 size = len(self._collection) 

3255 if not size: 

3256 res += self._fallback 

3257 return res 

3258 elif size == 1: 

3259 return to_str(self._collection[0]).with_base_color(color) 

3260 elif size == 2: 

3261 res.append_colorized_str(to_str(self._collection[0]).with_base_color(color)) 

3262 res.append_str(self._sep if self._sep_two is None else self._sep_two) 

3263 res.append_colorized_str(to_str(self._collection[1]).with_base_color(color)) 

3264 return res 

3265 

3266 if self._limit: 

3267 limit = last_i = self._limit 

3268 else: 

3269 limit = size 

3270 last_i = size - 1 

3271 

3272 sep = self._sep 

3273 sep_last = self._sep if self._sep_last is None else self._sep_last 

3274 

3275 do_sep = False 

3276 for i, value in enumerate(self._collection): 

3277 if i == last_i and limit + 1 < size: 

3278 res.append_str(self._limit_msg.format(n=size - limit)) 

3279 break 

3280 if do_sep: 

3281 if i == last_i: 

3282 res.append_str(sep_last) 

3283 else: 

3284 res.append_str(sep) 

3285 res.append_colorized_str(to_str(value).with_base_color(color)) 

3286 do_sep = True 

3287 return res 

3288 

3289 

3290@_t.final 

3291class JoinStr(_JoinBase): 

3292 """ 

3293 Lazy wrapper that calls :meth:`~ReprContext.str` on elements of the given collection, 

3294 then joins the results using the given separator. 

3295 

3296 :param collection: 

3297 collection that will be printed. 

3298 :param sep: 

3299 separator that's printed between elements of the collection. 

3300 :param sep_two: 

3301 separator that's used when there are only two elements in the collection. 

3302 Defaults to `sep`. 

3303 :param sep_last: 

3304 separator that's used between the last and prior-to-last element 

3305 of the collection. Defaults to `sep`. 

3306 :param fallback: 

3307 printed if collection is empty. 

3308 :param color: 

3309 color applied to elements of the collection. 

3310 :param limit: 

3311 truncate number of entries to this limit. 

3312 :param limit_msg: 

3313 message that replaces truncated part. Will be :meth:`formatted <str.format>` 

3314 with a single keyword argument `n` -- number of truncated entries. Default 

3315 is ``"+{n} more"``. 

3316 :example: 

3317 .. code-block:: python 

3318 

3319 values = ["foo", "bar"] 

3320 yuio.io.info("Available values: %s", yuio.string.JoinStr(values)) 

3321 

3322 """ 

3323 

3324 def __colorized_str__(self, ctx: ReprContext) -> ColorizedString: 

3325 return self._render(ctx.theme, ctx.str) 

3326 

3327 

3328@_t.final 

3329class JoinRepr(_JoinBase): 

3330 """ 

3331 Lazy wrapper that calls :meth:`~ReprContext.repr` on elements of the given collection, 

3332 then joins the results using the given separator. 

3333 

3334 :param collection: 

3335 collection that will be printed. 

3336 :param sep: 

3337 separator that's printed between elements of the collection. 

3338 :param sep_two: 

3339 separator that's used when there are only two elements in the collection. 

3340 Defaults to `sep`. 

3341 :param sep_last: 

3342 separator that's used between the last and prior-to-last element 

3343 of the collection. Defaults to `sep`. 

3344 :param fallback: 

3345 printed if collection is empty. 

3346 :param color: 

3347 color applied to elements of the collection. 

3348 :param limit: 

3349 truncate number of entries to this limit. 

3350 :param limit_msg: 

3351 message that replaces truncated part. Will be ``%``-formatted with a single 

3352 keyword argument `n` -- number of truncated entries. Default 

3353 is ``"+{n} more"``. 

3354 :example: 

3355 .. code-block:: python 

3356 

3357 values = ["foo", "bar"] 

3358 yuio.io.info("Available values: %s", yuio.string.JoinRepr(values)) 

3359 

3360 """ 

3361 

3362 def __colorized_str__(self, ctx: ReprContext) -> ColorizedString: 

3363 return self._render(ctx.theme, ctx.repr) 

3364 

3365 

3366And = JoinStr.and_ 

3367""" 

3368Shortcut for :meth:`JoinStr.and_`. 

3369 

3370""" 

3371 

3372 

3373Or = JoinStr.or_ 

3374""" 

3375Shortcut for :meth:`JoinStr.or_`. 

3376 

3377""" 

3378 

3379 

3380@_t.final 

3381@repr_from_rich 

3382class Stack(_StrBase): 

3383 """ 

3384 Lazy wrapper that joins multiple :obj:`Colorable` objects with newlines, 

3385 effectively stacking them one on top of another. 

3386 

3387 :param args: 

3388 colorables to stack. 

3389 :example: 

3390 :: 

3391 

3392 >>> print( 

3393 ... yuio.string.Stack( 

3394 ... yuio.string.Format("<c bold magenta>Example:</c>"), 

3395 ... yuio.string.Indent( 

3396 ... yuio.string.Hl( 

3397 ... \""" 

3398 ... { 

3399 ... "foo": "bar" 

3400 ... } 

3401 ... \""", 

3402 ... syntax="json", 

3403 ... ), 

3404 ... indent="-> ", 

3405 ... ), 

3406 ... ) 

3407 ... ) 

3408 Example: 

3409 -> { 

3410 -> "foo": "bar" 

3411 -> } 

3412 

3413 """ 

3414 

3415 def __init__(self, *args: Colorable): 

3416 self._args = args 

3417 

3418 def __rich_repr__(self) -> RichReprResult: 

3419 yield from ((None, arg) for arg in self._args) 

3420 

3421 def __colorized_str__(self, ctx: ReprContext) -> ColorizedString: 

3422 res = ColorizedString() 

3423 sep = False 

3424 for arg in self._args: 

3425 if sep: 

3426 res.append_color(_Color.NONE) 

3427 res.append_str("\n") 

3428 res += ctx.str(arg) 

3429 sep = True 

3430 return res 

3431 

3432 

3433@_t.final 

3434@repr_from_rich 

3435class Link(_StrBase): 

3436 """ 

3437 Lazy wrapper that adds a hyperlink to whatever is passed to it. 

3438 

3439 :param msg: 

3440 link body. 

3441 :param url: 

3442 link url, should be properly urlencoded. 

3443 

3444 """ 

3445 

3446 def __init__(self, msg: Colorable, /, *, url: str): 

3447 self._msg = msg 

3448 self._url = url 

3449 

3450 @classmethod 

3451 def from_path(cls, msg: Colorable, /, *, path: str | pathlib.Path) -> _t.Self: 

3452 """ 

3453 Create a link to a local file. 

3454 

3455 Ensures that file path is absolute and properly formatted. 

3456 

3457 :param msg: 

3458 link body. 

3459 :param path: 

3460 path to a file. 

3461 

3462 """ 

3463 

3464 url = pathlib.Path(path).expanduser().absolute().as_uri() 

3465 return cls(msg, url=url) 

3466 

3467 def __rich_repr__(self) -> RichReprResult: 

3468 yield None, self._msg 

3469 yield "url", self._url 

3470 

3471 def __colorized_str__(self, ctx: ReprContext) -> ColorizedString: 

3472 res = ColorizedString() 

3473 if not ctx.term.supports_colors or yuio.term.detect_ci(): 

3474 res.append_colorized_str(ctx.str(self._msg)) 

3475 res.append_str(" ") 

3476 res.start_no_wrap() 

3477 res.append_str("[") 

3478 res.append_str(self._url) 

3479 res.append_str("]") 

3480 res.end_no_wrap() 

3481 else: 

3482 res.start_link(self._url) 

3483 res.append_colorized_str(ctx.str(self._msg)) 

3484 res.end_link() 

3485 return res 

3486 

3487 

3488@_t.final 

3489@repr_from_rich 

3490class Indent(_StrBase): 

3491 """ 

3492 Lazy wrapper that indents the message during formatting. 

3493 

3494 .. seealso:: 

3495 

3496 :meth:`ColorizedString.indent`. 

3497 

3498 :param msg: 

3499 message to indent. 

3500 :param indent: 

3501 this will be prepended to the first line in the string. 

3502 Defaults to two spaces. 

3503 :param continuation_indent: 

3504 this will be prepended to subsequent lines in the string. 

3505 Defaults to `indent`. 

3506 :example: 

3507 .. code-block:: python 

3508 

3509 config = ... 

3510 yuio.io.info( 

3511 "Loaded config:\\n`%#+s`", yuio.string.Indent(yuio.string.Repr(config)) 

3512 ) 

3513 

3514 """ 

3515 

3516 def __init__( 

3517 self, 

3518 msg: Colorable, 

3519 /, 

3520 *, 

3521 indent: AnyString | int = " ", 

3522 continuation_indent: AnyString | int | None = None, 

3523 ): 

3524 self._msg = msg 

3525 self._indent: AnyString | int = indent 

3526 self._continuation_indent: AnyString | int | None = continuation_indent 

3527 

3528 def __rich_repr__(self) -> RichReprResult: 

3529 yield None, self._msg 

3530 yield "indent", self._indent, " " 

3531 yield "continuation_indent", self._continuation_indent, None 

3532 

3533 def __colorized_str__(self, ctx: ReprContext) -> ColorizedString: 

3534 if isinstance(self._indent, int): 

3535 indent = ColorizedString(" " * self._indent) 

3536 else: 

3537 indent = ColorizedString(self._indent) 

3538 if self._continuation_indent is None: 

3539 continuation_indent = indent 

3540 elif isinstance(self._continuation_indent, int): 

3541 continuation_indent = ColorizedString(" " * self._continuation_indent) 

3542 else: 

3543 continuation_indent = ColorizedString(self._continuation_indent) 

3544 

3545 indent_width = max(indent.width, continuation_indent.width) 

3546 width = max(1, ctx.width - indent_width) 

3547 

3548 return ctx.str(self._msg, width=width).indent(indent, continuation_indent) 

3549 

3550 

3551@_t.final 

3552@repr_from_rich 

3553class Md(_StrBase): 

3554 """Md(msg: typing.LiteralString, /, *args, width: int | None | yuio.Missing = yuio.MISSING, dedent: bool = True, allow_headings: bool = True) 

3555 Md(msg: str, /, *, width: int | None | yuio.Missing = yuio.MISSING, dedent: bool = True, allow_headings: bool = True) 

3556 

3557 Lazy wrapper that renders markdown during formatting. 

3558 

3559 :param md: 

3560 text to format. 

3561 :param width: 

3562 if given, overrides settings passed to :class:`ReprContext` for this call. 

3563 :param dedent: 

3564 whether to remove leading indent from text. 

3565 :param allow_headings: 

3566 whether to render headings as actual headings or as paragraphs. 

3567 

3568 """ 

3569 

3570 def __init__( 

3571 self, 

3572 md: str, 

3573 /, 

3574 *, 

3575 width: int | None = None, 

3576 dedent: bool = True, 

3577 allow_headings: bool = True, 

3578 ): 

3579 self._md: str = md 

3580 self._width: int | None = width 

3581 self._dedent: bool = dedent 

3582 self._allow_headings: bool = allow_headings 

3583 

3584 def __rich_repr__(self) -> RichReprResult: 

3585 yield None, self._md 

3586 yield "width", self._width, yuio.MISSING 

3587 yield "dedent", self._dedent, True 

3588 yield "allow_headings", self._allow_headings, True 

3589 

3590 def __colorized_str__(self, ctx: ReprContext) -> ColorizedString: 

3591 import yuio.doc 

3592 import yuio.md 

3593 

3594 width = self._width or ctx.width 

3595 with ctx.with_settings(width=width): 

3596 formatter = yuio.doc.Formatter( 

3597 ctx, 

3598 allow_headings=self._allow_headings, 

3599 ) 

3600 

3601 res = ColorizedString() 

3602 res.start_no_wrap() 

3603 sep = False 

3604 for line in formatter.format(yuio.md.parse(self._md, dedent=self._dedent)): 

3605 if sep: 

3606 res += "\n" 

3607 res += line 

3608 sep = True 

3609 res.end_no_wrap() 

3610 

3611 return res 

3612 

3613 

3614@_t.final 

3615@repr_from_rich 

3616class Rst(_StrBase): 

3617 """Rst(msg: typing.LiteralString, /, *args, width: int | None | yuio.Missing = yuio.MISSING, dedent: bool = True, allow_headings: bool = True) 

3618 Rst(msg: str, /, *, width: int | None | yuio.Missing = yuio.MISSING, dedent: bool = True, allow_headings: bool = True) 

3619 

3620 Lazy wrapper that renders ReStructuredText during formatting. 

3621 

3622 :param rst: 

3623 text to format. 

3624 :param width: 

3625 if given, overrides settings passed to :class:`ReprContext` for this call. 

3626 :param dedent: 

3627 whether to remove leading indent from text. 

3628 :param allow_headings: 

3629 whether to render headings as actual headings or as paragraphs. 

3630 

3631 """ 

3632 

3633 def __init__( 

3634 self, 

3635 rst: str, 

3636 /, 

3637 *, 

3638 width: int | None = None, 

3639 dedent: bool = True, 

3640 allow_headings: bool = True, 

3641 ): 

3642 self._rst: str = rst 

3643 self._width: int | None = width 

3644 self._dedent: bool = dedent 

3645 self._allow_headings: bool = allow_headings 

3646 

3647 def __rich_repr__(self) -> RichReprResult: 

3648 yield None, self._rst 

3649 yield "width", self._width, yuio.MISSING 

3650 yield "dedent", self._dedent, True 

3651 yield "allow_headings", self._allow_headings, True 

3652 

3653 def __colorized_str__(self, ctx: ReprContext) -> ColorizedString: 

3654 import yuio.doc 

3655 import yuio.rst 

3656 

3657 width = self._width or ctx.width 

3658 with ctx.with_settings(width=width): 

3659 formatter = yuio.doc.Formatter( 

3660 ctx, 

3661 allow_headings=self._allow_headings, 

3662 ) 

3663 

3664 res = ColorizedString() 

3665 res.start_no_wrap() 

3666 sep = False 

3667 for line in formatter.format( 

3668 yuio.rst.parse(self._rst, dedent=self._dedent) 

3669 ): 

3670 if sep: 

3671 res += "\n" 

3672 res += line 

3673 sep = True 

3674 res.end_no_wrap() 

3675 

3676 return res 

3677 

3678 

3679@_t.final 

3680@repr_from_rich 

3681class Hl(_StrBase): 

3682 """Hl(code: typing.LiteralString, /, *args, syntax: str, dedent: bool = True) 

3683 Hl(code: str, /, *, syntax: str, dedent: bool = True) 

3684 

3685 Lazy wrapper that highlights code during formatting. 

3686 

3687 :param md: 

3688 code to highlight. 

3689 :param args: 

3690 arguments for ``%``-formatting the highlighted code. 

3691 :param syntax: 

3692 name of syntax or a :class:`~yuio.hl.SyntaxHighlighter` instance. 

3693 :param dedent: 

3694 whether to remove leading indent from code. 

3695 

3696 """ 

3697 

3698 @_t.overload 

3699 def __init__( 

3700 self, 

3701 code: _t.LiteralString, 

3702 /, 

3703 *args: _t.Any, 

3704 syntax: str, 

3705 dedent: bool = True, 

3706 ): ... 

3707 @_t.overload 

3708 def __init__( 

3709 self, 

3710 code: str, 

3711 /, 

3712 *, 

3713 syntax: str, 

3714 dedent: bool = True, 

3715 ): ... 

3716 def __init__( 

3717 self, 

3718 code: str, 

3719 /, 

3720 *args: _t.Any, 

3721 syntax: str, 

3722 dedent: bool = True, 

3723 ): 

3724 self._code: str = code 

3725 self._args: tuple[_t.Any, ...] = args 

3726 self._syntax: str = syntax 

3727 self._dedent: bool = dedent 

3728 

3729 def __rich_repr__(self) -> RichReprResult: 

3730 yield None, self._code 

3731 yield from ((None, arg) for arg in self._args) 

3732 yield "syntax", self._syntax 

3733 yield "dedent", self._dedent, True 

3734 

3735 def __colorized_str__(self, ctx: ReprContext) -> ColorizedString: 

3736 import yuio.hl 

3737 

3738 highlighter, syntax_name = yuio.hl.get_highlighter(self._syntax) 

3739 code = self._code 

3740 if self._dedent: 

3741 code = _dedent(code) 

3742 code = code.rstrip() 

3743 

3744 res = ColorizedString() 

3745 res.start_no_wrap() 

3746 res += highlighter.highlight(code, theme=ctx.theme, syntax=syntax_name) 

3747 res.end_no_wrap() 

3748 if self._args: 

3749 res = res.percent_format(self._args, ctx) 

3750 

3751 return res 

3752 

3753 

3754@_t.final 

3755@repr_from_rich 

3756class Wrap(_StrBase): 

3757 """ 

3758 Lazy wrapper that wraps the message during formatting. 

3759 

3760 .. seealso:: 

3761 

3762 :meth:`ColorizedString.wrap`. 

3763 

3764 :param msg: 

3765 message to wrap. 

3766 :param width: 

3767 if given, overrides settings passed to :class:`ReprContext` for this call. 

3768 :param preserve_spaces: 

3769 if set to :data:`True`, all spaces are preserved. 

3770 Otherwise, consecutive spaces are collapsed when newline break occurs. 

3771 

3772 Note that tabs always treated as a single whitespace. 

3773 :param preserve_newlines: 

3774 if set to :data:`True` (default), text is additionally wrapped 

3775 on newline sequences. When this happens, the newline sequence that wrapped 

3776 the line will be placed into :attr:`~ColorizedString.explicit_newline`. 

3777 

3778 If set to :data:`False`, newline sequences are treated as whitespaces. 

3779 :param break_long_words: 

3780 if set to :data:`True` (default), words that don't fit into a single line 

3781 will be split into multiple lines. 

3782 :param overflow: 

3783 Pass :data:`True` to trim overflowing lines and replace them with ellipsis. 

3784 :param break_long_nowrap_words: 

3785 if set to :data:`True`, words in no-wrap regions that don't fit 

3786 into a single line will be split into multiple lines. 

3787 :param indent: 

3788 this will be prepended to the first line in the string. 

3789 Defaults to two spaces. 

3790 :param continuation_indent: 

3791 this will be prepended to subsequent lines in the string. 

3792 Defaults to `indent`. 

3793 

3794 """ 

3795 

3796 def __init__( 

3797 self, 

3798 msg: Colorable, 

3799 /, 

3800 *, 

3801 width: int | None = None, 

3802 preserve_spaces: bool = False, 

3803 preserve_newlines: bool = True, 

3804 break_long_words: bool = True, 

3805 break_long_nowrap_words: bool = False, 

3806 overflow: bool | str = False, 

3807 indent: AnyString | int = "", 

3808 continuation_indent: AnyString | int | None = None, 

3809 ): 

3810 self._msg = msg 

3811 self._width: int | None = width 

3812 self._preserve_spaces = preserve_spaces 

3813 self._preserve_newlines = preserve_newlines 

3814 self._break_long_words = break_long_words 

3815 self._break_long_nowrap_words = break_long_nowrap_words 

3816 self._overflow = overflow 

3817 self._indent: AnyString | int = indent 

3818 self._continuation_indent: AnyString | int | None = continuation_indent 

3819 

3820 def __rich_repr__(self) -> RichReprResult: 

3821 yield None, self._msg 

3822 yield "width", self._width, None 

3823 yield "indent", self._indent, "" 

3824 yield "continuation_indent", self._continuation_indent, None 

3825 yield "preserve_spaces", self._preserve_spaces, None 

3826 yield "preserve_newlines", self._preserve_newlines, True 

3827 yield "break_long_words", self._break_long_words, True 

3828 yield "break_long_nowrap_words", self._break_long_nowrap_words, False 

3829 

3830 def __colorized_str__(self, ctx: ReprContext) -> ColorizedString: 

3831 if isinstance(self._indent, int): 

3832 indent = ColorizedString(" " * self._indent) 

3833 else: 

3834 indent = ColorizedString(self._indent) 

3835 if self._continuation_indent is None: 

3836 continuation_indent = indent 

3837 elif isinstance(self._continuation_indent, int): 

3838 continuation_indent = ColorizedString(" " * self._continuation_indent) 

3839 else: 

3840 continuation_indent = ColorizedString(self._continuation_indent) 

3841 

3842 width = self._width or ctx.width 

3843 indent_width = max(indent.width, continuation_indent.width) 

3844 inner_width = max(1, width - indent_width) 

3845 

3846 overflow = self._overflow 

3847 if overflow is True: 

3848 overflow = ctx.get_msg_decoration("overflow") 

3849 

3850 res = ColorizedString() 

3851 res.start_no_wrap() 

3852 sep = False 

3853 for line in ctx.str(self._msg, width=inner_width).wrap( 

3854 width, 

3855 preserve_spaces=self._preserve_spaces, 

3856 preserve_newlines=self._preserve_newlines, 

3857 break_long_words=self._break_long_words, 

3858 break_long_nowrap_words=self._break_long_nowrap_words, 

3859 overflow=overflow, 

3860 indent=indent, 

3861 continuation_indent=continuation_indent, 

3862 ): 

3863 if sep: 

3864 res.append_str("\n") 

3865 res.append_colorized_str(line) 

3866 sep = True 

3867 res.end_no_wrap() 

3868 

3869 return res 

3870 

3871 

3872@_t.final 

3873@repr_from_rich 

3874class WithBaseColor(_StrBase): 

3875 """ 

3876 Lazy wrapper that applies the given color "under" the given colorable. 

3877 That is, all colors in the rendered colorable will be combined with this color 

3878 on the left: ``base_color | color``. 

3879 

3880 .. seealso:: 

3881 

3882 :meth:`ColorizedString.with_base_color`. 

3883 

3884 :param msg: 

3885 message to highlight. 

3886 :param base_color: 

3887 color that will be added under the message. 

3888 

3889 """ 

3890 

3891 def __init__( 

3892 self, 

3893 msg: Colorable, 

3894 /, 

3895 *, 

3896 base_color: str | _Color, 

3897 ): 

3898 self._msg = msg 

3899 self._base_color = base_color 

3900 

3901 def __rich_repr__(self) -> RichReprResult: 

3902 yield None, self._msg 

3903 yield "base_color", self._base_color 

3904 

3905 def __colorized_str__(self, ctx: ReprContext) -> ColorizedString: 

3906 return ctx.str(self._msg).with_base_color(ctx.to_color(self._base_color)) 

3907 

3908 

3909@repr_from_rich 

3910class Hr(_StrBase): 

3911 """Hr(msg: Colorable = "", /, *, weight: int | str = 1, overflow: bool | str = True, **kwargs) 

3912 

3913 Produces horizontal ruler when converted to string. 

3914 

3915 :param msg: 

3916 any colorable that will be placed in the middle of the ruler. 

3917 :param weight: 

3918 weight or style of the ruler: 

3919 

3920 - ``0`` prints no ruler (but still prints centered text), 

3921 - ``1`` prints normal ruler, 

3922 - ``2`` prints bold ruler. 

3923 

3924 Additional styles can be added through 

3925 :attr:`Theme.msg_decorations <yuio.theme.Theme.msg_decorations_unicode>`. 

3926 :param width: 

3927 if given, overrides settings passed to :class:`ReprContext` for this call. 

3928 :param overflow: 

3929 pass :data:`False` to disable trimming `msg` to terminal width. 

3930 :param kwargs: 

3931 Other keyword arguments override corresponding decorations from the theme: 

3932 

3933 :`left_start`: 

3934 start of the ruler to the left of the message. 

3935 :`left_middle`: 

3936 filler of the ruler to the left of the message. 

3937 :`left_end`: 

3938 end of the ruler to the left of the message. 

3939 :`middle`: 

3940 filler of the ruler that's used if `msg` is empty. 

3941 :`right_start`: 

3942 start of the ruler to the right of the message. 

3943 :`right_middle`: 

3944 filler of the ruler to the right of the message. 

3945 :`right_end`: 

3946 end of the ruler to the right of the message. 

3947 

3948 """ 

3949 

3950 def __init__( 

3951 self, 

3952 msg: Colorable = "", 

3953 /, 

3954 *, 

3955 width: int | None = None, 

3956 overflow: bool | str = True, 

3957 weight: int | str = 1, 

3958 left_start: str | None = None, 

3959 left_middle: str | None = None, 

3960 left_end: str | None = None, 

3961 middle: str | None = None, 

3962 right_start: str | None = None, 

3963 right_middle: str | None = None, 

3964 right_end: str | None = None, 

3965 ): 

3966 self._msg = msg 

3967 self._width = width 

3968 self._overflow = overflow 

3969 self._weight = weight 

3970 self._left_start = left_start 

3971 self._left_middle = left_middle 

3972 self._left_end = left_end 

3973 self._middle = middle 

3974 self._right_start = right_start 

3975 self._right_middle = right_middle 

3976 self._right_end = right_end 

3977 

3978 def __rich_repr__(self) -> RichReprResult: 

3979 yield None, self._msg, None 

3980 yield "weight", self._weight, None 

3981 yield "width", self._width, None 

3982 yield "overflow", self._overflow, None 

3983 yield "left_start", self._left_start, None 

3984 yield "left_middle", self._left_middle, None 

3985 yield "left_end", self._left_end, None 

3986 yield "middle", self._middle, None 

3987 yield "right_start", self._right_start, None 

3988 yield "right_middle", self._right_middle, None 

3989 yield "right_end", self._right_end, None 

3990 

3991 def __colorized_str__(self, ctx: ReprContext) -> ColorizedString: 

3992 width = self._width or ctx.width 

3993 

3994 color = ctx.get_color(f"msg/decoration:hr/{self._weight}") 

3995 

3996 res = ColorizedString(color) 

3997 res.start_no_wrap() 

3998 

3999 msg = ctx.str(self._msg) 

4000 if not msg: 

4001 res.append_str(self._make_whole(width, ctx)) 

4002 return res 

4003 

4004 overflow = self._overflow 

4005 if overflow is True: 

4006 overflow = ctx.get_msg_decoration("overflow") 

4007 

4008 sep = False 

4009 for line in msg.wrap( 

4010 width, preserve_spaces=True, break_long_words=False, overflow=overflow 

4011 ): 

4012 if sep: 

4013 res.append_color(yuio.color.Color.NONE) 

4014 res.append_str("\n") 

4015 res.append_color(color) 

4016 

4017 line_w = line.width 

4018 line_w_fill = max(0, width - line_w) 

4019 line_w_fill_l = line_w_fill // 2 

4020 line_w_fill_r = line_w_fill - line_w_fill_l 

4021 if not line_w_fill_l and not line_w_fill_r: 

4022 res.append_colorized_str(line) 

4023 return res 

4024 

4025 res.append_str(self._make_left(line_w_fill_l, ctx)) 

4026 res.append_colorized_str(line) 

4027 res.append_str(self._make_right(line_w_fill_r, ctx)) 

4028 

4029 sep = True 

4030 

4031 return res 

4032 

4033 def _make_left(self, w: int, ctx: ReprContext): 

4034 weight = self._weight 

4035 start = ( 

4036 self._left_start 

4037 if self._left_start is not None 

4038 else ctx.get_msg_decoration(f"hr/{weight}/left_start") 

4039 ) 

4040 middle = ( 

4041 self._left_middle 

4042 if self._left_middle is not None 

4043 else ctx.get_msg_decoration(f"hr/{weight}/left_middle") 

4044 ) or " " 

4045 end = ( 

4046 self._left_end 

4047 if self._left_end is not None 

4048 else ctx.get_msg_decoration(f"hr/{weight}/left_end") 

4049 ) 

4050 

4051 return _make_left(w, start, middle, end) 

4052 

4053 def _make_right(self, w: int, ctx: ReprContext): 

4054 weight = self._weight 

4055 start = ( 

4056 self._right_start 

4057 if self._right_start is not None 

4058 else ctx.get_msg_decoration(f"hr/{weight}/right_start") 

4059 ) 

4060 middle = ( 

4061 self._right_middle 

4062 if self._right_middle is not None 

4063 else ctx.get_msg_decoration(f"hr/{weight}/right_middle") 

4064 ) or " " 

4065 end = ( 

4066 self._right_end 

4067 if self._right_end is not None 

4068 else ctx.get_msg_decoration(f"hr/{weight}/right_end") 

4069 ) 

4070 

4071 return _make_right(w, start, middle, end) 

4072 

4073 def _make_whole(self, w: int, ctx: ReprContext): 

4074 weight = self._weight 

4075 start = ( 

4076 self._left_start 

4077 if self._left_start is not None 

4078 else ctx.get_msg_decoration(f"hr/{weight}/left_start") 

4079 ) 

4080 middle = ( 

4081 self._middle 

4082 if self._middle is not None 

4083 else ctx.get_msg_decoration(f"hr/{weight}/middle") 

4084 ) or " " 

4085 end = ( 

4086 self._right_end 

4087 if self._right_end is not None 

4088 else ctx.get_msg_decoration(f"hr/{weight}/right_end") 

4089 ) 

4090 

4091 start_w = line_width(start) 

4092 middle_w = line_width(middle) 

4093 end_w = line_width(end) 

4094 

4095 if w >= start_w: 

4096 w -= start_w 

4097 else: 

4098 start = "" 

4099 if w >= end_w: 

4100 w -= end_w 

4101 else: 

4102 end = "" 

4103 middle_times = w // middle_w 

4104 w -= middle_times * middle_w 

4105 middle *= middle_times 

4106 return start + middle + end + " " * w 

4107 

4108 

4109def _make_left( 

4110 w: int, 

4111 start: str, 

4112 middle: str, 

4113 end: str, 

4114): 

4115 start_w = line_width(start) 

4116 middle_w = line_width(middle) 

4117 end_w = line_width(end) 

4118 

4119 if w >= end_w: 

4120 w -= end_w 

4121 else: 

4122 end = "" 

4123 if w >= start_w: 

4124 w -= start_w 

4125 else: 

4126 start = "" 

4127 middle_times = w // middle_w 

4128 w -= middle_times * middle_w 

4129 middle *= middle_times 

4130 return start + middle + end + " " * w 

4131 

4132 

4133def _make_right( 

4134 w: int, 

4135 start: str, 

4136 middle: str, 

4137 end: str, 

4138): 

4139 start_w = line_width(start) 

4140 middle_w = line_width(middle) 

4141 end_w = line_width(end) 

4142 

4143 if w >= start_w: 

4144 w -= start_w 

4145 else: 

4146 start = "" 

4147 if w >= end_w: 

4148 w -= end_w 

4149 else: 

4150 end = "" 

4151 middle_times = w // middle_w 

4152 w -= middle_times * middle_w 

4153 middle *= middle_times 

4154 return " " * w + start + middle + end 

4155 

4156 

4157def _eng_key_cardinal(n: float): 

4158 n = abs(n) 

4159 if n == 1: 

4160 return "one" 

4161 else: 

4162 return "other" 

4163 

4164 

4165def _eng_key_ordinal(n: float): 

4166 n = abs(n) 

4167 

4168 if n % 10 == 1 and n % 100 != 11: 

4169 return "one" 

4170 elif n % 10 == 2 and n % 100 != 12: 

4171 return "two" 

4172 elif n % 10 == 3 and n % 100 != 13: 

4173 return "few" 

4174 else: 

4175 return "other" 

4176 

4177 

4178@_t.final 

4179@repr_from_rich 

4180class Plural(_StrBase): 

4181 """ 

4182 Lazy wrapper that pluralizes the given string by adding ``s`` to its end. 

4183 

4184 :param n: 

4185 number to be used for pluralization. 

4186 :param one: 

4187 singular form of the word, i.e. "one thing". 

4188 Will be :attr:`formatted <str.format>` with a single keyword argument `n` -- 

4189 the given number. 

4190 :param other: 

4191 plural form of the word, i.e. "other number of things"; 

4192 defaults to :samp:`"{one}s"`. 

4193 :param forms: 

4194 additional forms of the word, only used with custom `key`. 

4195 :param key: 

4196 can be used to provide pluralization for non-english words. This callable 

4197 should take a number and return a string ``"one"``, ``"other"``, or a key 

4198 of the `forms` dictionary. 

4199 :example: 

4200 .. code-block:: python 

4201 

4202 n = 5 

4203 yuio.io.info("Loaded %s", yuio.string.Plural(n, "a sample", "{n} samples")) 

4204 

4205 With custom `key`: 

4206 

4207 .. code-block:: python 

4208 

4209 lt_plural_key = lambda n: ( 

4210 "one" 

4211 if n % 10 == 1 and not 11 <= n % 100 <= 19 

4212 else "few" 

4213 if 2 <= n % 10 <= 9 and not 11 <= n % 100 <= 19 

4214 else "many" 

4215 if n != int(n) 

4216 else "other" 

4217 ) 

4218 

4219 for n in [1, 2, 0.1, 10]: 

4220 yuio.io.info( 

4221 "Rasta %s", 

4222 yuio.string.Plural( 

4223 n, 

4224 one="{n} obuolys", 

4225 few="{n} obuoliai", 

4226 many="{n} obuolio", 

4227 other="{n} obuolių", 

4228 key=lt_plural_key, 

4229 ), 

4230 ) 

4231 """ 

4232 

4233 def __init__( 

4234 self, 

4235 n: float, 

4236 /, 

4237 one: str = "", 

4238 other: str | None = None, 

4239 *, 

4240 key: _t.Callable[[float], str] | None = None, 

4241 **forms, 

4242 ): 

4243 if other is None: 

4244 other = one + "s" 

4245 

4246 self._n = n 

4247 self._forms = forms 

4248 self._forms["one"] = one 

4249 self._forms["other"] = other 

4250 self._key = key or _eng_key_cardinal 

4251 

4252 def __rich_repr__(self) -> RichReprResult: 

4253 yield None, self._n 

4254 yield from self._forms.items() 

4255 yield "key", self._key 

4256 

4257 def __colorized_str__(self, ctx: ReprContext) -> ColorizedString: 

4258 key = self._key(self._n) 

4259 if key in self._forms: 

4260 return ColorizedString(self._forms[key].format(n=self._n)) 

4261 else: 

4262 return ColorizedString(self._forms["other"].format(n=self._n)) 

4263 

4264 

4265def Ordinal(n: float, /): 

4266 """ 

4267 Lazy wrapper that formats numbers as English ordinals (i.e. ``1st``, ``2nd``, etc.) 

4268 

4269 :param n: 

4270 number to format. 

4271 :example: 

4272 .. code-block:: python 

4273 

4274 for n in range(5): 

4275 print(yuio.string.Ordinal(n + 1)) 

4276 

4277 """ 

4278 

4279 return Plural( 

4280 n, 

4281 one="{n}st", 

4282 two="{n}nd", 

4283 few="{n}rd", 

4284 other="{n}th", 

4285 key=_eng_key_ordinal, 

4286 )