Source code for paftacular.util

import re
from collections import Counter
from decimal import Decimal
from enum import Enum
from math import isfinite

from .errors import PaftacularError


[docs] def to_enum[E: Enum](cls: type[E], value: object, what: str) -> E: """Convert ``value`` to the enum ``cls``, raising :class:`PaftacularError` naming ``what``.""" try: return cls(value) except ValueError: choices = ", ".join(str(member.value) for member in cls) # type: ignore[attr-defined] raise PaftacularError(f"Invalid {what} {value!r}. Expected one of: {choices}") from None
[docs] def validate_number(value: float) -> None: """Require a number representable as a finite float, excluding booleans.""" if isinstance(value, bool) or not isinstance(value, int | float): raise PaftacularError("Expected a finite number") try: finite = isfinite(value) except OverflowError: finite = False if not finite: raise PaftacularError("Expected a finite number")
[docs] def format_number(value: float, *, minimum_places: int = 0) -> str: """Render a finite number as a decimal that round trips through float.""" validate_number(value) whole, _, fraction = format(Decimal(str(value)), "f").partition(".") fraction = fraction.rstrip("0").ljust(minimum_places, "0") return whole + ("." + fraction if fraction else "")
[docs] def validate_integer(value: int, name: str, *, minimum: int = 0) -> None: """Reject booleans, nonintegral types, and values below the minimum.""" if type(value) is not int or value < minimum: raise PaftacularError(f"{name} must be an integer >= {minimum}, got {value!r}")
[docs] def parse_formula(formula: str) -> Counter[str]: """ Parse a chemical formula into element counts, supporting isotopes. Args: formula: Chemical formula string (e.g., "H2O", "CO2", "[13C2]H6") Returns: Counter mapping element symbols to their counts - Regular elements: "H", "O", "Ca" - Isotopes: "13C", "2H" (without brackets) """ if not formula: raise PaftacularError("Empty formula string") element_counts = Counter() i = 0 while i < len(formula): # Skip whitespace if formula[i].isspace(): i += 1 continue # Handle isotope notation: [13C2] or [13C] if formula[i] == "[": close = formula.find("]", i) if close == -1: raise PaftacularError(f"Unclosed bracket at position {i}") # Extract content inside brackets: "13C2" or "13C" content = formula[i + 1 : close] # Parse: isotope_number + element + optional_count # Pattern: digits followed by element (capital + optional lowercase) + optional digits match = re.fullmatch(r"([0-9]+)([A-Z][a-z]?)([0-9]*)", content) if not match: raise PaftacularError(f"Invalid isotope format: [{content}]") isotope_num, element, count_str = match.groups() count = int(count_str) if count_str else 1 # Use isotope notation WITHOUT brackets as key: 13C element_key = f"{isotope_num}{element}" element_counts[element_key] += count i = close + 1 # Handle regular element: C2, Ca, H elif "A" <= formula[i] <= "Z": # Get element symbol (capital + optional lowercase) element = formula[i] i += 1 if i < len(formula) and "a" <= formula[i] <= "z": element += formula[i] i += 1 # Get optional count count_str = "" # ASCII digits only: str.isdigit() also accepts superscripts that int() rejects. while i < len(formula) and formula[i] in "0123456789": count_str += formula[i] i += 1 count = int(count_str) if count_str else 1 element_counts[element] += count else: raise PaftacularError(f"Unexpected character '{formula[i]}' at position {i}") if not element_counts: raise PaftacularError(f"No elements found in formula: '{formula}'") return element_counts