Coverage for yuio / string.py: 98%

1328 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2025-12-04 10:05 +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:`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 

13This is a low-level module upon which :mod:`yuio.io` builds 

14its higher-level abstraction. 

15 

16.. autoclass:: ColorizedString 

17 :members: 

18 

19.. autoclass:: Link 

20 :members: 

21 

22 

23Parsing color tags 

24------------------ 

25 

26.. autofunction:: colorize 

27 

28.. autofunction:: strip_color_tags 

29 

30 

31Pretty ``str`` and ``repr`` 

32--------------------------- 

33 

34.. autofunction:: colorized_str 

35 

36.. autofunction:: colorized_repr 

37 

38 

39.. _pretty-protocol: 

40 

41Pretty printing protocol 

42------------------------ 

43 

44Yuio searches for special methods on your objects when rendering them. 

45 

46``__colorized_str__``, ``__colorized_repr__`` 

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

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

49 

50 .. warning:: 

51 

52 Don't call :func:`colorized_repr` or :func:`colorized_str` from these 

53 implementations, as this may result in infinite recursion. Instead, 

54 use :meth:`ReprContext.repr` and :meth:`ReprContext.str`. 

55 

56 .. tip:: 

57 

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

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

60 

61 **Example:** 

62 

63 .. code-block:: python 

64 

65 class MyObject: 

66 def __init__(self, value): 

67 self.value = value 

68 

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

70 result = yuio.string.ColorizedString() 

71 result += ctx.theme.get_color("magenta") 

72 result += "MyObject" 

73 result += ctx.theme.get_color("normal") 

74 result += "MyObject" 

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

76 result += ctx.theme.get_color("normal") 

77 result += ")" 

78 return result 

79 

80``__rich_repr__`` 

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

82 describing object's arguments: 

83 

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

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

86 is not equal to default, 

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

88 

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

90 

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

92 

93 **Example:** 

94 

95 .. code-block:: python 

96 

97 class MyObject: 

98 def __init__(self, value1, value2): 

99 self.value1 = value1 

100 self.value2 = value2 

101 

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

103 yield "value1", self.value1 

104 yield "value2", self.value2 

105 

106.. autoclass:: ReprContext 

107 :members: 

108 

109.. type:: RichReprResult 

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

111 

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

113 allows tuples, not arbitrary values. 

114 

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

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

117 

118 

119.. type:: ColorizedStrProtocol 

120 

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

122 

123.. type:: ColorizedReprProtocol 

124 

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

126 

127.. type:: RichReprProtocol 

128 

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

130 

131.. type:: Printable 

132 

133 Any object that supports printing. 

134 

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

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

137 

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

139 

140.. type:: Colorable 

141 :canonical: Printable | ColorizedStrProtocol | ColorizedReprProtocol | RichReprProtocol | str | BaseException 

142 

143 An object that supports colorized printing. 

144 

145.. autofunction:: repr_from_rich 

146 

147 

148.. _formatting-utilities: 

149 

150Formatting utilities 

151-------------------- 

152 

153.. autoclass:: Format 

154 :members: 

155 

156.. autoclass:: Repr 

157 :members: 

158 

159.. autoclass:: TypeRepr 

160 :members: 

161 

162.. autoclass:: JoinStr 

163 :members: 

164 :inherited-members: 

165 

166.. autoclass:: JoinRepr 

167 :members: 

168 :inherited-members: 

169 

170.. autofunction:: And 

171 

172.. autofunction:: Or 

173 

174.. autoclass:: Stack 

175 :members: 

176 

177.. autoclass:: Indent 

178 :members: 

179 

180.. autoclass:: Md 

181 :members: 

182 

183.. autoclass:: Hl 

184 :members: 

185 

186.. autoclass:: Wrap 

187 :members: 

188 

189.. autoclass:: WithBaseColor 

190 :members: 

191 

192.. autoclass:: Hr 

193 :members: 

194 

195 

196Helper types 

197------------ 

198 

199.. type:: AnyString 

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

201 

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

203 or a normal colorized string). 

204 

205.. autodata:: NO_WRAP_START 

206 

207.. autodata:: NO_WRAP_END 

208 

209.. type:: NoWrapMarker 

210 NoWrapStart 

211 NoWrapEnd 

212 

213 Type of a no-wrap marker. 

214 

215.. autofunction:: line_width 

216 

217""" 

218 

219from __future__ import annotations 

220 

221import abc 

222import collections 

223import dataclasses 

224import functools 

225import os 

226import re 

227import reprlib 

228import shutil 

229import string 

230import types 

231import unicodedata 

232from enum import Enum 

233 

234import yuio 

235import yuio.color 

236import yuio.theme 

237from yuio import _typing as _t 

238from yuio.color import Color as _Color 

239from yuio.util import UserString as _UserString 

240from yuio.util import dedent as _dedent 

241 

242if _t.TYPE_CHECKING: 

243 import yuio.md 

244 

245__all__ = [ 

246 "NO_WRAP_END", 

247 "NO_WRAP_START", 

248 "And", 

249 "AnyString", 

250 "Colorable", 

251 "ColorizedReprProtocol", 

252 "ColorizedStrProtocol", 

253 "ColorizedString", 

254 "Esc", 

255 "Format", 

256 "Hl", 

257 "Hr", 

258 "Indent", 

259 "JoinRepr", 

260 "JoinStr", 

261 "Link", 

262 "Md", 

263 "NoWrapEnd", 

264 "NoWrapMarker", 

265 "NoWrapStart", 

266 "Or", 

267 "Printable", 

268 "Repr", 

269 "ReprContext", 

270 "RichReprProtocol", 

271 "RichReprResult", 

272 "Stack", 

273 "TypeRepr", 

274 "WithBaseColor", 

275 "Wrap", 

276 "colorize", 

277 "colorized_repr", 

278 "colorized_str", 

279 "line_width", 

280 "repr_from_rich", 

281 "strip_color_tags", 

282] 

283 

284 

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

286 """ 

287 Calculates string width when the string is displayed 

288 in a terminal. 

289 

290 This function makes effort to detect wide characters 

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

292 with extended grapheme clusters, and so it may fail 

293 for emojis with modifiers, or other complex characters. 

294 

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

296 of four code points: 

297 

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

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

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

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

302 

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

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

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

306 

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

308 6 

309 

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

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

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

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

314 

315 """ 

316 

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

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

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

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

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

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

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

324 

325 if s.isascii(): 

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

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

328 return len(s) 

329 else: 

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

331 return sum( 

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

333 for c in s 

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

335 ) 

336 

337 

338RichReprResult: _t.TypeAlias = _t.Iterable[ 

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

340] 

341""" 

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

343 

344""" 

345 

346 

347@_t.runtime_checkable 

348class ColorizedStrProtocol(_t.Protocol): 

349 """ 

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

351 

352 """ 

353 

354 @abc.abstractmethod 

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

356 

357 

358@_t.runtime_checkable 

359class ColorizedReprProtocol(_t.Protocol): 

360 """ 

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

362 

363 """ 

364 

365 @abc.abstractmethod 

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

367 

368 

369@_t.runtime_checkable 

370class RichReprProtocol(_t.Protocol): 

371 """ 

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

373 

374 """ 

375 

376 @abc.abstractmethod 

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

378 

379 

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

381""" 

382Any object that supports printing. 

383 

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

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

386 

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

388 

389""" 

390 

391 

392Colorable: _t.TypeAlias = ( 

393 Printable 

394 | ColorizedStrProtocol 

395 | ColorizedReprProtocol 

396 | RichReprProtocol 

397 | str 

398 | BaseException 

399) 

400""" 

401Any object that supports colorized printing. 

402 

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

404:class:`ColorableProtocol`. Additionally, you can pass any object that has 

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

406your intent to print it. 

407 

408""" 

409 

410 

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

412 

413 

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

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

416 

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

418 

419 :param cls: 

420 class that needs ``__repr__``. 

421 :returns: 

422 always returns ``cls``. 

423 :example: 

424 .. code-block:: python 

425 

426 @yuio.string.repr_from_rich 

427 class MyClass: 

428 def __init__(self, value): 

429 self.value = value 

430 

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

432 yield "value", self.value 

433 

434 :: 

435 

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

437 MyClass(value='plush!') 

438 

439 

440 """ 

441 

442 setattr(cls, "__repr__", _repr_from_rich_impl) 

443 return cls 

444 

445 

446def _repr_from_rich_impl(self: RichReprProtocol): 

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

448 args = rich_repr() 

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

450 else: 

451 args = [] 

452 angular = False 

453 

454 if args is None: 

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

456 

457 res = [] 

458 

459 if angular: 

460 res.append("<") 

461 res.append(self.__class__.__name__) 

462 if angular: 

463 res.append(" ") 

464 else: 

465 res.append("(") 

466 

467 sep = False 

468 for arg in args: 

469 if isinstance(arg, tuple): 

470 if len(arg) == 3: 

471 key, child, default = arg 

472 if default == child: 

473 continue 

474 elif len(arg) == 2: 

475 key, child = arg 

476 elif len(arg) == 1: 

477 key, child = None, arg[0] 

478 else: 

479 key, child = None, arg 

480 else: 

481 key, child = None, arg 

482 

483 if sep: 

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

485 if key: 

486 res.append(str(key)) 

487 res.append("=") 

488 res.append(repr(child)) 

489 sep = True 

490 

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

492 

493 return "".join(res) 

494 

495 

496class _NoWrapMarker(Enum): 

497 """ 

498 Type for a no-wrap marker. 

499 

500 """ 

501 

502 NO_WRAP_START = "<no_wrap_start>" 

503 NO_WRAP_END = "<no_wrap_end>" 

504 

505 def __repr__(self): 

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

507 

508 def __str__(self) -> str: 

509 return self.value # pragma: no cover 

510 

511 

512NoWrapStart: _t.TypeAlias = _t.Literal[_NoWrapMarker.NO_WRAP_START] 

513""" 

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

515 

516""" 

517 

518NO_WRAP_START: NoWrapStart = _NoWrapMarker.NO_WRAP_START 

519""" 

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

521 

522""" 

523 

524 

525NoWrapEnd: _t.TypeAlias = _t.Literal[_NoWrapMarker.NO_WRAP_END] 

526""" 

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

528 

529""" 

530 

531NO_WRAP_END: NoWrapEnd = _NoWrapMarker.NO_WRAP_END 

532""" 

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

534 

535""" 

536 

537NoWrapMarker: _t.TypeAlias = NoWrapStart | NoWrapEnd 

538""" 

539Type of a no-wrap marker. 

540 

541""" 

542 

543 

544@_t.final 

545@repr_from_rich 

546class ColorizedString: 

547 """ColorizedString(content: AnyString = '', /) 

548 

549 A string with colors. 

550 

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

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

553 

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

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

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

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

558 

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

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

561 

562 :param content: 

563 initial content of the string. Can be :class:`str`, color, no-wrap marker, 

564 or another colorized string. 

565 

566 

567 **String combination semantics** 

568 

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

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

571 

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

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

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

575 this region will be terminated after appending. 

576 

577 Thus, appending a colorized string does not change current color 

578 or no-wrap setting:: 

579 

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

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

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

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

584 >>> s1 # doctest: +NORMALIZE_WHITESPACE 

585 ColorizedString([yuio.string.NO_WRAP_START, 

586 <Color fore=<RED>>, 

587 'red nowrap text']) 

588 

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

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

591 >>> s2 += "green text " 

592 >>> s2 += s1 

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

594 >>> s2 # doctest: +NORMALIZE_WHITESPACE 

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

596 'green text ', 

597 yuio.string.NO_WRAP_START, 

598 <Color fore=<RED>>, 

599 'red nowrap text', 

600 yuio.string.NO_WRAP_END, 

601 <Color fore=<GREEN>>, 

602 ' green text continues']) 

603 

604 """ 

605 

606 # Invariants: 

607 # 

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

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

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

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

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

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

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

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

616 # `end-no-wrap` yet. 

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

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

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

620 

621 def __init__( 

622 self, 

623 content: AnyString = "", 

624 /, 

625 *, 

626 _isolate_colors: bool = True, 

627 ): 

628 if isinstance(content, ColorizedString): 

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

630 self._last_color = content._last_color 

631 self._active_color = content._active_color 

632 self._explicit_newline = content._explicit_newline 

633 self._len = content._len 

634 self._has_no_wrap = content._has_no_wrap 

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

636 self.__dict__["width"] = width 

637 else: 

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

639 self._active_color = _Color.NONE 

640 self._last_color: _Color | None = None 

641 self._explicit_newline: str = "" 

642 self._len = 0 

643 self._has_no_wrap = False 

644 

645 if not _isolate_colors: 

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

647 self._last_color = self._active_color 

648 

649 if content: 

650 self += content 

651 

652 @property 

653 def explicit_newline(self) -> str: 

654 """ 

655 Explicit newline indicates that a line of a wrapped text 

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

657 

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

659 

660 """ 

661 

662 return self._explicit_newline 

663 

664 @property 

665 def active_color(self) -> _Color: 

666 """ 

667 Last color appended to this string. 

668 

669 """ 

670 

671 return self._active_color 

672 

673 @functools.cached_property 

674 def width(self) -> int: 

675 """ 

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

677 

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

679 

680 """ 

681 

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

683 

684 @property 

685 def len(self) -> int: 

686 """ 

687 Line length in bytes, ignoring all colors. 

688 

689 """ 

690 

691 return self._len 

692 

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

694 """ 

695 Append new color to this string. 

696 

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

698 is appended after it. 

699 

700 :param color: 

701 color to append. 

702 

703 """ 

704 

705 self._active_color = color 

706 

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

708 """ 

709 Append new plain string to this string. 

710 

711 :param s: 

712 plain string to append. 

713 

714 """ 

715 

716 if not s: 

717 return 

718 if self._last_color != self._active_color: 

719 self._parts.append(self._active_color) 

720 self._last_color = self._active_color 

721 self._parts.append(s) 

722 self._len += len(s) 

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

724 

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

726 """ 

727 Append new colorized string to this string. 

728 

729 :param s: 

730 colorized string to append. 

731 

732 """ 

733 if not s: 

734 # Nothing to append. 

735 return 

736 

737 parts = s._parts 

738 

739 # Cleanup color at the beginning of the string. 

740 for i, part in enumerate(parts): 

741 if part in (NO_WRAP_START, NO_WRAP_END): 

742 continue 

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

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

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

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

747 # invariants. 

748 break 

749 

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

751 # We can remove it without changing the outcome. 

752 if part == self._last_color: 

753 if i == 0: 

754 parts = parts[i + 1 :] 

755 else: 

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

757 

758 break 

759 

760 if self._has_no_wrap: 

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

762 self._parts.extend( 

763 part for part in parts if part not in (NO_WRAP_START, NO_WRAP_END) 

764 ) 

765 else: 

766 # We're not in a no-wrap sequence. We preserve no-wrap regions from the 

767 # appended string, but we make sure that they don't affect anything 

768 # appended after. 

769 self._parts.extend(parts) 

770 if s._has_no_wrap: 

771 self._has_no_wrap = True 

772 self.end_no_wrap() 

773 

774 self._last_color = s._last_color 

775 self._len += s._len 

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

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

778 else: 

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

780 

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

782 """ 

783 Append a no-wrap marker. 

784 

785 :param m: 

786 no-wrap marker, will be dispatched 

787 to :meth:`~ColorizedString.start_no_wrap` 

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

789 

790 """ 

791 

792 if m is NO_WRAP_START: 

793 self.start_no_wrap() 

794 else: 

795 self.end_no_wrap() 

796 

797 def start_no_wrap(self): 

798 """ 

799 Start a no-wrap region. 

800 

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

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

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

804 and ``preserve_newlines`` settings. 

805 

806 """ 

807 

808 if self._has_no_wrap: 

809 return 

810 

811 self._has_no_wrap = True 

812 self._parts.append(NO_WRAP_START) 

813 

814 def end_no_wrap(self): 

815 """ 

816 End a no-wrap region. 

817 

818 """ 

819 

820 if not self._has_no_wrap: 

821 return 

822 

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

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

825 self._parts.pop() 

826 else: 

827 self._parts.append(NO_WRAP_END) 

828 

829 self._has_no_wrap = False 

830 

831 def extend( 

832 self, 

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

834 /, 

835 ): 

836 """ 

837 Extend string from iterable of raw parts. 

838 

839 :param parts: 

840 raw parts that will be appended to the string. 

841 

842 """ 

843 

844 for part in parts: 

845 self += part 

846 

847 def copy(self) -> ColorizedString: 

848 """ 

849 Copy this string. 

850 

851 :returns: 

852 copy of the string. 

853 

854 """ 

855 

856 return ColorizedString(self) 

857 

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

859 l, r = ColorizedString(), ColorizedString() 

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

861 r._active_color = l._active_color 

862 r._has_no_wrap = l._has_no_wrap 

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

864 r._active_color = self._active_color 

865 return l, r 

866 

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

868 """ 

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

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

871 ``base_color | color``. 

872 

873 :param base_color: 

874 color that will be added under the string. 

875 :returns: 

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

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

878 :example: 

879 :: 

880 

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

882 ... "part 1", 

883 ... yuio.color.Color.FORE_GREEN, 

884 ... "part 2", 

885 ... ]) 

886 >>> s2 = s1.with_base_color( 

887 ... yuio.color.Color.FORE_RED 

888 ... | yuio.color.Color.STYLE_BOLD 

889 ... ) 

890 >>> s2 # doctest: +NORMALIZE_WHITESPACE 

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

892 'part 1', 

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

894 'part 2']) 

895 

896 """ 

897 

898 if base_color == _Color.NONE: 

899 return self 

900 

901 res = ColorizedString() 

902 

903 for part in self._parts: 

904 if isinstance(part, _Color): 

905 res.append_color(base_color | part) 

906 else: 

907 res += part 

908 res._active_color = base_color | self._active_color 

909 if self._last_color is not None: 

910 res._last_color = base_color | self._last_color 

911 

912 return res 

913 

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

915 """ 

916 Convert colors in this string to ANSI escape sequences. 

917 

918 :param term: 

919 terminal that will be used to print the resulting string. 

920 :returns: 

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

922 escape sequences. 

923 

924 """ 

925 

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

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

928 else: 

929 parts: list[str] = [] 

930 cur_link: Link | None = None 

931 for part in self: 

932 if isinstance(part, Link): 

933 if not cur_link: 

934 cur_link = part 

935 elif cur_link.href == part.href: 

936 cur_link += part 

937 else: 

938 parts.append(cur_link.as_code(color_support)) 

939 cur_link = part 

940 continue 

941 elif cur_link: 

942 parts.append(cur_link.as_code(color_support)) 

943 cur_link = None 

944 if isinstance(part, _Color): 

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

946 elif isinstance(part, str): 

947 parts.append(part) 

948 if cur_link: 

949 parts.append(cur_link) 

950 if self._last_color != _Color.NONE: 

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

952 return parts 

953 

954 def wrap( 

955 self, 

956 width: int, 

957 /, 

958 *, 

959 preserve_spaces: bool = False, 

960 preserve_newlines: bool = True, 

961 break_long_words: bool = True, 

962 break_long_nowrap_words: bool = False, 

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

964 indent: AnyString | int = "", 

965 continuation_indent: AnyString | int | None = None, 

966 ) -> list[ColorizedString]: 

967 """ 

968 Wrap a long line of text into multiple lines. 

969 

970 :param preserve_spaces: 

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

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

973 

974 Note that tabs always treated as a single whitespace. 

975 :param preserve_newlines: 

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

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

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

979 

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

981 

982 .. list-table:: Whitespace sequences 

983 :header-rows: 1 

984 :stub-columns: 1 

985 

986 * - Sequence 

987 - ``preserve_newlines`` 

988 - Result 

989 * - ``\\n``, ``\\r\\n``, ``\\r`` 

990 - ``False`` 

991 - Treated as a single whitespace. 

992 * - ``\\n``, ``\\r\\n``, ``\\r`` 

993 - ``True`` 

994 - Creates a new line. 

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

996 - Any 

997 - Always creates a new line. 

998 

999 :param break_long_words: 

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

1001 will be split into multiple lines. 

1002 :param break_long_nowrap_words: 

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

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

1005 :param overflow: 

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

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

1008 :param indent: 

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

1010 :param continuation_indent: 

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

1012 :returns: 

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

1014 

1015 """ 

1016 

1017 return _TextWrapper( 

1018 width, 

1019 preserve_spaces=preserve_spaces, 

1020 preserve_newlines=preserve_newlines, 

1021 break_long_words=break_long_words, 

1022 break_long_nowrap_words=break_long_nowrap_words, 

1023 overflow=overflow, 

1024 indent=indent, 

1025 continuation_indent=continuation_indent, 

1026 ).wrap(self) 

1027 

1028 def indent( 

1029 self, 

1030 indent: AnyString | int = " ", 

1031 continuation_indent: AnyString | int | None = None, 

1032 ) -> ColorizedString: 

1033 """ 

1034 Indent this string. 

1035 

1036 :param indent: 

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

1038 Defaults to two spaces. 

1039 :param continuation_indent: 

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

1041 Defaults to ``indent``. 

1042 :returns: 

1043 indented string. 

1044 

1045 """ 

1046 

1047 if isinstance(indent, int): 

1048 indent = ColorizedString(" " * indent) 

1049 else: 

1050 indent = ColorizedString(indent) 

1051 if continuation_indent is None: 

1052 continuation_indent = indent 

1053 elif isinstance(continuation_indent, int): 

1054 continuation_indent = ColorizedString(" " * continuation_indent) 

1055 else: 

1056 continuation_indent = ColorizedString(continuation_indent) 

1057 

1058 if not indent and not continuation_indent: 

1059 return self 

1060 

1061 res = ColorizedString() 

1062 

1063 needs_indent = True 

1064 for part in self._parts: 

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

1066 res += part 

1067 continue 

1068 

1069 for line in _split_keep_link(part, _WORDSEP_NL_RE): 

1070 if not line: 

1071 continue 

1072 if needs_indent: 

1073 res.append_colorized_str(indent) 

1074 indent = continuation_indent 

1075 res.append_str(line) 

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

1077 

1078 return res 

1079 

1080 def percent_format( 

1081 self, args: _t.Any, ctx: yuio.theme.Theme | ReprContext | None = None 

1082 ) -> ColorizedString: 

1083 """ 

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

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

1086 

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

1088 

1089 :param args: 

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

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

1092 :param ctx: 

1093 :class:`ReprContext` or theme that will be passed to ``__colorized_str__`` 

1094 and ``__colorized_repr__``. If not given, uses theme 

1095 from :func:`yuio.io.get_theme`. 

1096 :returns: 

1097 formatted string. 

1098 :raises: 

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

1100 fails. 

1101 

1102 """ 

1103 

1104 return _percent_format(self, args, ctx) 

1105 

1106 def __len__(self) -> int: 

1107 return self.len 

1108 

1109 def __bool__(self) -> bool: 

1110 return self.len > 0 

1111 

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

1113 return self._parts.__iter__() 

1114 

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

1116 copy = self.copy() 

1117 copy += rhs 

1118 return copy 

1119 

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

1121 copy = ColorizedString(lhs) 

1122 copy += self 

1123 return copy 

1124 

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

1126 if isinstance(rhs, str): 

1127 self.append_str(rhs) 

1128 elif isinstance(rhs, ColorizedString): 

1129 self.append_colorized_str(rhs) 

1130 elif isinstance(rhs, _Color): 

1131 self.append_color(rhs) 

1132 elif rhs in (NO_WRAP_START, NO_WRAP_END): 

1133 self.append_no_wrap(rhs) 

1134 else: 

1135 self.extend(rhs) 

1136 

1137 return self 

1138 

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

1140 if isinstance(value, ColorizedString): 

1141 return self._parts == value._parts 

1142 else: 

1143 return NotImplemented 

1144 

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

1146 return not (self == value) 

1147 

1148 def __rich_repr__(self) -> RichReprResult: 

1149 yield None, self._parts 

1150 yield "explicit_newline", self._explicit_newline, "" 

1151 

1152 def __str__(self) -> str: 

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

1154 

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

1156 return self 

1157 

1158 

1159AnyString: _t.TypeAlias = ( 

1160 str 

1161 | ColorizedString 

1162 | _Color 

1163 | NoWrapMarker 

1164 | _t.Iterable[str | ColorizedString | _Color | NoWrapMarker] 

1165) 

1166""" 

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

1168 

1169""" 

1170 

1171 

1172_S_SYNTAX = re.compile( 

1173 r""" 

1174 % # Percent 

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

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

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

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

1179 [hlL]? # Unused length modifier 

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

1181 """, 

1182 re.VERBOSE, 

1183) 

1184 

1185 

1186def _percent_format( 

1187 s: ColorizedString, args: object, ctx: yuio.theme.Theme | ReprContext | None 

1188) -> ColorizedString: 

1189 if ctx is None: 

1190 import yuio.io 

1191 

1192 ctx = yuio.io.get_theme() 

1193 if not isinstance(ctx, ReprContext): 

1194 ctx = ReprContext(theme=ctx) 

1195 seen_mapping = False 

1196 arg_index = 0 

1197 res = ColorizedString() 

1198 for part in s: 

1199 if isinstance(part, str): 

1200 pos = 0 

1201 for match in _S_SYNTAX.finditer(part): 

1202 if pos < match.start(): 

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

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

1205 last_color = res.active_color 

1206 arg_index, replaced = _percent_format_repl( 

1207 match, args, arg_index, last_color, ctx 

1208 ) 

1209 res += replaced 

1210 res.append_color(last_color) 

1211 pos = match.end() 

1212 if pos < len(part): 

1213 res.append_str(part[pos:]) 

1214 else: 

1215 res += part 

1216 

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

1218 not isinstance(args, tuple) 

1219 and ( 

1220 not hasattr(args, "__getitem__") 

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

1222 ) 

1223 and not seen_mapping 

1224 and not arg_index 

1225 ): 

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

1227 

1228 return res 

1229 

1230 

1231def _percent_format_repl( 

1232 match: _t.StrReMatch, 

1233 args: object, 

1234 arg_index: int, 

1235 base_color: _Color, 

1236 ctx: ReprContext, 

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

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

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

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

1241 return arg_index, "%" 

1242 

1243 if match.group("format") in "rs": 

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

1245 

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

1247 try: 

1248 fmt_arg = args[mapping] # type: ignore 

1249 except TypeError: 

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

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

1252 if added_color: 

1253 fmt_args = {mapping: fmt_arg} 

1254 else: 

1255 fmt_args = args 

1256 elif isinstance(args, tuple): 

1257 try: 

1258 fmt_arg = args[arg_index] 

1259 except IndexError: 

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

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

1262 begin = arg_index + 1 

1263 end = arg_index = ( 

1264 arg_index 

1265 + 1 

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

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

1268 ) 

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

1270 elif arg_index == 0: 

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

1272 arg_index += 1 

1273 else: 

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

1275 

1276 fmt = match.group(0) % fmt_args 

1277 if added_color: 

1278 added_color = ctx.theme.to_color(added_color) 

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

1280 return arg_index, fmt 

1281 

1282 

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

1284 color = None 

1285 while isinstance(x, WithBaseColor): 

1286 x, base_color = x._msg, x._base_color 

1287 base_color = theme.to_color(base_color) 

1288 if color: 

1289 color = color | base_color 

1290 else: 

1291 color = base_color 

1292 else: 

1293 return x, color 

1294 

1295 

1296def _percent_format_repl_str( 

1297 match: _t.StrReMatch, 

1298 args: object, 

1299 arg_index: int, 

1300 base_color: _Color, 

1301 ctx: ReprContext, 

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

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

1304 if width_s == "*": 

1305 if not isinstance(args, tuple): 

1306 raise TypeError("* wants int") 

1307 try: 

1308 width = args[arg_index] 

1309 arg_index += 1 

1310 except (KeyError, IndexError): 

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

1312 if not isinstance(width, int): 

1313 raise TypeError("* wants int") 

1314 else: 

1315 width = int(width_s) 

1316 else: 

1317 width = None 

1318 

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

1320 if precision_s == "*": 

1321 if not isinstance(args, tuple): 

1322 raise TypeError("* wants int") 

1323 try: 

1324 precision = args[arg_index] 

1325 arg_index += 1 

1326 except (KeyError, IndexError): 

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

1328 if not isinstance(precision, int): 

1329 raise TypeError("* wants int") 

1330 else: 

1331 precision = int(precision_s) 

1332 else: 

1333 precision = None 

1334 

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

1336 try: 

1337 fmt_arg = args[mapping] # type: ignore 

1338 except TypeError: 

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

1340 elif isinstance(args, tuple): 

1341 try: 

1342 fmt_arg = args[arg_index] 

1343 arg_index += 1 

1344 except IndexError: 

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

1346 elif arg_index == 0: 

1347 fmt_arg = args 

1348 arg_index += 1 

1349 else: 

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

1351 

1352 flag = match.group("flag") 

1353 multiline = "+" in flag 

1354 highlighted = "#" in flag 

1355 if match.group("format") == "r": 

1356 res = ctx.repr(fmt_arg, multiline=multiline, highlighted=highlighted) 

1357 else: 

1358 res = ctx.str(fmt_arg, multiline=multiline, highlighted=highlighted) 

1359 

1360 if precision is not None and res.width > precision: 

1361 cut = ColorizedString() 

1362 for part in res: 

1363 if precision <= 0: 

1364 break 

1365 if isinstance(part, str): 

1366 part_width = line_width(part) 

1367 if part_width <= precision: 

1368 cut.append_str(part) 

1369 precision -= part_width 

1370 elif part.isascii(): 

1371 cut.append_str(part[:precision]) 

1372 break 

1373 else: 

1374 for j, ch in enumerate(part): 

1375 precision -= line_width(ch) 

1376 if precision == 0: 

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

1378 break 

1379 elif precision < 0: 

1380 cut.append_str(part[:j]) 

1381 cut.append_str(" ") 

1382 break 

1383 break 

1384 else: 

1385 cut += part 

1386 res = cut 

1387 

1388 if width is not None: 

1389 spacing = " " * (abs(width) - res.width) 

1390 if spacing: 

1391 if match.group("flag") == "-" or width < 0: 

1392 res = res + spacing 

1393 else: 

1394 res = spacing + res 

1395 

1396 return arg_index, res.with_base_color(base_color) 

1397 

1398 

1399__TAG_RE = re.compile( 

1400 r""" 

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

1402 | </c> # _Color tag close. 

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

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

1405 """ 

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

1407 re.VERBOSE | re.MULTILINE, 

1408) 

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

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

1411 

1412 

1413def colorize( 

1414 line: str, 

1415 /, 

1416 *args: _t.Any, 

1417 ctx: yuio.theme.Theme | ReprContext | None = None, 

1418 default_color: _Color | str = _Color.NONE, 

1419 parse_cli_flags_in_backticks: bool = False, 

1420) -> ColorizedString: 

1421 """ 

1422 Parse color tags and produce a colorized string. 

1423 

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

1425 and backticks within it. 

1426 

1427 :param line: 

1428 text to colorize. 

1429 :param args: 

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

1431 :param ctx: 

1432 :class:`ReprContext` or theme that will be used to look up color tags. 

1433 If not given, uses theme from :func:`yuio.io.get_theme`. 

1434 :param default_color: 

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

1436 :returns: 

1437 a colorized string. 

1438 

1439 """ 

1440 

1441 if ctx is None: 

1442 import yuio.io 

1443 

1444 ctx = yuio.io.get_theme() 

1445 if not isinstance(ctx, ReprContext): 

1446 ctx = ReprContext(theme=ctx) 

1447 

1448 default_color = ctx.theme.to_color(default_color) 

1449 

1450 res = ColorizedString(default_color) 

1451 

1452 stack = [default_color] 

1453 

1454 last_pos = 0 

1455 for tag in __TAG_RE.finditer(line): 

1456 res.append_str(line[last_pos : tag.start()]) 

1457 last_pos = tag.end() 

1458 

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

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

1461 res.append_color(color) 

1462 stack.append(color) 

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

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

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

1466 code = code[1:-1] 

1467 if ( 

1468 parse_cli_flags_in_backticks 

1469 and __FLAG_RE.match(code) 

1470 and not __NEG_NUM_RE.match(code) 

1471 ): 

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

1473 else: 

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

1475 res.start_no_wrap() 

1476 res.append_str(code) 

1477 res.end_no_wrap() 

1478 res.append_color(stack[-1]) 

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

1480 res.append_str(punct) 

1481 elif len(stack) > 1: 

1482 stack.pop() 

1483 res.append_color(stack[-1]) 

1484 

1485 res.append_str(line[last_pos:]) 

1486 

1487 if args: 

1488 return res.percent_format(args, ctx) 

1489 else: 

1490 return res 

1491 

1492 

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

1494 """ 

1495 Remove all color tags from a string. 

1496 

1497 """ 

1498 

1499 raw: list[str] = [] 

1500 

1501 last_pos = 0 

1502 for tag in __TAG_RE.finditer(s): 

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

1504 last_pos = tag.end() 

1505 

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

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

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

1509 code = code[1:-1] 

1510 raw.append(code) 

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

1512 raw.append(punct) 

1513 

1514 raw.append(s[last_pos:]) 

1515 

1516 return "".join(raw) 

1517 

1518 

1519class Esc(_UserString): 

1520 """ 

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

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

1523 

1524 """ 

1525 

1526 __slots__ = () 

1527 

1528 

1529class Link(_UserString): 

1530 """ 

1531 A :class:`str` wrapper with an attached hyperlink. 

1532 

1533 """ 

1534 

1535 __slots__ = ("__href",) 

1536 

1537 def __new__(cls, *args, href: str, **kwargs): 

1538 res = super().__new__(cls, *args, **kwargs) 

1539 res.__href = href 

1540 return res 

1541 

1542 @property 

1543 def href(self): 

1544 """ 

1545 Target link. 

1546 

1547 """ 

1548 

1549 return self.__href 

1550 

1551 def as_code(self, color_support: yuio.color.ColorSupport): 

1552 """ 

1553 Convert this link into an ANSI escape code with respect to the given 

1554 terminal capabilities. 

1555 

1556 :param color_support: 

1557 level of color support of a terminal. 

1558 :returns: 

1559 either ANSI escape code for this color or an empty string. 

1560 

1561 """ 

1562 

1563 if color_support < yuio.color.ColorSupport.ANSI_TRUE: 

1564 return "" 

1565 else: 

1566 return f"\x1b]8;;{self.__href}\x1b\\{self}\x1b]8;;\x1b\\" 

1567 

1568 def _wrap(self, data: str): 

1569 return self.__class__(data, href=self.__href) 

1570 

1571 def __repr__(self) -> str: 

1572 return f"Link({super().__repr__()}, href={self.href!r})" 

1573 

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

1575 return ColorizedString(self) 

1576 

1577 

1578def _split_keep_link(s: str, r: _t.StrRePattern): 

1579 if isinstance(s, Link): 

1580 href = s.href 

1581 ctor = lambda x: Link(x, href=href) 

1582 else: 

1583 ctor = s.__class__ 

1584 return [ctor(part) for part in r.split(s)] 

1585 

1586 

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

1588 

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

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

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

1592 

1593# Copied from textwrap with some modifications in newline handling 

1594_WORDSEP_RE = re.compile( 

1595 r""" 

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

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

1598 | # any whitespace 

1599 [ \t\b\f]+ 

1600 | # em-dash between words 

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

1602 | # word, possibly hyphenated 

1603 %(nws)s+? (?: 

1604 # hyphenated word 

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

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

1607 | # end of word 

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

1609 | # em-dash 

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

1611 ) 

1612 )""" 

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

1614 re.VERBOSE, 

1615) 

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

1617 

1618 

1619class _TextWrapper: 

1620 def __init__( 

1621 self, 

1622 width: float, 

1623 /, 

1624 *, 

1625 preserve_spaces: bool, 

1626 preserve_newlines: bool, 

1627 break_long_words: bool, 

1628 break_long_nowrap_words: bool, 

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

1630 indent: AnyString | int, 

1631 continuation_indent: AnyString | int | None, 

1632 ): 

1633 self.width: float = width # Actual type is `int | +inf`. 

1634 self.preserve_spaces: bool = preserve_spaces 

1635 self.preserve_newlines: bool = preserve_newlines 

1636 self.break_long_words: bool = break_long_words 

1637 self.break_long_nowrap_words: bool = break_long_nowrap_words 

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

1639 

1640 if isinstance(indent, int): 

1641 self.indent = ColorizedString(" " * indent) 

1642 else: 

1643 self.indent = ColorizedString(indent) 

1644 if continuation_indent is None: 

1645 self.continuation_indent = self.indent 

1646 elif isinstance(continuation_indent, int): 

1647 self.continuation_indent = ColorizedString(" " * continuation_indent) 

1648 else: 

1649 self.continuation_indent = ColorizedString(continuation_indent) 

1650 

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

1652 

1653 self.current_line = ColorizedString() 

1654 if self.indent: 

1655 self.current_line += self.indent 

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

1657 self.at_line_start: bool = True 

1658 self.has_ellipsis: bool = False 

1659 self.needs_space_before_word = False 

1660 self.space_before_word_href = None 

1661 

1662 self.nowrap_start_index = None 

1663 self.nowrap_start_width = 0 

1664 self.nowrap_start_added_space = False 

1665 

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

1667 self.current_line._explicit_newline = explicit_newline 

1668 self.lines.append(self.current_line) 

1669 

1670 self.current_line = ColorizedString(self.current_line.active_color) 

1671 

1672 if self.continuation_indent: 

1673 self.current_line += self.continuation_indent 

1674 

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

1676 self.at_line_start = True 

1677 self.has_ellipsis = False 

1678 self.nowrap_start_index = None 

1679 self.nowrap_start_width = 0 

1680 self.nowrap_start_added_space = False 

1681 self.needs_space_before_word = False 

1682 self.space_before_word_href = None 

1683 

1684 def _flush_line_part(self): 

1685 assert self.nowrap_start_index is not None 

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

1687 tail_width = self.current_line_width - self.nowrap_start_width 

1688 if ( 

1689 self.nowrap_start_added_space 

1690 and self.current_line._parts 

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

1692 ): 

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

1694 self.current_line._parts.pop() 

1695 self._flush_line() 

1696 self.current_line += tail 

1697 self.current_line.append_color(tail.active_color) 

1698 self.current_line_width += tail_width 

1699 

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

1701 if ( 

1702 self.overflow is not False 

1703 and self.current_line_width + word_width > self.width 

1704 ): 

1705 if isinstance(word, Esc): 

1706 if self.overflow: 

1707 self._add_ellipsis() 

1708 return 

1709 

1710 word_head_len = word_head_width = 0 

1711 

1712 for c in word: 

1713 c_width = line_width(c) 

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

1715 break 

1716 word_head_len += 1 

1717 word_head_width += c_width 

1718 

1719 if word_head_len: 

1720 self.current_line.append_str(word[:word_head_len]) 

1721 self.at_line_start = False 

1722 self.has_ellipsis = False 

1723 self.current_line_width += word_head_width 

1724 

1725 if self.overflow: 

1726 self._add_ellipsis() 

1727 else: 

1728 self.current_line.append_str(word) 

1729 self.current_line_width += word_width 

1730 self.has_ellipsis = False 

1731 self.at_line_start = False 

1732 

1733 def _append_space(self): 

1734 if self.needs_space_before_word: 

1735 word = " " 

1736 if self.space_before_word_href: 

1737 word = Link(word, href=self.space_before_word_href) 

1738 self._append_word(word, 1) 

1739 self.needs_space_before_word = False 

1740 self.space_before_word_href = None 

1741 

1742 def _add_ellipsis(self): 

1743 if self.has_ellipsis: 

1744 # Already has an ellipsis. 

1745 return 

1746 

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

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

1749 self.current_line.append_str(str(self.overflow)) 

1750 self.current_line_width += 1 

1751 self.at_line_start = False 

1752 self.has_ellipsis = True 

1753 elif not self.at_line_start: 

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

1755 parts = self.current_line._parts 

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

1757 part = parts[i] 

1758 if isinstance(part, str): 

1759 if not isinstance(part, (Esc, Link)): 

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

1761 self.has_ellipsis = True 

1762 return 

1763 

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

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

1766 word_head_len = word_head_width = 0 

1767 

1768 for c in word: 

1769 c_width = line_width(c) 

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

1771 break 

1772 word_head_len += 1 

1773 word_head_width += c_width 

1774 

1775 if self.at_line_start and not word_head_len: 

1776 if self.overflow: 

1777 return 

1778 else: 

1779 word_head_len = 1 

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

1781 

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

1783 

1784 word = word[word_head_len:] 

1785 word_width -= word_head_width 

1786 

1787 self._flush_line() 

1788 

1789 if word: 

1790 self._append_word(word, word_width) 

1791 

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

1793 nowrap = False 

1794 

1795 for part in text: 

1796 if isinstance(part, _Color): 

1797 if ( 

1798 self.needs_space_before_word 

1799 and self.current_line_width + self.needs_space_before_word 

1800 < self.width 

1801 ): 

1802 # Make sure any whitespace that was added before color 

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

1804 # will be wrapped soon anyways. 

1805 self._append_space() 

1806 self.needs_space_before_word = False 

1807 self.space_before_word_href = None 

1808 self.current_line.append_color(part) 

1809 continue 

1810 elif part is NO_WRAP_START: 

1811 if nowrap: # pragma: no cover 

1812 continue 

1813 if ( 

1814 self.needs_space_before_word 

1815 and self.current_line_width + self.needs_space_before_word 

1816 < self.width 

1817 ): 

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

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

1820 # will be wrapped soon anyways. 

1821 self._append_space() 

1822 self.nowrap_start_added_space = True 

1823 else: 

1824 self.nowrap_start_added_space = False 

1825 self.needs_space_before_word = False 

1826 self.space_before_word_href = None 

1827 if self.at_line_start: 

1828 self.nowrap_start_index = None 

1829 self.nowrap_start_width = 0 

1830 else: 

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

1832 self.nowrap_start_width = self.current_line_width 

1833 nowrap = True 

1834 continue 

1835 elif part is NO_WRAP_END: 

1836 nowrap = False 

1837 self.nowrap_start_index = None 

1838 self.nowrap_start_width = 0 

1839 self.nowrap_start_added_space = False 

1840 continue 

1841 

1842 esc = False 

1843 if isinstance(part, Esc): 

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

1845 esc = True 

1846 elif nowrap: 

1847 words = _split_keep_link(part, _WORDSEP_NL_RE) 

1848 else: 

1849 words = _split_keep_link(part, _WORDSEP_RE) 

1850 

1851 for word in words: 

1852 if not word: 

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

1854 continue 

1855 

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

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

1858 # need to split the word further. 

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

1860 self._flush_line(explicit_newline=word) 

1861 continue 

1862 else: 

1863 # Treat any newline sequence as a single space. 

1864 word = " " 

1865 

1866 isspace = not esc and word.isspace() 

1867 if isspace: 

1868 if ( 

1869 # Spaces are preserved in no-wrap sequences. 

1870 nowrap 

1871 # Spaces are explicitly preserved. 

1872 or self.preserve_spaces 

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

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

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

1876 or ( 

1877 self.at_line_start 

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

1879 ) 

1880 ): 

1881 word = word.translate(_SPACE_TRANS) 

1882 else: 

1883 self.needs_space_before_word = True 

1884 self.space_before_word_href = ( 

1885 word.href if isinstance(word, Link) else None 

1886 ) 

1887 continue 

1888 

1889 word_width = line_width(word) 

1890 

1891 if self._try_fit_word(word, word_width): 

1892 # Word fits onto the current line. 

1893 continue 

1894 

1895 if self.nowrap_start_index is not None: 

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

1897 self._flush_line_part() 

1898 

1899 if self._try_fit_word(word, word_width): 

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

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

1902 continue 

1903 

1904 if ( 

1905 not self.at_line_start 

1906 and ( 

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

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

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

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

1911 (not nowrap and not isspace) 

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

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

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

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

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

1917 or (nowrap and esc and self.break_long_nowrap_words) 

1918 ) 

1919 and not ( 

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

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

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

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

1924 # which will handle ellipsis for us. 

1925 self.overflow is not False 

1926 and esc 

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

1928 and ( 

1929 self.has_ellipsis 

1930 or self.current_line_width 

1931 + self.needs_space_before_word 

1932 + 1 

1933 <= self.width 

1934 ) 

1935 ) 

1936 ): 

1937 # Flush a non-empty line. 

1938 self._flush_line() 

1939 

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

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

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

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

1944 # we flush the line in the condition above. 

1945 if not esc and ( 

1946 (nowrap and self.break_long_nowrap_words) 

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

1948 ): 

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

1950 self._append_word_with_breaks(word, word_width) 

1951 else: 

1952 self._append_word(word, word_width) 

1953 

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

1955 self._flush_line() 

1956 

1957 return self.lines 

1958 

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

1960 if ( 

1961 self.current_line_width + word_width + self.needs_space_before_word 

1962 <= self.width 

1963 ): 

1964 self._append_space() 

1965 self._append_word(word, word_width) 

1966 return True 

1967 else: 

1968 return False 

1969 

1970 

1971class _ReprContextState(Enum): 

1972 START = 0 

1973 """ 

1974 Initial state. 

1975 

1976 """ 

1977 

1978 CONTAINER_START = 1 

1979 """ 

1980 Right after a token starting a container was pushed. 

1981 

1982 """ 

1983 

1984 ITEM_START = 2 

1985 """ 

1986 Right after a token separating container items was pushed. 

1987 

1988 """ 

1989 

1990 NORMAL = 3 

1991 """ 

1992 In the middle of a container element. 

1993 

1994 """ 

1995 

1996 

1997@_t.final 

1998class ReprContext: 

1999 """ 

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

2001 are handled properly. 

2002 

2003 :param theme: 

2004 theme will be passed to ``__colorized_repr__``. 

2005 :param multiline: 

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

2007 should be split into multiple lines. 

2008 :param highlighted: 

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

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

2011 :param max_depth: 

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

2013 are not rendered. 

2014 :param max_width: 

2015 maximum width of the content, used when wrapping text or rendering markdown. 

2016 

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

2018 

2019 """ 

2020 

2021 def __init__( 

2022 self, 

2023 *, 

2024 theme: yuio.theme.Theme | None = None, 

2025 multiline: bool = False, 

2026 highlighted: bool = False, 

2027 max_depth: int = 5, 

2028 max_width: int | None = None, 

2029 ): 

2030 if theme is None: 

2031 import yuio.io 

2032 

2033 theme = yuio.io.get_theme() 

2034 self._theme = theme 

2035 self._multiline = multiline 

2036 self._highlighted = highlighted 

2037 self._max_depth = max_depth 

2038 self._max_width = max(max_width or shutil.get_terminal_size().columns, 1) 

2039 

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

2041 self._line = ColorizedString() 

2042 self._indent = 0 

2043 self._state = _ReprContextState.START 

2044 self._pending_sep = None 

2045 

2046 import yuio.md 

2047 

2048 self._hl = yuio.md.SyntaxHighlighter.get_highlighter("repr") 

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

2050 

2051 @property 

2052 def theme(self) -> yuio.theme.Theme: 

2053 """ 

2054 Current theme. 

2055 

2056 """ 

2057 

2058 return self._theme # pragma: no cover 

2059 

2060 @property 

2061 def multiline(self) -> bool: 

2062 """ 

2063 Whether values rendered with ``repr`` are split into multiple lines. 

2064 

2065 """ 

2066 

2067 return self._multiline # pragma: no cover 

2068 

2069 @property 

2070 def highlighted(self) -> bool: 

2071 """ 

2072 Whether values rendered with ``repr`` are highlighted. 

2073 

2074 """ 

2075 

2076 return self._highlighted # pragma: no cover 

2077 

2078 @property 

2079 def max_depth(self) -> int: 

2080 """ 

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

2082 are not rendered. 

2083 

2084 """ 

2085 

2086 return self._max_depth # pragma: no cover 

2087 

2088 @property 

2089 def max_width(self) -> int: 

2090 """ 

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

2092 

2093 """ 

2094 

2095 return self._max_width # pragma: no cover 

2096 

2097 def _flush_sep(self): 

2098 if self._pending_sep is not None: 

2099 self._push_color("punct") 

2100 self._line.append_str(self._pending_sep) 

2101 self._pending_sep = None 

2102 

2103 def _flush_line(self): 

2104 if self._multiline: 

2105 self._line.append_color(_Color.NONE) 

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

2107 if self._indent: 

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

2109 

2110 def _push_color(self, tag: str): 

2111 if self._highlighted: 

2112 self._line.append_color( 

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

2114 ) 

2115 

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

2117 self._flush_sep() 

2118 

2119 if self._state in [ 

2120 _ReprContextState.CONTAINER_START, 

2121 _ReprContextState.ITEM_START, 

2122 ]: 

2123 self._flush_line() 

2124 

2125 self._push_color(tag) 

2126 self._line.append_str(content) 

2127 

2128 self._state = _ReprContextState.NORMAL 

2129 

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

2131 self._flush_sep() 

2132 self._pending_sep = sep 

2133 self._state = _ReprContextState.ITEM_START 

2134 

2135 def _start_container(self): 

2136 self._state = _ReprContextState.CONTAINER_START 

2137 self._indent += 1 

2138 

2139 def _end_container(self): 

2140 self._indent -= 1 

2141 

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

2143 self._flush_line() 

2144 

2145 self._state = _ReprContextState.NORMAL 

2146 self._pending_sep = None 

2147 

2148 def repr( 

2149 self, 

2150 value: _t.Any, 

2151 /, 

2152 *, 

2153 multiline: bool | None = None, 

2154 highlighted: bool | None = None, 

2155 max_width: int | None = None, 

2156 ) -> ColorizedString: 

2157 """ 

2158 Convert value to colorized string using repr methods. 

2159 

2160 :param value: 

2161 value to be rendered. 

2162 :param multiline: 

2163 if given, overrides settings passed to :func:`colorized_str` for this call. 

2164 :param highlighted: 

2165 if given, overrides settings passed to :func:`colorized_str` for this call. 

2166 :param max_width: 

2167 if given, overrides settings passed to :func:`colorized_str` for this call. 

2168 :returns: 

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

2170 :raises: 

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

2172 exception, this function returns a colorized string with 

2173 an error description. 

2174 

2175 """ 

2176 

2177 return self._print( 

2178 value, 

2179 multiline=multiline, 

2180 highlighted=highlighted, 

2181 use_str=False, 

2182 max_width=max_width, 

2183 ) 

2184 

2185 def str( 

2186 self, 

2187 value: _t.Any, 

2188 /, 

2189 *, 

2190 multiline: bool | None = None, 

2191 highlighted: bool | None = None, 

2192 max_width: int | None = None, 

2193 ) -> ColorizedString: 

2194 """ 

2195 Convert value to colorized string. 

2196 

2197 :param value: 

2198 value to be rendered. 

2199 :param multiline: 

2200 if given, overrides settings passed to :func:`colorized_str` for this call. 

2201 :param highlighted: 

2202 if given, overrides settings passed to :func:`colorized_str` for this call. 

2203 :param max_width: 

2204 if given, overrides settings passed to :func:`colorized_str` for this call. 

2205 :returns: 

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

2207 :raises: 

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

2209 exception, this function returns a colorized string with 

2210 an error description. 

2211 

2212 """ 

2213 

2214 return self._print( 

2215 value, 

2216 multiline=multiline, 

2217 highlighted=highlighted, 

2218 use_str=True, 

2219 max_width=max_width, 

2220 ) 

2221 

2222 def hl( 

2223 self, 

2224 value: str, 

2225 /, 

2226 *, 

2227 highlighted: bool | None = None, 

2228 ) -> ColorizedString: 

2229 """ 

2230 Highlight result of :func:`repr`. 

2231 

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

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

2234 

2235 :param value: 

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

2237 :returns: 

2238 highlighted string. 

2239 

2240 """ 

2241 

2242 highlighted = highlighted if highlighted is not None else self._highlighted 

2243 

2244 if highlighted: 

2245 return self._hl.highlight( 

2246 self._theme, value, default_color=self._base_color 

2247 ) 

2248 else: 

2249 return ColorizedString(value) 

2250 

2251 def _print( 

2252 self, 

2253 value: _t.Any, 

2254 multiline: bool | None, 

2255 highlighted: bool | None, 

2256 max_width: int | None, 

2257 use_str: bool, 

2258 ) -> ColorizedString: 

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

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

2261 old_pending_sep, self._pending_sep = self._pending_sep, None 

2262 old_multiline, self._multiline = ( 

2263 self._multiline, 

2264 (self._multiline if multiline is None else multiline), 

2265 ) 

2266 old_highlighted, self._highlighted = ( 

2267 self._highlighted, 

2268 (self._highlighted if highlighted is None else highlighted), 

2269 ) 

2270 old_max_width, self._max_width = ( 

2271 self._max_width, 

2272 (self._max_width if max_width is None else max_width), 

2273 ) 

2274 

2275 try: 

2276 self._print_nested(value, use_str) 

2277 return self._line 

2278 except Exception as e: 

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

2280 res = ColorizedString() 

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

2282 res.append_str(f"{_t.type_repr(type(e))}: {e}") 

2283 return res 

2284 finally: 

2285 self._line = old_line 

2286 self._state = old_state 

2287 self._pending_sep = old_pending_sep 

2288 self._multiline = old_multiline 

2289 self._highlighted = old_highlighted 

2290 self._max_width = old_max_width 

2291 

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

2293 if id(value) in self._seen or self._indent > self._max_depth: 

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

2295 return 

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

2297 old_indent = self._indent 

2298 try: 

2299 if use_str: 

2300 self._print_nested_as_str(value) 

2301 else: 

2302 self._print_nested_as_repr(value) 

2303 finally: 

2304 self._indent = old_indent 

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

2306 

2307 def _print_nested_as_str(self, value): 

2308 if isinstance(value, type): 

2309 # This is a type. 

2310 self._print_plain(value, convert=_t.type_repr) 

2311 elif hasattr(value, "__colorized_str__"): 

2312 # Has `__colorized_str__`. 

2313 self._print_colorized_str(value) 

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

2315 # Has custom `__str__`. 

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

2317 else: 

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

2319 self._print_nested_as_repr(value) 

2320 

2321 def _print_nested_as_repr(self, value): 

2322 if isinstance(value, type): 

2323 # This is a type. 

2324 self._print_plain(value, convert=_t.type_repr) 

2325 elif hasattr(value, "__colorized_repr__"): 

2326 # Has `__colorized_repr__`. 

2327 self._print_colorized_repr(value) 

2328 elif hasattr(value, "__rich_repr__"): 

2329 # Has `__rich_repr__`. 

2330 self._print_rich_repr(value) 

2331 elif isinstance(value, _CONTAINER_TYPES): 

2332 # Is a known container. 

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

2334 if isinstance(value, ty): 

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

2336 repr_fn(self, value) # type: ignore 

2337 else: 

2338 self._print_plain(value) 

2339 break 

2340 elif dataclasses.is_dataclass(value): 

2341 # Is a dataclass. 

2342 self._print_dataclass(value) 

2343 else: 

2344 # Fall back to regular `__repr__`. 

2345 self._print_plain(value) 

2346 

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

2348 convert = convert or repr 

2349 

2350 self._flush_sep() 

2351 

2352 if self._state in [ 

2353 _ReprContextState.CONTAINER_START, 

2354 _ReprContextState.ITEM_START, 

2355 ]: 

2356 self._flush_line() 

2357 

2358 if hl and self._highlighted: 

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

2360 self._theme, convert(value), default_color=self._base_color 

2361 ) 

2362 else: 

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

2364 

2365 self._state = _ReprContextState.NORMAL 

2366 

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

2368 if name: 

2369 self._push_token(name, "type") 

2370 self._push_token(obrace, "punct") 

2371 if self._indent >= self._max_depth: 

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

2373 else: 

2374 self._start_container() 

2375 for item in items: 

2376 self._print_nested(item) 

2377 self._terminate_item() 

2378 self._end_container() 

2379 self._push_token(cbrace, "punct") 

2380 

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

2382 if name: 

2383 self._push_token(name, "type") 

2384 self._push_token(obrace, "punct") 

2385 if self._indent >= self._max_depth: 

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

2387 else: 

2388 self._start_container() 

2389 for key, value in items: 

2390 self._print_nested(key) 

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

2392 self._print_nested(value) 

2393 self._terminate_item() 

2394 self._end_container() 

2395 self._push_token(cbrace, "punct") 

2396 

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

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

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

2400 if self._indent >= self._max_depth: 

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

2402 else: 

2403 self._start_container() 

2404 self._print_nested(value.default_factory) 

2405 self._terminate_item() 

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

2407 self._terminate_item() 

2408 self._end_container() 

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

2410 

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

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

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

2414 if self._indent >= self._max_depth: 

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

2416 else: 

2417 self._start_container() 

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

2419 self._terminate_item() 

2420 if value.maxlen is not None: 

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

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

2423 self._print_nested(value.maxlen) 

2424 self._terminate_item() 

2425 self._end_container() 

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

2427 

2428 def _print_dataclass(self, value): 

2429 try: 

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

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

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

2433 dataclasses.__file__, 

2434 reprlib.__file__, 

2435 ) 

2436 except Exception: # pragma: no cover 

2437 has_custom_repr = True 

2438 

2439 if has_custom_repr: 

2440 self._print_plain(value) 

2441 return 

2442 

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

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

2445 

2446 if self._indent >= self._max_depth: 

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

2448 else: 

2449 self._start_container() 

2450 for field in dataclasses.fields(value): 

2451 if not field.repr: 

2452 continue 

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

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

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

2456 self._terminate_item() 

2457 self._end_container() 

2458 

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

2460 

2461 def _print_colorized_repr(self, value): 

2462 self._flush_sep() 

2463 

2464 if self._state in [ 

2465 _ReprContextState.CONTAINER_START, 

2466 _ReprContextState.ITEM_START, 

2467 ]: 

2468 self._flush_line() 

2469 

2470 res = value.__colorized_repr__(self) 

2471 if not isinstance(res, ColorizedString): 

2472 raise TypeError( 

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

2474 ) 

2475 self._line += res 

2476 

2477 self._state = _ReprContextState.NORMAL 

2478 

2479 def _print_colorized_str(self, value): 

2480 self._flush_sep() 

2481 

2482 if self._state in [ 

2483 _ReprContextState.CONTAINER_START, 

2484 _ReprContextState.ITEM_START, 

2485 ]: # pragma: no cover 

2486 self._flush_line() 

2487 # This never happens because `_state` is always `START` 

2488 # when rendering as `str`. 

2489 

2490 res = value.__colorized_str__(self) 

2491 if not isinstance(res, ColorizedString): 

2492 raise TypeError( 

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

2494 ) 

2495 self._line += res 

2496 self._state = _ReprContextState.NORMAL 

2497 

2498 def _print_rich_repr(self, value): 

2499 rich_repr = getattr(value, "__rich_repr__") 

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

2501 

2502 if angular: 

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

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

2505 if angular: 

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

2507 else: 

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

2509 

2510 if self._indent >= self._max_depth: 

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

2512 else: 

2513 self._start_container() 

2514 args = rich_repr() 

2515 if args is None: 

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

2517 for arg in args: 

2518 if isinstance(arg, tuple): 

2519 if len(arg) == 3: 

2520 key, child, default = arg 

2521 if default == child: 

2522 continue 

2523 elif len(arg) == 2: 

2524 key, child = arg 

2525 elif len(arg) == 1: 

2526 key, child = None, arg[0] 

2527 else: 

2528 key, child = None, arg 

2529 else: 

2530 key, child = None, arg 

2531 

2532 if key: 

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

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

2535 self._print_nested(child) 

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

2537 self._end_container() 

2538 

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

2540 

2541 

2542_CONTAINERS = { 

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

2544 collections.defaultdict: ReprContext._print_defaultdict, 

2545 collections.deque: ReprContext._print_dequeue, 

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

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

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

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

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

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

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

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

2554 types.MappingProxyType: lambda _: lambda c, o: c._print_dict( 

2555 "mappingproxy", "({", "})", o.items() 

2556 ), 

2557} 

2558_CONTAINER_TYPES = tuple(_CONTAINERS) 

2559 

2560 

2561def colorized_str( 

2562 value: _t.Any, 

2563 /, 

2564 theme: yuio.theme.Theme | None = None, 

2565 **kwargs, 

2566) -> ColorizedString: 

2567 """ 

2568 Like :class:`str() <str>`, but uses ``__colorized_str__`` and returns 

2569 a colorized string. 

2570 

2571 This function is used when formatting values 

2572 via :meth:`ColorizedString.percent_format`, or printing them via :mod:`yuio.io` 

2573 functions. 

2574 

2575 :param value: 

2576 value to colorize. 

2577 :param theme: 

2578 theme will be passed to ``__colorized_str__``. 

2579 :param kwargs: 

2580 all other keyword arguments will be forwarded to :class:`ReprContext`. 

2581 :returns: 

2582 a colorized string containing representation of ``value``. 

2583 :raises: 

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

2585 exception, this function returns a colorized string with an error description. 

2586 

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

2588 

2589 """ 

2590 

2591 ctx = ReprContext(theme=theme, **kwargs) 

2592 return ctx.str(value) 

2593 

2594 

2595def colorized_repr( 

2596 value: _t.Any, 

2597 /, 

2598 theme: yuio.theme.Theme | None = None, 

2599 **kwargs, 

2600) -> ColorizedString: 

2601 """ 

2602 Like :func:`repr`, but uses ``__colorized_repr__`` and returns 

2603 a colorized string. 

2604 

2605 This function is used when formatting values 

2606 via :meth:`ColorizedString.percent_format`, or printing them via :mod:`yuio.io` 

2607 functions. 

2608 

2609 :param value: 

2610 value to colorize. 

2611 :param theme: 

2612 theme will be passed to ``__colorized_repr__``. 

2613 :param kwargs: 

2614 all other keyword arguments will be forwarded to :class:`ReprContext`. 

2615 :returns: 

2616 a colorized string containing representation of ``value``. 

2617 :raises: 

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

2619 exception, this function returns a colorized string with an error description. 

2620 

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

2622 

2623 """ 

2624 

2625 ctx = ReprContext(theme=theme, **kwargs) 

2626 return ctx.repr(value) 

2627 

2628 

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

2630 """ 

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

2632 

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

2634 were given, and returns ``msg`` unchanged. 

2635 

2636 """ 

2637 

2638 if isinstance(msg, str): 

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

2640 else: 

2641 if args: 

2642 raise TypeError( 

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

2644 ) 

2645 return msg 

2646 

2647 

2648class _StrBase(abc.ABC): 

2649 def __str__(self) -> str: 

2650 return str(ReprContext().str(self)) 

2651 

2652 @abc.abstractmethod 

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

2654 raise NotImplementedError() 

2655 

2656 

2657@repr_from_rich 

2658class Format(_StrBase): 

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

2660 Format(msg: str, /) 

2661 

2662 Lazy wrapper that ``%``-formats the given message. 

2663 

2664 This utility allows saving ``%``-formatted messages and performing actual 

2665 formatting lazily when requested. Color tags and backticks are handled as usual. 

2666 

2667 :param msg: 

2668 message to format. 

2669 :param args: 

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

2671 :example: 

2672 :: 

2673 

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

2675 >>> print(message) 

2676 Hello, world! 

2677 

2678 """ 

2679 

2680 @_t.overload 

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

2682 @_t.overload 

2683 def __init__(self, msg: str, /): ... 

2684 def __init__(self, msg: str, /, *args: _t.Any): 

2685 self._msg: str = msg 

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

2687 

2688 def __rich_repr__(self) -> RichReprResult: 

2689 yield None, self._msg 

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

2691 

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

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

2694 

2695 

2696@_t.final 

2697@repr_from_rich 

2698class Repr: 

2699 """ 

2700 Lazy wrapper that calls :func:`colorized_repr` on the given value. 

2701 

2702 :param value: 

2703 value to repr. 

2704 :param multiline: 

2705 if given, overrides settings passed to :func:`colorized_repr` for this call. 

2706 :param highlighted: 

2707 if given, overrides settings passed to :func:`colorized_repr` for this call. 

2708 :example: 

2709 .. code-block:: python 

2710 

2711 config = ... 

2712 yuio.io.info( 

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

2714 ) 

2715 

2716 """ 

2717 

2718 def __init__( 

2719 self, 

2720 value: _t.Any, 

2721 /, 

2722 *, 

2723 multiline: bool | None = None, 

2724 highlighted: bool | None = None, 

2725 ): 

2726 self.value = value 

2727 self.multiline = multiline 

2728 self.highlighted = highlighted 

2729 

2730 def __rich_repr__(self) -> RichReprResult: 

2731 yield None, self.value 

2732 yield "multiline", self.multiline, None 

2733 yield "highlighted", self.highlighted, None 

2734 

2735 def __str__(self): 

2736 return repr(self.value) 

2737 

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

2739 return ctx.repr( 

2740 self.value, multiline=self.multiline, highlighted=self.highlighted 

2741 ) 

2742 

2743 

2744@_t.final 

2745@repr_from_rich 

2746class TypeRepr(_StrBase): 

2747 """ 

2748 Lazy wrapper that calls :func:`typing.type_repr` on the given value 

2749 and highlights the result. 

2750 

2751 :param ty: 

2752 type to format. 

2753 

2754 If ``ty`` is a string, :func:`typing.type_repr` is not called on it, allowing 

2755 you to mix types and arbitrary descriptions. 

2756 :param highlighted: 

2757 if given, overrides settings passed to :func:`colorized_repr` for this call. 

2758 :example: 

2759 .. invisible-code-block: python 

2760 

2761 value = ... 

2762 

2763 .. code-block:: python 

2764 

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

2766 

2767 """ 

2768 

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

2770 self._ty = ty 

2771 self._highlighted = highlighted 

2772 

2773 def __rich_repr__(self) -> RichReprResult: 

2774 yield None, self._ty 

2775 yield "highlighted", self._highlighted, None 

2776 

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

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

2779 self._ty, (str, ColorizedString) 

2780 ): 

2781 return ColorizedString(self._ty) 

2782 else: 

2783 return ctx.hl(_t.type_repr(self._ty), highlighted=self._highlighted) 

2784 

2785 

2786@repr_from_rich 

2787class _JoinBase(_StrBase): 

2788 def __init__( 

2789 self, 

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

2791 /, 

2792 *, 

2793 sep: str = ", ", 

2794 sep_two: str | None = None, 

2795 sep_last: str | None = None, 

2796 fallback: AnyString = "", 

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

2798 ): 

2799 self.__collection = collection 

2800 self._sep = sep 

2801 self._sep_two = sep_two 

2802 self._sep_last = sep_last 

2803 self._fallback: AnyString = fallback 

2804 self._color = color 

2805 

2806 @functools.cached_property 

2807 def _collection(self): 

2808 return list(self.__collection) 

2809 

2810 @classmethod 

2811 def or_( 

2812 cls, 

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

2814 /, 

2815 *, 

2816 fallback: AnyString = "", 

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

2818 ) -> _t.Self: 

2819 """ 

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

2821 

2822 :example: 

2823 :: 

2824 

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

2826 1, 2, or 3 

2827 

2828 """ 

2829 

2830 return cls( 

2831 collection, sep_last=", or ", sep_two=" or ", fallback=fallback, color=color 

2832 ) 

2833 

2834 @classmethod 

2835 def and_( 

2836 cls, 

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

2838 /, 

2839 *, 

2840 fallback: AnyString = "", 

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

2842 ) -> _t.Self: 

2843 """ 

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

2845 

2846 :example: 

2847 :: 

2848 

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

2850 1, 2, and 3 

2851 

2852 """ 

2853 

2854 return cls( 

2855 collection, 

2856 sep_last=", and ", 

2857 sep_two=" and ", 

2858 fallback=fallback, 

2859 color=color, 

2860 ) 

2861 

2862 def __rich_repr__(self) -> RichReprResult: 

2863 yield None, self._collection 

2864 yield "sep", self._sep, ", " 

2865 yield "sep_two", self._sep_two, None 

2866 yield "sep_last", self._sep_last, None 

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

2868 

2869 def _render( 

2870 self, 

2871 theme: yuio.theme.Theme, 

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

2873 ) -> ColorizedString: 

2874 res = ColorizedString() 

2875 color = theme.to_color(self._color) 

2876 

2877 size = len(self._collection) 

2878 if not size: 

2879 res += self._fallback 

2880 return res 

2881 elif size == 1: 

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

2883 elif size == 2: 

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

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

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

2887 return res 

2888 

2889 last_i = size - 1 

2890 

2891 sep = self._sep 

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

2893 

2894 do_sep = False 

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

2896 if do_sep: 

2897 if i == last_i: 

2898 res.append_str(sep_last) 

2899 else: 

2900 res.append_str(sep) 

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

2902 do_sep = True 

2903 return res 

2904 

2905 

2906@_t.final 

2907class JoinStr(_JoinBase): 

2908 """ 

2909 Lazy wrapper that calls :class:`colorized_str` on elements of the given collection, 

2910 then joins the results using the given separator. 

2911 

2912 :param collection: 

2913 collection that will be printed. 

2914 :param sep: 

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

2916 :param sep_two: 

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

2918 Defaults to ``sep``. 

2919 :param sep_last: 

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

2921 of the collection. Defaults to ``sep``. 

2922 :param fallback: 

2923 printed if collection is empty. 

2924 :param color: 

2925 color applied to elements of the collection. 

2926 :example: 

2927 .. code-block:: python 

2928 

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

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

2931 

2932 """ 

2933 

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

2935 return self._render(ctx._theme, ctx.str) 

2936 

2937 

2938@_t.final 

2939class JoinRepr(_JoinBase): 

2940 """ 

2941 Lazy wrapper that calls :class:`colorized_repr` on elements of the given collection, 

2942 then joins the results using the given separator. 

2943 

2944 :param collection: 

2945 collection that will be printed. 

2946 :param sep: 

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

2948 :param sep_two: 

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

2950 Defaults to ``sep``. 

2951 :param sep_last: 

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

2953 of the collection. Defaults to ``sep``. 

2954 :param fallback: 

2955 printed if collection is empty. 

2956 :param color: 

2957 color applied to elements of the collection. 

2958 :example: 

2959 .. code-block:: python 

2960 

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

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

2963 

2964 """ 

2965 

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

2967 return self._render(ctx._theme, ctx.repr) 

2968 

2969 

2970And = JoinStr.and_ 

2971""" 

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

2973 

2974""" 

2975 

2976 

2977Or = JoinStr.or_ 

2978""" 

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

2980 

2981""" 

2982 

2983 

2984@_t.final 

2985@repr_from_rich 

2986class Stack(_StrBase): 

2987 """ 

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

2989 effectively stacking them one on top of another. 

2990 

2991 :param args: 

2992 colorables to stack. 

2993 :example: 

2994 :: 

2995 

2996 >>> print( 

2997 ... yuio.string.Stack( 

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

2999 ... yuio.string.Indent( 

3000 ... yuio.string.Hl( 

3001 ... \""" 

3002 ... { 

3003 ... "foo": "bar" 

3004 ... } 

3005 ... \""", 

3006 ... syntax="json", 

3007 ... ), 

3008 ... indent="-> ", 

3009 ... ), 

3010 ... ) 

3011 ... ) 

3012 Example: 

3013 -> { 

3014 -> "foo": "bar" 

3015 -> } 

3016 

3017 """ 

3018 

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

3020 self._args = args 

3021 

3022 def __rich_repr__(self) -> RichReprResult: 

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

3024 

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

3026 res = ColorizedString() 

3027 sep = False 

3028 for arg in self._args: 

3029 if sep: 

3030 res.append_color(_Color.NONE) 

3031 res.append_str("\n") 

3032 res += ctx.str(arg) 

3033 sep = True 

3034 return res 

3035 

3036 

3037@_t.final 

3038@repr_from_rich 

3039class Indent(_StrBase): 

3040 """ 

3041 Lazy wrapper that indents the message during formatting. 

3042 

3043 .. seealso:: 

3044 

3045 :meth:`ColorizedString.indent`. 

3046 

3047 :param msg: 

3048 message to indent. 

3049 :param indent: 

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

3051 Defaults to two spaces. 

3052 :param continuation_indent: 

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

3054 Defaults to ``indent``. 

3055 :example: 

3056 .. code-block:: python 

3057 

3058 config = ... 

3059 yuio.io.info( 

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

3061 ) 

3062 

3063 """ 

3064 

3065 def __init__( 

3066 self, 

3067 msg: Colorable, 

3068 /, 

3069 *, 

3070 indent: AnyString | int = " ", 

3071 continuation_indent: AnyString | int | None = None, 

3072 ): 

3073 self._msg = msg 

3074 self._indent: AnyString | int = indent 

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

3076 

3077 def __rich_repr__(self) -> RichReprResult: 

3078 yield None, self._msg 

3079 yield "indent", self._indent, " " 

3080 yield "continuation_indent", self._continuation_indent, None 

3081 

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

3083 if isinstance(self._indent, int): 

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

3085 else: 

3086 indent = ColorizedString(self._indent) 

3087 if self._continuation_indent is None: 

3088 continuation_indent = indent 

3089 elif isinstance(self._continuation_indent, int): 

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

3091 else: 

3092 continuation_indent = ColorizedString(self._continuation_indent) 

3093 

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

3095 max_width = max(1, ctx.max_width - indent_width) 

3096 

3097 return ctx.str(self._msg, max_width=max_width).indent( 

3098 indent, continuation_indent 

3099 ) 

3100 

3101 

3102@_t.final 

3103@repr_from_rich 

3104class Md(_StrBase): 

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

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

3107 

3108 Lazy wrapper that renders markdown during formatting. 

3109 

3110 :param md: 

3111 markdown to format. 

3112 :param args: 

3113 arguments for ``%``-formatting the rendered markdown. 

3114 :param max_width: 

3115 if given, overrides settings passed to :func:`colorized_repr` for this call. 

3116 :param dedent: 

3117 whether to remove leading indent from markdown. 

3118 :param allow_headings: 

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

3120 

3121 """ 

3122 

3123 @_t.overload 

3124 def __init__( 

3125 self, 

3126 md: _t.LiteralString, 

3127 /, 

3128 *args: _t.Any, 

3129 max_width: int | None = None, 

3130 dedent: bool = True, 

3131 allow_headings: bool = True, 

3132 ): ... 

3133 @_t.overload 

3134 def __init__( 

3135 self, 

3136 md: str, 

3137 /, 

3138 *, 

3139 max_width: int | None = None, 

3140 dedent: bool = True, 

3141 allow_headings: bool = True, 

3142 ): ... 

3143 def __init__( 

3144 self, 

3145 md: str, 

3146 /, 

3147 *args: _t.Any, 

3148 max_width: int | None = None, 

3149 dedent: bool = True, 

3150 allow_headings: bool = True, 

3151 ): 

3152 self._md: str = md 

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

3154 self._max_width: int | None = max_width 

3155 self._dedent: bool = dedent 

3156 self._allow_headings: bool = allow_headings 

3157 

3158 def __rich_repr__(self) -> RichReprResult: 

3159 yield None, self._md 

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

3161 yield "max_width", self._max_width, yuio.MISSING 

3162 yield "dedent", self._dedent, True 

3163 yield "allow_headings", self._allow_headings, True 

3164 

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

3166 import yuio.md 

3167 

3168 max_width = self._max_width or ctx.max_width 

3169 

3170 formatter = yuio.md.MdFormatter( 

3171 ctx.theme, 

3172 width=max_width, 

3173 allow_headings=self._allow_headings, 

3174 ) 

3175 

3176 res = ColorizedString() 

3177 res.start_no_wrap() 

3178 sep = False 

3179 for line in formatter.format(self._md, dedent=self._dedent): 

3180 if sep: 

3181 res += "\n" 

3182 res += line 

3183 sep = True 

3184 res.end_no_wrap() 

3185 if self._args: 

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

3187 

3188 return res 

3189 

3190 

3191@_t.final 

3192@repr_from_rich 

3193class Hl(_StrBase): 

3194 """Hl(code: typing.LiteralString, /, *args, syntax: str | yuio.md.SyntaxHighlighter, dedent: bool = True) 

3195 Hl(code: str, /, *, syntax: str | yuio.md.SyntaxHighlighter, dedent: bool = True) 

3196 

3197 Lazy wrapper that highlights code during formatting. 

3198 

3199 :param md: 

3200 code to highlight. 

3201 :param args: 

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

3203 :param syntax: 

3204 name of syntax or a :class:`~yuio.md.SyntaxHighlighter` instance. 

3205 :param dedent: 

3206 whether to remove leading indent from code. 

3207 

3208 """ 

3209 

3210 @_t.overload 

3211 def __init__( 

3212 self, 

3213 code: _t.LiteralString, 

3214 /, 

3215 *args: _t.Any, 

3216 syntax: str | yuio.md.SyntaxHighlighter, 

3217 dedent: bool = True, 

3218 ): ... 

3219 @_t.overload 

3220 def __init__( 

3221 self, 

3222 code: str, 

3223 /, 

3224 *, 

3225 syntax: str | yuio.md.SyntaxHighlighter, 

3226 dedent: bool = True, 

3227 ): ... 

3228 def __init__( 

3229 self, 

3230 code: str, 

3231 /, 

3232 *args: _t.Any, 

3233 syntax: str | yuio.md.SyntaxHighlighter, 

3234 dedent: bool = True, 

3235 ): 

3236 self._code: str = code 

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

3238 self._syntax: str | yuio.md.SyntaxHighlighter = syntax 

3239 self._dedent: bool = dedent 

3240 

3241 def __rich_repr__(self) -> RichReprResult: 

3242 yield None, self._code 

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

3244 yield "syntax", self._syntax 

3245 yield "dedent", self._dedent, True 

3246 

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

3248 import yuio.md 

3249 

3250 syntax = ( 

3251 self._syntax 

3252 if isinstance(self._syntax, yuio.md.SyntaxHighlighter) 

3253 else yuio.md.SyntaxHighlighter.get_highlighter(self._syntax) 

3254 ) 

3255 code = self._code 

3256 if self._dedent: 

3257 code = _dedent(code) 

3258 code = code.rstrip() 

3259 

3260 res = ColorizedString() 

3261 res.start_no_wrap() 

3262 res += syntax.highlight(ctx.theme, code) 

3263 res.end_no_wrap() 

3264 if self._args: 

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

3266 

3267 return res 

3268 

3269 

3270@_t.final 

3271@repr_from_rich 

3272class Wrap(_StrBase): 

3273 """ 

3274 Lazy wrapper that wraps the message during formatting. 

3275 

3276 .. seealso:: 

3277 

3278 :meth:`ColorizedString.wrap`. 

3279 

3280 :param msg: 

3281 message to wrap. 

3282 :param max_width: 

3283 if given, overrides settings passed to :func:`colorized_repr` for this call. 

3284 :param preserve_spaces: 

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

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

3287 

3288 Note that tabs always treated as a single whitespace. 

3289 :param preserve_newlines: 

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

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

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

3293 

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

3295 :param break_long_words: 

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

3297 will be split into multiple lines. 

3298 :param overflow: 

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

3300 :param break_long_nowrap_words: 

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

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

3303 :param indent: 

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

3305 Defaults to two spaces. 

3306 :param continuation_indent: 

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

3308 Defaults to ``indent``. 

3309 

3310 """ 

3311 

3312 def __init__( 

3313 self, 

3314 msg: Colorable, 

3315 /, 

3316 *, 

3317 max_width: int | None = None, 

3318 preserve_spaces: bool = False, 

3319 preserve_newlines: bool = True, 

3320 break_long_words: bool = True, 

3321 break_long_nowrap_words: bool = False, 

3322 overflow: bool | str = False, 

3323 indent: AnyString | int = "", 

3324 continuation_indent: AnyString | int | None = None, 

3325 ): 

3326 self._msg = msg 

3327 self._max_width: int | None = max_width 

3328 self._preserve_spaces = preserve_spaces 

3329 self._preserve_newlines = preserve_newlines 

3330 self._break_long_words = break_long_words 

3331 self._break_long_nowrap_words = break_long_nowrap_words 

3332 self._overflow = overflow 

3333 self._indent: AnyString | int = indent 

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

3335 

3336 def __rich_repr__(self) -> RichReprResult: 

3337 yield None, self._msg 

3338 yield "max_width", self._max_width, None 

3339 yield "indent", self._indent, "" 

3340 yield "continuation_indent", self._continuation_indent, None 

3341 yield "preserve_spaces", self._preserve_spaces, None 

3342 yield "preserve_newlines", self._preserve_newlines, True 

3343 yield "break_long_words", self._break_long_words, True 

3344 yield "break_long_nowrap_words", self._break_long_nowrap_words, False 

3345 

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

3347 if isinstance(self._indent, int): 

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

3349 else: 

3350 indent = ColorizedString(self._indent) 

3351 if self._continuation_indent is None: 

3352 continuation_indent = indent 

3353 elif isinstance(self._continuation_indent, int): 

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

3355 else: 

3356 continuation_indent = ColorizedString(self._continuation_indent) 

3357 

3358 max_width = self._max_width or ctx.max_width 

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

3360 inner_max_width = max(1, max_width - indent_width) 

3361 

3362 overflow = self._overflow 

3363 if overflow is True: 

3364 overflow = ctx.theme.msg_decorations.get("overflow", "") 

3365 

3366 res = ColorizedString() 

3367 res.start_no_wrap() 

3368 sep = False 

3369 for line in ctx.str(self._msg, max_width=inner_max_width).wrap( 

3370 max_width, 

3371 preserve_spaces=self._preserve_spaces, 

3372 preserve_newlines=self._preserve_newlines, 

3373 break_long_words=self._break_long_words, 

3374 break_long_nowrap_words=self._break_long_nowrap_words, 

3375 overflow=overflow, 

3376 indent=indent, 

3377 continuation_indent=continuation_indent, 

3378 ): 

3379 if sep: 

3380 res.append_str("\n") 

3381 res.append_colorized_str(line) 

3382 sep = True 

3383 res.end_no_wrap() 

3384 

3385 return res 

3386 

3387 

3388@_t.final 

3389@repr_from_rich 

3390class WithBaseColor(_StrBase): 

3391 """ 

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

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

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

3395 

3396 .. seealso:: 

3397 

3398 :meth:`ColorizedString.with_base_color`. 

3399 

3400 :param msg: 

3401 message to highlight. 

3402 :param base_color: 

3403 color that will be added under the message. 

3404 

3405 """ 

3406 

3407 def __init__( 

3408 self, 

3409 msg: Colorable, 

3410 /, 

3411 *, 

3412 base_color: str | _Color, 

3413 ): 

3414 self._msg = msg 

3415 self._base_color = base_color 

3416 

3417 def __rich_repr__(self) -> RichReprResult: 

3418 yield None, self._msg 

3419 yield "base_color", self._base_color 

3420 

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

3422 return ctx.str(self._msg).with_base_color(ctx.theme.to_color(self._base_color)) 

3423 

3424 

3425@repr_from_rich 

3426class Hr(_StrBase): 

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

3428 

3429 Produces horizontal ruler when converted to string. 

3430 

3431 :param msg: 

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

3433 :param weight: 

3434 weight or style of the ruler: 

3435 

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

3437 - ``1`` prints normal ruler, 

3438 - ``2`` prints bold ruler. 

3439 

3440 Additional styles can be added through 

3441 :attr:`Theme.msg_decorations <yuio.theme.Theme.msg_decorations>`. 

3442 :param max_width: 

3443 if given, overrides settings passed to :func:`colorized_repr` for this call. 

3444 :param overflow: 

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

3446 :param kwargs: 

3447 Other keyword arguments override corresponding decorations from the theme: 

3448 

3449 :``left_start``: 

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

3451 :``left_middle``: 

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

3453 :``left_end``: 

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

3455 :``middle``: 

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

3457 :``right_start``: 

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

3459 :``right_middle``: 

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

3461 :``right_end``: 

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

3463 

3464 """ 

3465 

3466 def __init__( 

3467 self, 

3468 msg: Colorable = "", 

3469 /, 

3470 *, 

3471 max_width: int | None = None, 

3472 overflow: bool | str = True, 

3473 weight: int | str = 1, 

3474 left_start: str | None = None, 

3475 left_middle: str | None = None, 

3476 left_end: str | None = None, 

3477 middle: str | None = None, 

3478 right_start: str | None = None, 

3479 right_middle: str | None = None, 

3480 right_end: str | None = None, 

3481 ): 

3482 self._msg = msg 

3483 self._max_width = max_width 

3484 self._overflow = overflow 

3485 self._weight = weight 

3486 self._left_start = left_start 

3487 self._left_middle = left_middle 

3488 self._left_end = left_end 

3489 self._middle = middle 

3490 self._right_start = right_start 

3491 self._right_middle = right_middle 

3492 self._right_end = right_end 

3493 

3494 def __rich_repr__(self) -> RichReprResult: 

3495 yield None, self._msg, None 

3496 yield "weight", self._weight, None 

3497 yield "max_width", self._max_width, None 

3498 yield "overflow", self._overflow, None 

3499 yield "left_start", self._left_start, None 

3500 yield "left_middle", self._left_middle, None 

3501 yield "left_end", self._left_end, None 

3502 yield "middle", self._middle, None 

3503 yield "right_start", self._right_start, None 

3504 yield "right_middle", self._right_middle, None 

3505 yield "right_end", self._right_end, None 

3506 

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

3508 max_width = self._max_width or ctx.max_width 

3509 

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

3511 

3512 res = ColorizedString(color) 

3513 res.start_no_wrap() 

3514 

3515 msg = ctx.str(self._msg) 

3516 if not msg: 

3517 res.append_str(self._make_whole(max_width, ctx.theme.msg_decorations)) 

3518 return res 

3519 

3520 overflow = self._overflow 

3521 if overflow is True: 

3522 overflow = ctx.theme.msg_decorations.get("overflow", "") 

3523 

3524 sep = False 

3525 for line in msg.wrap( 

3526 max_width, preserve_spaces=True, break_long_words=False, overflow=overflow 

3527 ): 

3528 if sep: 

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

3530 res.append_str("\n") 

3531 res.append_color(color) 

3532 

3533 line_w = line.width 

3534 line_w_fill = max(0, max_width - line_w) 

3535 line_w_fill_l = line_w_fill // 2 

3536 line_w_fill_r = line_w_fill - line_w_fill_l 

3537 if not line_w_fill_l and not line_w_fill_r: 

3538 res.append_colorized_str(line) 

3539 return res 

3540 

3541 res.append_str(self._make_left(line_w_fill_l, ctx.theme.msg_decorations)) 

3542 res.append_colorized_str(line) 

3543 res.append_str(self._make_right(line_w_fill_r, ctx.theme.msg_decorations)) 

3544 

3545 sep = True 

3546 

3547 return res 

3548 

3549 def _make_left(self, w: int, msg_decorations: _t.Mapping[str, str]): 

3550 weight = self._weight 

3551 start = ( 

3552 self._left_start 

3553 if self._left_start is not None 

3554 else msg_decorations.get(f"hr/{weight}/left_start", "") 

3555 ) 

3556 middle = ( 

3557 self._left_middle 

3558 if self._left_middle is not None 

3559 else msg_decorations.get(f"hr/{weight}/left_middle") 

3560 ) or " " 

3561 end = ( 

3562 self._left_end 

3563 if self._left_end is not None 

3564 else msg_decorations.get(f"hr/{weight}/left_end", "") 

3565 ) 

3566 

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

3568 

3569 def _make_right(self, w: int, msg_decorations: _t.Mapping[str, str]): 

3570 weight = self._weight 

3571 start = ( 

3572 self._right_start 

3573 if self._right_start is not None 

3574 else msg_decorations.get(f"hr/{weight}/right_start", "") 

3575 ) 

3576 middle = ( 

3577 self._right_middle 

3578 if self._right_middle is not None 

3579 else msg_decorations.get(f"hr/{weight}/right_middle") 

3580 ) or " " 

3581 end = ( 

3582 self._right_end 

3583 if self._right_end is not None 

3584 else msg_decorations.get(f"hr/{weight}/right_end", "") 

3585 ) 

3586 

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

3588 

3589 def _make_whole(self, w: int, msg_decorations: _t.Mapping[str, str]): 

3590 weight = self._weight 

3591 start = ( 

3592 self._left_start 

3593 if self._left_start is not None 

3594 else msg_decorations.get(f"hr/{weight}/left_start", " ") 

3595 ) 

3596 middle = ( 

3597 self._middle 

3598 if self._middle is not None 

3599 else msg_decorations.get(f"hr/{weight}/middle") 

3600 ) or " " 

3601 end = ( 

3602 self._right_end 

3603 if self._right_end is not None 

3604 else msg_decorations.get(f"hr/{weight}/right_end", " ") 

3605 ) 

3606 

3607 start_w = line_width(start) 

3608 middle_w = line_width(middle) 

3609 end_w = line_width(end) 

3610 

3611 if w >= start_w: 

3612 w -= start_w 

3613 else: 

3614 start = "" 

3615 if w >= end_w: 

3616 w -= end_w 

3617 else: 

3618 end = "" 

3619 middle_times = w // middle_w 

3620 w -= middle_times * middle_w 

3621 middle *= middle_times 

3622 return start + middle + end + " " * w 

3623 

3624 

3625def _make_left( 

3626 w: int, 

3627 start: str, 

3628 middle: str, 

3629 end: str, 

3630): 

3631 start_w = line_width(start) 

3632 middle_w = line_width(middle) 

3633 end_w = line_width(end) 

3634 

3635 if w >= end_w: 

3636 w -= end_w 

3637 else: 

3638 end = "" 

3639 if w >= start_w: 

3640 w -= start_w 

3641 else: 

3642 start = "" 

3643 middle_times = w // middle_w 

3644 w -= middle_times * middle_w 

3645 middle *= middle_times 

3646 return start + middle + end + " " * w 

3647 

3648 

3649def _make_right( 

3650 w: int, 

3651 start: str, 

3652 middle: str, 

3653 end: str, 

3654): 

3655 start_w = line_width(start) 

3656 middle_w = line_width(middle) 

3657 end_w = line_width(end) 

3658 

3659 if w >= start_w: 

3660 w -= start_w 

3661 else: 

3662 start = "" 

3663 if w >= end_w: 

3664 w -= end_w 

3665 else: 

3666 end = "" 

3667 middle_times = w // middle_w 

3668 w -= middle_times * middle_w 

3669 middle *= middle_times 

3670 return " " * w + start + middle + end