docs for muutils v0.6.17
View Source on GitHub

# muutils.misc

miscellaneous utilities

- `stable_hash` for hashing that is stable across runs
- `muutils.misc.sequence` for sequence manipulation, applying mappings, and string-like operations on lists
- `muutils.misc.string` for sanitizing things for filenames, adjusting docstrings, and converting dicts to filenames
- `muutils.misc.numerical` for turning numbers into nice strings and back
- `muutils.misc.freezing` for freezing things
- `muutils.misc.classes` for some weird class utilities

View Source
View on GitHub

     1"""miscellaneous utilities
     2
     3- `stable_hash` for hashing that is stable across runs
     4- `muutils.misc.sequence` for sequence manipulation, applying mappings, and string-like operations on lists
     5- `muutils.misc.string` for sanitizing things for filenames, adjusting docstrings, and converting dicts to filenames
     6- `muutils.misc.numerical` for turning numbers into nice strings and back
     7- `muutils.misc.freezing` for freezing things
     8- `muutils.misc.classes` for some weird class utilities
     9"""
    10
    11from muutils.misc.hashing import stable_hash
    12from muutils.misc.sequence import (
    13    WhenMissing,
    14    empty_sequence_if_attr_false,
    15    flatten,
    16    list_split,
    17    list_join,
    18    apply_mapping,
    19    apply_mapping_chain,
    20)
    21from muutils.misc.string import (
    22    sanitize_name,
    23    sanitize_fname,
    24    sanitize_identifier,
    25    dict_to_filename,
    26    dynamic_docstring,
    27)
    28from muutils.misc.numerical import (
    29    shorten_numerical_to_str,
    30    str_to_numeric,
    31    _SHORTEN_MAP,
    32)
    33from muutils.misc.freezing import (
    34    FrozenDict,
    35    FrozenList,
    36    freeze,
    37)
    38from muutils.misc.classes import (
    39    is_abstract,
    40    get_all_subclasses,
    41    isinstance_by_type_name,
    42    IsDataclass,
    43    get_hashable_eq_attrs,
    44    dataclass_set_equals,
    45)
    46
    47
    48__all__ = [
    49    # submodules
    50    "classes",
    51    "freezing",
    52    "hashing",
    53    "numerical",
    54    "sequence",
    55    "string",
    56    # imports
    57    "stable_hash",
    58    "WhenMissing",
    59    "empty_sequence_if_attr_false",
    60    "flatten",
    61    "list_split",
    62    "list_join",
    63    "apply_mapping",
    64    "apply_mapping_chain",
    65    "sanitize_name",
    66    "sanitize_fname",
    67    "sanitize_identifier",
    68    "dict_to_filename",
    69    "dynamic_docstring",
    70    "shorten_numerical_to_str",
    71    "str_to_numeric",
    72    "_SHORTEN_MAP",
    73    "FrozenDict",
    74    "FrozenList",
    75    "freeze",
    76    "is_abstract",
    77    "get_all_subclasses",
    78    "isinstance_by_type_name",
    79    "IsDataclass",
    80    "get_hashable_eq_attrs",
    81    "dataclass_set_equals",
    82]

def stable_hash(s: str | bytes) -> int:
View Source
View on GitHub

     8def stable_hash(s: str | bytes) -> int:
     9    """Returns a stable hash of the given string. not cryptographically secure, but stable between runs"""
    10    # init hash object and update with string
    11    s_bytes: bytes
    12    if isinstance(s, str):
    13        s_bytes = bytes(s, "UTF-8")
    14    else:
    15        s_bytes = s
    16    hash_obj: hashlib._Hash = hashlib.sha256(s_bytes)
    17    # get digest and convert to int
    18    return int.from_bytes(hash_obj.digest(), "big")

Returns a stable hash of the given string. not cryptographically secure, but stable between runs

WhenMissing = typing.Literal['except', 'skip', 'include']
def empty_sequence_if_attr_false(itr: Iterable[Any], attr_owner: Any, attr_name: str) -> Iterable[Any]:
View Source
View on GitHub

    22def empty_sequence_if_attr_false(
    23    itr: Iterable[Any],
    24    attr_owner: Any,
    25    attr_name: str,
    26) -> Iterable[Any]:
    27    """Returns `itr` if `attr_owner` has the attribute `attr_name` and it boolean casts to `True`. Returns an empty sequence otherwise.
    28
    29    Particularly useful for optionally inserting delimiters into a sequence depending on an `TokenizerElement` attribute.
    30
    31    # Parameters:
    32    - `itr: Iterable[Any]`
    33        The iterable to return if the attribute is `True`.
    34    - `attr_owner: Any`
    35        The object to check for the attribute.
    36    - `attr_name: str`
    37        The name of the attribute to check.
    38
    39    # Returns:
    40    - `itr: Iterable` if `attr_owner` has the attribute `attr_name` and it boolean casts to `True`, otherwise an empty sequence.
    41    - `()` an empty sequence if the attribute is `False` or not present.
    42    """
    43    return itr if bool(getattr(attr_owner, attr_name, False)) else ()

Returns `itr` if `attr_owner` has the attribute `attr_name` and it boolean casts to `True`. Returns an empty sequence otherwise.

Particularly useful for optionally inserting delimiters into a sequence depending on an `TokenizerElement` attribute.

# Parameters:

- `itr: Iterable[Any]` The iterable to return if the attribute is `True`.
- `attr_owner: Any` The object to check for the attribute.
- `attr_name: str` The name of the attribute to check.

# Returns:

- `itr: Iterable` if `attr_owner` has the attribute `attr_name` and it boolean casts to `True`, otherwise an empty sequence.
- `()` an empty sequence if the attribute is `False` or not present.

def flatten(it: Iterable[Any], levels_to_flatten: int | None = None) -> Generator:
View Source
View on GitHub

    46def flatten(it: Iterable[Any], levels_to_flatten: int | None = None) -> Generator:
    47    """
    48    Flattens an arbitrarily nested iterable.
    49    Flattens all iterable data types except for `str` and `bytes`.
    50
    51    # Returns
    52    Generator over the flattened sequence.
    53
    54    # Parameters
    55    - `it`: Any arbitrarily nested iterable.
    56    - `levels_to_flatten`: Number of levels to flatten by, starting at the outermost layer. If `None`, performs full flattening.
    57    """
    58    for x in it:
    59        # TODO: swap type check with more general check for __iter__() or __next__() or whatever
    60        if (
    61            hasattr(x, "__iter__")
    62            and not isinstance(x, (str, bytes))
    63            and (levels_to_flatten is None or levels_to_flatten > 0)
    64        ):
    65            yield from flatten(
    66                x, None if levels_to_flatten is None else levels_to_flatten - 1
    67            )
    68        else:
    69            yield x

Flattens an arbitrarily nested iterable. Flattens all iterable data types except for `str` and `bytes`.

# Returns

Generator over the flattened sequence.

# Parameters

- `it`: Any arbitrarily nested iterable.
- `levels_to_flatten`: Number of levels to flatten by, starting at the outermost layer. If `None`, performs full flattening.

def list_split(lst: list, val: Any) -> list[list]:
View Source
View on GitHub

     76def list_split(lst: list, val: Any) -> list[list]:
     77    """split a list into sublists by `val`. similar to "a_b_c".split("_")
     78
     79    ```python
     80    >>> list_split([1,2,3,0,4,5,0,6], 0)
     81    [[1, 2, 3], [4, 5], [6]]
     82    >>> list_split([0,1,2,3], 0)
     83    [[], [1, 2, 3]]
     84    >>> list_split([1,2,3], 0)
     85    [[1, 2, 3]]
     86    >>> list_split([], 0)
     87    [[]]
     88    ```
     89
     90    """
     91
     92    if len(lst) == 0:
     93        return [[]]
     94
     95    output: list[list] = [
     96        [],
     97    ]
     98
     99    for x in lst:
    100        if x == val:
    101            output.append([])
    102        else:
    103            output[-1].append(x)
    104    return output

split a list into sublists by `val`. similar to "a_b_c".split("_")

    >>> list_split([1,2,3,0,4,5,0,6], 0)
    [[1, 2, 3], [4, 5], [6]]
    >>> list_split([0,1,2,3], 0)
    [[], [1, 2, 3]]
    >>> list_split([1,2,3], 0)
    [[1, 2, 3]]
    >>> list_split([], 0)
    [[]]

def list_join(lst: list, factory: Callable) -> list:
View Source
View on GitHub

    107def list_join(lst: list, factory: Callable) -> list:
    108    """add a *new* instance of `factory()` between each element of `lst`
    109
    110    ```python
    111    >>> list_join([1,2,3], lambda : 0)
    112    [1,0,2,0,3]
    113    >>> list_join([1,2,3], lambda: [time.sleep(0.1), time.time()][1])
    114    [1, 1600000000.0, 2, 1600000000.1, 3]
    115    ```
    116    """
    117
    118    if len(lst) == 0:
    119        return []
    120
    121    output: list = [
    122        lst[0],
    123    ]
    124
    125    for x in lst[1:]:
    126        output.append(factory())
    127        output.append(x)
    128
    129    return output

add a new instance of `factory()` between each element of `lst`

    >>> list_join([1,2,3], lambda : 0)
    [1,0,2,0,3]
    >>> list_join([1,2,3], lambda: [time.sleep(0.1), time.time()][1])
    [1, 1600000000.0, 2, 1600000000.1, 3]

def apply_mapping( mapping: Mapping[~_AM_K, ~_AM_V], iter: Iterable[~_AM_K], when_missing: Literal['except', 'skip', 'include'] = 'skip') -> list[typing.Union[~_AM_K, ~_AM_V]]:
View Source
View on GitHub

    139def apply_mapping(
    140    mapping: Mapping[_AM_K, _AM_V],
    141    iter: Iterable[_AM_K],
    142    when_missing: WhenMissing = "skip",
    143) -> list[Union[_AM_K, _AM_V]]:
    144    """Given an iterable and a mapping, apply the mapping to the iterable with certain options
    145
    146    Gotcha: if `when_missing` is invalid, this is totally fine until a missing key is actually encountered.
    147
    148    Note: you can use this with `muutils.kappa.Kappa` if you want to pass a function instead of a dict
    149
    150    # Parameters:
    151     - `mapping : Mapping[_AM_K, _AM_V]`
    152        must have `__contains__` and `__getitem__`, both of which take `_AM_K` and the latter returns `_AM_V`
    153     - `iter : Iterable[_AM_K]`
    154        the iterable to apply the mapping to
    155     - `when_missing : WhenMissing`
    156        what to do when a key is missing from the mapping -- this is what distinguishes this function from `map`
    157        you can choose from `"skip"`, `"include"` (without converting), and `"except"`
    158       (defaults to `"skip"`)
    159
    160    # Returns:
    161    return type is one of:
    162     - `list[_AM_V]` if `when_missing` is `"skip"` or `"except"`
    163     - `list[Union[_AM_K, _AM_V]]` if `when_missing` is `"include"`
    164
    165    # Raises:
    166     - `KeyError` : if the item is missing from the mapping and `when_missing` is `"except"`
    167     - `ValueError` : if `when_missing` is invalid
    168    """
    169    output: list[Union[_AM_K, _AM_V]] = list()
    170    item: _AM_K
    171    for item in iter:
    172        if item in mapping:
    173            output.append(mapping[item])
    174            continue
    175        if when_missing == "skip":
    176            continue
    177        elif when_missing == "include":
    178            output.append(item)
    179        elif when_missing == "except":
    180            raise KeyError(f"item {item} is missing from mapping {mapping}")
    181        else:
    182            raise ValueError(
    183                f"invalid value for {when_missing = }\n{item = }\n{mapping = }"
    184            )
    185    return output

Given an iterable and a mapping, apply the mapping to the iterable with certain options

Gotcha: if `when_missing` is invalid, this is totally fine until a missing key is actually encountered.

Note: you can use this with `muutils.kappa.Kappa` if you want to pass a function instead of a dict

# Parameters:

- `mapping : Mapping[_AM_K, _AM_V]` must have `__contains__` and `__getitem__`, both of which take `_AM_K` and the latter returns `_AM_V`
- `iter : Iterable[_AM_K]` the iterable to apply the mapping to
- `when_missing : WhenMissing` what to do when a key is missing from the mapping -- this is what distinguishes this function from `map` you can choose from `"skip"`, `"include"` (without converting), and `"except"` (defaults to `"skip"`)

# Returns:

return type is one of:

- `list[_AM_V]` if `when_missing` is `"skip"` or `"except"`
- `list[Union[_AM_K, _AM_V]]` if `when_missing` is `"include"`

# Raises:

- `KeyError` : if the item is missing from the mapping and `when_missing` is `"except"`
- `ValueError` : if `when_missing` is invalid

def apply_mapping_chain( mapping: Mapping[~_AM_K, Iterable[~_AM_V]], iter: Iterable[~_AM_K], when_missing: Literal['except', 'skip', 'include'] = 'skip') -> list[typing.Union[~_AM_K, ~_AM_V]]:
View Source
View on GitHub

    188def apply_mapping_chain(
    189    mapping: Mapping[_AM_K, Iterable[_AM_V]],
    190    iter: Iterable[_AM_K],
    191    when_missing: WhenMissing = "skip",
    192) -> list[Union[_AM_K, _AM_V]]:
    193    """Given an iterable and a mapping, chain the mappings together
    194
    195    Gotcha: if `when_missing` is invalid, this is totally fine until a missing key is actually encountered.
    196
    197    Note: you can use this with `muutils.kappa.Kappa` if you want to pass a function instead of a dict
    198
    199    # Parameters:
    200    - `mapping : Mapping[_AM_K, Iterable[_AM_V]]`
    201        must have `__contains__` and `__getitem__`, both of which take `_AM_K` and the latter returns `Iterable[_AM_V]`
    202    - `iter : Iterable[_AM_K]`
    203        the iterable to apply the mapping to
    204    - `when_missing : WhenMissing`
    205        what to do when a key is missing from the mapping -- this is what distinguishes this function from `map`
    206        you can choose from `"skip"`, `"include"` (without converting), and `"except"`
    207    (defaults to `"skip"`)
    208
    209    # Returns:
    210    return type is one of:
    211     - `list[_AM_V]` if `when_missing` is `"skip"` or `"except"`
    212     - `list[Union[_AM_K, _AM_V]]` if `when_missing` is `"include"`
    213
    214    # Raises:
    215    - `KeyError` : if the item is missing from the mapping and `when_missing` is `"except"`
    216    - `ValueError` : if `when_missing` is invalid
    217
    218    """
    219    output: list[Union[_AM_K, _AM_V]] = list()
    220    item: _AM_K
    221    for item in iter:
    222        if item in mapping:
    223            output.extend(mapping[item])
    224            continue
    225        if when_missing == "skip":
    226            continue
    227        elif when_missing == "include":
    228            output.append(item)
    229        elif when_missing == "except":
    230            raise KeyError(f"item {item} is missing from mapping {mapping}")
    231        else:
    232            raise ValueError(
    233                f"invalid value for {when_missing = }\n{item = }\n{mapping = }"
    234            )
    235    return output

Given an iterable and a mapping, chain the mappings together

Gotcha: if `when_missing` is invalid, this is totally fine until a missing key is actually encountered.

Note: you can use this with `muutils.kappa.Kappa` if you want to pass a function instead of a dict

# Parameters:

- `mapping : Mapping[_AM_K, Iterable[_AM_V]]` must have `__contains__` and `__getitem__`, both of which take `_AM_K` and the latter returns `Iterable[_AM_V]`
- `iter : Iterable[_AM_K]` the iterable to apply the mapping to
- `when_missing : WhenMissing` what to do when a key is missing from the mapping -- this is what distinguishes this function from `map` you can choose from `"skip"`, `"include"` (without converting), and `"except"` (defaults to `"skip"`)

# Returns:

return type is one of:

- `list[_AM_V]` if `when_missing` is `"skip"` or `"except"`
- `list[Union[_AM_K, _AM_V]]` if `when_missing` is `"include"`

# Raises:

- `KeyError` : if the item is missing from the mapping and `when_missing` is `"except"`
- `ValueError` : if `when_missing` is invalid

def sanitize_name( name: str | None, additional_allowed_chars: str = '', replace_invalid: str = '', when_none: str | None = '_None_', leading_digit_prefix: str = '') -> str:
View Source
View on GitHub

     8def sanitize_name(
     9    name: str | None,
    10    additional_allowed_chars: str = "",
    11    replace_invalid: str = "",
    12    when_none: str | None = "_None_",
    13    leading_digit_prefix: str = "",
    14) -> str:
    15    """sanitize a string, leaving only alphanumerics and `additional_allowed_chars`
    16
    17    # Parameters:
    18     - `name : str | None`
    19       input string
    20     - `additional_allowed_chars : str`
    21       additional characters to allow, none by default
    22       (defaults to `""`)
    23     - `replace_invalid : str`
    24        character to replace invalid characters with
    25       (defaults to `""`)
    26     - `when_none : str | None`
    27        string to return if `name` is `None`. if `None`, raises an exception
    28       (defaults to `"_None_"`)
    29     - `leading_digit_prefix : str`
    30        character to prefix the string with if it starts with a digit
    31       (defaults to `""`)
    32
    33    # Returns:
    34     - `str`
    35        sanitized string
    36    """
    37
    38    if name is None:
    39        if when_none is None:
    40            raise ValueError("name is None")
    41        else:
    42            return when_none
    43
    44    sanitized: str = ""
    45    for char in name:
    46        if char.isalnum():
    47            sanitized += char
    48        elif char in additional_allowed_chars:
    49            sanitized += char
    50        else:
    51            sanitized += replace_invalid
    52
    53    if sanitized[0].isdigit():
    54        sanitized = leading_digit_prefix + sanitized
    55
    56    return sanitized

sanitize a string, leaving only alphanumerics and `additional_allowed_chars`

# Parameters:

- `name : str | None` input string
- `additional_allowed_chars : str` additional characters to allow, none by default (defaults to `""`)
- `replace_invalid : str` character to replace invalid characters with (defaults to `""`)
- `when_none : str | None` string to return if `name` is `None`. if `None`, raises an exception (defaults to `"_None_"`)
- `leading_digit_prefix : str` character to prefix the string with if it starts with a digit (defaults to `""`)

# Returns:

- `str` sanitized string

def sanitize_fname(fname: str | None, **kwargs) -> str:
View Source
View on GitHub

    59def sanitize_fname(fname: str | None, **kwargs) -> str:
    60    """sanitize a filename to posix standards
    61
    62    - leave only alphanumerics, `_` (underscore), '-' (dash) and `.` (period)
    63    """
    64    return sanitize_name(fname, additional_allowed_chars="._-", **kwargs)

sanitize a filename to posix standards

- leave only alphanumerics, `_` (underscore), '-' (dash) and `.` (period)

def sanitize_identifier(fname: str | None, **kwargs) -> str:
View Source
View on GitHub

    67def sanitize_identifier(fname: str | None, **kwargs) -> str:
    68    """sanitize an identifier (variable or function name)
    69
    70    - leave only alphanumerics and `_` (underscore)
    71    - prefix with `_` if it starts with a digit
    72    """
    73    return sanitize_name(
    74        fname, additional_allowed_chars="_", leading_digit_prefix="_", **kwargs
    75    )

sanitize an identifier (variable or function name)

- leave only alphanumerics and `_` (underscore)
- prefix with `_` if it starts with a digit

def dict_to_filename( data: dict, format_str: str = '{key}_{val}', separator: str = '.', max_length: int = 255):
View Source
View on GitHub

     78def dict_to_filename(
     79    data: dict,
     80    format_str: str = "{key}_{val}",
     81    separator: str = ".",
     82    max_length: int = 255,
     83):
     84    # Convert the dictionary items to a list of strings using the format string
     85    formatted_items: list[str] = [
     86        format_str.format(key=k, val=v) for k, v in data.items()
     87    ]
     88
     89    # Join the formatted items using the separator
     90    joined_str: str = separator.join(formatted_items)
     91
     92    # Remove special characters and spaces
     93    sanitized_str: str = sanitize_fname(joined_str)
     94
     95    # Check if the length is within limits
     96    if len(sanitized_str) <= max_length:
     97        return sanitized_str
     98
     99    # If the string is too long, generate a hash
    100    return f"h_{stable_hash(sanitized_str)}"

def dynamic_docstring(**doc_params):
View Source
View on GitHub

    103def dynamic_docstring(**doc_params):
    104    def decorator(func):
    105        if func.__doc__:
    106            func.__doc__ = func.__doc__.format(**doc_params)
    107        return func
    108
    109    return decorator

def shorten_numerical_to_str( num: int | float, small_as_decimal: bool = True, precision: int = 1) -> str:
View Source
View on GitHub

    23def shorten_numerical_to_str(
    24    num: int | float,
    25    small_as_decimal: bool = True,
    26    precision: int = 1,
    27) -> str:
    28    """shorten a large numerical value to a string
    29    1234 -> 1K
    30
    31    precision guaranteed to 1 in 10, but can be higher. reverse of `str_to_numeric`
    32    """
    33
    34    # small values are returned as is
    35    num_abs: float = abs(num)
    36    if num_abs < 1e3:
    37        return str(num)
    38
    39    # iterate over suffixes from largest to smallest
    40    for i, (val, suffix) in enumerate(_SHORTEN_TUPLES):
    41        if num_abs > val or i == len(_SHORTEN_TUPLES) - 1:
    42            if (num_abs < val * 10) and small_as_decimal:
    43                return f"{num / val:.{precision}f}{suffix}"
    44            elif num_abs < val * 1e3:
    45                return f"{int(round(num / val))}{suffix}"
    46
    47    return f"{num:.{precision}f}"

shorten a large numerical value to a string 1234 -> 1K

precision guaranteed to 1 in 10, but can be higher. reverse of `str_to_numeric`

def str_to_numeric( quantity: str, mapping: None | bool | dict[str, int | float] = True) -> int | float:
View Source
View on GitHub

     50def str_to_numeric(
     51    quantity: str,
     52    mapping: None | bool | dict[str, int | float] = True,
     53) -> int | float:
     54    """Convert a string representing a quantity to a numeric value.
     55
     56    The string can represent an integer, python float, fraction, or shortened via `shorten_numerical_to_str`.
     57
     58    # Examples:
     59    ```
     60    >>> str_to_numeric("5")
     61    5
     62    >>> str_to_numeric("0.1")
     63    0.1
     64    >>> str_to_numeric("1/5")
     65    0.2
     66    >>> str_to_numeric("-1K")
     67    -1000.0
     68    >>> str_to_numeric("1.5M")
     69    1500000.0
     70    >>> str_to_numeric("1.2e2")
     71    120.0
     72    ```
     73
     74    """
     75
     76    # check is string
     77    if not isinstance(quantity, str):
     78        raise TypeError(
     79            f"quantity must be a string, got '{type(quantity) = }' '{quantity = }'"
     80        )
     81
     82    # basic int conversion
     83    try:
     84        quantity_int: int = int(quantity)
     85        return quantity_int
     86    except ValueError:
     87        pass
     88
     89    # basic float conversion
     90    try:
     91        quantity_float: float = float(quantity)
     92        return quantity_float
     93    except ValueError:
     94        pass
     95
     96    # mapping
     97    _mapping: dict[str, int | float]
     98    if mapping is True or mapping is None:
     99        _mapping = _REVERSE_SHORTEN_MAP
    100    else:
    101        _mapping = mapping  # type: ignore[assignment]
    102
    103    quantity_original: str = quantity
    104
    105    quantity = quantity.strip()
    106
    107    result: int | float
    108    multiplier: int | float = 1
    109
    110    # detect if it has a suffix
    111    suffixes_detected: list[bool] = [suffix in quantity for suffix in _mapping]
    112    n_suffixes_detected: int = sum(suffixes_detected)
    113    if n_suffixes_detected == 0:
    114        # no suffix
    115        pass
    116    elif n_suffixes_detected == 1:
    117        # find multiplier
    118        for suffix, mult in _mapping.items():
    119            if quantity.endswith(suffix):
    120                # remove suffix, store multiplier, and break
    121                quantity = quantity[: -len(suffix)].strip()
    122                multiplier = mult
    123                break
    124        else:
    125            raise ValueError(f"Invalid suffix in {quantity_original}")
    126    else:
    127        # multiple suffixes
    128        raise ValueError(f"Multiple suffixes detected in {quantity_original}")
    129
    130    # fractions
    131    if "/" in quantity:
    132        try:
    133            assert quantity.count("/") == 1, "too many '/'"
    134            # split and strip
    135            num, den = quantity.split("/")
    136            num = num.strip()
    137            den = den.strip()
    138            num_sign: int = 1
    139            # negative numbers
    140            if num.startswith("-"):
    141                num_sign = -1
    142                num = num[1:]
    143            # assert that both are digits
    144            assert (
    145                num.isdigit() and den.isdigit()
    146            ), "numerator and denominator must be digits"
    147            # return the fraction
    148            result = num_sign * (
    149                int(num) / int(den)
    150            )  # this allows for fractions with suffixes, which is weird, but whatever
    151        except AssertionError as e:
    152            raise ValueError(f"Invalid fraction {quantity_original}: {e}") from e
    153
    154    # decimals
    155    else:
    156        try:
    157            result = int(quantity)
    158        except ValueError:
    159            try:
    160                result = float(quantity)
    161            except ValueError as e:
    162                raise ValueError(
    163                    f"Invalid quantity {quantity_original} ({quantity})"
    164                ) from e
    165
    166    return result * multiplier

Convert a string representing a quantity to a numeric value.

The string can represent an integer, python float, fraction, or shortened via `shorten_numerical_to_str`.

# Examples:

    >>> str_to_numeric("5")
    5
    >>> str_to_numeric("0.1")
    0.1
    >>> str_to_numeric("1/5")
    0.2
    >>> str_to_numeric("-1K")
    -1000.0
    >>> str_to_numeric("1.5M")
    1500000.0
    >>> str_to_numeric("1.2e2")
    120.0

_SHORTEN_MAP = {1000.0: 'K', 1000000.0: 'M', 1000000000.0: 'B', 1000000000000.0: 't', 1000000000000000.0: 'q', 1e+18: 'Q'}
class FrozenDict(builtins.dict):
View Source
View on GitHub

     5class FrozenDict(dict):
     6    def __setitem__(self, key, value):
     7        raise AttributeError("dict is frozen")
     8
     9    def __delitem__(self, key):
    10        raise AttributeError("dict is frozen")

##### Inherited Members

builtins.dict
  get
  setdefault
  pop
  popitem
  keys
  items
  values
  update
  fromkeys
  clear
  copy

class FrozenList(builtins.list):
View Source
View on GitHub

    13class FrozenList(list):
    14    def __setitem__(self, index, value):
    15        raise AttributeError("list is frozen")
    16
    17    def __delitem__(self, index):
    18        raise AttributeError("list is frozen")
    19
    20    def append(self, value):
    21        raise AttributeError("list is frozen")
    22
    23    def extend(self, iterable):
    24        raise AttributeError("list is frozen")
    25
    26    def insert(self, index, value):
    27        raise AttributeError("list is frozen")
    28
    29    def remove(self, value):
    30        raise AttributeError("list is frozen")
    31
    32    def pop(self, index=-1):
    33        raise AttributeError("list is frozen")
    34
    35    def clear(self):
    36        raise AttributeError("list is frozen")

Built-in mutable sequence.

If no argument is given, the constructor creates a new empty list. The argument must be an iterable if specified.

def append(self, value):
View Source
View on GitHub

    20    def append(self, value):
    21        raise AttributeError("list is frozen")

Append object to the end of the list.

def extend(self, iterable):
View Source
View on GitHub

    23    def extend(self, iterable):
    24        raise AttributeError("list is frozen")

Extend list by appending elements from the iterable.

def insert(self, index, value):
View Source
View on GitHub

    26    def insert(self, index, value):
    27        raise AttributeError("list is frozen")

Insert object before index.

def remove(self, value):
View Source
View on GitHub

    29    def remove(self, value):
    30        raise AttributeError("list is frozen")

Remove first occurrence of value.

Raises ValueError if the value is not present.

def pop(self, index=-1):
View Source
View on GitHub

    32    def pop(self, index=-1):
    33        raise AttributeError("list is frozen")

Remove and return item at index (default last).

Raises IndexError if list is empty or index is out of range.

def clear(self):
View Source
View on GitHub

    35    def clear(self):
    36        raise AttributeError("list is frozen")

Remove all items from list.

##### Inherited Members

builtins.list
  list
  copy
  index
  count
  reverse
  sort

def freeze(instance: object) -> object:
View Source
View on GitHub

     39def freeze(instance: object) -> object:
     40    """recursively freeze an object in-place so that its attributes and elements cannot be changed
     41
     42    messy in the sense that sometimes the object is modified in place, but you can't rely on that. always use the return value.
     43
     44    the [gelidum](https://github.com/diegojromerolopez/gelidum/) package is a more complete implementation of this idea
     45
     46    """
     47
     48    # mark as frozen
     49    if hasattr(instance, "_IS_FROZEN"):
     50        if instance._IS_FROZEN:
     51            return instance
     52
     53    # try to mark as frozen
     54    try:
     55        instance._IS_FROZEN = True  # type: ignore[attr-defined]
     56    except AttributeError:
     57        pass
     58
     59    # skip basic types, weird things, or already frozen things
     60    if isinstance(instance, (bool, int, float, str, bytes)):
     61        pass
     62
     63    elif isinstance(instance, (type(None), type(Ellipsis))):
     64        pass
     65
     66    elif isinstance(instance, (FrozenList, FrozenDict, frozenset)):
     67        pass
     68
     69    # handle containers
     70    elif isinstance(instance, list):
     71        for i in range(len(instance)):
     72            instance[i] = freeze(instance[i])
     73        instance = FrozenList(instance)
     74
     75    elif isinstance(instance, tuple):
     76        instance = tuple(freeze(item) for item in instance)
     77
     78    elif isinstance(instance, set):
     79        instance = frozenset({freeze(item) for item in instance})
     80
     81    elif isinstance(instance, dict):
     82        for key, value in instance.items():
     83            instance[key] = freeze(value)
     84        instance = FrozenDict(instance)
     85
     86    # handle custom classes
     87    else:
     88        # set everything in the __dict__ to frozen
     89        instance.__dict__ = freeze(instance.__dict__)  # type: ignore[assignment]
     90
     91        # create a new class which inherits from the original class
     92        class FrozenClass(instance.__class__):  # type: ignore[name-defined]
     93            def __setattr__(self, name, value):
     94                raise AttributeError("class is frozen")
     95
     96        FrozenClass.__name__ = f"FrozenClass__{instance.__class__.__name__}"
     97        FrozenClass.__module__ = instance.__class__.__module__
     98        FrozenClass.__doc__ = instance.__class__.__doc__
     99
    100        # set the instance's class to the new class
    101        try:
    102            instance.__class__ = FrozenClass
    103        except TypeError as e:
    104            raise TypeError(
    105                f"Cannot freeze:\n{instance = }\n{instance.__class__ = }\n{FrozenClass = }"
    106            ) from e
    107
    108    return instance

recursively freeze an object in-place so that its attributes and elements cannot be changed

messy in the sense that sometimes the object is modified in place, but you can't rely on that. always use the return value.

the gelidum package is a more complete implementation of this idea

def is_abstract(cls: type) -> bool:
View Source
View on GitHub

    15def is_abstract(cls: type) -> bool:
    16    """
    17    Returns if a class is abstract.
    18    """
    19    if not hasattr(cls, "__abstractmethods__"):
    20        return False  # an ordinary class
    21    elif len(cls.__abstractmethods__) == 0:
    22        return False  # a concrete implementation of an abstract class
    23    else:
    24        return True  # an abstract class

Returns if a class is abstract.

def get_all_subclasses(class_: type, include_self=False) -> set[type]:
View Source
View on GitHub

    27def get_all_subclasses(class_: type, include_self=False) -> set[type]:
    28    """
    29    Returns a set containing all child classes in the subclass graph of `class_`.
    30    I.e., includes subclasses of subclasses, etc.
    31
    32    # Parameters
    33    - `include_self`: Whether to include `class_` itself in the returned set
    34    - `class_`: Superclass
    35
    36    # Development
    37    Since most class hierarchies are small, the inefficiencies of the existing recursive implementation aren't problematic.
    38    It might be valuable to refactor with memoization if the need arises to use this function on a very large class hierarchy.
    39    """
    40    subs: set[type] = set(
    41        flatten(
    42            get_all_subclasses(sub, include_self=True)
    43            for sub in class_.__subclasses__()
    44            if sub is not None
    45        )
    46    )
    47    if include_self:
    48        subs.add(class_)
    49    return subs

Returns a set containing all child classes in the subclass graph of `class_`. I.e., includes subclasses of subclasses, etc.

# Parameters

- `include_self`: Whether to include `class_` itself in the returned set
- `class_`: Superclass

# Development

Since most class hierarchies are small, the inefficiencies of the existing recursive implementation aren't problematic. It might be valuable to refactor with memoization if the need arises to use this function on a very large class hierarchy.

def isinstance_by_type_name(o: object, type_name: str):
View Source
View on GitHub

    52def isinstance_by_type_name(o: object, type_name: str):
    53    """Behaves like stdlib `isinstance` except it accepts a string representation of the type rather than the type itself.
    54    This is a hacky function intended to circumvent the need to import a type into a module.
    55    It is susceptible to type name collisions.
    56
    57    # Parameters
    58    `o`: Object (not the type itself) whose type to interrogate
    59    `type_name`: The string returned by `type_.__name__`.
    60    Generic types are not supported, only types that would appear in `type_.__mro__`.
    61    """
    62    return type_name in {s.__name__ for s in type(o).__mro__}

Behaves like stdlib `isinstance` except it accepts a string representation of the type rather than the type itself. This is a hacky function intended to circumvent the need to import a type into a module. It is susceptible to type name collisions.

# Parameters

`o`: Object (not the type itself) whose type to interrogate `type_name`: The string returned by `type_.__name__`. Generic types are not supported, only types that would appear in `type_.__mro__`.

@runtime_checkable
class IsDataclass(typing.Protocol):
View Source
View on GitHub

    69@runtime_checkable
    70class IsDataclass(Protocol):
    71    # Generic type for any dataclass instance
    72    # https://stackoverflow.com/questions/54668000/type-hint-for-an-instance-of-a-non-specific-dataclass
    73    __dataclass_fields__: ClassVar[dict[str, Any]]

Base class for protocol classes.

Protocol classes are defined as::

    class Proto(Protocol):
        def meth(self) -> int:
            ...

Such classes are primarily used with static type checkers that recognize structural subtyping (static duck-typing).

For example::

    class C:
        def meth(self) -> int:
            return 0

    def func(x: Proto) -> int:
        return x.meth()

    func(C())  # Passes static type check

See PEP 544 for details. Protocol classes decorated with @typing.runtime_checkable act as simple-minded runtime protocols that check only the presence of given attributes, ignoring their type signatures. Protocol classes can be generic, they are defined as::

    class GenProto[T](Protocol):
        def meth(self) -> T:
            ...

IsDataclass(*args, **kwargs)
View Source
View on GitHub

    1710def _no_init_or_replace_init(self, *args, **kwargs):
    1711    cls = type(self)
    1712
    1713    if cls._is_protocol:
    1714        raise TypeError('Protocols cannot be instantiated')
    1715
    1716    # Already using a custom `__init__`. No need to calculate correct
    1717    # `__init__` to call. This can lead to RecursionError. See bpo-45121.
    1718    if cls.__init__ is not _no_init_or_replace_init:
    1719        return
    1720
    1721    # Initially, `__init__` of a protocol subclass is set to `_no_init_or_replace_init`.
    1722    # The first instantiation of the subclass will call `_no_init_or_replace_init` which
    1723    # searches for a proper new `__init__` in the MRO. The new `__init__`
    1724    # replaces the subclass' old `__init__` (ie `_no_init_or_replace_init`). Subsequent
    1725    # instantiation of the protocol subclass will thus use the new
    1726    # `__init__` and no longer call `_no_init_or_replace_init`.
    1727    for base in cls.__mro__:
    1728        init = base.__dict__.get('__init__', _no_init_or_replace_init)
    1729        if init is not _no_init_or_replace_init:
    1730            cls.__init__ = init
    1731            break
    1732    else:
    1733        # should not happen
    1734        cls.__init__ = object.__init__
    1735
    1736    cls.__init__(self, *args, **kwargs)

def get_hashable_eq_attrs(dc: IsDataclass) -> tuple[typing.Any]:
View Source
View on GitHub

    76def get_hashable_eq_attrs(dc: IsDataclass) -> tuple[Any]:
    77    """Returns a tuple of all fields used for equality comparison, including the type of the dataclass itself.
    78    The type is included to preserve the unequal equality behavior of instances of different dataclasses whose fields are identical.
    79    Essentially used to generate a hashable dataclass representation for equality comparison even if it's not frozen.
    80    """
    81    return *(
    82        getattr(dc, fld.name)
    83        for fld in filter(lambda x: x.compare, dc.__dataclass_fields__.values())
    84    ), type(dc)

Returns a tuple of all fields used for equality comparison, including the type of the dataclass itself. The type is included to preserve the unequal equality behavior of instances of different dataclasses whose fields are identical. Essentially used to generate a hashable dataclass representation for equality comparison even if it's not frozen.

def dataclass_set_equals( coll1: Iterable[IsDataclass], coll2: Iterable[IsDataclass]) -> bool:
View Source
View on GitHub

    87def dataclass_set_equals(
    88    coll1: Iterable[IsDataclass], coll2: Iterable[IsDataclass]
    89) -> bool:
    90    """Compares 2 collections of dataclass instances as if they were sets.
    91    Duplicates are ignored in the same manner as a set.
    92    Unfrozen dataclasses can't be placed in sets since they're not hashable.
    93    Collections of them may be compared using this function.
    94    """
    95
    96    return {get_hashable_eq_attrs(x) for x in coll1} == {
    97        get_hashable_eq_attrs(y) for y in coll2
    98    }

Compares 2 collections of dataclass instances as if they were sets. Duplicates are ignored in the same manner as a set. Unfrozen dataclasses can't be placed in sets since they're not hashable. Collections of them may be compared using this function.