Coverage for colour/utilities/verbose.py: 97%
217 statements
« prev ^ index » next coverage.py v7.11.0, created at 2025-11-15 19:01 +1300
« prev ^ index » next coverage.py v7.11.0, created at 2025-11-15 19:01 +1300
1"""
2Verbose
3=======
5Define verbose output, logging, and warning management utilities.
6"""
8from __future__ import annotations
10import functools
11import logging
12import os
13import sys
14import traceback
15import typing
16import warnings
17from collections import defaultdict
18from contextlib import contextmanager, suppress
19from itertools import chain
20from textwrap import TextWrapper
21from warnings import filterwarnings, formatwarning, warn
23import numpy as np
25if 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 )
40from colour.hints import LiteralWarning, cast
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"
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]
74LOGGER = logging.getLogger(__name__)
77class MixinLogging:
78 """
79 Provide logging capabilities through mixin inheritance.
81 This mixin extends class functionality to enable structured logging,
82 allowing consistent logging behaviour across the codebase.
84 Attributes
85 ----------
86 - :func:`~colour.utilities.MixinLogging.MAPPING_LOGGING_LEVEL_TO_CALLABLE`
88 Methods
89 -------
90 - :func:`~colour.utilities.MixinLogging.log`
91 """
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 }
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.
115 Parameters
116 ----------
117 message
118 Message to log.
119 verbosity
120 Verbosity level.
121 """
123 self.MAPPING_LOGGING_LEVEL_TO_CALLABLE[verbosity]( # pyright: ignore
124 "%s: %s",
125 self.name, # pyright: ignore
126 message,
127 )
130class ColourWarning(Warning):
131 """
132 Define the base class for *Colour* warnings.
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 """
140class ColourUsageWarning(Warning):
141 """
142 Define the base class for *Colour* usage warnings.
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 """
151class ColourRuntimeWarning(Warning):
152 """
153 Define the base class for *Colour* runtime warnings.
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 """
162def 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.
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.
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 """
215 ideal_width = width - padding * 2 - 2
217 def inner(text: str) -> str:
218 """Format and pads inner text for the message box."""
220 return (
221 f"*{' ' * padding}"
222 f"{text}{' ' * (width - len(text) - padding * 2 - 2)}"
223 f"{' ' * padding}*"
224 )
226 print_callable("=" * width)
227 print_callable(inner(""))
229 wrapper = TextWrapper(
230 width=ideal_width, break_long_words=False, replace_whitespace=False
231 )
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()))
237 print_callable(inner(""))
238 print_callable("=" * width)
241def 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.
253 This definition is activated by setting the
254 *COLOUR_SCIENCE__COLOUR__SHOW_WARNINGS_WITH_TRACEBACK* environment
255 variable before importing *colour*.
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.
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 """
285 frame_range = (1, None)
287 file = sys.stderr if file is None else file
289 if file is None:
290 return
292 try:
293 # Generating a traceback to print useful warning origin.
294 frame_in, frame_out = frame_range
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
309 traceback.print_stack(frame, frame_out, file)
310 file.write(formatwarning(message, category, filename, lineno, line))
311 except (OSError, UnicodeError):
312 pass
315if os.environ.get( # pragma: no cover
316 "COLOUR_SCIENCE__COLOUR__SHOW_WARNINGS_WITH_TRACEBACK"
317):
318 warnings.showwarning = show_warning # pragma: no cover
321def warning(*args: Any, **kwargs: Any) -> None:
322 """
323 Issue a warning.
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.
333 Examples
334 --------
335 >>> warning("This is a warning!") # doctest: +SKIP
336 """
338 kwargs["category"] = kwargs.get("category", ColourWarning)
340 warn(*args, **kwargs)
343def runtime_warning(*args: Any, **kwargs: Any) -> None:
344 """
345 Issue a runtime warning.
347 Other Parameters
348 ----------------
349 args
350 Warning message and optional arguments for message formatting.
351 kwargs
352 Keyword arguments for controlling warning behaviour.
354 Examples
355 --------
356 >>> runtime_warning("This is a runtime warning!") # doctest: +SKIP
357 """
359 kwargs["category"] = ColourRuntimeWarning
361 warning(*args, **kwargs)
364def usage_warning(*args: Any, **kwargs: Any) -> None:
365 """
366 Issue a usage warning.
368 Other Parameters
369 ----------------
370 args
371 Warning message and optional arguments for message formatting.
372 kwargs
373 Keyword arguments for controlling warning behaviour.
375 Examples
376 --------
377 >>> usage_warning("This is an usage warning!") # doctest: +SKIP
378 """
380 kwargs["category"] = ColourUsageWarning
382 warning(*args, **kwargs)
385def 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.
394 The possible values for all the actions, i.e., each argument, are as
395 follows:
397 - *None* (No action is taken)
398 - *True* (*ignore*)
399 - *False* (*default*)
400 - *error*
401 - *ignore*
402 - *always*
403 - *default*
404 - *module*
405 - *once*
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.
422 Examples
423 --------
424 Filtering *Colour* runtime warnings:
426 >>> filter_warnings(colour_runtime_warnings=True)
428 Filtering *Colour* usage warnings:
430 >>> filter_warnings(colour_usage_warnings=True)
432 Filtering *Colour* warnings:
434 >>> filter_warnings(colour_warnings=True)
436 Filtering all the *Colour* and also Python warnings:
438 >>> filter_warnings(python_warnings=True)
440 Enabling all the *Colour* and Python warnings:
442 >>> filter_warnings(*[False] * 4)
444 Enabling all the *Colour* and Python warnings using the *default* action:
446 >>> filter_warnings(*["default"] * 4)
448 Setting back the default state:
450 >>> filter_warnings(colour_runtime_warnings=True)
451 """
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
462 if isinstance(action, str):
463 action = cast("LiteralWarning", str(action)) # noqa: PLW2901
464 else:
465 action = "ignore" if action else "default" # noqa: PLW2901
467 filterwarnings(action, category=category)
470def as_bool(a: str) -> bool:
471 """
472 Convert the specified string to a boolean value.
474 The following string values evaluate to *True*: "1", "On", and "True".
475 All other string values evaluate to *False*.
477 Parameters
478 ----------
479 a
480 String to convert to boolean.
482 Returns
483 -------
484 :class:`bool`
485 Boolean representation of the specified string.
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 """
503 return a.lower() in ["1", "on", "true"]
506# Defaulting to filter *Colour* runtime warnings.
507filter_warnings(
508 colour_runtime_warnings=as_bool(
509 os.environ.get("COLOUR_SCIENCE__FILTER_RUNTIME_WARNINGS", "True")
510 )
511)
513if (
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 )
522if (
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 )
531if (
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 )
541@contextmanager
542def 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.
552 The possible values for all the actions, i.e., each argument, are as
553 follows:
555 - *None* (No action is taken)
556 - *True* (*ignore*)
557 - *False* (*default*)
558 - *error*
559 - *ignore*
560 - *always*
561 - *default*
562 - *module*
563 - *once*
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 """
582 filters = warnings.filters
583 show_warnings = warnings.showwarning
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 )
592 try:
593 yield
594 finally:
595 warnings.filters = filters
596 warnings.showwarning = show_warnings
599class suppress_stdout:
600 """
601 Define a context manager and decorator to temporarily suppress standard output.
603 Examples
604 --------
605 >>> with suppress_stdout():
606 ... print("Hello World!")
607 >>> print("Hello World!")
608 Hello World!
609 """
611 def __enter__(self) -> Self:
612 """
613 Redirect standard output to null device upon context manager entry.
614 """
616 self._stdout = sys.stdout
617 sys.stdout = open(os.devnull, "w")
619 return self
621 def __exit__(self, *args: Any) -> None:
622 """
623 Restore standard output upon context manager exit.
624 """
626 sys.stdout.close()
627 sys.stdout = self._stdout
629 def __call__(self, function: Callable) -> Callable: # pragma: no cover
630 """Call the wrapped definition with suppressed output."""
632 @functools.wraps(function)
633 def wrapper(*args: Any, **kwargs: Any) -> Callable:
634 with self:
635 return function(*args, **kwargs)
637 return wrapper
640@contextmanager
641def numpy_print_options(*args: Any, **kwargs: Any) -> Generator:
642 """
643 Implement a context manager for temporarily modifying *NumPy* array
644 print options.
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`.
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 """
662 options = np.get_printoptions()
663 np.set_printoptions(*args, **kwargs)
664 try:
665 yield
666 finally:
667 np.set_printoptions(**options)
670ANCILLARY_COLOUR_SCIENCE_PACKAGES: Dict[str, str] = {}
671"""
672Ancillary *colour-science.org* packages to describe.
674ANCILLARY_COLOUR_SCIENCE_PACKAGES
675"""
677ANCILLARY_RUNTIME_PACKAGES: Dict[str, str] = {}
678"""
679Ancillary runtime packages to describe.
681ANCILLARY_RUNTIME_PACKAGES
682"""
684ANCILLARY_DEVELOPMENT_PACKAGES: Dict[str, str] = {}
685"""
686Ancillary development packages to describe.
688ANCILLARY_DEVELOPMENT_PACKAGES
689"""
691ANCILLARY_EXTRAS_PACKAGES: Dict[str, str] = {}
692"""
693Ancillary extras packages to describe.
695ANCILLARY_EXTRAS_PACKAGES
696"""
699def 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.
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.
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.
733 Returns
734 -------
735 :class:`collections.defaultdict`
736 Environment.
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 """
812 environment: defaultdict = defaultdict(dict)
814 environment["Interpreter"]["python"] = sys.version
816 import subprocess # noqa: PLC0415
818 import colour # noqa: PLC0415
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__
834 environment["colour-science.org"]["colour"] = version
835 environment["colour-science.org"].update(ANCILLARY_COLOUR_SCIENCE_PACKAGES)
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__
855 # OpenImageIO
856 with suppress(ImportError): # pragma: no cover
857 namespace = __import__("OpenImageIO")
858 environment["Runtime"]["OpenImageIO"] = namespace.VERSION_STRING
860 # xxhash
861 with suppress(ImportError): # pragma: no cover
862 namespace = __import__("xxhash")
863 environment["Runtime"]["xxhash"] = namespace.version.VERSION
865 environment["Runtime"].update(ANCILLARY_RUNTIME_PACKAGES)
867 def _get_package_version(package: str, mapping: Mapping) -> str:
868 """Return specified package version."""
870 namespace = __import__(package)
872 if package in mapping:
873 import pkg_resources # noqa: PLC0415
875 distributions = list(pkg_resources.working_set)
877 for distribution in distributions:
878 if distribution.project_name == mapping[package]:
879 return distribution.version
881 return namespace.__version__
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
914 environment["Development"][package] = version
915 except Exception: # pragma: no cover # noqa: BLE001, PERF203, S112
916 continue
918 environment["Development"].update(ANCILLARY_DEVELOPMENT_PACKAGES)
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
927 environment["Extras"][package] = version
928 except Exception: # pragma: no cover # noqa: BLE001, PERF203, S112
929 continue
931 environment["Extras"].update(ANCILLARY_EXTRAS_PACKAGES)
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
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"
954 message += "\n"
956 message_box(message.strip(), **kwargs)
958 return environment
961def 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.
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.
986 Returns
987 -------
988 :class:`str`
989 Formatted multi-line string representation.
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 """
1046 attribute_defaults = {
1047 "name": None,
1048 "label": None,
1049 "formatter": str,
1050 "header": False,
1051 "section": False,
1052 "line_break": False,
1053 }
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
1068 representation = []
1069 for attribute in attributes:
1070 attribute = dict(attribute_defaults, **attribute) # noqa: PLW2901
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)
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)
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}")
1099 if attribute["header"]:
1100 representation.append(header_underline * len(representation[-1]))
1102 if attribute["section"]:
1103 representation.append(section_underline * len(representation[-1]))
1104 else:
1105 representation.append("")
1107 return "\n".join(representation)
1110def 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.
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.
1127 Returns
1128 -------
1129 :class:`str`
1130 (Almost) evaluable string representation.
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 """
1160 attribute_defaults = {"name": None, "formatter": repr}
1162 justify = len(f"{object_.__class__.__name__}") + 1
1164 def _format(attribute: dict) -> str:
1165 """Format specified attribute and its value."""
1167 if attribute["name"] is not None:
1168 value = attribute["formatter"](getattr(object_, attribute["name"]))
1169 else:
1170 value = attribute["formatter"](None)
1172 if value is None:
1173 return str(None)
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]
1181 lines = value.splitlines()
1183 if len(lines) > 1:
1184 for i, line in enumerate(lines[1:]):
1185 lines[i + 1] = f"{'':{justify}}{line}"
1187 return "\n".join(lines)
1189 attribute = dict(attribute_defaults, **attributes.pop(0))
1191 representation = [f"{object_.__class__.__name__}({_format(attribute)}"]
1193 for attribute in attributes:
1194 attribute = dict(attribute_defaults, **attribute) # noqa: PLW2901
1196 representation.append(f"{'':{justify}}{_format(attribute)}")
1198 return "{})".format(",\n".join(representation))