Coverage for utilities/verbose.py: 72%

1"""

2 Verbose

3=======

4

5 Define verbose output, logging, and warning management utilities.

6"""

7

8 from __future__ import annotations

9

10 import functools

11 import logging

12 import os

13 import sys

14 import traceback

15 import typing

16 import warnings

17 from collections import defaultdict

18 from contextlib import contextmanager, suppress

19 from itertools import chain

20 from textwrap import TextWrapper

21 from warnings import filterwarnings, formatwarning, warn

22

23 import numpy as np

24

25 if typing.TYPE_CHECKING:

26 from colour.hints import (

27 Any,

28 Callable,

29 ClassVar,

30 Dict,

31 Generator,

32 List,

33 Literal,

34 Mapping,

35 Self,

36 TextIO,

37 Type,

38 )

39

40 from colour.hints import LiteralWarning, cast

41

42 __author__ = "Colour Developers"

43 __copyright__ = "Copyright 2013 Colour Developers"

44 __license__ = "BSD-3-Clause - https://opensource.org/licenses/BSD-3-Clause"

45 __maintainer__ = "Colour Developers"

46 __email__ = "colour-developers@colour-science.org"

47 __status__ = "Production"

48

49 __all__ = [

50 "LOGGER",

51 "MixinLogging",

52 "ColourWarning",

53 "ColourUsageWarning",

54 "ColourRuntimeWarning",

55 "message_box",

56 "show_warning",

57 "warning",

58 "runtime_warning",

59 "usage_warning",

60 "filter_warnings",

61 "as_bool",

62 "suppress_warnings",

63 "suppress_stdout",

64 "numpy_print_options",

65 "ANCILLARY_COLOUR_SCIENCE_PACKAGES",

66 "ANCILLARY_RUNTIME_PACKAGES",

67 "ANCILLARY_DEVELOPMENT_PACKAGES",

68 "ANCILLARY_EXTRAS_PACKAGES",

69 "describe_environment",

70 "multiline_str",

71 "multiline_repr",

72]

73

74 LOGGER = logging.getLogger(__name__)

75

76

77 class MixinLogging:

78 """

79 Provide logging capabilities through mixin inheritance.

80

81 This mixin extends class functionality to enable structured logging,

82 allowing consistent logging behaviour across the codebase.

83

84 Attributes

85 ----------

86 - :func:`~colour.utilities.MixinLogging.MAPPING_LOGGING_LEVEL_TO_CALLABLE`

87

88 Methods

89 -------

90 - :func:`~colour.utilities.MixinLogging.log`

91 """

92

93 MAPPING_LOGGING_LEVEL_TO_CALLABLE: ClassVar = { # pyright: ignore

94 "critical": LOGGER.critical,

95 "error": LOGGER.error,

96 "warning": LOGGER.warning,

97 "info": LOGGER.info,

98 "debug": LOGGER.debug,

99 }

100

101 def log(

102 self,

103 message: str,

104 verbosity: Literal[

105 "critical",

106 "error",

107 "warning",

108 "info",

109 "debug",

110 ] = "info",

111 ) -> None:

112 """

113 Log the specified message using the specified verbosity level.

114

115 Parameters

116 ----------

117 message

118 Message to log.

119 verbosity

120 Verbosity level.

121 """

122

123 self.MAPPING_LOGGING_LEVEL_TO_CALLABLE[verbosity]( # pyright: ignore

124 "%s: %s",

125 self.name, # pyright: ignore

126 message,

127 )

128

129

130 class ColourWarning(Warning):

131 """

132 Define the base class for *Colour* warnings.

133

134 This class serves as the foundational warning type for the *Colour*

135 library, inheriting from the standard :class:`Warning` class to provide

136 consistent warning behaviour across the library.

137 """

138

139

140 class ColourUsageWarning(Warning):

141 """

142 Define the base class for *Colour* usage warnings.

143

144 This class serves as the foundation for all usage-related warnings in the

145 *Colour* library, providing a consistent interface for alerting users to

146 non-critical issues during runtime operations. It is a subclass of the

147 :class:`colour.utilities.ColourWarning` class.

148 """

149

150

151 class ColourRuntimeWarning(Warning):

152 """

153 Define the base class for *Colour* runtime warnings.

154

155 This class serves as the foundation for all runtime warnings in the

156 *Colour* library, providing a consistent interface for alerting users to

157 runtime issues. It is a subclass of the

158 :class:`colour.utilities.ColourWarning` class.

159 """

160

161

162 def message_box(

163 message: str,

164 width: int = 79,

165 padding: int = 3,

166 print_callable: Callable = print,

167) -> None:

168 """

169 Print a message inside a formatted box.

170

171 Parameters

172 ----------

173 message

174 Message to print inside the box.

175 width

176 Width of the message box in characters.

177 padding

178 Number of spaces for padding on each side of the message.

179 print_callable

180 Callable used to print the formatted message box.

181

182 Examples

183 --------

184 >>> message = (

185 ... "Lorem ipsum dolor sit amet, consectetur adipiscing elit, "

186 ... "sed do eiusmod tempor incididunt ut labore et dolore magna "

187 ... "aliqua."

188 ... )

189 >>> message_box(message, width=75)

190 ===========================================================================

191 * *

192 * Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do *

193 * eiusmod tempor incididunt ut labore et dolore magna aliqua. *

194 * *

195 ===========================================================================

196 >>> message_box(message, width=60)

197 ============================================================

198 * *

199 * Lorem ipsum dolor sit amet, consectetur adipiscing *

200 * elit, sed do eiusmod tempor incididunt ut labore et *

201 * dolore magna aliqua. *

202 * *

203 ============================================================

204 >>> message_box(message, width=75, padding=16)

205 ===========================================================================

206 * *

207 * Lorem ipsum dolor sit amet, consectetur *

208 * adipiscing elit, sed do eiusmod tempor *

209 * incididunt ut labore et dolore magna *

210 * aliqua. *

211 * *

212 ===========================================================================

213 """

214

215 ideal_width = width - padding * 2 - 2

216

217 def inner(text: str) -> str:

218 """Format and pads inner text for the message box."""

219

220 return (

221 f"*{' ' * padding}"

222 f"{text}{' ' * (width - len(text) - padding * 2 - 2)}"

223 f"{' ' * padding}*"

224 )

225

226 print_callable("=" * width)

227 print_callable(inner(""))

228

229 wrapper = TextWrapper(

230 width=ideal_width, break_long_words=False, replace_whitespace=False

231 )

232

233 lines = [wrapper.wrap(line) for line in message.split("\n")]

234 for line in chain(*[" " if len(line) == 0 else line for line in lines]):

235 print_callable(inner(line.expandtabs()))

236

237 print_callable(inner(""))

238 print_callable("=" * width)

239

240

241 def show_warning(

242 message: Warning | str,

243 category: Type[Warning],

244 filename: str,

245 lineno: int,

246 file: TextIO | None = None,

247 line: str | None = None,

248) -> None:

249 """

250 Display a warning message with enhanced formatting that enables

251 traceback printing.

252

253 This definition is activated by setting the

254 *COLOUR_SCIENCE__COLOUR__SHOW_WARNINGS_WITH_TRACEBACK* environment

255 variable before importing *colour*.

256

257 Parameters

258 ----------

259 message

260 Warning message.

261 category

262 :class:`Warning` sub-class.

263 filename

264 File path to read the line at ``lineno`` from if ``line`` is

265 None.

266 lineno

267 Line number to read the line at in ``filename`` if ``line`` is

268 None.

269 file

270 :class:`file` object to write the warning to, defaults to

271 :attr:`sys.stderr` attribute.

272 line

273 Source code to be included in the warning message.

274

275 Notes

276 -----

277 - Setting the

278 *COLOUR_SCIENCE__COLOUR__SHOW_WARNINGS_WITH_TRACEBACK*

279 environment variable replaces the :func:`warnings.showwarning`

280 definition with the :func:`colour.utilities.show_warning`

281 definition, providing complete traceback from the point where

282 the warning occurred.

283 """

284

285 frame_range = (1, None)

286

287 file = sys.stderr if file is None else file

288

289 if file is None:

290 return

291

292 try:

293 # Generating a traceback to print useful warning origin.

294 frame_in, frame_out = frame_range

295

296 try:

297 raise ZeroDivisionError # noqa: TRY301

298 except ZeroDivisionError:

299 exception_traceback = sys.exc_info()[2]

300 frame = (

301 exception_traceback.tb_frame.f_back

302 if exception_traceback is not None

303 else None

304 )

305 while frame_in and frame is not None:

306 frame = frame.f_back

307 frame_in -= 1

308

309 traceback.print_stack(frame, frame_out, file)

310 file.write(formatwarning(message, category, filename, lineno, line))

311 except (OSError, UnicodeError):

312 pass

313

314

315 if os.environ.get( # pragma: no cover

316 "COLOUR_SCIENCE__COLOUR__SHOW_WARNINGS_WITH_TRACEBACK"

317):

318 warnings.showwarning = show_warning # pragma: no cover

319

320

321 def warning(*args: Any, **kwargs: Any) -> None:

322 """

323 Issue a warning.

324

325 Other Parameters

326 ----------------

327 args

328 Warning message and optional arguments for message formatting.

329 kwargs

330 Keyword arguments for controlling warning behaviour, including

331 category, stacklevel, and source filtering options.

332

333 Examples

334 --------

335 >>> warning("This is a warning!") # doctest: +SKIP

336 """

337

338 kwargs["category"] = kwargs.get("category", ColourWarning)

339

340 warn(*args, **kwargs)

341

342

343 def runtime_warning(*args: Any, **kwargs: Any) -> None:

344 """

345 Issue a runtime warning.

346

347 Other Parameters

348 ----------------

349 args

350 Warning message and optional arguments for message formatting.

351 kwargs

352 Keyword arguments for controlling warning behaviour.

353

354 Examples

355 --------

356 >>> runtime_warning("This is a runtime warning!") # doctest: +SKIP

357 """

358

359 kwargs["category"] = ColourRuntimeWarning

360

361 warning(*args, **kwargs)

362

363

364 def usage_warning(*args: Any, **kwargs: Any) -> None:

365 """

366 Issue a usage warning.

367

368 Other Parameters

369 ----------------

370 args

371 Warning message and optional arguments for message formatting.

372 kwargs

373 Keyword arguments for controlling warning behaviour.

374

375 Examples

376 --------

377 >>> usage_warning("This is an usage warning!") # doctest: +SKIP

378 """

379

380 kwargs["category"] = ColourUsageWarning

381

382 warning(*args, **kwargs)

383

384

385 def filter_warnings(

386 colour_runtime_warnings: bool | LiteralWarning | None = None,

387 colour_usage_warnings: bool | LiteralWarning | None = None,

388 colour_warnings: bool | LiteralWarning | None = None,

389 python_warnings: bool | LiteralWarning | None = None,

390) -> None:

391 """

392 Filter *Colour* and optionally overall Python warnings.

393

394 The possible values for all the actions, i.e., each argument, are as

395 follows:

396

397 - *None* (No action is taken)

398 - *True* (*ignore*)

399 - *False* (*default*)

400 - *error*

401 - *ignore*

402 - *always*

403 - *default*

404 - *module*

405 - *once*

406

407 Parameters

408 ----------

409 colour_runtime_warnings

410 Whether to filter *Colour* runtime warnings using the specified

411 action value.

412 colour_usage_warnings

413 Whether to filter *Colour* usage warnings using the specified

414 action value.

415 colour_warnings

416 Whether to filter *Colour* warnings, this also filters *Colour*

417 usage and runtime warnings using the specified action value.

418 python_warnings

419 Whether to filter *Python* warnings using the specified action

420 value.

421

422 Examples

423 --------

424 Filtering *Colour* runtime warnings:

425

426 >>> filter_warnings(colour_runtime_warnings=True)

427

428 Filtering *Colour* usage warnings:

429

430 >>> filter_warnings(colour_usage_warnings=True)

431

432 Filtering *Colour* warnings:

433

434 >>> filter_warnings(colour_warnings=True)

435

436 Filtering all the *Colour* and also Python warnings:

437

438 >>> filter_warnings(python_warnings=True)

439

440 Enabling all the *Colour* and Python warnings:

441

442 >>> filter_warnings(*[False] * 4)

443

444 Enabling all the *Colour* and Python warnings using the *default* action:

445

446 >>> filter_warnings(*["default"] * 4)

447

448 Setting back the default state:

449

450 >>> filter_warnings(colour_runtime_warnings=True)

451 """

452

453 for action, category in [

454 (colour_warnings, ColourWarning),

455 (colour_runtime_warnings, ColourRuntimeWarning),

456 (colour_usage_warnings, ColourUsageWarning),

457 (python_warnings, Warning),

458 ]:

459 if action is None:

460 continue

461

462 if isinstance(action, str):

463 action = cast("LiteralWarning", str(action)) # noqa: PLW2901

464 else:

465 action = "ignore" if action else "default" # noqa: PLW2901

466

467 filterwarnings(action, category=category)

468

469

470 def as_bool(a: str) -> bool:

471 """

472 Convert the specified string to a boolean value.

473

474 The following string values evaluate to *True*: "1", "On", and "True".

475 All other string values evaluate to *False*.

476

477 Parameters

478 ----------

479 a

480 String to convert to boolean.

481

482 Returns

483 -------

484 :class:`bool`

485 Boolean representation of the specified string.

486

487 Examples

488 --------

489 >>> as_bool("1")

490 True

491 >>> as_bool("On")

492 True

493 >>> as_bool("True")

494 True

495 >>> as_bool("0")

496 False

497 >>> as_bool("Off")

498 False

499 >>> as_bool("False")

500 False

501 """

502

503 return a.lower() in ["1", "on", "true"]

504

505

506# Defaulting to filter *Colour* runtime warnings.

507 filter_warnings(

508 colour_runtime_warnings=as_bool(

509 os.environ.get("COLOUR_SCIENCE__FILTER_RUNTIME_WARNINGS", "True")

510 )

511)

512

513 if (

514 os.environ.get("COLOUR_SCIENCE__FILTER_USAGE_WARNINGS") is not None

515): # pragma: no cover

516 filter_warnings(

517 colour_usage_warnings=as_bool(

518 os.environ["COLOUR_SCIENCE__FILTER_USAGE_WARNINGS"]

519 )

520 )

521

522 if (

523 os.environ.get("COLOUR_SCIENCE__FILTER_COLOUR_WARNINGS") is not None

524): # pragma: no cover

525 filter_warnings(

526 colour_usage_warnings=as_bool(

527 os.environ["COLOUR_SCIENCE__FILTER_WARNINGS"],

528 )

529 )

530

531 if (

532 os.environ.get("COLOUR_SCIENCE__FILTER_PYTHON_WARNINGS") is not None

533): # pragma: no cover

534 filter_warnings(

535 colour_usage_warnings=as_bool(

536 os.environ["COLOUR_SCIENCE__FILTER_PYTHON_WARNINGS"]

537 )

538 )

539

540

541@contextmanager

542 def suppress_warnings(

543 colour_runtime_warnings: bool | LiteralWarning | None = None,

544 colour_usage_warnings: bool | LiteralWarning | None = None,

545 colour_warnings: bool | LiteralWarning | None = None,

546 python_warnings: bool | LiteralWarning | None = None,

547) -> Generator:

548 """

549 Suppress *Colour* and optionally overall Python warnings within a

550 context.

551

552 The possible values for all the actions, i.e., each argument, are as

553 follows:

554

555 - *None* (No action is taken)

556 - *True* (*ignore*)

557 - *False* (*default*)

558 - *error*

559 - *ignore*

560 - *always*

561 - *default*

562 - *module*

563 - *once*

564

565 Parameters

566 ----------

567 colour_runtime_warnings

568 Whether to filter *Colour* runtime warnings according to the

569 specified action value.

570 colour_usage_warnings

571 Whether to filter *Colour* usage warnings according to the

572 specified action value.

573 colour_warnings

574 Whether to filter *Colour* warnings, this also filters *Colour*

575 usage and runtime warnings according to the specified action

576 value.

577 python_warnings

578 Whether to filter *Python* warnings according to the specified

579 action value.

580 """

581

582 filters = warnings.filters

583 show_warnings = warnings.showwarning

584

585 filter_warnings(

586 colour_warnings=colour_warnings,

587 colour_runtime_warnings=colour_runtime_warnings,

588 colour_usage_warnings=colour_usage_warnings,

589 python_warnings=python_warnings,

590 )

591

592 try:

593 yield

594 finally:

595 warnings.filters = filters

596 warnings.showwarning = show_warnings

597

598

599 class suppress_stdout:

600 """

601 Define a context manager and decorator to temporarily suppress standard output.

602

603 Examples

604 --------

605 >>> with suppress_stdout():

606 ... print("Hello World!")

607 >>> print("Hello World!")

608 Hello World!

609 """

610

611 def __enter__(self) -> Self:

612 """

613 Redirect standard output to null device upon context manager entry.

614 """

615

616 self._stdout = sys.stdout

617 sys.stdout = open(os.devnull, "w")

618

619 return self

620

621 def __exit__(self, *args: Any) -> None:

622 """

623 Restore standard output upon context manager exit.

624 """

625

626 sys.stdout.close()

627 sys.stdout = self._stdout

628

629 def __call__(self, function: Callable) -> Callable: # pragma: no cover

630 """Call the wrapped definition with suppressed output."""

631

632 @functools.wraps(function)

633 def wrapper(*args: Any, **kwargs: Any) -> Callable:

634 with self:

635 return function(*args, **kwargs)

636

637 return wrapper

638

639

640@contextmanager

641 def numpy_print_options(*args: Any, **kwargs: Any) -> Generator:

642 """

643 Implement a context manager for temporarily modifying *NumPy* array

644 print options.

645

646 Other Parameters

647 ----------------

648 args

649 Positional arguments passed to :func:`numpy.set_printoptions`.

650 kwargs

651 Keyword arguments passed to :func:`numpy.set_printoptions`.

652

653 Examples

654 --------

655 >>> np.array([np.pi]) # doctest: +ELLIPSIS

656 array([ 3.1415926...])

657 >>> with numpy_print_options(formatter={"float": "{:0.1f}".format}):

658 ... np.array([np.pi])

659 array([3.1])

660 """

661

662 options = np.get_printoptions()

663 np.set_printoptions(*args, **kwargs)

664 try:

665 yield

666 finally:

667 np.set_printoptions(**options)

668

669

670 ANCILLARY_COLOUR_SCIENCE_PACKAGES: Dict[str, str] = {}

671"""

672 Ancillary *colour-science.org* packages to describe.

673

674 ANCILLARY_COLOUR_SCIENCE_PACKAGES

675"""

676

677 ANCILLARY_RUNTIME_PACKAGES: Dict[str, str] = {}

678"""

679 Ancillary runtime packages to describe.

680

681 ANCILLARY_RUNTIME_PACKAGES

682"""

683

684 ANCILLARY_DEVELOPMENT_PACKAGES: Dict[str, str] = {}

685"""

686 Ancillary development packages to describe.

687

688 ANCILLARY_DEVELOPMENT_PACKAGES

689"""

690

691 ANCILLARY_EXTRAS_PACKAGES: Dict[str, str] = {}

692"""

693 Ancillary extras packages to describe.

694

695 ANCILLARY_EXTRAS_PACKAGES

696"""

697

698

699 def describe_environment(

700 runtime_packages: bool = True,

701 development_packages: bool = False,

702 extras_packages: bool = False,

703 print_environment: bool = True,

704 **kwargs: Any,

705) -> defaultdict:

706 """

707 Describe the *Colour* runtime environment, including interpreter details

708 and package versions.

709

710 Parameters

711 ----------

712 runtime_packages

713 Whether to return the runtime packages versions.

714 development_packages

715 Whether to return the development packages versions.

716 extras_packages

717 Whether to return the extras packages versions.

718 print_environment

719 Whether to print the environment.

720

721 Other Parameters

722 ----------------

723 padding

724 {:func:`colour.utilities.message_box`},

725 Padding on each side of the message.

726 print_callable

727 {:func:`colour.utilities.message_box`},

728 Callable used to print the message box.

729 width

730 {:func:`colour.utilities.message_box`},

731 Message box width.

732

733 Returns

734 -------

735 :class:`collections.defaultdict`

736 Environment.

737

738 Examples

739 --------

740 >>> environment = describe_environment(width=75) # doctest: +SKIP

741 ===========================================================================

742 * *

743 * Interpreter : *

744 * python : 3.12.4 (main, Jun 6 2024, 18:26:44) [Clang 15.0.0 *

745 * (clang-1500.3.9.4)] *

746 * *

747 * colour-science.org : *

748 * colour : v0.4.3-282-gcb450ff50 *

749 * *

750 * Runtime : *

751 * imageio : 2.35.1 *

752 * matplotlib : 3.9.2 *

753 * networkx : 3.3 *

754 * numpy : 2.1.1 *

755 * pandas : 2.2.3 *

756 * pydot : 3.0.2 *

757 * PyOpenColorIO : 2.3.2 *

758 * scipy : 1.14.1 *

759 * tqdm : 4.66.5 *

760 * trimesh : 4.4.9 *

761 * OpenImageIO : 2.5.14.0 *

762 * xxhash : 3.5.0 *

763 * *

764 ===========================================================================

765 >>> environment = describe_environment(True, True, True, width=75)

766 ... # doctest: +SKIP

767 ===========================================================================

768 * *

769 * Interpreter : *

770 * python : 3.12.4 (main, Jun 6 2024, 18:26:44) [Clang 15.0.0 *

771 * (clang-1500.3.9.4)] *

772 * *

773 * colour-science.org : *

774 * colour : v0.4.3-282-gcb450ff50 *

775 * *

776 * Runtime : *

777 * imageio : 2.35.1 *

778 * matplotlib : 3.9.2 *

779 * networkx : 3.3 *

780 * numpy : 2.1.1 *

781 * pandas : 2.2.3 *

782 * pydot : 3.0.2 *

783 * PyOpenColorIO : 2.3.2 *

784 * scipy : 1.14.1 *

785 * tqdm : 4.66.5 *

786 * trimesh : 4.4.9 *

787 * OpenImageIO : 2.5.14.0 *

788 * xxhash : 3.5.0 *

789 * *

790 * Development : *

791 * biblib-simple : 0.1.2 *

792 * coverage : 6.5.0 *

793 * coveralls : 4.0.1 *

794 * invoke : 2.2.0 *

795 * pre-commit : 3.8.0 *

796 * pydata-sphinx-theme : 0.15.4 *

797 * pyright : 1.1.382.post1 *

798 * pytest : 8.3.3 *

799 * pytest-cov : 5.0.0 *

800 * restructuredtext-lint : 1.4.0 *

801 * sphinxcontrib-bibtex : 2.6.3 *

802 * toml : 0.10.2 *

803 * twine : 5.1.1 *

804 * *

805 * Extras : *

806 * ipywidgets : 8.1.5 *

807 * notebook : 7.2.2 *

808 * *

809 ===========================================================================

810 """

811

812 environment: defaultdict = defaultdict(dict)

813

814 environment["Interpreter"]["python"] = sys.version

815

816 import subprocess # noqa: PLC0415

817

818 import colour # noqa: PLC0415

819

820 # TODO: Implement support for "pyproject.toml" file whenever "TOML" is

821 # supported in the standard library.

822 # NOTE: A few clauses are not reached and a few packages are not available

823 # during continuous integration and are thus ignored for coverage.

824 try: # pragma: no cover

825 output = subprocess.check_output(

826 ["git", "describe"], # noqa: S607

827 cwd=colour.__path__[0],

828 stderr=subprocess.STDOUT,

829 ).strip()

830 version = output.decode("utf-8")

831 except Exception: # pragma: no cover # noqa: BLE001

832 version = colour.__version__

833

834 environment["colour-science.org"]["colour"] = version

835 environment["colour-science.org"].update(ANCILLARY_COLOUR_SCIENCE_PACKAGES)

836

837 if runtime_packages:

838 for package in [

839 "imageio",

840 "matplotlib",

841 "networkx",

842 "numpy",

843 "pandas",

844 "pydot",

845 "PyOpenColorIO",

846 "scipy",

847 "tqdm",

848 "trimesh",

849 ]:

850 with suppress(ImportError):

851 namespace = __import__(package)

852 with suppress(AttributeError):

853 environment["Runtime"][package] = namespace.__version__

854

855 # OpenImageIO

856 with suppress(ImportError): # pragma: no cover

857 namespace = __import__("OpenImageIO")

858 environment["Runtime"]["OpenImageIO"] = namespace.VERSION_STRING

859

860 # xxhash

861 with suppress(ImportError): # pragma: no cover

862 namespace = __import__("xxhash")

863 environment["Runtime"]["xxhash"] = namespace.version.VERSION

864

865 environment["Runtime"].update(ANCILLARY_RUNTIME_PACKAGES)

866

867 def _get_package_version(package: str, mapping: Mapping) -> str:

868 """Return specified package version."""

869

870 namespace = __import__(package)

871

872 if package in mapping:

873 import pkg_resources # noqa: PLC0415

874

875 distributions = list(pkg_resources.working_set)

876

877 for distribution in distributions:

878 if distribution.project_name == mapping[package]:

879 return distribution.version

880

881 return namespace.__version__

882

883 if development_packages:

884 mapping = {

885 "biblib.bib": "biblib-simple",

886 "pre_commit": "pre-commit",

887 "pydata_sphinx_theme": "pydata-sphinx-theme",

888 "pytest_cov": "pytest-cov",

889 "pytest_xdist": "pytest-xdist",

890 "restructuredtext_lint": "restructuredtext-lint",

891 "sphinxcontrib.bibtex": "sphinxcontrib-bibtex",

892 }

893 for package in [

894 "biblib.bib",

895 "coverage",

896 "coveralls",

897 "invoke",

898 "jupyter",

899 "pre_commit",

900 "pydata_sphinx_theme",

901 "pyright",

902 "pytest",

903 "pytest_cov",

904 "pytest_xdist",

905 "restructuredtext_lint",

906 "sphinxcontrib.bibtex",

907 "toml",

908 "twine",

909 ]:

910 try:

911 version = _get_package_version(package, mapping)

912 package = mapping.get(package, package) # noqa: PLW2901

913

914 environment["Development"][package] = version

915 except Exception: # pragma: no cover # noqa: BLE001, PERF203, S112

916 continue

917

918 environment["Development"].update(ANCILLARY_DEVELOPMENT_PACKAGES)

919

920 if extras_packages:

921 mapping = {}

922 for package in ["ipywidgets", "notebook"]:

923 try:

924 version = _get_package_version(package, mapping)

925 package = mapping.get(package, package) # noqa: PLW2901

926

927 environment["Extras"][package] = version

928 except Exception: # pragma: no cover # noqa: BLE001, PERF203, S112

929 continue

930

931 environment["Extras"].update(ANCILLARY_EXTRAS_PACKAGES)

932

933 if print_environment:

934 message = ""

935 for category in (

936 "Interpreter",

937 "colour-science.org",

938 "Runtime",

939 "Development",

940 "Extras",

941 ):

942 elements = environment.get(category)

943 if not elements:

944 continue

945

946 message += f"{category} :\n"

947 for key, value in elements.items():

948 lines = value.split("\n")

949 message += f" {key} : {lines.pop(0)}\n"

950 indentation = len(f" {key} : ")

951 for line in lines: # pragma: no cover

952 message += f"{' ' * indentation}{line}\n"

953

954 message += "\n"

955

956 message_box(message.strip(), **kwargs)

957

958 return environment

959

960

961 def multiline_str(

962 object_: Any,

963 attributes: List[dict],

964 header_underline: str = "=",

965 section_underline: str = "-",

966 separator: str = " : ",

967) -> str:

968 """

969 Generate a formatted multi-line string representation of the specified

970 object.

971

972 Parameters

973 ----------

974 object_

975 Object to format into a string representation.

976 attributes

977 Attributes to format, provided as a list of dictionaries with

978 formatting specifications.

979 header_underline

980 Underline character to use for header sections.

981 section_underline

982 Underline character to use for subsections.

983 separator

984 Separator to use when formatting the attributes and their values.

985

986 Returns

987 -------

988 :class:`str`

989 Formatted multi-line string representation.

990

991 Examples

992 --------

993 >>> class Data:

994 ... def __init__(self, a: str, b: int, c: list):

995 ... self._a = a

996 ... self._b = b

997 ... self._c = c

998 ...

999 ... def __str__(self) -> str:

1000 ... return multiline_str(

1001 ... self,

1002 ... [

1003 ... {

1004 ... "formatter": lambda x: (

1005 ... f"Object - {self.__class__.__name__}"

1006 ... ),

1007 ... "header": True,

1008 ... },

1009 ... {"line_break": True},

1010 ... {"label": "Data", "section": True},

1011 ... {"line_break": True},

1012 ... {"label": "String", "section": True},

1013 ... {"name": "_a", "label": 'String "a"'},

1014 ... {"line_break": True},

1015 ... {"label": "Integer", "section": True},

1016 ... {"name": "_b", "label": 'Integer "b"'},

1017 ... {"line_break": True},

1018 ... {"label": "List", "section": True},

1019 ... {

1020 ... "name": "_c",

1021 ... "label": 'List "c"',

1022 ... "formatter": lambda x: "; ".join(x),

1023 ... },

1024 ... ],

1025 ... )

1026 >>> print(Data("Foo", 1, ["John", "Doe"]))

1027 Object - Data

1028 =============

1029 <BLANKLINE>

1030 Data

1031 ----

1032 <BLANKLINE>

1033 String

1034 ------

1035 String "a" : Foo

1036 <BLANKLINE>

1037 Integer

1038 -------

1039 Integer "b" : 1

1040 <BLANKLINE>

1041 List

1042 ----

1043 List "c" : John; Doe

1044 """

1045

1046 attribute_defaults = {

1047 "name": None,

1048 "label": None,

1049 "formatter": str,

1050 "header": False,

1051 "section": False,

1052 "line_break": False,

1053 }

1054

1055 try:

1056 justify = max(

1057 len(attribute["label"])

1058 for attribute in attributes

1059 if (

1060 attribute.get("label")

1061 and not attribute.get("header")

1062 and not attribute.get("section")

1063 )

1064 )

1065 except ValueError: # pragma: no cover

1066 justify = 0

1067

1068 representation = []

1069 for attribute in attributes:

1070 attribute = dict(attribute_defaults, **attribute) # noqa: PLW2901

1071

1072 if not attribute["line_break"]:

1073 if attribute["name"] is not None:

1074 formatted = attribute["formatter"](getattr(object_, attribute["name"]))

1075 else:

1076 formatted = attribute["formatter"](None)

1077

1078 if (

1079 attribute["label"] is not None

1080 and not attribute["header"]

1081 and not attribute["section"]

1082 ):

1083 lines = formatted.splitlines()

1084 if len(lines) > 1:

1085 for i, line in enumerate(lines[1:]):

1086 lines[i + 1] = f"{'':{justify}}{' ' * len(separator)}{line}"

1087 formatted = "\n".join(lines)

1088

1089 representation.append(

1090 f"{attribute['label']:{justify}}{separator}{formatted}",

1091 )

1092 elif attribute["label"] is not None and (

1093 attribute["header"] or attribute["section"]

1094 ):

1095 representation.append(attribute["label"])

1096 else:

1097 representation.append(f"{formatted}")

1098

1099 if attribute["header"]:

1100 representation.append(header_underline * len(representation[-1]))

1101

1102 if attribute["section"]:

1103 representation.append(section_underline * len(representation[-1]))

1104 else:

1105 representation.append("")

1106

1107 return "\n".join(representation)

1108

1109

1110 def multiline_repr(

1111 object_: Any,

1112 attributes: List[dict],

1113 reduce_array_representation: bool = True,

1114) -> str:

1115 """

1116 Generate an evaluable string representation of the specified object.

1117

1118 Parameters

1119 ----------

1120 object_

1121 Object to format.

1122 attributes

1123 Attributes to format.

1124 reduce_array_representation

1125 Whether to remove the *Numpy* `array(` and `)` affixes.

1126

1127 Returns

1128 -------

1129 :class:`str`

1130 (Almost) evaluable string representation.

1131

1132 Examples

1133 --------

1134 >>> class Data:

1135 ... def __init__(self, a: str, b: int, c: list):

1136 ... self._a = a

1137 ... self._b = b

1138 ... self._c = c

1139 ...

1140 ... def __repr__(self) -> str:

1141 ... return multiline_repr(

1142 ... self,

1143 ... [

1144 ... {"name": "_a"},

1145 ... {"name": "_b"},

1146 ... {

1147 ... "name": "_c",

1148 ... "formatter": lambda x: repr(x)

1149 ... .replace("[", "(")

1150 ... .replace("]", ")"),

1151 ... },

1152 ... ],

1153 ... )

1154 >>> Data("Foo", 1, ["John", "Doe"])

1155 Data('Foo',

1156 1,

1157 ('John', 'Doe'))

1158 """

1159

1160 attribute_defaults = {"name": None, "formatter": repr}

1161

1162 justify = len(f"{object_.__class__.__name__}") + 1

1163

1164 def _format(attribute: dict) -> str:

1165 """Format specified attribute and its value."""

1166

1167 if attribute["name"] is not None:

1168 value = attribute["formatter"](getattr(object_, attribute["name"]))

1169 else:

1170 value = attribute["formatter"](None)

1171

1172 if value is None:

1173 return str(None)

1174

1175 if reduce_array_representation and value.startswith("array("):

1176 lines = value.splitlines()

1177 for i, line in enumerate(lines):

1178 lines[i] = line[6:]

1179 value = "\n".join(lines)[:-1]

1180

1181 lines = value.splitlines()

1182

1183 if len(lines) > 1:

1184 for i, line in enumerate(lines[1:]):

1185 lines[i + 1] = f"{'':{justify}}{line}"

1186

1187 return "\n".join(lines)

1188

1189 attribute = dict(attribute_defaults, **attributes.pop(0))

1190

1191 representation = [f"{object_.__class__.__name__}({_format(attribute)}"]

1192

1193 for attribute in attributes:

1194 attribute = dict(attribute_defaults, **attribute) # noqa: PLW2901

1195

1196 representation.append(f"{'':{justify}}{_format(attribute)}")

1197

1198 return "{})".format(",\n".join(representation))