Skip to content

utils

enums

General-purpose enumerations shared across the library.

VerbosityEnum

Bases: str, Enum

Console output verbosity level.

Controls how much information is printed during operations such as data loading, fitting, and saving.

Members

FULL Multi-line output with headers, tables, and details. SHORT Single-line status messages per action. SILENT No console output.

Source code in src/easydiffraction/utils/enums.py
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
class VerbosityEnum(str, Enum):
    """
    Console output verbosity level.

    Controls how much information is printed during operations such as
    data loading, fitting, and saving.

    Members
    -------
    FULL Multi-line output with headers, tables, and details. SHORT
    Single-line status messages per action. SILENT No console output.
    """

    FULL = 'full'
    SHORT = 'short'
    SILENT = 'silent'

    @classmethod
    def default(cls) -> VerbosityEnum:
        """Return the default verbosity (FULL)."""
        return cls.FULL

default() classmethod

Return the default verbosity (FULL).

Source code in src/easydiffraction/utils/enums.py
27
28
29
30
@classmethod
def default(cls) -> VerbosityEnum:
    """Return the default verbosity (FULL)."""
    return cls.FULL

environment

can_update_ipython_display()

Return True if IPython HTML display utilities are available.

This indicates we can safely construct IPython.display.HTML and update a display handle.

Source code in src/easydiffraction/utils/environment.py
146
147
148
149
150
151
152
153
154
155
156
157
158
def can_update_ipython_display() -> bool:
    """
    Return True if IPython HTML display utilities are available.

    This indicates we can safely construct ``IPython.display.HTML`` and
    update a display handle.
    """
    try:
        from IPython.display import HTML  # type: ignore[import-not-found]  # noqa: F401

        return True
    except Exception:
        return False

can_use_ipython_display(handle)

Return True if we can update the given IPython DisplayHandle.

Combines type checking of the handle with availability of IPython HTML utilities.

Source code in src/easydiffraction/utils/environment.py
161
162
163
164
165
166
167
168
169
170
171
def can_use_ipython_display(handle: object) -> bool:
    """
    Return True if we can update the given IPython DisplayHandle.

    Combines type checking of the handle with availability of IPython
    HTML utilities.
    """
    try:
        return is_ipython_display_handle(handle) and can_update_ipython_display()
    except Exception:
        return False

in_colab()

Check whether the current environment is Google Colab.

Returns:

Type Description
bool

True if running in Google Colab, False otherwise.

Source code in src/easydiffraction/utils/environment.py
48
49
50
51
52
53
54
55
56
57
58
59
60
def in_colab() -> bool:
    """
    Check whether the current environment is Google Colab.

    Returns
    -------
    bool
        True if running in Google Colab, False otherwise.
    """
    try:
        return find_spec('google.colab') is not None
    except ModuleNotFoundError:  # pragma: no cover - importlib edge case
        return False

in_github_ci()

Return True when running under GitHub Actions CI.

Returns:

Type Description
bool

True if env var GITHUB_ACTIONS is set, False otherwise.

Source code in src/easydiffraction/utils/environment.py
104
105
106
107
108
109
110
111
112
113
def in_github_ci() -> bool:
    """
    Return True when running under GitHub Actions CI.

    Returns
    -------
    bool
        True if env var ``GITHUB_ACTIONS`` is set, False otherwise.
    """
    return os.environ.get('GITHUB_ACTIONS') is not None

in_jupyter()

Return True when running inside a Jupyter Notebook.

Returns:

Type Description
bool

True if inside a Jupyter Notebook, False otherwise.

Source code in src/easydiffraction/utils/environment.py
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
def in_jupyter() -> bool:
    """
    Return True when running inside a Jupyter Notebook.

    Returns
    -------
    bool
        True if inside a Jupyter Notebook, False otherwise.
    """
    try:
        import IPython  # type: ignore[import-not-found]
    except ImportError:  # pragma: no cover - optional dependency
        ipython_mod = None
    else:
        ipython_mod = IPython
    if ipython_mod is None:
        return False
    if in_pycharm():
        return False
    if in_colab():
        return True

    try:
        ip = ipython_mod.get_ipython()  # type: ignore[attr-defined]
        if ip is None:
            return False
        # Prefer config-based detection when available (works with
        # tests).
        has_cfg = hasattr(ip, 'config') and isinstance(ip.config, dict)
        if has_cfg and 'IPKernelApp' in ip.config:  # type: ignore[index]
            return True
        shell = ip.__class__.__name__
        if shell == 'ZMQInteractiveShell':  # Jupyter or qtconsole
            return True
        if shell == 'TerminalInteractiveShell':
            return False
        return False
    except Exception:
        return False

in_pycharm()

Check whether the current environment is PyCharm.

Returns:

Type Description
bool

True if running inside PyCharm, False otherwise.

Source code in src/easydiffraction/utils/environment.py
36
37
38
39
40
41
42
43
44
45
def in_pycharm() -> bool:
    """
    Check whether the current environment is PyCharm.

    Returns
    -------
    bool
        True if running inside PyCharm, False otherwise.
    """
    return os.environ.get('PYCHARM_HOSTED') == '1'

in_pytest()

Determine whether the code is running inside a pytest session.

Returns:

Type Description
bool

True if pytest is loaded, False otherwise.

Source code in src/easydiffraction/utils/environment.py
11
12
13
14
15
16
17
18
19
20
def in_pytest() -> bool:
    """
    Determine whether the code is running inside a pytest session.

    Returns
    -------
    bool
        True if pytest is loaded, False otherwise.
    """
    return 'pytest' in sys.modules

in_warp()

Determine whether the terminal is the Warp terminal emulator.

Returns:

Type Description
bool

True if the TERM_PROGRAM environment variable equals 'WarpTerminal', False otherwise.

Source code in src/easydiffraction/utils/environment.py
23
24
25
26
27
28
29
30
31
32
33
def in_warp() -> bool:
    """
    Determine whether the terminal is the Warp terminal emulator.

    Returns
    -------
    bool
        True if the TERM_PROGRAM environment variable equals
        ``'WarpTerminal'``, False otherwise.
    """
    return os.getenv('TERM_PROGRAM') == 'WarpTerminal'

is_ipython_display_handle(obj)

Return True if obj is an IPython DisplayHandle instance.

Tries to import IPython.display.DisplayHandle and uses isinstance when available. Falls back to a conservative module name heuristic if IPython is missing. Any errors result in False.

Source code in src/easydiffraction/utils/environment.py
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
def is_ipython_display_handle(obj: object) -> bool:
    """
    Return True if ``obj`` is an IPython DisplayHandle instance.

    Tries to import ``IPython.display.DisplayHandle`` and uses
    ``isinstance`` when available. Falls back to a conservative module
    name heuristic if IPython is missing. Any errors result in
    ``False``.
    """
    try:  # Fast path when IPython is available
        from IPython.display import DisplayHandle  # type: ignore[import-not-found]

        try:
            return isinstance(obj, DisplayHandle)
        except Exception:
            return False
    except Exception:
        # Fallback heuristic when IPython is unavailable
        try:
            mod = getattr(getattr(obj, '__class__', None), '__module__', '')
            return isinstance(mod, str) and mod.startswith('IPython')
        except Exception:
            return False

logging

Project-wide logging utilities built on top of Rich.

Provides a shared Rich console, a compact/verbose logger with consistent formatting, Jupyter traceback handling, and a small printing façade tailored to the configured console.

ConsoleManager

Central provider for shared Rich Console instance.

Source code in src/easydiffraction/utils/logging.py
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
class ConsoleManager:
    """Central provider for shared Rich Console instance."""

    _MIN_CONSOLE_WIDTH = 130
    _instance: Console | None = None

    @staticmethod
    def _detect_width() -> int:
        """
        Detect a suitable console width for the shared Console.

        Returns
        -------
        int
            The detected terminal width, clamped at
            ``_MIN_CONSOLE_WIDTH`` to avoid cramped layouts.
        """
        min_width = ConsoleManager._MIN_CONSOLE_WIDTH
        try:
            width = shutil.get_terminal_size().columns
        except Exception:
            width = min_width
        return max(width, min_width)

    @classmethod
    def get(cls) -> Console:
        """Return a shared Rich Console instance."""
        if cls._instance is None:
            cls._instance = Console(
                width=cls._detect_width(),
                force_jupyter=False,
            )
        return cls._instance

get() classmethod

Return a shared Rich Console instance.

Source code in src/easydiffraction/utils/logging.py
137
138
139
140
141
142
143
144
145
@classmethod
def get(cls) -> Console:
    """Return a shared Rich Console instance."""
    if cls._instance is None:
        cls._instance = Console(
            width=cls._detect_width(),
            force_jupyter=False,
        )
    return cls._instance

ConsolePrinter

Printer utility for the shared console with left padding.

Source code in src/easydiffraction/utils/logging.py
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
class ConsolePrinter:
    """Printer utility for the shared console with left padding."""

    _console = ConsoleManager.get()

    @classmethod
    def print(cls, *objects: object, **kwargs: object) -> None:
        """
        Print objects to the console with left padding.

        - Renderables (Rich types like Text, Table, Panel, etc.) are
        kept as-is. - Non-renderables (ints, floats, Path, etc.) are
        converted to   str().
        """
        safe_objects = []
        for obj in objects:
            if isinstance(obj, RenderableType):
                safe_objects.append(obj)
            elif isinstance(obj, Path):
                safe_objects.append(str(obj))
            else:
                safe_objects.append(str(obj))

        # If multiple objects, join with spaces
        renderable = (
            ' '.join(str(o) for o in safe_objects)
            if all(isinstance(o, str) for o in safe_objects)
            else Group(*safe_objects)
        )

        cls._console.print(renderable, **kwargs)

    @classmethod
    def paragraph(cls, title: str) -> None:
        """
        Print a bold blue paragraph heading.

        Parameters
        ----------
        title : str
            Heading text; substrings enclosed in single quotes are
            rendered without the bold-blue style.
        """
        parts = re.split(r"('.*?')", title)
        text = Text()
        for part in parts:
            if part.startswith("'") and part.endswith("'"):
                text.append(part)
            else:
                text.append(part, style='bold blue')
        formatted = f'{text.markup}'
        if not in_jupyter():
            formatted = f'\n{formatted}'
        cls._console.print(formatted)

    @classmethod
    def section(cls, title: str) -> None:
        """Format a section header with bold green text."""
        full_title = f'{title.upper()}'
        line = '—' * len(full_title)
        formatted = f'[bold green]{line}\n{full_title}\n{line}[/bold green]'
        if not in_jupyter():
            formatted = f'\n{formatted}'
        cls._console.print(formatted)

    @classmethod
    def chapter(cls, title: str) -> None:
        """Format a chapter header in bold magenta, uppercase."""
        width = ConsoleManager._detect_width()
        symbol = '—'
        full_title = f' {title.upper()} '
        pad_len = (width - len(full_title)) // 2
        padding = symbol * pad_len
        line = f'[bold magenta]{padding}{full_title}{padding}[/bold magenta]'
        if len(line) < width:
            line += symbol
        formatted = f'{line}'
        if not in_jupyter():
            formatted = f'\n{formatted}'
        cls._console.print(formatted)

chapter(title) classmethod

Format a chapter header in bold magenta, uppercase.

Source code in src/easydiffraction/utils/logging.py
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
@classmethod
def chapter(cls, title: str) -> None:
    """Format a chapter header in bold magenta, uppercase."""
    width = ConsoleManager._detect_width()
    symbol = '—'
    full_title = f' {title.upper()} '
    pad_len = (width - len(full_title)) // 2
    padding = symbol * pad_len
    line = f'[bold magenta]{padding}{full_title}{padding}[/bold magenta]'
    if len(line) < width:
        line += symbol
    formatted = f'{line}'
    if not in_jupyter():
        formatted = f'\n{formatted}'
    cls._console.print(formatted)

paragraph(title) classmethod

Print a bold blue paragraph heading.

Parameters:

Name Type Description Default
title str

Heading text; substrings enclosed in single quotes are rendered without the bold-blue style.

required
Source code in src/easydiffraction/utils/logging.py
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
@classmethod
def paragraph(cls, title: str) -> None:
    """
    Print a bold blue paragraph heading.

    Parameters
    ----------
    title : str
        Heading text; substrings enclosed in single quotes are
        rendered without the bold-blue style.
    """
    parts = re.split(r"('.*?')", title)
    text = Text()
    for part in parts:
        if part.startswith("'") and part.endswith("'"):
            text.append(part)
        else:
            text.append(part, style='bold blue')
    formatted = f'{text.markup}'
    if not in_jupyter():
        formatted = f'\n{formatted}'
    cls._console.print(formatted)

print(*objects, **kwargs) classmethod

Print objects to the console with left padding.

  • Renderables (Rich types like Text, Table, Panel, etc.) are kept as-is. - Non-renderables (ints, floats, Path, etc.) are converted to str().
Source code in src/easydiffraction/utils/logging.py
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
@classmethod
def print(cls, *objects: object, **kwargs: object) -> None:
    """
    Print objects to the console with left padding.

    - Renderables (Rich types like Text, Table, Panel, etc.) are
    kept as-is. - Non-renderables (ints, floats, Path, etc.) are
    converted to   str().
    """
    safe_objects = []
    for obj in objects:
        if isinstance(obj, RenderableType):
            safe_objects.append(obj)
        elif isinstance(obj, Path):
            safe_objects.append(str(obj))
        else:
            safe_objects.append(str(obj))

    # If multiple objects, join with spaces
    renderable = (
        ' '.join(str(o) for o in safe_objects)
        if all(isinstance(o, str) for o in safe_objects)
        else Group(*safe_objects)
    )

    cls._console.print(renderable, **kwargs)

section(title) classmethod

Format a section header with bold green text.

Source code in src/easydiffraction/utils/logging.py
685
686
687
688
689
690
691
692
693
@classmethod
def section(cls, title: str) -> None:
    """Format a section header with bold green text."""
    full_title = f'{title.upper()}'
    line = '—' * len(full_title)
    formatted = f'[bold green]{line}\n{full_title}\n{line}[/bold green]'
    if not in_jupyter():
        formatted = f'\n{formatted}'
    cls._console.print(formatted)

ExceptionHookManager

Handles installation and restoration of exception hooks.

Source code in src/easydiffraction/utils/logging.py
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
class ExceptionHookManager:
    """Handles installation and restoration of exception hooks."""

    @staticmethod
    def install_verbose_hook(logger: logging.Logger) -> None:
        """
        Install a verbose exception hook that prints rich tracebacks.

        Parameters
        ----------
        logger : logging.Logger
            Logger used to emit the exception information.
        """
        if not hasattr(Logger, '_orig_excepthook'):
            Logger._orig_excepthook = sys.excepthook  # type: ignore[attr-defined]

        def aligned_excepthook(
            exc_type: type[BaseException],
            exc: BaseException,
            tb: 'TracebackType | None',
        ) -> None:
            """Log the exception with full traceback via Rich."""
            original_args = getattr(exc, 'args', tuple())
            message = str(exc)
            with suppress(Exception):
                exc.args = tuple()
            try:
                logger.error(message, exc_info=(exc_type, exc, tb))
            except Exception:
                logger.error('Unhandled exception (logging failure)')
            finally:
                with suppress(Exception):
                    exc.args = original_args

        sys.excepthook = aligned_excepthook  # type: ignore[assignment]

    @staticmethod
    def install_compact_hook(logger: logging.Logger) -> None:
        """
        Install a compact exception hook that logs message-only.

        Parameters
        ----------
        logger : logging.Logger
            Logger used to emit the error message.
        """
        if not hasattr(Logger, '_orig_excepthook'):
            Logger._orig_excepthook = sys.excepthook  # type: ignore[attr-defined]

        def compact_excepthook(
            _exc_type: type[BaseException],
            exc: BaseException,
            _tb: 'TracebackType | None',
        ) -> None:
            """Log the exception message and exit."""
            logger.error(str(exc))
            raise SystemExit(1)

        sys.excepthook = compact_excepthook  # type: ignore[assignment]

    @staticmethod
    def restore_original_hook() -> None:
        """Restore the original sys.excepthook if it was overridden."""
        if hasattr(Logger, '_orig_excepthook'):
            sys.excepthook = Logger._orig_excepthook  # type: ignore[attr-defined]

    # Jupyter-specific traceback suppression (inlined here)
    @staticmethod
    def _suppress_traceback(logger: object) -> object:
        """
        Build a Jupyter exception callback that logs the message only.

        Parameters
        ----------
        logger : object
            Logger used to emit error messages.

        Returns
        -------
        object
            A callable suitable for IPython's set_custom_exc that
            suppresses full tracebacks and logs only the exception
            message.
        """

        def suppress_jupyter_traceback(*args: object, **kwargs: object) -> None:
            """Log only the exception message."""
            try:
                _evalue = (
                    args[2] if len(args) > 2 else kwargs.get('_evalue') or kwargs.get('evalue')
                )
                logger.error(str(_evalue))
            except Exception as err:
                logger.debug('Jupyter traceback suppressor failed: %r', err)
            return None

        return suppress_jupyter_traceback

    @staticmethod
    def install_jupyter_traceback_suppressor(logger: logging.Logger) -> None:
        """
        Install a Jupyter/IPython exception handler for tracebacks.

        Parameters
        ----------
        logger : logging.Logger
            Logger used to emit error messages.
        """
        try:
            from IPython import get_ipython

            ip = get_ipython()
            if ip is not None:
                ip.set_custom_exc(
                    (BaseException,), ExceptionHookManager._suppress_traceback(logger)
                )
        except Exception as err:
            msg = f'Failed to install Jupyter traceback suppressor: {err!r}'
            logger.debug(msg)

install_compact_hook(logger) staticmethod

Install a compact exception hook that logs message-only.

Parameters:

Name Type Description Default
logger Logger

Logger used to emit the error message.

required
Source code in src/easydiffraction/utils/logging.py
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
@staticmethod
def install_compact_hook(logger: logging.Logger) -> None:
    """
    Install a compact exception hook that logs message-only.

    Parameters
    ----------
    logger : logging.Logger
        Logger used to emit the error message.
    """
    if not hasattr(Logger, '_orig_excepthook'):
        Logger._orig_excepthook = sys.excepthook  # type: ignore[attr-defined]

    def compact_excepthook(
        _exc_type: type[BaseException],
        exc: BaseException,
        _tb: 'TracebackType | None',
    ) -> None:
        """Log the exception message and exit."""
        logger.error(str(exc))
        raise SystemExit(1)

    sys.excepthook = compact_excepthook  # type: ignore[assignment]

install_jupyter_traceback_suppressor(logger) staticmethod

Install a Jupyter/IPython exception handler for tracebacks.

Parameters:

Name Type Description Default
logger Logger

Logger used to emit error messages.

required
Source code in src/easydiffraction/utils/logging.py
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
@staticmethod
def install_jupyter_traceback_suppressor(logger: logging.Logger) -> None:
    """
    Install a Jupyter/IPython exception handler for tracebacks.

    Parameters
    ----------
    logger : logging.Logger
        Logger used to emit error messages.
    """
    try:
        from IPython import get_ipython

        ip = get_ipython()
        if ip is not None:
            ip.set_custom_exc(
                (BaseException,), ExceptionHookManager._suppress_traceback(logger)
            )
    except Exception as err:
        msg = f'Failed to install Jupyter traceback suppressor: {err!r}'
        logger.debug(msg)

install_verbose_hook(logger) staticmethod

Install a verbose exception hook that prints rich tracebacks.

Parameters:

Name Type Description Default
logger Logger

Logger used to emit the exception information.

required
Source code in src/easydiffraction/utils/logging.py
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
@staticmethod
def install_verbose_hook(logger: logging.Logger) -> None:
    """
    Install a verbose exception hook that prints rich tracebacks.

    Parameters
    ----------
    logger : logging.Logger
        Logger used to emit the exception information.
    """
    if not hasattr(Logger, '_orig_excepthook'):
        Logger._orig_excepthook = sys.excepthook  # type: ignore[attr-defined]

    def aligned_excepthook(
        exc_type: type[BaseException],
        exc: BaseException,
        tb: 'TracebackType | None',
    ) -> None:
        """Log the exception with full traceback via Rich."""
        original_args = getattr(exc, 'args', tuple())
        message = str(exc)
        with suppress(Exception):
            exc.args = tuple()
        try:
            logger.error(message, exc_info=(exc_type, exc, tb))
        except Exception:
            logger.error('Unhandled exception (logging failure)')
        finally:
            with suppress(Exception):
                exc.args = original_args

    sys.excepthook = aligned_excepthook  # type: ignore[assignment]

restore_original_hook() staticmethod

Restore the original sys.excepthook if it was overridden.

Source code in src/easydiffraction/utils/logging.py
301
302
303
304
305
@staticmethod
def restore_original_hook() -> None:
    """Restore the original sys.excepthook if it was overridden."""
    if hasattr(Logger, '_orig_excepthook'):
        sys.excepthook = Logger._orig_excepthook  # type: ignore[attr-defined]

IconifiedRichHandler

Bases: RichHandler

RichHandler using icons (compact) or names (verbose).

Source code in src/easydiffraction/utils/logging.py
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
class IconifiedRichHandler(RichHandler):
    """RichHandler using icons (compact) or names (verbose)."""

    _icons = {
        logging.CRITICAL: '💀',
        logging.ERROR: '❌',
        logging.WARNING: '⚠️',
        logging.DEBUG: '⚙️',
        logging.INFO: 'ℹ️',
    }

    def __init__(self, *args: object, mode: str = 'compact', **kwargs: object) -> None:
        super().__init__(*args, **kwargs)
        self.mode = mode

    def get_level_text(self, record: logging.LogRecord) -> Text:
        """
        Return an icon or level name for the log record.

        Parameters
        ----------
        record : logging.LogRecord
            The log record being rendered.

        Returns
        -------
        Text
            A Rich Text object with the level indicator.
        """
        if self.mode == 'compact':
            icon = self._icons.get(record.levelno, record.levelname)
            if in_warp() and not in_jupyter() and icon in ['⚠️', '⚙️', 'ℹ️']:
                icon = icon + ' '  # add space to align with two-char icons
            return Text(icon)
        else:
            # Use RichHandler's default level text for verbose mode
            return super().get_level_text(record)

    def render_message(self, record: logging.LogRecord, message: str) -> Text:
        """
        Render the log message body as a Rich Text object.

        Parameters
        ----------
        record : logging.LogRecord
            The log record being rendered.
        message : str
            Pre-formatted log message string.

        Returns
        -------
        Text
            A Rich Text object with the rendered message.
        """
        if self.mode == 'compact':
            try:
                return Text.from_markup(message)
            except Exception:
                return Text(str(message))
        return super().render_message(record, message)

get_level_text(record)

Return an icon or level name for the log record.

Parameters:

Name Type Description Default
record LogRecord

The log record being rendered.

required

Returns:

Type Description
Text

A Rich Text object with the level indicator.

Source code in src/easydiffraction/utils/logging.py
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
def get_level_text(self, record: logging.LogRecord) -> Text:
    """
    Return an icon or level name for the log record.

    Parameters
    ----------
    record : logging.LogRecord
        The log record being rendered.

    Returns
    -------
    Text
        A Rich Text object with the level indicator.
    """
    if self.mode == 'compact':
        icon = self._icons.get(record.levelno, record.levelname)
        if in_warp() and not in_jupyter() and icon in ['⚠️', '⚙️', 'ℹ️']:
            icon = icon + ' '  # add space to align with two-char icons
        return Text(icon)
    else:
        # Use RichHandler's default level text for verbose mode
        return super().get_level_text(record)

render_message(record, message)

Render the log message body as a Rich Text object.

Parameters:

Name Type Description Default
record LogRecord

The log record being rendered.

required
message str

Pre-formatted log message string.

required

Returns:

Type Description
Text

A Rich Text object with the rendered message.

Source code in src/easydiffraction/utils/logging.py
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
def render_message(self, record: logging.LogRecord, message: str) -> Text:
    """
    Render the log message body as a Rich Text object.

    Parameters
    ----------
    record : logging.LogRecord
        The log record being rendered.
    message : str
        Pre-formatted log message string.

    Returns
    -------
    Text
        A Rich Text object with the rendered message.
    """
    if self.mode == 'compact':
        try:
            return Text.from_markup(message)
        except Exception:
            return Text(str(message))
    return super().render_message(record, message)

Logger

Centralized logging with Rich formatting and two modes.

Environment variables: ED_LOG_MODE: set default mode ('verbose' or 'compact') ED_LOG_LEVEL: set default level ('DEBUG', 'INFO', etc.)

Source code in src/easydiffraction/utils/logging.py
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
class Logger:
    """
    Centralized logging with Rich formatting and two modes.

    Environment variables: ED_LOG_MODE: set default mode ('verbose' or
    'compact') ED_LOG_LEVEL: set default level ('DEBUG', 'INFO', etc.)
    """

    # --- Enums ---
    class Mode(Enum):
        """Output modes (see :class:`Logger`)."""

        VERBOSE = 'verbose'  # rich traceback panel
        COMPACT = 'compact'  # single line; no traceback

        @classmethod
        def default(cls) -> Logger.Mode:
            """Return the default output mode (compact)."""
            return cls.COMPACT

    class Level(IntEnum):
        """Mirror stdlib logging levels."""

        DEBUG = logging.DEBUG
        INFO = logging.INFO
        WARNING = logging.WARNING
        ERROR = logging.ERROR
        CRITICAL = logging.CRITICAL

        @classmethod
        def default(cls) -> Logger.Level:
            """Return the default log level (WARNING)."""
            return cls.WARNING

    class Reaction(Enum):
        """Reaction to errors (see :class:`Logger`)."""

        RAISE = auto()
        WARN = auto()

        @classmethod
        def default(cls) -> Logger.Reaction:
            """Return the default error reaction (RAISE)."""
            return cls.RAISE

    # --- Internal state ---
    _logger = logging.getLogger('easydiffraction')
    _configured = False
    _mode: Mode = Mode.VERBOSE
    _reaction: Reaction = Reaction.RAISE  # TODO: not default?
    _console = ConsoleManager.get()

    # ===== CONFIGURATION =====
    @classmethod
    def configure(
        cls,
        *,
        mode: Mode | None = None,
        level: Level | None = None,
        reaction: Reaction | None = None,
        rich_tracebacks: bool | None = None,
    ) -> None:
        """
        Configure logger.

        mode: default COMPACT in Jupyter else VERBOSE level: minimum log
        level rich_tracebacks: override automatic choice

        Environment variables: ED_LOG_MODE: set default mode ('verbose'
        or 'compact') ED_LOG_LEVEL: set default level ('DEBUG', 'INFO',
        etc.)
        """
        env_mode = os.getenv('ED_LOG_MODE')
        env_level = os.getenv('ED_LOG_LEVEL')
        env_reaction = os.getenv('ED_LOG_REACTION')

        # Read from environment if not provided
        if mode is None and env_mode is not None:
            with suppress(ValueError):
                mode = Logger.Mode(env_mode.lower())
        if level is None and env_level is not None:
            with suppress(KeyError):
                level = Logger.Level[env_level.upper()]
        if reaction is None and env_reaction is not None:
            with suppress(KeyError):
                reaction = Logger.Reaction[env_reaction.upper()]

        # Set defaults if still None
        if mode is None:
            mode = Logger.Mode.default()
        if level is None:
            level = Logger.Level.default()
        if reaction is None:
            reaction = Logger.Reaction.default()

        cls._mode = mode
        cls._reaction = reaction

        if rich_tracebacks is None:
            rich_tracebacks = mode == Logger.Mode.VERBOSE

        LoggerConfig.configure(
            logger=cls._logger,
            mode=mode,
            level=level,
            rich_tracebacks=rich_tracebacks,
        )
        cls._configured = True

    @classmethod
    def _install_jupyter_traceback_suppressor(cls) -> None:
        """Install the Jupyter traceback suppressor safely."""
        ExceptionHookManager.install_jupyter_traceback_suppressor(cls._logger)

    # ===== Helpers =====
    @classmethod
    def set_mode(cls, mode: Mode) -> None:
        """
        Set the output mode and reconfigure the logger.

        Parameters
        ----------
        mode : Mode
            The desired output mode (VERBOSE or COMPACT).
        """
        cls.configure(mode=mode, level=cls.Level(cls._logger.level))

    @classmethod
    def set_level(cls, level: Level) -> None:
        """
        Set the minimum log level and reconfigure the logger.

        Parameters
        ----------
        level : Level
            The desired minimum log level.
        """
        cls.configure(mode=cls._mode, level=level)

    @classmethod
    def mode(cls) -> Mode:
        """
        Return the currently active output mode.

        Returns
        -------
        Mode
            The current Logger.Mode value.
        """
        return cls._mode

    @classmethod
    def _lazy_config(cls) -> None:
        if not cls._configured:  # pragma: no cover - trivial
            cls.configure()

    # ===== Core Routing =====
    @classmethod
    def handle(
        cls,
        *messages: str,
        level: Level = Level.ERROR,
        exc_type: type[BaseException] | None = AttributeError,
    ) -> None:
        """Route a log message (see class docs for policy)."""
        cls._lazy_config()
        message = ' '.join(messages)
        # Prioritize explicit UserWarning path so pytest captures
        # warnings
        if exc_type is UserWarning:
            if in_pytest():
                warnings.warn(message, UserWarning, stacklevel=2)
            else:
                cls._logger.warning(message)
            return
        # Special handling for Reaction.WARN (non-warning cases)
        if cls._reaction is cls.Reaction.WARN:
            # Log as error/critical (keep icon) but continue execution
            cls._logger.log(int(level), message)
            return
        if exc_type is not None:
            if cls._mode is cls.Mode.VERBOSE:
                raise exc_type(message)
            if cls._mode is cls.Mode.COMPACT:
                raise exc_type(message) from None
        cls._logger.log(int(level), message)

    # ==================================================================
    # CONVENIENCE API
    # ==================================================================

    @classmethod
    def debug(cls, *messages: str) -> None:
        """
        Log one or more messages at DEBUG level.

        Parameters
        ----------
        *messages : str
            Message parts joined with a space before logging.
        """
        cls.handle(*messages, level=cls.Level.DEBUG, exc_type=None)

    @classmethod
    def info(cls, *messages: str) -> None:
        """
        Log one or more messages at INFO level.

        Parameters
        ----------
        *messages : str
            Message parts joined with a space before logging.
        """
        cls.handle(*messages, level=cls.Level.INFO, exc_type=None)

    @classmethod
    def warning(cls, *messages: str, exc_type: type[BaseException] | None = None) -> None:
        """
        Log one or more messages at WARNING level.

        Parameters
        ----------
        *messages : str
            Message parts joined with a space before logging.
        exc_type : type[BaseException] | None, default=None
            If provided, raise this exception type instead of logging.
        """
        cls.handle(*messages, level=cls.Level.WARNING, exc_type=exc_type)

    @classmethod
    def error(cls, *messages: str, exc_type: type[BaseException] = AttributeError) -> None:
        """
        Log one or more messages at ERROR level.

        Parameters
        ----------
        *messages : str
            Message parts joined with a space before logging.
        exc_type : type[BaseException], default=AttributeError
            Exception type to raise in VERBOSE/COMPACT mode.
        """
        cls.handle(*messages, level=cls.Level.ERROR, exc_type=exc_type)

    @classmethod
    def critical(cls, *messages: str, exc_type: type[BaseException] = RuntimeError) -> None:
        """
        Log one or more messages at CRITICAL level.

        Parameters
        ----------
        *messages : str
            Message parts joined with a space before logging.
        exc_type : type[BaseException], default=RuntimeError
            Exception type to raise in VERBOSE/COMPACT mode.
        """
        cls.handle(*messages, level=cls.Level.CRITICAL, exc_type=exc_type)

Level

Bases: IntEnum

Mirror stdlib logging levels.

Source code in src/easydiffraction/utils/logging.py
387
388
389
390
391
392
393
394
395
396
397
398
399
class Level(IntEnum):
    """Mirror stdlib logging levels."""

    DEBUG = logging.DEBUG
    INFO = logging.INFO
    WARNING = logging.WARNING
    ERROR = logging.ERROR
    CRITICAL = logging.CRITICAL

    @classmethod
    def default(cls) -> Logger.Level:
        """Return the default log level (WARNING)."""
        return cls.WARNING
default() classmethod

Return the default log level (WARNING).

Source code in src/easydiffraction/utils/logging.py
396
397
398
399
@classmethod
def default(cls) -> Logger.Level:
    """Return the default log level (WARNING)."""
    return cls.WARNING

Mode

Bases: Enum

Output modes (see :class:Logger).

Source code in src/easydiffraction/utils/logging.py
376
377
378
379
380
381
382
383
384
385
class Mode(Enum):
    """Output modes (see :class:`Logger`)."""

    VERBOSE = 'verbose'  # rich traceback panel
    COMPACT = 'compact'  # single line; no traceback

    @classmethod
    def default(cls) -> Logger.Mode:
        """Return the default output mode (compact)."""
        return cls.COMPACT
default() classmethod

Return the default output mode (compact).

Source code in src/easydiffraction/utils/logging.py
382
383
384
385
@classmethod
def default(cls) -> Logger.Mode:
    """Return the default output mode (compact)."""
    return cls.COMPACT

Reaction

Bases: Enum

Reaction to errors (see :class:Logger).

Source code in src/easydiffraction/utils/logging.py
401
402
403
404
405
406
407
408
409
410
class Reaction(Enum):
    """Reaction to errors (see :class:`Logger`)."""

    RAISE = auto()
    WARN = auto()

    @classmethod
    def default(cls) -> Logger.Reaction:
        """Return the default error reaction (RAISE)."""
        return cls.RAISE
default() classmethod

Return the default error reaction (RAISE).

Source code in src/easydiffraction/utils/logging.py
407
408
409
410
@classmethod
def default(cls) -> Logger.Reaction:
    """Return the default error reaction (RAISE)."""
    return cls.RAISE

configure(*, mode=None, level=None, reaction=None, rich_tracebacks=None) classmethod

Configure logger.

mode: default COMPACT in Jupyter else VERBOSE level: minimum log level rich_tracebacks: override automatic choice

Environment variables: ED_LOG_MODE: set default mode ('verbose' or 'compact') ED_LOG_LEVEL: set default level ('DEBUG', 'INFO', etc.)

Source code in src/easydiffraction/utils/logging.py
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
@classmethod
def configure(
    cls,
    *,
    mode: Mode | None = None,
    level: Level | None = None,
    reaction: Reaction | None = None,
    rich_tracebacks: bool | None = None,
) -> None:
    """
    Configure logger.

    mode: default COMPACT in Jupyter else VERBOSE level: minimum log
    level rich_tracebacks: override automatic choice

    Environment variables: ED_LOG_MODE: set default mode ('verbose'
    or 'compact') ED_LOG_LEVEL: set default level ('DEBUG', 'INFO',
    etc.)
    """
    env_mode = os.getenv('ED_LOG_MODE')
    env_level = os.getenv('ED_LOG_LEVEL')
    env_reaction = os.getenv('ED_LOG_REACTION')

    # Read from environment if not provided
    if mode is None and env_mode is not None:
        with suppress(ValueError):
            mode = Logger.Mode(env_mode.lower())
    if level is None and env_level is not None:
        with suppress(KeyError):
            level = Logger.Level[env_level.upper()]
    if reaction is None and env_reaction is not None:
        with suppress(KeyError):
            reaction = Logger.Reaction[env_reaction.upper()]

    # Set defaults if still None
    if mode is None:
        mode = Logger.Mode.default()
    if level is None:
        level = Logger.Level.default()
    if reaction is None:
        reaction = Logger.Reaction.default()

    cls._mode = mode
    cls._reaction = reaction

    if rich_tracebacks is None:
        rich_tracebacks = mode == Logger.Mode.VERBOSE

    LoggerConfig.configure(
        logger=cls._logger,
        mode=mode,
        level=level,
        rich_tracebacks=rich_tracebacks,
    )
    cls._configured = True

critical(*messages, exc_type=RuntimeError) classmethod

Log one or more messages at CRITICAL level.

Parameters:

Name Type Description Default
*messages str

Message parts joined with a space before logging.

()
exc_type type[BaseException]

Exception type to raise in VERBOSE/COMPACT mode.

RuntimeError
Source code in src/easydiffraction/utils/logging.py
610
611
612
613
614
615
616
617
618
619
620
621
622
@classmethod
def critical(cls, *messages: str, exc_type: type[BaseException] = RuntimeError) -> None:
    """
    Log one or more messages at CRITICAL level.

    Parameters
    ----------
    *messages : str
        Message parts joined with a space before logging.
    exc_type : type[BaseException], default=RuntimeError
        Exception type to raise in VERBOSE/COMPACT mode.
    """
    cls.handle(*messages, level=cls.Level.CRITICAL, exc_type=exc_type)

debug(*messages) classmethod

Log one or more messages at DEBUG level.

Parameters:

Name Type Description Default
*messages str

Message parts joined with a space before logging.

()
Source code in src/easydiffraction/utils/logging.py
558
559
560
561
562
563
564
565
566
567
568
@classmethod
def debug(cls, *messages: str) -> None:
    """
    Log one or more messages at DEBUG level.

    Parameters
    ----------
    *messages : str
        Message parts joined with a space before logging.
    """
    cls.handle(*messages, level=cls.Level.DEBUG, exc_type=None)

error(*messages, exc_type=AttributeError) classmethod

Log one or more messages at ERROR level.

Parameters:

Name Type Description Default
*messages str

Message parts joined with a space before logging.

()
exc_type type[BaseException]

Exception type to raise in VERBOSE/COMPACT mode.

AttributeError
Source code in src/easydiffraction/utils/logging.py
596
597
598
599
600
601
602
603
604
605
606
607
608
@classmethod
def error(cls, *messages: str, exc_type: type[BaseException] = AttributeError) -> None:
    """
    Log one or more messages at ERROR level.

    Parameters
    ----------
    *messages : str
        Message parts joined with a space before logging.
    exc_type : type[BaseException], default=AttributeError
        Exception type to raise in VERBOSE/COMPACT mode.
    """
    cls.handle(*messages, level=cls.Level.ERROR, exc_type=exc_type)

handle(*messages, level=Level.ERROR, exc_type=AttributeError) classmethod

Route a log message (see class docs for policy).

Source code in src/easydiffraction/utils/logging.py
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
@classmethod
def handle(
    cls,
    *messages: str,
    level: Level = Level.ERROR,
    exc_type: type[BaseException] | None = AttributeError,
) -> None:
    """Route a log message (see class docs for policy)."""
    cls._lazy_config()
    message = ' '.join(messages)
    # Prioritize explicit UserWarning path so pytest captures
    # warnings
    if exc_type is UserWarning:
        if in_pytest():
            warnings.warn(message, UserWarning, stacklevel=2)
        else:
            cls._logger.warning(message)
        return
    # Special handling for Reaction.WARN (non-warning cases)
    if cls._reaction is cls.Reaction.WARN:
        # Log as error/critical (keep icon) but continue execution
        cls._logger.log(int(level), message)
        return
    if exc_type is not None:
        if cls._mode is cls.Mode.VERBOSE:
            raise exc_type(message)
        if cls._mode is cls.Mode.COMPACT:
            raise exc_type(message) from None
    cls._logger.log(int(level), message)

info(*messages) classmethod

Log one or more messages at INFO level.

Parameters:

Name Type Description Default
*messages str

Message parts joined with a space before logging.

()
Source code in src/easydiffraction/utils/logging.py
570
571
572
573
574
575
576
577
578
579
580
@classmethod
def info(cls, *messages: str) -> None:
    """
    Log one or more messages at INFO level.

    Parameters
    ----------
    *messages : str
        Message parts joined with a space before logging.
    """
    cls.handle(*messages, level=cls.Level.INFO, exc_type=None)

mode() classmethod

Return the currently active output mode.

Returns:

Type Description
Mode

The current Logger.Mode value.

Source code in src/easydiffraction/utils/logging.py
506
507
508
509
510
511
512
513
514
515
516
@classmethod
def mode(cls) -> Mode:
    """
    Return the currently active output mode.

    Returns
    -------
    Mode
        The current Logger.Mode value.
    """
    return cls._mode

set_level(level) classmethod

Set the minimum log level and reconfigure the logger.

Parameters:

Name Type Description Default
level Level

The desired minimum log level.

required
Source code in src/easydiffraction/utils/logging.py
494
495
496
497
498
499
500
501
502
503
504
@classmethod
def set_level(cls, level: Level) -> None:
    """
    Set the minimum log level and reconfigure the logger.

    Parameters
    ----------
    level : Level
        The desired minimum log level.
    """
    cls.configure(mode=cls._mode, level=level)

set_mode(mode) classmethod

Set the output mode and reconfigure the logger.

Parameters:

Name Type Description Default
mode Mode

The desired output mode (VERBOSE or COMPACT).

required
Source code in src/easydiffraction/utils/logging.py
482
483
484
485
486
487
488
489
490
491
492
@classmethod
def set_mode(cls, mode: Mode) -> None:
    """
    Set the output mode and reconfigure the logger.

    Parameters
    ----------
    mode : Mode
        The desired output mode (VERBOSE or COMPACT).
    """
    cls.configure(mode=mode, level=cls.Level(cls._logger.level))

warning(*messages, exc_type=None) classmethod

Log one or more messages at WARNING level.

Parameters:

Name Type Description Default
*messages str

Message parts joined with a space before logging.

()
exc_type type[BaseException] | None

If provided, raise this exception type instead of logging.

None
Source code in src/easydiffraction/utils/logging.py
582
583
584
585
586
587
588
589
590
591
592
593
594
@classmethod
def warning(cls, *messages: str, exc_type: type[BaseException] | None = None) -> None:
    """
    Log one or more messages at WARNING level.

    Parameters
    ----------
    *messages : str
        Message parts joined with a space before logging.
    exc_type : type[BaseException] | None, default=None
        If provided, raise this exception type instead of logging.
    """
    cls.handle(*messages, level=cls.Level.WARNING, exc_type=exc_type)

LoggerConfig

Facade for logger configuration, delegates to helpers.

Source code in src/easydiffraction/utils/logging.py
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
class LoggerConfig:
    """Facade for logger configuration, delegates to helpers."""

    @staticmethod
    def setup_handlers(
        logger: logging.Logger,
        *,
        level: int,
        rich_tracebacks: bool,
        mode: str = 'compact',
    ) -> None:
        """
        Install Rich handler and optional Jupyter traceback support.

        Parameters
        ----------
        logger : logging.Logger
            Logger instance to attach handlers to.
        level : int
            Minimum log level to emit.
        rich_tracebacks : bool
            Whether to enable Rich tracebacks.
        mode : str, default='compact'
            Output mode name ("compact" or "verbose").
        """
        logger.handlers.clear()
        logger.propagate = False
        logger.setLevel(level)

        if in_jupyter():
            traceback.install(
                show_locals=False,
                suppress=['easydiffraction'],
            )

        console = ConsoleManager.get()
        handler = IconifiedRichHandler(
            rich_tracebacks=rich_tracebacks,
            markup=True,
            show_time=False,
            show_path=False,
            tracebacks_show_locals=False,
            tracebacks_suppress=['easydiffraction'],
            tracebacks_max_frames=10,
            console=console,
            mode=mode,
        )
        handler.setFormatter(logging.Formatter('%(message)s'))
        logger.addHandler(handler)

    @staticmethod
    def configure(
        logger: logging.Logger,
        *,
        mode: 'Logger.Mode',
        level: 'Logger.Level',
        rich_tracebacks: bool,
    ) -> None:
        """
        Configure the logger with RichHandler and exception hooks.

        Parameters
        ----------
        logger : logging.Logger
            Logger instance to configure.
        mode : 'Logger.Mode'
            Output mode (compact or verbose).
        level : 'Logger.Level'
            Minimum log level to emit.
        rich_tracebacks : bool
            Whether to enable Rich tracebacks.
        """
        LoggerConfig.setup_handlers(
            logger,
            level=int(level),
            rich_tracebacks=rich_tracebacks,
            mode=mode.value,
        )

        if rich_tracebacks and mode == Logger.Mode.VERBOSE:
            ExceptionHookManager.install_verbose_hook(logger)
        elif mode == Logger.Mode.COMPACT:
            ExceptionHookManager.install_compact_hook(logger)
            ExceptionHookManager.install_jupyter_traceback_suppressor(logger)
        else:
            ExceptionHookManager.restore_original_hook()

configure(logger, *, mode, level, rich_tracebacks) staticmethod

Configure the logger with RichHandler and exception hooks.

Parameters:

Name Type Description Default
logger Logger

Logger instance to configure.

required
mode 'Logger.Mode'

Output mode (compact or verbose).

required
level 'Logger.Level'

Minimum log level to emit.

required
rich_tracebacks bool

Whether to enable Rich tracebacks.

required
Source code in src/easydiffraction/utils/logging.py
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
@staticmethod
def configure(
    logger: logging.Logger,
    *,
    mode: 'Logger.Mode',
    level: 'Logger.Level',
    rich_tracebacks: bool,
) -> None:
    """
    Configure the logger with RichHandler and exception hooks.

    Parameters
    ----------
    logger : logging.Logger
        Logger instance to configure.
    mode : 'Logger.Mode'
        Output mode (compact or verbose).
    level : 'Logger.Level'
        Minimum log level to emit.
    rich_tracebacks : bool
        Whether to enable Rich tracebacks.
    """
    LoggerConfig.setup_handlers(
        logger,
        level=int(level),
        rich_tracebacks=rich_tracebacks,
        mode=mode.value,
    )

    if rich_tracebacks and mode == Logger.Mode.VERBOSE:
        ExceptionHookManager.install_verbose_hook(logger)
    elif mode == Logger.Mode.COMPACT:
        ExceptionHookManager.install_compact_hook(logger)
        ExceptionHookManager.install_jupyter_traceback_suppressor(logger)
    else:
        ExceptionHookManager.restore_original_hook()

setup_handlers(logger, *, level, rich_tracebacks, mode='compact') staticmethod

Install Rich handler and optional Jupyter traceback support.

Parameters:

Name Type Description Default
logger Logger

Logger instance to attach handlers to.

required
level int

Minimum log level to emit.

required
rich_tracebacks bool

Whether to enable Rich tracebacks.

required
mode str

Output mode name ("compact" or "verbose").

'compact'
Source code in src/easydiffraction/utils/logging.py
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
@staticmethod
def setup_handlers(
    logger: logging.Logger,
    *,
    level: int,
    rich_tracebacks: bool,
    mode: str = 'compact',
) -> None:
    """
    Install Rich handler and optional Jupyter traceback support.

    Parameters
    ----------
    logger : logging.Logger
        Logger instance to attach handlers to.
    level : int
        Minimum log level to emit.
    rich_tracebacks : bool
        Whether to enable Rich tracebacks.
    mode : str, default='compact'
        Output mode name ("compact" or "verbose").
    """
    logger.handlers.clear()
    logger.propagate = False
    logger.setLevel(level)

    if in_jupyter():
        traceback.install(
            show_locals=False,
            suppress=['easydiffraction'],
        )

    console = ConsoleManager.get()
    handler = IconifiedRichHandler(
        rich_tracebacks=rich_tracebacks,
        markup=True,
        show_time=False,
        show_path=False,
        tracebacks_show_locals=False,
        tracebacks_suppress=['easydiffraction'],
        tracebacks_max_frames=10,
        console=console,
        mode=mode,
    )
    handler.setFormatter(logging.Formatter('%(message)s'))
    logger.addHandler(handler)

utils

download_all_tutorials(destination='tutorials', overwrite=False)

Download all available tutorial notebooks.

Example: paths = download_all_tutorials(destination="tutorials")

Parameters:

Name Type Description Default
destination str

Directory to save the files into (created if missing).

'tutorials'
overwrite bool

Whether to overwrite files if they already exist.

False

Returns:

Type Description
list[str]

List of full paths to the downloaded files.

Source code in src/easydiffraction/utils/utils.py
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
def download_all_tutorials(
    destination: str = 'tutorials',
    overwrite: bool = False,
) -> list[str]:
    """
    Download all available tutorial notebooks.

    Example: paths = download_all_tutorials(destination="tutorials")

    Parameters
    ----------
    destination : str, default='tutorials'
        Directory to save the files into (created if missing).
    overwrite : bool, default=False
        Whether to overwrite files if they already exist.

    Returns
    -------
    list[str]
        List of full paths to the downloaded files.
    """
    index = _fetch_tutorials_index()
    if not index:
        console.print('❌ No tutorials available to download.')
        return []

    version = _get_version_for_url()
    console.print(f'📥 Downloading all tutorials for easydiffraction v{version}...')

    downloaded_paths = []
    for tutorial_id in sorted(index.keys(), key=lambda x: int(x) if x.isdigit() else x):
        try:
            path = download_tutorial(
                id=tutorial_id,
                destination=destination,
                overwrite=overwrite,
            )
            downloaded_paths.append(path)
        except Exception as e:
            log.warning(f'Failed to download tutorial #{tutorial_id}: {e}')

    console.print(f'✅ Downloaded {len(downloaded_paths)} tutorials to "{destination}/"')
    return downloaded_paths

download_data(id, destination='data', overwrite=False)

Download a dataset by numeric ID using the remote diffraction index.

Example: path = download_data(id=12, destination="data")

Parameters:

Name Type Description Default
id int | str

Numeric dataset id (e.g. 12).

required
destination str

Directory to save the file into (created if missing).

'data'
overwrite bool

Whether to overwrite the file if it already exists.

False

Returns:

Type Description
str

Full path to the downloaded file as string.

Raises:

Type Description
KeyError

If the id is not found in the index.

Source code in src/easydiffraction/utils/utils.py
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
def download_data(
    id: int | str,
    destination: str = 'data',
    overwrite: bool = False,
) -> str:
    """
    Download a dataset by numeric ID using the remote diffraction index.

    Example: path = download_data(id=12, destination="data")

    Parameters
    ----------
    id : int | str
        Numeric dataset id (e.g. 12).
    destination : str, default='data'
        Directory to save the file into (created if missing).
    overwrite : bool, default=False
        Whether to overwrite the file if it already exists.

    Returns
    -------
    str
        Full path to the downloaded file as string.

    Raises
    ------
    KeyError
        If the id is not found in the index.
    """
    index = _fetch_data_index()
    key = str(id)

    if key not in index:
        # Provide a helpful message (and keep KeyError semantics)
        available = ', '.join(
            sorted(index.keys(), key=lambda s: int(s) if s.isdigit() else s)[:20]
        )
        raise KeyError(f'Unknown dataset id={id}. Example available ids: {available} ...')

    record = index[key]
    url = record['url']
    _validate_url(url)

    known_hash = _normalize_known_hash(record.get('hash'))
    fname = _filename_for_id_from_url(id, url)

    dest_path = pathlib.Path(destination)
    dest_path.mkdir(parents=True, exist_ok=True)
    file_path = dest_path / fname

    description = record.get('description', '')
    message = f'Data #{id}'
    if description:
        message += f': {description}'

    console.paragraph('Getting data...')
    console.print(f'{message}')

    if file_path.exists():
        if not overwrite:
            console.print(
                f"✅ Data #{id} already present at '{file_path}'. Keeping existing file."
            )
            return str(file_path)
        log.debug(f"Data #{id} already present at '{file_path}', but will be overwritten.")
        file_path.unlink()

    # Pooch downloads to destination with our controlled filename.
    pooch.retrieve(
        url=url,
        known_hash=known_hash,
        fname=fname,
        path=str(dest_path),
    )

    console.print(f"✅ Data #{id} downloaded to '{file_path}'")
    return str(file_path)

download_tutorial(id, destination='tutorials', overwrite=False)

Download a tutorial notebook by numeric ID.

Example: path = download_tutorial(id=1, destination="tutorials")

Parameters:

Name Type Description Default
id int | str

Numeric tutorial id (e.g. 1).

required
destination str

Directory to save the file into (created if missing).

'tutorials'
overwrite bool

Whether to overwrite the file if it already exists.

False

Returns:

Type Description
str

Full path to the downloaded file as string.

Raises:

Type Description
KeyError

If the id is not found in the index.

Source code in src/easydiffraction/utils/utils.py
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
def download_tutorial(
    id: int | str,
    destination: str = 'tutorials',
    overwrite: bool = False,
) -> str:
    """
    Download a tutorial notebook by numeric ID.

    Example: path = download_tutorial(id=1, destination="tutorials")

    Parameters
    ----------
    id : int | str
        Numeric tutorial id (e.g. 1).
    destination : str, default='tutorials'
        Directory to save the file into (created if missing).
    overwrite : bool, default=False
        Whether to overwrite the file if it already exists.

    Returns
    -------
    str
        Full path to the downloaded file as string.

    Raises
    ------
    KeyError
        If the id is not found in the index.
    """
    index = _fetch_tutorials_index()
    key = str(id)

    if key not in index:
        available = ', '.join(
            sorted(index.keys(), key=lambda s: int(s) if s.isdigit() else s)[:20]
        )
        raise KeyError(f'Unknown tutorial id={id}. Available ids: {available}')

    record = index[key]
    url_template = record['url']
    url = _resolve_tutorial_url(url_template)
    _validate_url(url)

    fname = f'ed-{id}.ipynb'

    dest_path = pathlib.Path(destination)
    dest_path.mkdir(parents=True, exist_ok=True)
    file_path = dest_path / fname

    title = record.get('title', '')
    message = f'Tutorial #{id}'
    if title:
        message += f': {title}'

    console.paragraph('Getting tutorial...')
    console.print(f'{message}')

    if file_path.exists():
        if not overwrite:
            console.print(
                f"✅ Tutorial #{id} already present at '{file_path}'. Keeping existing file."
            )
            return str(file_path)
        log.debug(f"Tutorial #{id} already present at '{file_path}', but will be overwritten.")
        file_path.unlink()

    # Download the notebook
    with _safe_urlopen(url) as resp:
        file_path.write_bytes(resp.read())

    console.print(f"✅ Tutorial #{id} downloaded to '{file_path}'")
    return str(file_path)

list_tutorials()

Display a table of available tutorial notebooks.

Shows tutorial ID, filename and title for all tutorials available for the current version of easydiffraction.

Source code in src/easydiffraction/utils/utils.py
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
def list_tutorials() -> None:
    """
    Display a table of available tutorial notebooks.

    Shows tutorial ID, filename and title for all tutorials available
    for the current version of easydiffraction.
    """
    index = _fetch_tutorials_index()
    if not index:
        console.print('❌ No tutorials available.')
        return

    version = _get_version_for_url()
    console.paragraph(f'Tutorials available for easydiffraction v{version}:')

    columns_headers = ['id', 'file', 'title']
    columns_alignment = ['right', 'left', 'left']
    columns_data = []

    for tutorial_id in index:
        record = index[tutorial_id]
        filename = f'ed-{tutorial_id}.ipynb'
        title = record.get('title', '')
        columns_data.append([tutorial_id, filename, title])

    render_table(
        columns_headers=columns_headers,
        columns_data=columns_data,
        columns_alignment=columns_alignment,
    )

package_version(package_name)

Get the installed version string of the specified package.

Parameters:

Name Type Description Default
package_name str

The name of the package to query.

required

Returns:

Type Description
str | None

The raw version string (may include local part, e.g., '1.2.3+abc123'), or None if the package is not installed.

Source code in src/easydiffraction/utils/utils.py
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
def package_version(package_name: str) -> str | None:
    """
    Get the installed version string of the specified package.

    Parameters
    ----------
    package_name : str
        The name of the package to query.

    Returns
    -------
    str | None
        The raw version string (may include local part, e.g.,
        '1.2.3+abc123'), or None if the package is not installed.
    """
    try:
        return version(package_name)
    except PackageNotFoundError:
        return None

render_cif(cif_text)

Display CIF text as a formatted table in Jupyter or terminal.

Parameters:

Name Type Description Default
cif_text str

The CIF text to display.

required
Source code in src/easydiffraction/utils/utils.py
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
def render_cif(cif_text: str) -> None:
    """
    Display CIF text as a formatted table in Jupyter or terminal.

    Parameters
    ----------
    cif_text : str
        The CIF text to display.
    """
    # Split into lines
    lines: List[str] = [line for line in cif_text.splitlines()]

    # Convert each line into a single-column format for table rendering
    columns: List[List[str]] = [[line] for line in lines]

    # Render the table using left alignment and no headers
    render_table(
        columns_headers=['CIF'],
        columns_alignment=['left'],
        columns_data=columns,
    )

render_table(columns_data, columns_alignment, columns_headers=None, display_handle=None)

Render tabular data to the active display backend.

Parameters:

Name Type Description Default
columns_data object

A list of rows, where each row is a list of cell values.

required
columns_alignment object

A list of alignment strings (e.g. 'left', 'right', 'center') matching the number of columns.

required
columns_headers object

Optional list of column header strings.

None
display_handle object

Optional display handle for in-place updates (e.g. in Jupyter or a terminal Live context).

None
Source code in src/easydiffraction/utils/utils.py
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
def render_table(
    columns_data: object,
    columns_alignment: object,
    columns_headers: object = None,
    display_handle: object = None,
) -> None:
    """
    Render tabular data to the active display backend.

    Parameters
    ----------
    columns_data : object
        A list of rows, where each row is a list of cell values.
    columns_alignment : object
        A list of alignment strings (e.g. ``'left'``, ``'right'``,
        ``'center'``) matching the number of columns.
    columns_headers : object, default=None
        Optional list of column header strings.
    display_handle : object, default=None
        Optional display handle for in-place updates (e.g. in Jupyter or
        a terminal Live context).
    """
    headers = [
        (col, align) for col, align in zip(columns_headers, columns_alignment, strict=False)
    ]
    df = pd.DataFrame(columns_data, columns=pd.MultiIndex.from_tuples(headers))

    tabler = TableRenderer.get()
    tabler.render(df, display_handle=display_handle)

show_version()

Print the installed version of the easydiffraction package.

Source code in src/easydiffraction/utils/utils.py
495
496
497
498
def show_version() -> None:
    """Print the installed version of the easydiffraction package."""
    current_ed_version = package_version('easydiffraction')
    console.print(f'Current easydiffraction v{current_ed_version}')

sin_theta_over_lambda_to_d_spacing(sin_theta_over_lambda)

Convert sin(theta)/lambda to d-spacing.

Parameters:

Name Type Description Default
sin_theta_over_lambda object

sin(theta)/lambda in 1/Å (float or np.ndarray).

required

Returns:

Type Description
object

d-spacing in Å (float or np.ndarray).

Source code in src/easydiffraction/utils/utils.py
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
def sin_theta_over_lambda_to_d_spacing(sin_theta_over_lambda: object) -> object:
    """
    Convert sin(theta)/lambda to d-spacing.

    Parameters
    ----------
    sin_theta_over_lambda : object
        sin(theta)/lambda in 1/Å (float or np.ndarray).

    Returns
    -------
    object
        d-spacing in Å (float or np.ndarray).
    """
    # Avoid division by zero
    with np.errstate(divide='ignore', invalid='ignore'):
        d = 1 / (2 * sin_theta_over_lambda)
        # Set non-positive inputs to NaN
        d = np.where(sin_theta_over_lambda > 0, d, np.nan)
    return d

str_to_ufloat(s, default=None)

Parse a CIF-style numeric string into a ufloat.

Examples of supported input: - "3.566" → ufloat(3.566, nan) - "3.566(2)" → ufloat(3.566, 0.002) - None → ufloat(default, nan)

Behavior: - If the input string contains a value with parentheses (e.g. "3.566(2)"), the number in parentheses is interpreted as an estimated standard deviation (esd) in the last digit(s). - If the input string has no parentheses, an uncertainty of NaN is assigned to indicate "no esd provided". - If parsing fails, the function falls back to the given default value with uncertainty NaN.

Parameters:

Name Type Description Default
s Optional[str]

Numeric string in CIF format (e.g. "3.566", "3.566(2)") or None.

required
default Optional[float]

Default value to use if s is None or parsing fails.

None

Returns:

Type Description
UFloat

An uncertainties.UFloat object with the parsed value and uncertainty. The uncertainty will be NaN if not specified or parsing failed.

Source code in src/easydiffraction/utils/utils.py
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
def str_to_ufloat(s: Optional[str], default: Optional[float] = None) -> UFloat:
    """
    Parse a CIF-style numeric string into a ufloat.

    Examples of supported input: - "3.566" → ufloat(3.566, nan) -
    "3.566(2)" → ufloat(3.566, 0.002) - None → ufloat(default, nan)

    Behavior: - If the input string contains a value with parentheses
    (e.g. "3.566(2)"), the number in parentheses is interpreted as an
    estimated standard deviation (esd) in the last digit(s). - If the
    input string has no parentheses, an uncertainty of NaN is assigned
    to indicate "no esd provided". - If parsing fails, the function
    falls back to the given ``default`` value with uncertainty NaN.

    Parameters
    ----------
    s : Optional[str]
        Numeric string in CIF format (e.g. "3.566", "3.566(2)") or None.
    default : Optional[float], default=None
        Default value to use if ``s`` is None or parsing fails.

    Returns
    -------
    UFloat
        An ``uncertainties.UFloat`` object with the parsed value and
        uncertainty. The uncertainty will be NaN if not specified or
        parsing failed.
    """
    if s is None:
        return ufloat(default, np.nan)

    if '(' not in s and ')' not in s:
        s = f'{s}(nan)'
    try:
        return ufloat_fromstr(s)
    except Exception:
        return ufloat(default, np.nan)

stripped_package_version(package_name)

Get installed package version, stripped of local version parts.

Returns only the public version segment (e.g., '1.2.3' or '1.2.3.post4'), omitting any local segment (e.g., '+d136').

Parameters:

Name Type Description Default
package_name str

The name of the package to query.

required

Returns:

Type Description
str | None

The public version string, or None if the package is not installed.

Source code in src/easydiffraction/utils/utils.py
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
def stripped_package_version(package_name: str) -> str | None:
    """
    Get installed package version, stripped of local version parts.

    Returns only the public version segment (e.g., '1.2.3' or
    '1.2.3.post4'), omitting any local segment (e.g., '+d136').

    Parameters
    ----------
    package_name : str
        The name of the package to query.

    Returns
    -------
    str | None
        The public version string, or None if the package is not
        installed.
    """
    v_str = package_version(package_name)
    if v_str is None:
        return None
    try:
        v = Version(v_str)
        return str(v.public)
    except Exception:
        return v_str

tof_to_d(tof, offset, linear, quad, quad_eps=1e-20)

Convert time-of-flight to d-spacing using quadratic calibration.

Model: TOF = offset + linear * d + quad * d²

The function: - Uses a linear fallback when the quadratic term is effectively zero. - Solves the quadratic for d and selects the smallest positive, finite root. - Returns NaN where no valid solution exists. - Expects tof as a NumPy array; output matches its shape.

Parameters:

Name Type Description Default
tof ndarray

Time-of-flight values (µs). Must be a NumPy array.

required
offset float

Calibration offset (µs).

required
linear float

Linear calibration coefficient (µs/Å).

required
quad float

Quadratic calibration coefficient (µs/Ų).

required
quad_eps float

Threshold to treat quad as zero.

1e-20

Returns:

Type Description
ndarray

d-spacing values (Å), NaN where invalid.

Raises:

Type Description
TypeError

If tof is not a NumPy array or coefficients are not real numbers.

Source code in src/easydiffraction/utils/utils.py
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
def tof_to_d(
    tof: np.ndarray,
    offset: float,
    linear: float,
    quad: float,
    quad_eps: float = 1e-20,
) -> np.ndarray:
    """
    Convert time-of-flight to d-spacing using quadratic calibration.

    Model: TOF = offset + linear * d + quad * d²

    The function: - Uses a linear fallback when the quadratic term is
    effectively zero. - Solves the quadratic for d and selects the
    smallest positive, finite root. - Returns NaN where no valid
    solution exists. - Expects ``tof`` as a NumPy array; output matches
    its shape.

    Parameters
    ----------
    tof : np.ndarray
        Time-of-flight values (µs). Must be a NumPy array.
    offset : float
        Calibration offset (µs).
    linear : float
        Linear calibration coefficient (µs/Å).
    quad : float
        Quadratic calibration coefficient (µs/Ų).
    quad_eps : float, default=1e-20
        Threshold to treat ``quad`` as zero.

    Returns
    -------
    np.ndarray
        d-spacing values (Å), NaN where invalid.

    Raises
    ------
    TypeError
        If ``tof`` is not a NumPy array or coefficients are not real
        numbers.
    """
    # Type checks
    if not isinstance(tof, np.ndarray):
        raise TypeError(f"'tof' must be a NumPy array, got {type(tof).__name__}")
    for name, val in (
        ('offset', offset),
        ('linear', linear),
        ('quad', quad),
        ('quad_eps', quad_eps),
    ):
        if not isinstance(val, (int, float, np.integer, np.floating)):
            raise TypeError(f"'{name}' must be a real number, got {type(val).__name__}")

    # Output initialized to NaN
    d_out = np.full_like(tof, np.nan, dtype=float)

    # 1) If quadratic term is effectively zero, use linear formula:
    #    TOF ≈ offset + linear * d =>
    #    d ≈ (tof - offset) / linear
    if abs(quad) < quad_eps:
        if linear != 0.0:
            d = (tof - offset) / linear
            # Keep only positive, finite results
            valid = np.isfinite(d) & (d > 0)
            d_out[valid] = d[valid]
        # If B == 0 too, there's no solution; leave NaN
        return d_out

    # 2) If quadratic term is significant, solve the quadratic equation:
    #    TOF = offset + linear * d + quad * d² =>
    #    quad * d² + linear * d + (offset - tof) = 0
    discr = linear**2 - 4 * quad * (offset - tof)
    has_real_roots = discr >= 0

    if np.any(has_real_roots):
        sqrt_discr = np.sqrt(discr[has_real_roots])

        root_1 = (-linear + sqrt_discr) / (2 * quad)
        root_2 = (-linear - sqrt_discr) / (2 * quad)

        # Pick smallest positive, finite root per element
        # Stack roots for comparison
        roots = np.stack((root_1, root_2), axis=0)
        # Replace non-finite or negative roots with NaN
        roots = np.where(np.isfinite(roots) & (roots > 0), roots, np.nan)
        # Choose the smallest positive root or NaN if none are valid
        chosen = np.nanmin(roots, axis=0)

        d_out[has_real_roots] = chosen

    return d_out

twotheta_to_d(twotheta, wavelength)

Convert 2-theta to d-spacing using Bragg's law.

Parameters:

Name Type Description Default
twotheta object

2-theta angle in degrees (float or np.ndarray).

required
wavelength float

Wavelength in Å.

required

Returns:

Type Description
object

d-spacing in Å (float or np.ndarray).

Source code in src/easydiffraction/utils/utils.py
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
def twotheta_to_d(twotheta: object, wavelength: float) -> object:
    """
    Convert 2-theta to d-spacing using Bragg's law.

    Parameters
    ----------
    twotheta : object
        2-theta angle in degrees (float or np.ndarray).
    wavelength : float
        Wavelength in Å.

    Returns
    -------
    object
        d-spacing in Å (float or np.ndarray).
    """
    # Convert twotheta from degrees to radians
    theta_rad = np.radians(twotheta / 2)

    # Calculate d-spacing using Bragg's law
    d = wavelength / (2 * np.sin(theta_rad))

    return d