Module CNLib.cnlib.cnfunctions

A collection of common functions used by CN software

Functions

pascal_case: Convert a class name to it's Pascal equivalent do_bool: Convert other values, like integers or strings, to bools dpretty: Pretty print a dict lpretty: Pretty print a list pp: Pretty print a dictionary or list combine_dicts: Update a dictionary with one or more dictionaries sh: Run a command string in the shell load_dicts: Combines dictionaries from all found paths save_dict: Save a dictionary to all paths

Functions

def combine_dicts(dicts_new, dict_old=None)
Expand source code
def combine_dicts(dicts_new, dict_old=None):
    """
    Update a dictionary with entries from another dict

    Args:
        dicts_new: A dictionary or list of dictionaries containing new
        keys/values to be updated in the old dictionary
        dict_old: The dictionary defined as the one to receive updates

    Returns:
        The updated dict_old, filled with updates from dict_new

    This function takes key/value pairs from each of the new dicts and
    adds/overwrites these keys and values in dict_old, preserving any values
    that are blank or None in dict_new. It is also recursive, so a dict or list
    as a value will be handled correctly.
    """

    # default return val
    if dict_old is None:
        dict_old = {}

    # sanity check
    if isinstance(dicts_new, dict):
        dicts_new = [dicts_new]
    if len(dicts_new) == 0:
        return dict_old
    if not dict_old:
        dict_old = {}

    # go through the new dicts in order
    for dict_new in dicts_new:

        # for each k,v pair in dict_n
        for k, v in dict_new.items():
            # copy whole value if key is missing
            if not k in dict_old:
                dict_old[k] = v

            # if the key is present in both
            else:
                # if the value is a dict
                if isinstance(v, dict):
                    # start recursing
                    # recurse using the current key and value
                    dict_old[k] = combine_dicts([v], dict_old[k])

                # if the value is a list
                elif isinstance(v, list):
                    list_old = dict_old[k]
                    for list_item in v:
                        list_old.append(list_item)

                # if the value is not a dict or a list
                else:
                    # just copy value from one dict to the other
                    dict_old[k] = v

    # return the updated dict_old
    return dict_old

Update a dictionary with entries from another dict

Args

dicts_new
A dictionary or list of dictionaries containing new
keys/values to be updated in the old dictionary
dict_old
The dictionary defined as the one to receive updates

Returns

The updated dict_old, filled with updates from dict_new This function takes key/value pairs from each of the new dicts and adds/overwrites these keys and values in dict_old, preserving any values that are blank or None in dict_new. It is also recursive, so a dict or list as a value will be handled correctly.

def compare_versions(ver_old, ver_new)
Expand source code
def compare_versions(ver_old, ver_new):
    """
    Compare two version strings for relativity

    Args:
        ver_old: Old version string
        ver_new: New version string

    Returns:
        An integer representing the relativity of the two version strings.
        S_VER_SAME means the two versions are equal,
        S_VER_NEWER means new_ver is newer than old_ver (or there is no old_ver), and
        S_VER_OLDER means new_ver is older than old_ver.

    This method compares two version strings and determines which is older,
    which is newer, or if they are equal. Note that this method converts
    only the first three parts of a semantic version string
    (https://semver.org/).
    """

    # test for new install (don't try to regex)
    if not ver_old or ver_old == "":
        return S_VER_NEWER

    # test for equal (just save some cpu cycles)
    if ver_old == ver_new:
        return S_VER_SAME

    # compare version string parts (only x.x.x)
    res_old = re.search(R_VERSION, ver_old)
    res_new = re.search(R_VERSION, ver_new)

    # if both version strings are valid
    if res_old and res_new:

        # make a list of groups to check
        lst_groups = [
            R_VERSION_GROUP_MAJ,
            R_VERSION_GROUP_MIN,
            R_VERSION_GROUP_REV,
        ]

        # for each part as int
        for group in lst_groups:
            old_val = int(res_old.group(group))
            new_val = int(res_new.group(group))

            # slide out at the first difference
            if old_val < new_val:
                return S_VER_NEWER
            elif old_val > new_val:
                return S_VER_OLDER
            # parts are equal, go to the next one
            else:
                continue
    else:
        raise OSError(S_ERR_VERSION)

    # return same if all parts equal
    return S_VER_SAME

Compare two version strings for relativity

Args

ver_old
Old version string
ver_new
New version string

Returns

An integer representing the relativity of the two version strings. S_VER_SAME means the two versions are equal, S_VER_NEWER means new_ver is newer than old_ver (or there is no old_ver), and S_VER_OLDER means new_ver is older than old_ver. This method compares two version strings and determines which is older, which is newer, or if they are equal. Note that this method converts only the first three parts of a semantic version string (https://semver.org/).

def dialog(message, buttons, default='', btn_sep='/', msg_fmt='{} [{}]: ')
Expand source code
def dialog(message, buttons, default="", btn_sep="/", msg_fmt="{} [{}]: "):
    """
    Create a dialog-like question and return the result
    
    Args:
        message: The message to display
        buttons: List of single char answers to the question
        default: The button item to return when the user presses Enter at the 
            question (default: "")
        btn_sep: Char to use to separate button items
        msg_fmt: Format string to present message/buttons to the user

    Returns:
        A lowercased string that matches a button (or an empty string if the \
            entered option is not in the button list)

    This method returns the string entered on the command line in response to a
    question. If the entered option does not match any of the buttons, a blank
    string is returned. If you set a default and the option entered is just the
    Return key, the default string will be returned. If no default is present,
    the entered string must match one of the buttons array values. All returned
    values are lowercased. The question will be repeatedly printed to the 
    screen until a valid entry is made.

    Note that if default == "", pressing Enter is not considered a valid entry.
    """

    # make all params lowercase
    buttons = [item.lower() for item in buttons]
    default = default.lower()

    # --------------------------------------------------------------------------

    # if we passes a default
    if default != "":

        # find the default
        if not default in buttons:

            # not found, add at end of buttons
            buttons.append(default)

        # upper case it
        buttons[buttons.index(default)] = default.upper()

    # --------------------------------------------------------------------------

    # add buttons to message
    btns_all = btn_sep.join(buttons)
    str_fmt = msg_fmt.format(message, btns_all)

    # lower everything again for compare
    buttons = [item.lower() for item in buttons]

    # --------------------------------------------------------------------------

    while True:

        # ask the question, get the result
        inp = input(str_fmt)
        inp = inp.lower()

        # # no input (empty)
        if inp == "" and default != "":
            return default

        # input a button
        if inp in buttons:
            return inp

Create a dialog-like question and return the result

Args

message
The message to display
buttons
List of single char answers to the question
default
The button item to return when the user presses Enter at the question (default: "")
btn_sep
Char to use to separate button items
msg_fmt
Format string to present message/buttons to the user

Returns

A lowercased string that matches a button (or an empty string if the entered option is not in the button list) This method returns the string entered on the command line in response to a question. If the entered option does not match any of the buttons, a blank string is returned. If you set a default and the option entered is just the Return key, the default string will be returned. If no default is present, the entered string must match one of the buttons array values. All returned values are lowercased. The question will be repeatedly printed to the screen until a valid entry is made.

Note that if default == "", pressing Enter is not considered a valid entry.

def do_bool(val)
Expand source code
def do_bool(val):
    """
    Convert other values, like integers or strings, to bools

    Args:
        val: The value to convert to a bool

    Returns:
        A boolean value converted from the argument

    Converts integers and strings to boolean values based on the rules.
    """

    # lower all test vals
    rules_true = [item.lower() for item in L_RULES_TRUE]

    # return result
    return str(val).lower() in rules_true

Convert other values, like integers or strings, to bools

Args

val
The value to convert to a bool

Returns

A boolean value converted from the argument Converts integers and strings to boolean values based on the rules.

def dpretty(dict_print, indent_size=4, indent_level=0, label=None)
Expand source code
def dpretty(dict_print, indent_size=4, indent_level=0, label=None):
    """
    Pretty print a dict

    Args:
        dict_print: The dictionary to print
        indent_size: The number of spaces to use for each indent level
        (default: 4)
        indent_level: The number of indent levels to use for this part of the
        print process (default: 0)
        label: The string to use as a label (default: None)

    Returns:
        The formatted string to print

    Raises:
        OSError if the first param is not a dict

    Formats a dictionary nicely so it can be printed to the console.
    """

    # sanity check
    if not isinstance(dict_print, dict):
        raise OSError(S_ERR_NOT_DICT)

    # default out
    out = ""

    # print label
    if label is not None:
        out += label + ": "

    # convert indent_size to string and multiply by indent_level
    indent_str = (" " * indent_size) * (indent_level)

    # items will need an extra indent, since they don't recurse
    indent_str_next = (" " * indent_size) * (indent_level + 1)

    # default result opening brace (no indent in case it is nested and is
    # preceded by a key)
    out += indent_str + "{\n"

    # for each entry
    for k, v in dict_print.items():

        # print the key
        out += indent_str_next + f'"{k}": '

        # if the value is a list
        if isinstance(v, list):

            # recurse the value and increase indent level
            ret = (
                lpretty(
                    v,
                    indent_size=indent_size,
                    indent_level=indent_level + 1,
                    label=None,
                )
                + "\n"
            )
            ret = ret.lstrip()
            out += ret

        # if the value is a dict
        elif isinstance(v, dict):

            # recurse the value and increase indent level
            ret = (
                dpretty(
                    v,
                    indent_size=indent_size,
                    indent_level=indent_level + 1,
                    label=None,
                )
                + "\n"
            )
            ret = ret.lstrip()
            out += ret

        # if it is a single entry (str, int, bool)
        else:

            # print the value, quoting it if it is a string
            if isinstance(v, str):
                out += f'"{v}",\n'
            else:
                out += f"{v},\n"

    # get original indent
    indent_str = (" " * indent_size) * indent_level

    # # add closing bracket
    out += indent_str + "}"

    # return result
    return out

Pretty print a dict

Args

dict_print
The dictionary to print
indent_size
The number of spaces to use for each indent level
(default: 4)
indent_level
The number of indent levels to use for this part of the
print process (default: 0)
label
The string to use as a label (default: None)

Returns

The formatted string to print

Raises

OSError if the first param is not a dict Formats a dictionary nicely so it can be printed to the console.

def fix_globs(dir_start, dict_in)
Expand source code
def fix_globs(dir_start, dict_in):
    """
    Convert items in blacklist to absolute Path objects

    Args:
        dict_in: the dictionary with glob strings

    Returns:
        A dictionary of Path objects representing the globs

    Get absolute paths for all entries in the blacklist.
    """

    # the un-globbed dict to return
    result = {}
    dir_start = Path(dir_start)

    # make a copy and remove path separators in one shot
    # NB: this is mostly for glob support, as globs cannot end in path
    # separators
    for key, val in dict_in.items():
        dict_in[key] = [item.rstrip("/") for item in val]

    # support for absolute/relative/glob
    # NB: adapted from cntree.py

    # for each section of blacklist
    for key, val in dict_in.items():

        # convert all items in list to Path objects
        paths = [Path(item) for item in val]

        # move absolute paths to one list
        abs_paths = [item for item in paths if item.is_absolute()]

        # move relative/glob paths to another list
        other_paths = [item for item in paths if not item.is_absolute()]

        # convert relative/glob paths back to strings
        other_strings = [str(item) for item in other_paths]

        # get glob results as generators
        glob_results = [dir_start.glob(item) for item in other_strings]

        # start with absolutes
        new_val = abs_paths

        # for each generator
        for item in glob_results:
            # add results as whole shebang
            new_val += list(item)

        # set the list as the result list
        result[key] = new_val

    # return the un-globbed dict
    return result

Convert items in blacklist to absolute Path objects

Args

dict_in
the dictionary with glob strings

Returns

A dictionary of Path objects representing the globs Get absolute paths for all entries in the blacklist.

def get_dict_from_file(path_cfg)
Expand source code
def get_dict_from_file(path_cfg):
    """
    Open a json file and return the dict inside

    Args:
        path_cfg: Path to the file containing the dict

    Returns:
        The dict contained in the file

    Opens the specified file and returns the config dict found in it.
    """

    # set conf dict
    try:
        with open(path_cfg, "r", encoding="UTF-8") as a_file:
            return json.load(a_file)

    # file not found
    except FileNotFoundError as e:
        raise OSError(S_ERR_NOT_FOUND.format(path_cfg)) from e

    # not valid json in file
    except json.JSONDecodeError as e:
        raise OSError(S_ERR_NOT_JSON.format(path_cfg)) from e

Open a json file and return the dict inside

Args

path_cfg
Path to the file containing the dict

Returns

The dict contained in the file Opens the specified file and returns the config dict found in it.

def load_dicts(paths, start_dict=None)
Expand source code
def load_dicts(paths, start_dict=None):
    """
    Combines dictionaries from all found paths

    Args:
        paths: The file path or list of file paths to load
        start_dict: The starting dict and final dict after combining (default:
        None)

    Returns:
        The final combined dictionary

    Raises:
        FileNotFoundError: If the file does not exist
        json.JSONDecodeError: If the file is not a valid JSON file

    Load the dictionaries from all files and use combine_dicts to combine them.
    """

    # sanity check
    if isinstance(paths, (str, Path)):
        paths = [paths]

    # set the default result
    if start_dict is None:
        start_dict = {}

    # loop through possible files
    for path in paths:

        # sanity check
        path = Path(path).resolve()

        # sanity check
        if path is None or not path.exists():
            print(S_ERR_NOT_EXIST.format(path))
            continue

        # try each option
        try:

            # open the file
            with open(path, "r", encoding="UTF-8") as a_file:
                # load dict from file
                new_dict = json.load(a_file)

                # combine new dict with previous
                start_dict = combine_dicts([new_dict], start_dict)

        # file not JSON
        except json.JSONDecodeError as e:
            raise OSError(S_ERR_NOT_VALID.format(path)) from e

    # return the final dict
    return start_dict

Combines dictionaries from all found paths

Args

paths
The file path or list of file paths to load
start_dict
The starting dict and final dict after combining (default:

None)

Returns

The final combined dictionary

Raises

FileNotFoundError
If the file does not exist
json.JSONDecodeError
If the file is not a valid JSON file

Load the dictionaries from all files and use combine_dicts to combine them.

def lpretty(list_print, indent_size=4, indent_level=0, label=None)
Expand source code
def lpretty(list_print, indent_size=4, indent_level=0, label=None):
    """
    Pretty print a list

    Args:
        list_print: The list to print
        indent_size: The number of spaces to use for each indent level
        (default: 4)
        indent_level: The number of indent levels to use for this part of the
        print process (default: 0)
        label: The string to use as a label (default: None)

    Returns:
        The formatted string to print

    Raises:
        OSError if the first param is not a list

    Formats a list nicely so it can be printed to the console.
    """

    # sanity check
    if not isinstance(list_print, list):
        raise OSError(S_ERR_NOT_LIST)

    # default out
    out = ""

    # print label
    if label is not None:
        out += label + ": "

    # convert indent_size to string and multiply by indent_level
    indent_str = (" " * indent_size) * (indent_level)

    # items will need an extra indent, since they don't recurse
    indent_str_next = (" " * indent_size) * (indent_level + 1)

    # default result opening brace (no indent in case it is nested and is
    # preceded by a key)
    out += indent_str + "[\n"

    # for each entry
    for v in list_print:

        # if the value is a list
        if isinstance(v, list):

            # recurse the value and increase indent level
            ret = (
                lpretty(
                    v,
                    indent_size=indent_size,
                    indent_level=indent_level + 1,
                    label=None,
                )
                + "\n"
            )
            out += ret

        # if the value is a dict
        elif isinstance(v, dict):

            # recurse the value and increase indent level
            ret = (
                dpretty(
                    v,
                    indent_size=indent_size,
                    indent_level=indent_level + 1,
                    label=None,
                )
                + "\n"
            )
            out += ret

        # if it is a single entry (str, int, bool)
        else:

            # print the value, quoting it if it is a string
            if isinstance(v, str):
                out += indent_str_next + f'"{v}",\n'
            else:
                out += indent_str_next + f"{v},\n"

    # get original indent
    indent_str = (" " * indent_size) * indent_level

    # # add closing bracket
    out += indent_str + "]"

    # return result
    return out

Pretty print a list

Args

list_print
The list to print
indent_size
The number of spaces to use for each indent level
(default: 4)
indent_level
The number of indent levels to use for this part of the
print process (default: 0)
label
The string to use as a label (default: None)

Returns

The formatted string to print

Raises

OSError if the first param is not a list Formats a list nicely so it can be printed to the console.

def pascal_case(a_str)
Expand source code
def pascal_case(a_str):
    """
    Format a string in Pascal case

    Args:
        a_str: A string to convert to Pascal case

    Returns;
        The Pascal cased string

    Formats the given string to a Pascal case equivalent, ie. "my_class"
    becomes "MyClass".
    """

    # do formatting
    name_pascal = a_str
    name_pascal = name_pascal.replace("_", " ")
    name_pascal = name_pascal.replace("-", " ")
    name_pascal = name_pascal.title()
    name_pascal = name_pascal.replace(" ", "")

    # return result
    return name_pascal

Format a string in Pascal case

Args

a_str
A string to convert to Pascal case

Returns; The Pascal cased string

Formats the given string to a Pascal case equivalent, ie. "my_class" becomes "MyClass".

def pp(obj, indent_size=4, label=None)
Expand source code
def pp(obj, indent_size=4, label=None):
    """
    Pretty print a dictionary or list

    Args:
        obj: The dictionary or list to print
        indent_size: The number of spaces to use for each indent level
        (default: 4)
        label: The string to use as a label (default: None)

    Returns:
        The object formatted for printing

    Raises:
        OSError if the first param is not a dict or list

    Formats a dictionary or list nicely and prints it to the console. Note that
    this method includes magic commas in the output, and therefore cannot be
    used to create true JSON-compatible strings. It should only be used for
    debugging.
    """

    # the default result
    result = ""

    # call different pretty functions depending on the object type
    if isinstance(obj, dict):
        result = dpretty(obj, indent_size, 0, label)
    elif isinstance(obj, list):
        result = lpretty(obj, indent_size, 0, label)
    else:
        raise OSError(S_ERR_NOT_DICT_OR_LIST)

    # print the result
    print(result)

Pretty print a dictionary or list

Args

obj
The dictionary or list to print
indent_size
The number of spaces to use for each indent level
(default: 4)
label
The string to use as a label (default: None)

Returns

The object formatted for printing

Raises

OSError if the first param is not a dict or list Formats a dictionary or list nicely and prints it to the console. Note that this method includes magic commas in the output, and therefore cannot be used to create true JSON-compatible strings. It should only be used for debugging.

def save_dict(a_dict, paths)
Expand source code
def save_dict(a_dict, paths):
    """
    Save a dictionary to all paths

    Args:
        a_dict: The dictionary to save to the file
        paths: The path or list of paths to save to

    Raises:
        OSError: If the file does not exist and can't be created

    Save the dictionary to a file at all the specified locations.
    """

    # sanity check
    if isinstance(paths, (str, Path)):
        paths = [paths]

    # loop through possible files
    for path in paths:

        # sanity check
        path = Path(path).resolve()

        # try each option
        try:

            # make sure path is absolute
            if not path.is_absolute():
                print(S_ERR_NOT_CREATE.format(path))
                continue

            # first make dirs
            path.parent.mkdir(parents=True, exist_ok=True)

            # open the file
            with open(path, "w", encoding="UTF-8") as a_file:
                # save dict tp file
                json.dump(a_dict, a_file, indent=4)

        # raise an OS Error
        except OSError as e:
            raise OSError(S_ERR_NOT_CREATE.format(path)) from e

Save a dictionary to all paths

Args

a_dict
The dictionary to save to the file
paths
The path or list of paths to save to

Raises

OSError
If the file does not exist and can't be created

Save the dictionary to a file at all the specified locations.

def sh(cmd, shell=False)
Expand source code
def sh(cmd, shell=False):
    """
    Run a program or command string in the shell

    Args:
        cmd: The command line to run
        shell: If False (the default), run the cmd as one long string. If True,
        split the cmd into separate arguments

    Returns:
        The result of running the command line, as a
        subprocess.CompletedProcess object

    This is just a dumb convenience method to use subprocess with a string
    instead of having to convert a string to an array with shlex every time I
    need to run a shell command.
    """

    # make sure it's a string (sometime pass path object)
    cmd = str(cmd)

    # split the string using shell syntax (smart split/quote)
    # NB: only split if running a file - if running a shell cmd, don't split
    if not shell:
        cmd = shlex.split(cmd)

    # get result of running the shell command or bubble up an error
    try:
        res = subprocess.run(
            # the array of commands produced by shlex.split
            cmd,
            # if check is True, an exception will be raised if the return code
            # is not 0
            # if check is False, no exception is raised but res will be None,
            # meaning you have to test for it in the calling function
            # but that also means you have no information on WHY it failed
            check=True,
            # convert stdout/stderr from bytes to text
            text=True,
            # put stdout/stderr into res
            capture_output=True,
            # whether the call is a file w/ params (False) or a direct shell
            # input (True)
            shell=shell,
        )

    # check if it failed
    except subprocess.CalledProcessError as e:
        raise OSError(S_ERR_SHELL) from e

    # return the result
    return res

Run a program or command string in the shell

Args

cmd
The command line to run
shell
If False (the default), run the cmd as one long string. If True,

split the cmd into separate arguments

Returns

The result of running the command line, as a subprocess.CompletedProcess object This is just a dumb convenience method to use subprocess with a string instead of having to convert a string to an array with shlex every time I need to run a shell command.