Skip to content

utils

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
117
118
119
120
121
122
123
124
125
126
127
128
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
131
132
133
134
135
136
137
138
139
140
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()

Determines if the current environment is Google Colab.

Returns:

Name Type Description
bool bool

True if running in Google Colab, False otherwise.

Source code in src/easydiffraction/utils/environment.py
28
29
30
31
32
33
34
35
36
37
def in_colab() -> bool:
    """Determines if 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:

Name Type Description
bool bool

True if env var GITHUB_ACTIONS is set, False

bool

otherwise.

Source code in src/easydiffraction/utils/environment.py
78
79
80
81
82
83
84
85
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:

Name Type Description
bool bool

True if inside a Jupyter Notebook, False otherwise.

Source code in src/easydiffraction/utils/environment.py
40
41
42
43
44
45
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
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()

Determines if the current environment is PyCharm.

Returns:

Name Type Description
bool bool

True if running inside PyCharm, False otherwise.

Source code in src/easydiffraction/utils/environment.py
19
20
21
22
23
24
25
def in_pycharm() -> bool:
    """Determines if the current environment is PyCharm.

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

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
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
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
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
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:
            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
110
111
112
113
114
115
116
117
118
@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 that prints objects to the shared console with left padding.

Source code in src/easydiffraction/utils/logging.py
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
class ConsolePrinter:
    """Printer utility that prints objects to the shared console with
    left padding.
    """

    _console = ConsoleManager.get()

    @classmethod
    def print(cls, *objects, **kwargs):
        """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:
        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:
        """Formats a section header with bold green text."""
        full_title = f'{title.upper()}'
        line = '━' * len(full_title)
        formatted = f'[bold green]{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:
        """Formats a chapter header with bold magenta text, uppercase,
        and padding.
        """
        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

Formats a chapter header with bold magenta text, uppercase, and padding.

Source code in src/easydiffraction/utils/logging.py
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
@classmethod
def chapter(cls, title: str) -> None:
    """Formats a chapter header with bold magenta text, uppercase,
    and padding.
    """
    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)

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
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
@classmethod
def print(cls, *objects, **kwargs):
    """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

Formats a section header with bold green text.

Source code in src/easydiffraction/utils/logging.py
553
554
555
556
557
558
559
560
561
@classmethod
def section(cls, title: str) -> None:
    """Formats a section header with bold green text."""
    full_title = f'{title.upper()}'
    line = '━' * len(full_title)
    formatted = f'[bold green]{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
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
239
240
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
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.

        Args:
            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:
            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.

        Args:
            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:
            logger.error(str(exc))
            raise SystemExit(1)

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

    @staticmethod
    def restore_original_hook():
        """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):
        """Build a Jupyter custom exception callback that logs only the
        message.

        Args:
            logger: Logger used to emit error messages.

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

        def suppress_jupyter_traceback(*args, **kwargs):
            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 custom exception handler that
        suppresses tracebacks.

        Args:
            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
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
@staticmethod
def install_compact_hook(logger: logging.Logger) -> None:
    """Install a compact exception hook that logs message-only.

    Args:
        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:
        logger.error(str(exc))
        raise SystemExit(1)

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

install_jupyter_traceback_suppressor(logger) staticmethod

Install a Jupyter/IPython custom exception handler that suppresses tracebacks.

Parameters:

Name Type Description Default
logger Logger

Logger used to emit error messages.

required
Source code in src/easydiffraction/utils/logging.py
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
@staticmethod
def install_jupyter_traceback_suppressor(logger: logging.Logger) -> None:
    """Install a Jupyter/IPython custom exception handler that
    suppresses tracebacks.

    Args:
        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
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
@staticmethod
def install_verbose_hook(logger: logging.Logger) -> None:
    """Install a verbose exception hook that prints rich tracebacks.

    Args:
        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:
        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
254
255
256
257
258
@staticmethod
def restore_original_hook():
    """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 that uses icons for log levels in compact mode, Rich default in verbose mode.

Source code in src/easydiffraction/utils/logging.py
45
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
class IconifiedRichHandler(RichHandler):
    """RichHandler that uses icons for log levels in compact mode, Rich
    default in verbose mode.
    """

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

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

    def get_level_text(self, record: logging.LogRecord) -> Text:
        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:
        # In compact mode, let the icon come from get_level_text and
        # keep the message body unadorned. In verbose mode, defer to
        # RichHandler.
        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
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
360
361
362
363
364
365
366
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
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):
            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):
            return cls.WARNING

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

        RAISE = auto()
        WARN = auto()

        @classmethod
        def default(cls):
            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 traceback suppressor in Jupyter, safely and lint-
        clean.
        """
        ExceptionHookManager.install_jupyter_traceback_suppressor(cls._logger)

    # ===== Helpers =====
    @classmethod
    def set_mode(cls, mode: Mode) -> None:
        cls.configure(mode=mode, level=cls.Level(cls._logger.level))

    @classmethod
    def set_level(cls, level: Level) -> None:
        cls.configure(mode=cls._mode, level=level)

    @classmethod
    def mode(cls) -> Mode:
        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:
        cls.handle(*messages, level=cls.Level.DEBUG, exc_type=None)

    @classmethod
    def info(cls, *messages: str) -> None:
        cls.handle(*messages, level=cls.Level.INFO, exc_type=None)

    @classmethod
    def warning(cls, *messages: str, exc_type: type[BaseException] | None = None) -> None:
        cls.handle(*messages, level=cls.Level.WARNING, exc_type=exc_type)

    @classmethod
    def error(cls, *messages: str, exc_type: type[BaseException] = AttributeError) -> None:
        cls.handle(*messages, level=cls.Level.ERROR, exc_type=exc_type)

    @classmethod
    def critical(cls, *messages: str, exc_type: type[BaseException] = RuntimeError) -> None:
        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
332
333
334
335
336
337
338
339
340
341
342
343
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):
        return cls.WARNING

Mode

Bases: Enum

Output modes (see :class:Logger).

Source code in src/easydiffraction/utils/logging.py
322
323
324
325
326
327
328
329
330
class Mode(Enum):
    """Output modes (see :class:`Logger`)."""

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

    @classmethod
    def default(cls):
        return cls.COMPACT

Reaction

Bases: Enum

Reaction to errors (see :class:Logger).

Source code in src/easydiffraction/utils/logging.py
345
346
347
348
349
350
351
352
353
class Reaction(Enum):
    """Reaction to errors (see :class:`Logger`)."""

    RAISE = auto()
    WARN = auto()

    @classmethod
    def default(cls):
        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
363
364
365
366
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
@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

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
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
@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)

LoggerConfig

Facade for logger configuration, delegates to helpers.

Source code in src/easydiffraction/utils/logging.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
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.

        Args:
            logger: Logger instance to attach handlers to.
            level: Minimum log level to emit.
            rich_tracebacks: Whether to enable Rich tracebacks.
            mode: 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.

        Args:
            logger: Logger instance to configure.
            mode: Output mode (compact or verbose).
            level: Minimum log level to emit.
            rich_tracebacks: 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
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
@staticmethod
def configure(
    logger: logging.Logger,
    *,
    mode: 'Logger.Mode',
    level: 'Logger.Level',
    rich_tracebacks: bool,
) -> None:
    """Configure the logger with RichHandler and exception hooks.

    Args:
        logger: Logger instance to configure.
        mode: Output mode (compact or verbose).
        level: Minimum log level to emit.
        rich_tracebacks: 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
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
@staticmethod
def setup_handlers(
    logger: logging.Logger,
    *,
    level: int,
    rich_tracebacks: bool,
    mode: str = 'compact',
) -> None:
    """Install Rich handler and optional Jupyter traceback support.

    Args:
        logger: Logger instance to attach handlers to.
        level: Minimum log level to emit.
        rich_tracebacks: Whether to enable Rich tracebacks.
        mode: 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_from_repository(file_name, branch=None, destination='data', overwrite=False)

Download a data file from the EasyDiffraction repository on GitHub.

Parameters:

Name Type Description Default
file_name str

The file name to fetch (e.g., "NaCl.gr").

required
branch str | None

Branch to fetch from. If None, uses DATA_REPO_BRANCH.

None
destination str

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

'data'
overwrite bool

Whether to overwrite the file if it already exists. Defaults to False.

False
Source code in src/easydiffraction/utils/utils.py
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
def download_from_repository(
    file_name: str,
    branch: str | None = None,
    destination: str = 'data',
    overwrite: bool = False,
) -> None:
    """Download a data file from the EasyDiffraction repository on
    GitHub.

    Args:
        file_name: The file name to fetch (e.g., "NaCl.gr").
        branch: Branch to fetch from. If None, uses DATA_REPO_BRANCH.
        destination: Directory to save the file into (created if
            missing).
        overwrite: Whether to overwrite the file if it already exists.
            Defaults to False.
    """
    base = 'https://raw.githubusercontent.com'
    org = 'easyscience'
    repo = 'diffraction-lib'
    branch = branch or DATA_REPO_BRANCH  # Use the global branch variable if not provided
    path_in_repo = 'tutorials/data'
    url = f'{base}/{org}/{repo}/refs/heads/{branch}/{path_in_repo}/{file_name}'

    console.paragraph('Downloading...')
    console.print(f"File '{file_name}' from '{org}/{repo}'")

    dest_path = pathlib.Path(destination)
    file_path = dest_path / file_name
    if file_path.exists():
        if not overwrite:
            log.info(f"File '{file_path}' already exists and will not be overwritten.")
            return
        else:
            log.info(f"File '{file_path}' already exists and will be overwritten.")
            file_path.unlink()

    pooch.retrieve(
        url=url,
        known_hash=None,
        fname=file_name,
        path=destination,
    )

fetch_tutorial_list()

Return a list of available tutorial notebook filenames from the GitHub release that matches the installed version of easydiffraction, if possible. If the version-specific release is unavailable, falls back to the latest release.

This function does not fetch or display the tutorials themselves; it only lists the notebook filenames (e.g., '01-intro.ipynb', ...) found inside the 'tutorials.zip' asset of the appropriate GitHub release.

Returns:

Type Description
list[str]

list[str]: A sorted list of tutorial notebook filenames (without

list[str]

directories) extracted from the corresponding release's

list[str]

tutorials.zip, or an empty list if unavailable.

Source code in src/easydiffraction/utils/utils.py
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
def fetch_tutorial_list() -> list[str]:
    """Return a list of available tutorial notebook filenames from the
    GitHub release that matches the installed version of
    `easydiffraction`, if possible. If the version-specific release is
    unavailable, falls back to the latest release.

    This function does not fetch or display the tutorials themselves; it
    only lists the notebook filenames (e.g., '01-intro.ipynb', ...)
    found inside the 'tutorials.zip' asset of the appropriate GitHub
    release.

    Returns:
        list[str]: A sorted list of tutorial notebook filenames (without
        directories) extracted from the corresponding release's
        tutorials.zip, or an empty list if unavailable.
    """
    version_str = stripped_package_version('easydiffraction')
    tag = f'v{version_str}' if version_str is not None else None
    release_info = _get_release_info(tag)
    # Fallback to latest if tag fetch failed and tag was attempted
    if release_info is None and tag is not None:
        # Non-fatal during listing; warn and fall back silently
        log.warning('Falling back to latest release info...', exc_type=UserWarning)
        release_info = _get_release_info(None)
    if release_info is None:
        return []
    tutorial_asset = _get_tutorial_asset(release_info)
    if not tutorial_asset:
        log.warning("'tutorials.zip' not found in the release.", exc_type=UserWarning)
        return []
    download_url = tutorial_asset.get('browser_download_url')
    if not download_url:
        log.warning("'browser_download_url' not found for tutorials.zip.", exc_type=UserWarning)
        return []
    return _extract_notebooks_from_asset(download_url)

fetch_tutorials()

Download and extract the tutorials ZIP archive from the GitHub release matching the installed version of easydiffraction, if available. If the version-specific release is unavailable, falls back to the latest release.

The archive is extracted into the current working directory and then removed.

Source code in src/easydiffraction/utils/utils.py
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
def fetch_tutorials() -> None:
    """Download and extract the tutorials ZIP archive from the GitHub
    release matching the installed version of `easydiffraction`, if
    available. If the version-specific release is unavailable, falls
    back to the latest release.

    The archive is extracted into the current working directory and then
    removed.

    Args:
        None
    """
    version_str = stripped_package_version('easydiffraction')
    tag = f'v{version_str}' if version_str is not None else None
    release_info = _get_release_info(tag)
    # Fallback to latest if tag fetch failed and tag was attempted
    if release_info is None and tag is not None:
        log.error('Falling back to latest release info...')
        release_info = _get_release_info(None)
    if release_info is None:
        log.error('Unable to fetch release info.')
        return
    tutorial_asset = _get_tutorial_asset(release_info)
    if not tutorial_asset:
        log.error("'tutorials.zip' not found in the release.")
        return
    file_url = tutorial_asset.get('browser_download_url')
    if not file_url:
        log.error("'browser_download_url' not found for tutorials.zip.")
        return
    file_name = 'tutorials.zip'
    # Validate URL for security
    _validate_url(file_url)

    console.print('📥 Downloading tutorial notebooks...')
    with _safe_urlopen(file_url) as resp:
        pathlib.Path(file_name).write_bytes(resp.read())

    console.print('📦 Extracting tutorials to "tutorials/"...')
    with zipfile.ZipFile(file_name, 'r') as zip_ref:
        zip_ref.extractall()

    console.print('🧹 Cleaning up...')
    pathlib.Path(file_name).unlink()

    console.print('✅ Tutorials fetched successfully.')

get_value_from_xye_header(file_path, key)

Extracts a floating point value from the first line of the file, corresponding to the given key.

Parameters:

Name Type Description Default
file_path str

Path to the input file.

required
key str

The key to extract ('DIFC' or 'two_theta').

required

Returns:

Name Type Description
float

The extracted value.

Raises:

Type Description
ValueError

If the key is not found.

Source code in src/easydiffraction/utils/utils.py
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
def get_value_from_xye_header(file_path, key):
    """Extracts a floating point value from the first line of the file,
    corresponding to the given key.

    Parameters:
        file_path (str): Path to the input file.
        key (str): The key to extract ('DIFC' or 'two_theta').

    Returns:
        float: The extracted value.

    Raises:
        ValueError: If the key is not found.
    """
    pattern = rf'{key}\s*=\s*([-+]?[0-9]*\.?[0-9]+([eE][-+]?[0-9]+)?)'

    with pathlib.Path(file_path).open('r') as f:
        first_line = f.readline()

    match = re.search(pattern, first_line)
    if match:
        return float(match.group(1))
    else:
        raise ValueError(f'{key} not found in the header.')

list_tutorials()

List available tutorial notebooks.

Source code in src/easydiffraction/utils/utils.py
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
def list_tutorials():
    """List available tutorial notebooks.

    Args:
        None
    """
    tutorials = fetch_tutorial_list()
    columns_headers = ['name']
    columns_data = [[t] for t in tutorials]
    columns_alignment = ['left']

    released_ed_version = stripped_package_version('easydiffraction')

    console.print(f'Tutorials available for easydiffraction v{released_ed_version}:')
    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

str | None: The raw version string (may include local part,

str | None

e.g., '1.2.3+abc123'), or None if the package is not installed.

Source code in src/easydiffraction/utils/utils.py
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
def package_version(package_name: str) -> str | None:
    """Get the installed version string of the specified package.

    Args:
        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 the CIF text as a formatted table in Jupyter Notebook or terminal.

Parameters:

Name Type Description Default
cif_text

The CIF text to display.

required
Source code in src/easydiffraction/utils/utils.py
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
def render_cif(cif_text) -> None:
    """Display the CIF text as a formatted table in Jupyter Notebook or
    terminal.

    Args:
        cif_text: 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,
    )

show_version()

Print the installed version of the easydiffraction package.

Source code in src/easydiffraction/utils/utils.py
354
355
356
357
358
359
360
361
def show_version() -> None:
    """Print the installed version of the easydiffraction package.

    Args:
        None
    """
    current_ed_version = package_version('easydiffraction')
    console.print(f'Current easydiffraction v{current_ed_version}')

str_to_ufloat(s, default=None)

Parse a CIF-style numeric string into a ufloat with an optional uncertainty.

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 : str or None Numeric string in CIF format (e.g. "3.566", "3.566(2)") or None. default : float or None, optional Default value to use if s is None or parsing fails. Defaults to None.

Returns:

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
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
def str_to_ufloat(s: Optional[str], default: Optional[float] = None) -> UFloat:
    """Parse a CIF-style numeric string into a `ufloat` with an optional
    uncertainty.

    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 : str or None
        Numeric string in CIF format (e.g. "3.566", "3.566(2)") or None.
    default : float or None, optional
        Default value to use if `s` is None or parsing fails.
        Defaults to None.

    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 the installed version of the specified package, stripped of any local version part.

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

str | None: The public version string, or None if the package

str | None

is not installed.

Source code in src/easydiffraction/utils/utils.py
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
def stripped_package_version(package_name: str) -> str | None:
    """Get the installed version of the specified package, stripped of
    any local version part.

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

    Args:
        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 (TOF) to d-spacing using a 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. Defaults to 1e-20.

1e-20

Returns:

Type Description
ndarray

np.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
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
def tof_to_d(
    tof: np.ndarray,
    offset: float,
    linear: float,
    quad: float,
    quad_eps=1e-20,
) -> np.ndarray:
    """Convert time-of-flight (TOF) to d-spacing using a 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.

    Args:
        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, optional): Threshold to treat ``quad`` as zero.
            Defaults to 1e-20.

    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 float or ndarray

2-theta angle in degrees.

required
wavelength float

Wavelength in Å.

required

Returns:

Name Type Description
d float or ndarray

d-spacing in Å.

Source code in src/easydiffraction/utils/utils.py
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
def twotheta_to_d(twotheta, wavelength):
    """Convert 2-theta to d-spacing using Bragg's law.

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

    Returns:
        d (float or np.ndarray): d-spacing in Å.
    """
    # 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