# AUTOGENERATED! DO NOT EDIT! File to edit: ../nbs/00_helper.ipynb.

# %% ../nbs/00_helper.ipynb 2
from __future__ import annotations
from collections import OrderedDict
from collections.abc import Iterable
import datetime
from datetime import timezone
import errno
import glob
from graphlib import TopologicalSorter
from itertools import product
import os
from os import PathLike
from pathlib import Path
import platform
import random
import re
from typing import Callable, Optional, Pattern, Sequence, Union

import bs4
from bs4 import BeautifulSoup
from deprecated import deprecated
from natsort import natsorted

# %% auto 0
__all__ = ['ALPHABET_TO_ALPHABET_GROUP_DICT', 'ALPHABET_OR_GREEK_TO_ALPHABET_DICT', 'find_regex_in_text',
           'separate_indices_from_str', 'replace_string_by_indices', 'latex_indices', 'inline_latex_indices',
           'html_tag_str', 'find_lt_symbols_without_space_in_math_mode', 'add_space_to_lt_symbols_without_space',
           'remove_html_tags_in_text', 'add_HTML_tag_data_to_raw_text', 'double_asterisk_indices',
           'notation_asterisk_indices', 'definition_asterisk_indices', 'defs_and_notats_separations', 'is_number',
           'existing_path', 'file_existence_test', 'path_name_no_ext', 'path_no_ext', 'text_from_file',
           'files_of_format_sorted', 'current_time_formatted_to_minutes', 'containing_string_priority',
           'default_str_comparison', 'natsort_comparison', 'graph_for_topological_sort',
           'dict_with_keys_topologically_sorted', 'alphabet_to_alphabet_group', 'alphabet_or_latex_command_to_alphabet',
           'alphabet_or_latex_command_to_alphabet_group']

# %% ../nbs/00_helper.ipynb 5
def _test_directory() -> Path:
    """Returns the `nbs/_tests` directory of the `trouver` repository.
    
    Assumes that the current working directory is either the root of the
    repository or the `nbs` folder and the `nbs/_tests` folder exists in the
    repository but the root of the repository does not have a folder named
    `_tests`.
    """
    cwd = os.getcwd()
    cwd_name = os.path.basename(cwd)
    if cwd_name == 'nbs':
        return Path(cwd) / '_tests'
    else:
        return Path(cwd) / 'nbs'/ '_tests'

# %% ../nbs/00_helper.ipynb 11
def find_regex_in_text(
        text: str, # Text in which to find regex patter
        pattern: str | Pattern[str] # The regex pattern
        ) -> list[tuple[int, int]]: # Each tuple is of the form `(a,b)` where `text[a:b]` is the regex match.
    # TODO: rename into regex_indices_in_text
    # TODO: swap parameters.
    """Return ranges in `text` where `pattern` occurs.
    """
    matches = re.finditer(pattern, text)
    return [match.span() for match in matches]

# %% ../nbs/00_helper.ipynb 20
def separate_indices_from_str(
        text: str,
        indices: list[tuple[int, int]] # The indices for substrings in `text` to separate.
        ) -> list[str]: # Each str is a substring of `text`, either a substring of `text` specified by `indices`, or substrings in between the substrings specified by `indices`.
    """Divide `text` into parts along the substrings specified by `indices`. 

    Assumes that the pairs of indices specified by `indices` are in order from
    first to last and the ranges specified by these pairs are all disjoint.

    `''.join(output)` should recover `text`.
    """
    if not indices:
        return [text]
    parts = [text[:indices[0][0]]]
    for index_pair, next_pair in zip(indices, indices[1:]):
        parts.append(text[index_pair[0]:index_pair[1]])
        parts.append(text[index_pair[1]:next_pair[0]])
    last_pair = indices[-1]
    parts.append(text[last_pair[0]:last_pair[1]])
    parts.append(text[last_pair[1]:])
    return parts

# %% ../nbs/00_helper.ipynb 23
def replace_string_by_indices(
        string: str, # String in which to make replacemenets 
        replace_ranges: Sequence[Union[Sequence[int], int]], # A list of lists/tuples of int's or a single list/tuple of int's. Each 
        replace_with: Sequence[str] | str # The str(s) which will replace the substrings at `replace_ranges` in `string`. `replace_with` must be a str exactly when `replace_ranges` is a Sequence of a single Sequence of int.
        ) -> str:  # The str obtained by replacing the substrings at `replace_range` in `string` by the strs specified by `replace_with`.
    """Replace parts of ``string`` at the specified locations"

    Use this with `find_regex_in_text`.

    **Parameters**

    - ``string`` - `str`
    - ``replace_ranges`` - `Sequence[Sequence[int] | int]`
        - Either a list of lists/tuples of one or two int's. A list/tuple
        ``[a,b]`` or ``(a,b)`` means that ``string[a:b]`` is to be replaced.
        ``[a]`` or ``(a)`` means that ``string[a:]`` is to be replaced. The ranges should
        not overlap and should be arranged in chronological order.
    - ``replace_with`` - `Sequence[str] | str`
        - The str's which will replace the parts represented by 
        ``replace_ranges``. ``replace_ranges`` and ``replace_with`` must be
        both lists or both not lists. If they are lists, they must be of 
        the same length.

    **Returns**

    - str

    """
    if isinstance(replace_with, str):
        replace_ranges = [replace_ranges]
        replace_with = [replace_with]
    if len(replace_ranges) != len(replace_with):
        raise ValueError(
            'The lengths of `replace_ranges` and `replace_with` are different.')
    if len(replace_ranges) == 0:
        return string

    str_parts = _str_parts(string, replace_ranges, replace_with)
    return "".join(str_parts)


def _str_parts(string, replace_ranges, replace_with):
    """Divide `string` into parts divided outside of `replace_ranges`
    and with `replace_with` inserted."""
    str_parts = []
    for i, replace_string in enumerate(replace_ranges):
        replace_string = replace_with[i]
        if i > 0 and len(replace_ranges[i-1]) == 1:
            unreplaced_start_index = len(string)
        elif i > 0 and len(replace_ranges[i-1]) != 1:
            unreplaced_start_index = replace_ranges[i-1][1]
        else:
            unreplaced_start_index = 0
        unreplaced_end_index = replace_ranges[i][0]
        str_parts.append(string[unreplaced_start_index:unreplaced_end_index])
        str_parts.append(replace_string)
    # Add the last (unreplaced) part to str_parts.
    if len(replace_ranges[-1]) == 1:
        unreplaced_start_index = len(string)
    else:
        unreplaced_start_index = replace_ranges[-1][1]
    str_parts.append(string[unreplaced_start_index:])
    return str_parts

# %% ../nbs/00_helper.ipynb 29
def latex_indices(
        text: str,
        ) -> list[tuple[int, int]]:
    """Returns the indices in the text containing LaTeX str.
    
    This may not work correctly if the text has a LaTeX
    formatting issue or if any LaTeX string has a dollar sign `\$`.
    
    **Parameters**

    - text - str

    **Returns**

    - tuple[int]
        - Each tuple is of the form `(start, end)` where
        `text[start:end]` is a LaTeX string, including any leading trailing
        dollar signs (`$` or `$$`).
    """
    # r'(?<!\\)\$.*(?<!\\)\$|(?<!\\)\$(?<!\\)\$.*(?<!\\)\$(?<!\\)\$'
    # return find_regex_in_text(text, '\$\$[^\$]*\$\$|\$[^\$]*\$')

    # return find_regex_in_text(text, r'((?<!\\)\$\$?)[^\$]*\1')
    pattern = re.compile(r"(?<!\\)\$\$.*?(?<!\\)\$\$|(?<!\\)\$.*?(?<!\\)\$", re.DOTALL)
    return find_regex_in_text(text, pattern)


def inline_latex_indices(
        text: str,
        ) -> list[tuple[int, int]]:
    """Returns the indices in the text containing inline LaTeX str surrounded by
    `$$`.
    
    This may not work correctly if the text has a LaTeX
    formatting issue or if any LaTeX string has a dollar sign `\$`.
    
    **Parameters**

    - text - str

    **Returns**

    - tuple[int]
        - Each tuple is of the form `(start, end)` where
        `text[start:end]` is a LaTeX string, including any leading trailing
        dollar signs (`$$`).
    """
    # r'(?<!\\)\$.*(?<!\\)\$|(?<!\\)\$(?<!\\)\$.*(?<!\\)\$(?<!\\)\$'
    # return find_regex_in_text(text, '\$\$[^\$]*\$\$|\$[^\$]*\$')

    # return find_regex_in_text(text, r'((?<!\\)\$\$?)[^\$]*\1')
    pattern = re.compile(r"(?<!\\)\$\$.*?(?<!\\)\$\$", re.DOTALL)
    return find_regex_in_text(text, pattern)

# def math_mode_str_in_text(
#         text: str # The str in which to find the latex math mode str.
#         ) -> list[tuple[int, int]]: # Each tuple is of the form `(start,end)`, where `text[start:end]` is a part in `text` with LaTeX math mode text.
#     """
#     Return the indices of the math mode text.
#     """
#     pattern = re.compile(r"(?<!\\)\$\$.*?(?<!\\)\$\$|(?<!\\)\$.*?(?<!\\)\$", re.DOTALL)
#     return find_regex_in_text(text, pattern)

# %% ../nbs/00_helper.ipynb 36
#| export

# %% ../nbs/00_helper.ipynb 47
def html_tag_str(
        html_tag: bs4.element.Tag
        ) -> str:
    """
    Return the full string of `html_tag`, accounting for 
    special characters that `bs4` changes
    """
    special_chars = {'&lt;': '<', '&gt;': '>', '&amp;': '&'}
    text_to_return = str(html_tag)
    for special_char, replace_with in special_chars.items():
        text_to_return = text_to_return.replace(special_char, replace_with)
    return text_to_return

# %% ../nbs/00_helper.ipynb 52
def find_lt_symbols_without_space_in_math_mode(
        text: str
        ) -> list[int]: # The index of  
    """
    Return the indices in `text` with math mode less than `<` symbols without
    a space that follows.
    """
    latex_inds = latex_indices(text)
    lt_pattern = re.compile(r'<(?! )')
    inds_of_lt_without_spaces_after = []
    for start, end in latex_inds:
        latex_str = text[start:end]
        relative_lt_inds = find_regex_in_text(latex_str, lt_pattern)
        inds_of_lt_without_spaces_after.extend([
            start + relative_lt_ind for relative_lt_ind, _ in relative_lt_inds
        ])
    return inds_of_lt_without_spaces_after 


# %% ../nbs/00_helper.ipynb 56
def add_space_to_lt_symbols_without_space(
        text: str
        ) -> str:
    """Add space after less than `<` symbols if the symbol
    is not followed by a space.
    """
    lt_wo_space_inds = find_lt_symbols_without_space_in_math_mode(text)
    lt_wo_space_ranges = [(ind, ind+1) for ind in lt_wo_space_inds]
    return replace_string_by_indices(
        text, replace_ranges=lt_wo_space_ranges,
        replace_with=['< '] * len(lt_wo_space_inds))
    

# %% ../nbs/00_helper.ipynb 62
def remove_html_tags_in_text(
        text: str, # The text in which to remove the HTML tags.
        replace_with_attributes: Optional[Union[str, list[str]]] = None, # Attribute(s) within the HTML tags which should be used to replace the text of the tags. If `None`, then the texts are not replaced with the attributes. If multiple attributes are specified, then only one attribute is used to replace the text for each HTML tag (independently at random of other replacements). Each attribute's text has an equal chance of being selected for replacement. Repeats are ignored.
        definitely_replace: bool = False, # If `True` and if a given HTML tag has an attribute specified in `replace_with_attributes`, then the text for that tag will definitely be replaced by the text of one of the attributes. Otherwise, the original text and each attribute's text have an equal chance of being selected.
        seed: int = None # Random seed 
        ) -> tuple[str, list[tuple[bs4.element.Tag, int, int]]]: # The text `removed` without HTML tags and a list whose elements consist of the removed HTML tags and the starting and ending indices of the text corresponding to the removed tags within `removed`.
    """Remove the HTML tags in `text`.

    HTML tags are assumed to be not nested.

    """
    random.seed(seed)
    parsed_soup = BeautifulSoup(text, 'html.parser')
    replace_with_attributes = _init_replace_with_attributes(
        replace_with_attributes)

    position = 0
    replaced_contents = []
    for content in parsed_soup.contents:
        position = _process_content(
            parsed_soup, replace_with_attributes, definitely_replace, content,
            position, replaced_contents)
    text_to_return = html_tag_str(parsed_soup)
    return text_to_return, replaced_contents


def _init_replace_with_attributes(
        replace_with_attributes: Optional[Union[str, list[str]]]
        ) -> set[str]:
    if replace_with_attributes is None:
        replace_with_attributes = []
    elif isinstance(replace_with_attributes, str):
        replace_with_attributes = [replace_with_attributes]
    return set(replace_with_attributes)


def _select_replacement_text(
        content: bs4.element.Tag,
        replace_with_attributes: set[str],
        definitely_replace: bool) -> str:
    if not replace_with_attributes:
        return content.string
    selection_pool = []    
    if not definitely_replace:
        selection_pool.append(content.string)
    for attribute, value in content.attrs.items():
        if attribute not in replace_with_attributes:
            continue
        selection_pool.append(value)
    return random.choice(selection_pool)


def _process_content(
        parsed_soup: BeautifulSoup,
        replace_with_attributes: set[str],
        definitely_replace: bool,
        content,
        position: int,
        replaced_contents: list) -> int:
    
    if not isinstance(content, bs4.element.Tag):
        return position + len(content)
    replacement_text = _select_replacement_text(
        content, replace_with_attributes, definitely_replace)
    
    try:
        replaced_content = content.replace_with(
            parsed_soup.new_string(replacement_text))
    except TypeError as e:
        raise e

    replaced_contents.append((
        replaced_content,
        position,
        position + len(replacement_text)))
    return position + len(replacement_text)
    

# %% ../nbs/00_helper.ipynb 79
def add_HTML_tag_data_to_raw_text(
        text: str, # The text onto which to add HTML tags. This is assumed to contain no HTML tags.
        tags_and_locations: list[tuple[bs4.element.Tag, int, int]] # Each tuple consists of the tag object to add as well as the indices within `text` to. The ranges specified by the tuples are assumed to not overlap with one another.
        ) -> str: # The modification of `text` in which the tags are added at the specified locations; the ranges in `text` are replaced..
    """
    Add specified HTML tags to the specified locations/ranges in `text`.

    See the `add_HTML_tag_data_to_text` function for adding HTML
    tag data to text that may or may not already have HTML tags.
    """
    # sort by starting index
    tags_and_locations = sorted(
        tags_and_locations, key=lambda x: x[1])
    replace_ranges = [(start, end) for _, start, end in tags_and_locations]
    replace_with = [html_tag_str(html_tag) for html_tag, _, _ in tags_and_locations]
    return replace_string_by_indices(text, replace_ranges, replace_with)

# %% ../nbs/00_helper.ipynb 85
def double_asterisk_indices(
        text: str # the str in which to find the indices of double asterisk surrounded text.
        ) -> list[tuple[int, int]]: # Each tuple is of the form `(start,end)`, where `text[start:end]` is a part in `text` with double asterisks, including the double asterisks.
    # TODO: fix double asterisks in math mode.
    """Return the indices in `str` of text surrounded by double asterisks.
    
    Assumes there no LaTeX math mode string has double asterisks.

    **See Also**
    
    - `notation_asterisk_indices`
    - `definition_asterisk_indices`
    """
    return find_regex_in_text(text, pattern='\*\*[^*]+\*\*')



# %% ../nbs/00_helper.ipynb 87
def notation_asterisk_indices(
        text: str # the str in which to find the indices of notations surrounded by double asterisks.
        ) -> list[tuple[int, int]]: # Each tuple is of the form `(start,end)`, where `text[start:end]` is a part in `text` with LaTeX math mode text with double asterisks, including the double asterisks.
    """Return the indices of notation text surrounded by double asterisks.
    
    A double-asterisk-surrounded-text is a notation almost always
    when it is purely LaTeX math mode text. 

    Assumes that no LaTeX math mode string has the dollar sign character
    within it.
    """
    return find_regex_in_text(
        text, pattern='\*\*\$\$[^$]+\$\$\*\*|\*\*\$[^$]+\$\*\*')
    # I previous used this, but it was not picking up notation LaTeX str
    # containing asterisks, e.g. `**$\pi^*$**``, `**$\pi_*$**`.`
    return find_regex_in_text(
        text, pattern='\*\*\$\$[^*$]+\$\$\*\*|\*\*\$[^*$]+\$\*\*')


def definition_asterisk_indices(
        text: str # The str in which to find the indices of the definitions surrounded by double asterisks.
        ) -> list[tuple[int, int]]: # Each tuple is of the form `(start,end)`, where `text[start:end]` is a substring in `text` surrounded by double asterisks, including the double asterisks.
    """Return the indices of definition text surrounded by double asterisks.
    
    A double-asterisk-surrounded-text is a definition almost always
    when it is not purely LaTeX math mode text.
    
    Assumes that no LaTeX math mode string has double asterisks and that no
    LaTeX math mode string has the dollar sign character within it.
    """
    all_double_asterisks = double_asterisk_indices(text)
    notations = notation_asterisk_indices(text)
    return [tuppy for tuppy in all_double_asterisks if tuppy not in notations]

# %% ../nbs/00_helper.ipynb 101
def defs_and_notats_separations(
        text: str 
        )-> list[tuple[int, bool]]:
    """Finds the indices in the text where double asterisks occur and
    categorizes whether each index is for a definition or a notation.
    
    **Parameters**

    - text - str

    **Returns**

    - list[tuple[int, bool]]
        - Each tuple is of the form `(start, end, is_notation)`, where
        `text[start:end]` is the double-asterisk surrounded string,
        including the double asterisks.
    """
    all_double_asterisks = double_asterisk_indices(text)
    notations = notation_asterisk_indices(text)
    return [(start, end, (start, end) in notations)
            for start, end in all_double_asterisks]

# %% ../nbs/00_helper.ipynb 105
def is_number(
        x: Union[float, int, complex, str]
        ) -> bool:
    """Return `True` if the input `x` represents a number.
    
    This function is different from Python's built-in `is_numeric`
    function, which returns `True` when all characters of a string
    are digits.
    """
    if isinstance(x, (float, int, complex)):
        return True
    #For the case where string is None
    if x is None:
        return False
    if x and x[0] == '-': x = x[1:]
    return x.replace(".", "1", 1).isdigit()

# %% ../nbs/00_helper.ipynb 110
def existing_path(
        path: PathLike,  # A file or directory path. Either absolute or relative to `relative_to`.
        relative_to: Optional[PathLike] = None  # Path to the directory that `file` is relative to.  If `None`, then `path` is an absolute path.
        ) -> Path: # The path formed by `relative_to` adjoined with `path`.  Defaults to `None`
    """Returns a path relative to a specified path as an absolute path
    that exists.

    **Raises**

    - `FileNotFoundError`
        - If `relative_to` is not `None` but does not exist, or if
        `file` does not exist.
    - `ValueError`
        - If `relative_to` is not `None` and yet not an absolute path, or
        if `relative_to` is `None` at yet `path` is not an absolute path.
    
    **Notes**
    - This function may add the string `'\\\\?\\'` in front, which identifies
    very long paths.
    """
    if relative_to is not None:
        if not os.path.isabs(relative_to):
            raise ValueError(
                f'The parameter `relative_to` is expected to be an'
                f' absolute path, but it is not: {relative_to}')
        if not os.path.exists(relative_to):
            raise FileNotFoundError(
                errno.ENOENT, os.strerror(errno.ENOENT), relative_to)
        path = Path(relative_to) / path
    elif not os.path.isabs(path):
        raise ValueError(
            f'The parameter `path` is expected to be an absolute path,'
            f' but it is not: {path}')
    if not os.path.exists(path) and platform.system() == 'Windows':
        path = f'\\\\?\\{str(path)}'  # For long file names
    if not os.path.exists(path):
        raise FileNotFoundError(
            errno.ENOENT, os.strerror(errno.ENOENT), path)
    return Path(path)


@deprecated(reason="The function has been renamed to `existing_path`")
def file_existence_test(
        path: PathLike,  # A file or directory path. Either absolute or relative to `relative_to`.
        relative_to: Optional[PathLike] = None  # Path to the directory that `file` is relative to.  If `None`, then `path` is an absolute path.
        ) -> Path: # The path formed by `relative_to` adjoined with `path`.  Defaults to `None`
    """
    **Deprecated. Use `existing_path` instead.**
    
    Returns a path relative to a specified path as an absolute path
    that exists.

    **Raises**
    - `FileNotFoundError`
        - If `relative_to` is not `None` but does not exist, or if
        `file` does not exist.
    
    **Notes**
    - This function may add the string `'\\\\?\\'` in front, which identifies
    very long paths.
    """
    if relative_to is not None:
        if not os.path.isabs(relative_to):
            raise ValueError(
                f'The parameter `relative_to` is expected to be an'
                f' absolute path, but it is not: {relative_to}')
        if not os.path.exists(relative_to):
            raise FileNotFoundError(
                errno.ENOENT, os.strerror(errno.ENOENT), relative_to)
        path = Path(relative_to) / path
    elif not os.path.isabs(path):
        raise ValueError(
            f'The parmaeter `path` is expected to be an absolute path,'
            f' but it is not: {path}')
    if not os.path.exists(path) and platform.system() == 'Windows':
        path = f'\\\\?\\{str(path)}'  # For long file names
    if not os.path.exists(path):
        raise FileNotFoundError(
            errno.ENOENT, os.strerror(errno.ENOENT), path)
    return Path(path)

# %% ../nbs/00_helper.ipynb 123
def path_name_no_ext(
        path: PathLike # The path of the file or directory. This may be absolute or relative to any directory.
        ) -> str: # The name of the file or directory without the extension.
    """Return the name of a file or directory from its path without the
    extension.
    
    The file or directory does not have to exist.
    """
    name_with_extension = os.path.basename(path)
    return os.path.splitext(name_with_extension)[0]

# %% ../nbs/00_helper.ipynb 130
def path_no_ext(
    path: PathLike # The path of the file or directory. This may be absolute or relative to any directory.
    ) -> str: # The path of the file or directory without the extension. If `path` is a path to a directory, then the output should be essentially the same as `path`.
    """Returns the path of a file or directory without the extension.
    
    The file or directory does not have to exist.
    """
    return os.path.splitext(str(path))[0]

# %% ../nbs/00_helper.ipynb 134
def text_from_file(
        path: PathLike, # The absolute path of the file.
        encoding: str = 'utf8' # The encoding of the file to be read. Defaults to `'utf8'`.
        ) -> str: # The entire text from a file
    """Return the entire text from a file.

    Assuems that the file can be encoded in the specified `encoding`
    """
    with open(path, 'r', encoding=encoding) as file:
        text = file.read()
        file.close()
    return text

# %% ../nbs/00_helper.ipynb 137
def files_of_format_sorted(
        directory: PathLike, # The directory in which to find the files
        extension: str = 'txt' # Extension of the files to find. Defaults to 'txt'.
        ) -> list[str]:
    """Return a list of path str of files in the directory (but not subdirectories)
    sorted via `natsort`.
    """
    return natsorted(glob.glob(str(Path(directory) / f'*.{extension}')))

# %% ../nbs/00_helper.ipynb 141
def current_time_formatted_to_minutes(
        ) -> str:
    """Return the current time to minutes.

    **Returns**

    - str
        - In UTC time, to minutes.
    """
    dt = datetime.datetime.now(timezone.utc)
    formatted = dt.isoformat(timespec='minutes')
    return formatted[:16]

# %% ../nbs/00_helper.ipynb 149
def containing_string_priority(str1: str, str2: str) -> int:
    """Returns 1, 0, -1 depending on whether one string contains the other.
    
    TODO make the string containment criterion looser, e.g. finite Galois etale cover
    "contains" finite etale cover.
    
    **Parameters**
    - str1 - str
    - str2 - str
    """
    if str2 in str1:
        return -1
    elif str1 in str2:
        return 1
    else:
        # return len(str2) - len(str1)
        return 0


def default_str_comparison(str1: str, str2: str) -> int:
    """
    
    **Parameters**
    - str1 - str
    - str2 - str
    """
    if str1 > str2:
        return 1
    elif str1 < str2:
        return -1
    else:
        return 0


def natsort_comparison(str1: str, str2: str) -> int:
    """
    
    **Parameters**
    - str1 - str
    - str2 - str
    """
    if str1 == str2:
        return 0
    listy = [str1, str2]
    natsorted(listy)
    if listy[0] == str1:
        return -1
    else:
        return 1

# %% ../nbs/00_helper.ipynb 150
def graph_for_topological_sort(
        items_to_sort: Iterable[str],
        key_order: Callable[[str, str], int]) -> dict[str, set[str]]:
    """
    **Parameters**
    - items_to_sort - Iterable[str]
    - key_order: Callable[[str, str], int]
        - Comparing str1 against str2 results in a positive number if
        str1 is "greater" than str2 (i.e. str1 is of a later priority)
    
    **Returns**
    - dict[str, set[str]]
        - A dict whose keys are the elements `k` of `items_to_sort` and
        whose values are sets of elements `k2` of `items_to_sort` such that
        `key_order(k, k2) > 0`.
    """
    graph = dict()
    for key_1, key_2 in product(items_to_sort, items_to_sort):
        # print(key_1, key_2)
        if key_1 == key_2:
            continue
        # print(key_1, key_2)
        # print(key_order(key_1, key_2))
        if key_1 not in graph:
            graph[key_1] = set()
        if key_order(key_1, key_2) > 0:
            graph[key_1].add(key_2)
    return graph

# %% ../nbs/00_helper.ipynb 151
def dict_with_keys_topologically_sorted(
        dict_to_sort: dict[str],
        key_order: Callable[[str, str], int],
        reverse: bool = False) -> OrderedDict[str]:
    """Returns an OrderedDict whose keys are sorted topologically by
    the specified key order.
    
    **Parameters**
    - dict_to_sort - dict[str]
        - The dict whose keys need to be ordered.
    - key_order
        - The comparison function on the keys of `dict_to_sort`. Defaults to
        the key function for the comparison ``containing_string_priority``.
    - reverse - bool
        - Defaults to `False`
        
    **Returns**
    - OrderedDict[str]
    """
    graph = graph_for_topological_sort(dict_to_sort, key_order)
    ts = TopologicalSorter(graph)
    keys_ordered = list(ts.static_order())
    if reverse:
        keys_ordered = list(reversed(keys_ordered))
    return OrderedDict((key, dict_to_sort[key]) for key in keys_ordered)


# %% ../nbs/00_helper.ipynb 154
ALPHABET_TO_ALPHABET_GROUP_DICT = {'A': 'A-E', 'B': 'A-E', 'C': 'A-E', 'D': 'A-E', 'E': 'A-E', 'F': 'F-J', 'G': 'F-J', 'H': 'F-J', 'I': 'F-J', 'J': 'F-J', 'K': 'K-O', 'L': 'K-O', 'M': 'K-O', 'N': 'K-O', 'O': 'K-O', 'P': 'P-T', 'Q': 'P-T', 'R': 'P-T', 'S': 'P-T', 'T': 'P-T', 'U': 'U-Z', 'V': 'U-Z', 'W': 'U-Z', 'X': 'U-Z', 'Y': 'U-Z', 'Z': 'U-Z'}
ALPHABET_OR_GREEK_TO_ALPHABET_DICT = {}
def alphabet_to_alphabet_group(character) -> str:
    """
    Returns the alphabet group
    
    In my vaults, I often alphabetize things and also group
    the alphabet as follows:
    - A-E
    - F-J
    - K-O
    - P-T
    - U-V
    
    **Parameters**
    - character - str
    
    **Returns**
    - str or `None`
        - Returns `None` if `character` is not an alphabet.
    """
    character = character.upper()
    if character in ALPHABET_TO_ALPHABET_GROUP_DICT:
        return ALPHABET_TO_ALPHABET_GROUP_DICT[character]
    else:
        return None

def alphabet_or_latex_command_to_alphabet(character):
    """Returns the alphabet that the character "corresponds to".
    
    """
    # TODO
    return

def alphabet_or_latex_command_to_alphabet_group(character):
    return alphabet_to_alphabet_group(
        alphabet_or_latex_command_to_alphabet(character))
