""" Welcome to RP: Ryan Python """

from __future__ import unicode_literals
import sys
import threading
from builtins import *  # For autocompletion with pseudo_terminal
from time import sleep
import rp
from rp.libs.graveyard import *
import os
import time
import shlex
import sys
import operator
import platform
import random
import warnings
import traceback
import tempfile
import contextlib
import cachetools
import itertools
import math
import random
import re
from itertools import product as cartesian_product, chain, combinations as all_combinations
from functools import lru_cache, partial
from contextlib import contextmanager
from math import factorial
import datetime
from collections.abc import MutableSet
from collections import Counter

#Make glob both a function and module
import glob
glob.glob.__dict__.update(glob.__dict__)
glob = glob.glob

# Make copy both a function and module
import copy
copy.copy.__dict__.update(copy.__dict__)
copy = copy.copy

# Make time both a function and module : Can't because time.time is builtin

# Files that RP maintains
_rp_folder                    = __file__[: -len("r.py")]
_site_packages                = _rp_folder[: -len("/rp/")]
_local_clipboard_string_path  = os.path.join(_rp_folder, "r.py.rp_local_clipboard")
_cd_history_path              = os.path.join(_rp_folder, "r.py.rp_cd_history.txt")
_pyin_settings_file_path      = os.path.join(_rp_folder, "r.py.rp_pyin_settings")
_prompt_style_path            = os.path.join(_rp_folder, "r.py.rp_prompt_style")
history_filename              = os.path.join(_rp_folder, "r.py.history.txt")
rprc_file_path                = os.path.join(_rp_folder, ".rprc")
_local_obj_clipboard_path     = os.path.join(_rp_folder, ".rp_local_clipboard")
pterm_history_filename        = os.path.join(_rp_folder, "HISTORY")
_rp_documentation_file        = os.path.join(_rp_folder, "documentation.py")
_rp_git_dir                   = os.path.join(_rp_folder, "git")
_rp_downloads_folder          = os.path.join(_rp_folder, "downloads")
_rp_boot_cache                = os.path.join(_rp_folder, ".boot_cache")
_rp_outputs_folder            = os.path.join(_rp_folder, "outputs")
_old_gists_path               = os.path.join(_rp_folder, "old_gists.txt")
_old_gists_cache              = os.path.join(_rp_downloads_folder, 'gist_cache')
_claudecode_folder            = os.path.join(_rp_outputs_folder, "claudecode")
_google_fonts_download_folder = os.path.join(_rp_downloads_folder, "google_fonts")
_downloaded_font_dir          = os.path.join(_rp_downloads_folder, "fonts")
_rp_gists_path                = os.path.join(_rp_git_dir, "gists")
_cached_lexer_path            = os.path.join(_rp_boot_cache, "lexer.rpo")
_cached_code_styles_path      = os.path.join(_rp_boot_cache, "code_styles.rpo")
_cached_system_commands_path  = os.path.join(_rp_boot_cache, "system_commands.txt")
_ruff_cache_dir               = os.path.join(tempfile.gettempdir(), 'rp_ruff_cache')

#Add RP's libraries to system path
sys.path.append(_rp_folder)
from rp.libs.stamp_tensor import stamp_tensor, crop_tensor 
from rp.libs.tracetraptest import * #A few libs debugging features. These things mostly need to be renamed.
from rp.libs.debug_comment import debug_comment
import rp.rp_ptpython.prompt_style as ps

#Lazy load modules for a speed boost
try:
    import lazy_loader
    # ic = lazy_loader.load('icecream')
    np = lazy_loader.load('numpy')
    we = lazy_loader.load('web_evaluator')
    pickle = lazy_loader.load('pickle')
    multiprocessing = lazy_loader.load('multiprocessing')
except ImportError:
    print("r.py: lazy_loader not found, importing modules eagerly")

    try: import numpy as np
    except Exception as e: print("r.py import error: ",e)

    try: import web_evaluator as we
    except Exception as e: print("r.py import error: ",e)

#SETTINGS
_fast_pterm_history_size = 1024 * 1024

#EVER Wondering which part of the code imports numpy first to debug lazy loading speeds on rp import? This will help.
# class AccessTracer:
#     """
#     Wraps a lazy-loaded object to trace the first time an attribute is accessed.
#     """
#     def __init__(self, lazy_object, name):
#         # Use object.__setattr__ to avoid triggering our own __setattr__
#         object.__setattr__(self, "_lazy_object", lazy_object)
#         object.__setattr__(self, "_name", name)
#         object.__setattr__(self, "_triggered", False)
#     def __getattr__(self, item):
#         if not self._triggered:
#             print(f"\n--- LAZY LOADER TRIGGER: First access to '{self._name}.{item}' ---")
#             traceback.print_stack()
#             print("----------------------------------------------------------\n")
#             # Mark as triggered so this only runs once
#             object.__setattr__(self, "_triggered", True)
#         # Forward the call to the actual lazy object, which will then load the module
#         return getattr(self._lazy_object, item)
#     # This is needed for things like `np += 1` if that ever happens
#     def __setattr__(self, key, value):
#         # Trigger the trace if it's the first interaction
#         self.__getattr__(key)
#         # Forward the setattr to the now-loaded module
#         setattr(self._lazy_object, key, value)
# np = AccessTracer(np,'numpy')

#Let child processes use this rp instance. This is used in my vim config for \wp and \wc for instance
#There can be many rp's installed on a system. Whatever vim was started from this rp will use this rp.
os.environ['RP_SYS_EXECUTABLE']        = sys.executable
os.environ['RP_PUDB_SYS_VERSION_INFO'] = repr(list(sys.version_info)) # For PUDB editing in vim
os.environ['RP_SITE_PACKAGES']         = _site_packages               # For PUDB editing in vim

_original_pwd = os.getcwd()

# region ［entuple， detuple］

def entuple(x):
    """
    Ensures input is a tuple. If already a tuple, returns unchanged; otherwise wraps in tuple.
    For pesky petty things. Code is simpler than explanation here.
        
    Example:
        >>> entuple(5)
        (5,)
        >>> entuple((1, 2))
        (1, 2)
    """
    if isinstance(x,tuple):
        return x
    return x,

def detuple(x):
    """ 
    When len(x) == 1, returns x[0], otherwise returns x unchanged.

    For pesky petty things. Code is simpler than explanation here. 
    Primarily used for allowing functions to take either one iterable argument OR a vararg list of items
    Used commonly throughout RP's library functions to make them more convenient to use.

    Parameters:
        x: Any iterable or sequence type
        
    Returns:
        If x has exactly one element: returns x[0]  
        Otherwise: returns x unchanged
        
    Example:

        >>> def print_sum(*x):
        ...     x = detuple(x)
        ...     print(sum(x))
        >>> print_sum(1,2,3,4,5)   # 15
        >>> print_sum([1,2,3,4,5]) # 15

        >>> detuple((1,))
        1
        >>> detuple(([1,2,3],))
        [1, 2, 3]

    See Also: enlist, delist
    """
    try:
        if len(x) == 1:
            return x[0]
    except Exception:
        pass
    return x
# endregion
# region ［enlist， delist］
def enlist(x):
    """ For pesky petty things. Code is simpler than explanation here. """
    if isinstance(x,list):
        return x
    return [x]

def delist(x):
    """ For pesky petty things. Code is simpler than explanation here. """
    try:
        if len(x) == 1:
            return x[0]
    except Exception:
        pass
    return x
# endregion
# region  rCode: ［itc‚ run‚ fog‚ scoop‚ seq_map‚ par_map‚ seq‚ par‚ rev‚ pam‚ identity，list_flatten，summation，product］
#   ∞
#   ∫𝓍²∂𝓍
# ﹣∞
def itc(f,x):
    """
    Iterate to convergence, where f(x)==x.
    In other words, iterate f on x until we reach a fixed point.
    
    Args:
        f: Function to iterate
        x: Initial value
        
    Returns:
        The fixed point value where f(x) == x
        
    Example:
        >>> itc(lambda x: (x + 2/x) / 2, 1.0)  # Newton's method for sqrt(2)
        1.414213562373095
    """
    while True:
        y=f(x)
        if y==x:
            return y
        x=y
# region  ［run‚ fog］
def run_func(f,*g,**kwg):
    """
    Calls function f with given args and kwargs.
    Simple function call wrapper. Pop () ⟶ )(
    
    Args:
        f: Function to call
        *g: Positional arguments to pass to f
        **kwg: Keyword arguments to pass to f
        
    Returns:
        Result of f(*g, **kwg)
        
    Example:
        >>> run_func(print, "hello", "world")
        hello world
    """
    return f(*g,**kwg)
call = run_func
    
# endregion

# region［scoop］
# scoop could have been implemented with seq. I chose not to.
def scoop(funcⵓscoopˏnew,list_in,init_value=None):
    """
    Left-fold (reduce) over an iterable with an accumulator.

    Similar to functools.reduce, but tries to copy init_value to avoid mutation.

    Args:
        funcⵓscoopˏnew: function(acc, x) -> new_acc
        list_in: iterable input
        init_value: initial accumulator (optional)

    Example:
        >>> scoop(lambda a,b: a+b, [1,2,3], 0)
        6

    See Also: functools.reduce, seq, seq_map, par_map
    """
    from copy import copy,deepcopy
    # Try to make a copy just in case init_value is a list
    try:
        scoop_value=deepcopy(init_value)
    except Exception:
        try:
            scoop_value=copy(init_value)
        except Exception:
            scoop_value=init_value
    for element in list_in:
        scoop_value=funcⵓscoopˏnew(scoop_value,element)
    return scoop_value
# endregion
# region ［seq_map‚ par_map］
def seq_map(func,*iterables):
    """
    Like python's map, but with non-lazy evaluation (returns list immediately).
    Unlike python's map function which is lazy, this forces evaluation of all results.
    
    Args:
        func: Function to apply to each set of arguments
        *iterables: One or more iterables to process. func is applied to zip(*iterables)
        
    Returns:
        list: Results of applying func to each set of arguments
        
    Example:
        >>> seq_map(lambda x: x*2, [1,2,3])
        [2, 4, 6]
        
    """
    return list(map(func,*iterables))  # Basically it's exactly like python's built-in map function, except it forces it to evaluate everything inside it before it returns the output.






def par_map(func,*iterables,num_threads=None,buffer_limit=0):
    """
    Like python's map, but runs in parallel using multiple threads.

    See lazy_par_map for further doc. 
    
    Parameters:
        func (callable): The function to apply to each set of arguments
        *iterables: One or more iterables to process. func is applied to zip(*iterables)
        num_threads (int, optional): Number of worker threads. Defaults to 32.
                                     Set to 0 for sequential execution.
        buffer_limit (int, optional): Memory management parameter. Defaults to 0 (unlimited).
                                      Non-zero values conserve memory for large datasets.
                                      buffer_limit defaults to 0 because we return everything all at once anyway,
                                      and therefore don't care about how long we wait for the first item, 
                                      and we have to store all the outputs in memory anyway.

    Returns:
        list of results in input order

    Examples:
        >>> par_map(lambda x: [sleep(1), x*x][1], [1,2,3,4,5,6,7,8,9,10]) #Runs in one second
        [1, 4, 9, 16, 25, 36, 49, 64, 81, 100]

    See Also: lazy_par_map, seq, pam, map
    """
    return list(lazy_par_map(func,*iterables,num_threads=num_threads,buffer_limit=buffer_limit))



def lazy_par_map(func, *iterables, num_threads=None, buffer_limit=None):
    """
    A parallelized version of the built-in map using ThreadPoolExecutor.
    
    Parameters:
        - func: The function to apply to the items.
        - *iterables: Input iterables. The function is applied to the results of zipping these iterables.
        - num_threads (optional): The number of worker threads to use. 
                                 If 0, the function will work synchronously like the built-in map.
                                 If not provided, defaults to 32.
        - buffer_limit (optional): The maximum size of the buffer. Without this, this function isn't really very lazy lol.
                                   If set to 0, there's no constraint on the number of stored items and it will try to precalculate everything.
                                   A buffer_limit is useful for conserving memory, such as loading millions of images lazily in a dataloader that processes them slowly.
                                   For example, if we want to load 1,000,000 images from URLs lazily, if we don't have a buffer limit, 
                                       it will try making 1,000,000 http requests all at once - which can prevent the first ones from completing in a reasonable time.
                                       It also means you'd have to store all 1,000,000 images in memory even before you need them - which could crash your python runtime.
                                   If not provided, defaults to num_threads.

    Returns:
        - An iterator that yields results as tasks complete.

    Examples:
        def test_par_map():
            def func(index):
                time=random_float(0,1)
                sleep(time)
                print(index)
                return index
            return list(lazy_par_map(func,range(10),buffer_limit=3,num_threads=3))

        ans=test_par_map()
        assert ans==sorted(ans),'lazy_par_map failed to preserve order with a non-zero buffer_limit'
    """
    from concurrent.futures import ThreadPoolExecutor, wait, FIRST_COMPLETED
    
    if num_threads is not None and (not isinstance(num_threads, int) or num_threads < 0):
        raise ValueError("num_threads must be None or an integer >= 0")

    if buffer_limit is not None and (not isinstance(buffer_limit, int) or buffer_limit < 0):
        raise ValueError("buffer_limit must be None or an integer >= 0")

    if num_threads is None:
        num_threads = 32
    
    if buffer_limit is None:
        buffer_limit = num_threads

    if num_threads == 0:
        yield from map(func, *iterables)
        return
    
    iterable = zip(*iterables)
    iterator = enumerate(iter(iterable))
    
    with ThreadPoolExecutor(max_workers=num_threads) as executor:
        if not buffer_limit:
            yield from executor.map(func, *iterables)
            return
        
        futures = set()

        # We need to preserve the order of the inputs
        results = {} #Maps index -> result
        yield_index=0
        def wrapper(index):
            def new_func(*args):
                value=func(*args)
                return index,value
            return new_func

        # Load up the initial buffer_limit tasks
        for _ in range(buffer_limit):
            try:
                iterator_index, args = next(iterator)
                futures.add(executor.submit(wrapper(iterator_index), *args))
            except StopIteration:
                break

        while futures:
            done, _ = wait(futures, return_when=FIRST_COMPLETED)  # wait for any future to complete

            for future in done:
                result_index,value = future.result()
                results[result_index]=value
                futures.remove(future)
                
                try:
                    iterator_index,args = next(iterator)
                    futures.add(executor.submit(wrapper(iterator_index), *args))
                except StopIteration:
                    pass
                
            while yield_index in results:
                yield results[yield_index]
                del results[yield_index] #Don't leak memory!
                yield_index+=1
            
                


# endregion
# region ［seq‚ par］
def seq(funcs,*init):
    """Chains multiple functions together, where the output of one becomes the input of the next.

    - If funcs is a single function, calls funcs(*init) directly.
    - If funcs is an iterable of functions, applies them sequentially.
    - Functions returning None preserve the previous value (skip transformation).
    - Handles both single values and tuple arguments automatically.

    Args:
        funcs: Function or iterable of functions to apply sequentially.
        *init: Initial arguments to pass to the first function.

    Returns:
        Result of the final function in the chain, or initial value if empty chain.

    Examples:
        >>> seq([lambda x: x + 1, lambda x: x * 2, lambda x: x ** 2], 3)
        64
        >>> seq([str.strip, str.lower, lambda s: s.replace(' ', '_')], '  Hello World  ')
        'hello_world'

    See Also:
        - par(): Parallel version - applies multiple functions to same arguments.
        - pam(): Applies multiple functions to single argument set, returns list.  
        - rev(func, n): Creates function that applies func n times using seq.
        - scoop(): Functional reduce/fold operation for accumulation.
    """
    # The current flagship function of rCode. This function can, in theory, single-handedly replace all other rCode functions (except par, which is analogous to seq). (Though it might be inconvenient to do so)
    # Possible future add-on: Enable recursive calls with a special value of func? (Probably won't though)
    try:  # Usually funcs will be an iterable. But if it is not, this test will catch it. This is because seq(print,'hello world')≣seq([print],'hello world')
        funcs=list(funcs)  # A simple check to find out whether funcs is iterable or not. If it is, it becomes a list (even if it was originally, let's say, a tuple).
    except TypeError:  # 'funcs' was not iterable; ∴ 'funcs' must be a single, callable function
        return funcs(*init)  # Because we have not yet iterated, we contain certain that 'init' is a tuple.

    # assert isinstance(funcs,list) # Internal logic assertion. This should always be true because of 'funcs=[*funcs]'
    for func in funcs:  # If we reach this line, we know ∴ 'funcs' is a list.
        temp=func(*init) if isinstance(init,tuple) else func(init)
        if temp is not None:
            init=temp
    return init

def par(funcsᆢvoids,*params):
    """Executes multiple functions in parallel with the same arguments.

    This function is the parallel counterpart to seq(). It's designed for "void"
    functions (side effects) since return values are not collected.

    Args:
        funcsᆢvoids (iterable): Collection of functions to execute in parallel.
        *params: Arguments to pass to each function.

    Examples:
        >>> def send_email(msg): print(f"emailing: {msg}")
        >>> def send_sms(msg): print(f"smsing: {msg}")
        >>> par([send_email, send_sms], "System alert!")

    See Also:
        - seq(): Sequential function composition (par's counterpart).
        - par_map(): Parallel map over different arguments.
        - pam(): Apply multiple functions to same args (sequential).
    """
    # NOTE: PARAMS NEVER CHANGES!!! The applications of that would be too limited to justify the effort of creating it. Instead, this def simply treats all functions as voids in the same way that seq could.
    # seq's little sister, and child of par_map. Only analagous to seq in specific contexts. This function is NOT capable of returning anything useful due to the inherent nature of multi-threading.
    par_map(lambda func:func(*params),funcsᆢvoids)  # Shares a similar syntax to seq. AKA multiple functions with a single set of parameters.
# endregion
# region  ［rev］
rev=lambda f,n:lambda *𝓍_:seq([f] * n,*𝓍_)  # Pseudo-revolutions (technically iterations)     Ex: rev(lambda x:x+1,5)(0) == 5
# endregion
# region ［pam］
def pam(funcs,*args,**kwargs):
    """
    Applies multiple functions to the same arguments. pam is map spelt backwards.
    Maps multiple functions onto a single set of arguments (instead of map, which maps 
    multiple sets of arguments onto one function).
    
    Args:
        funcs: Iterable of functions to apply
        *args: Positional arguments to pass to each function
        **kwargs: Keyword arguments to pass to each function
        
    Returns:
        list: Results of applying each function to the given arguments
        
    Example:
        >>> pam([len, str.upper, str.lower], "Hello")
        [5, 'HELLO', 'hello']
    """
    assert is_iterable(funcs),str(funcs) + " ≣ funcs，is NOT iterable. Don't bother using pam! Pam is meant for mapping multiple functions onto one set of arguments; and from what I can tell you only have one function."
    return [f(*args,**kwargs) for f in funcs]
# endregion
# region ［identity］
def identity(*args):
    """
    The identity function. ƒ﹙𝓍﹚﹦ 𝓍    where   ƒ ≣ identity
    Examples:
      identity(2) == 2
      identity('Hello World!') == 'Hello World!'
      identity(1,2,3) == (1,2,3)  #When given multiple args, returns a tuple

    Better than "lambda x:x" because it shows up in stack traces as "identity" instead of an anonymous lambda function.
    Makes your life easier by making debugging easier. Used throughout RP's library functions.
    """
    return detuple(args)
# endregion
# region ［list_flatten］
#FORMERLY CALLED list_pop (a bit of a misnomer; I know that now, after having taken CSE214.)

def list_roll(x,shift=0):
    """
    Demo:
        >>> for _ in range(10):
        ...     print(list_roll(range(10),_))
        [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
        [9, 0, 1, 2, 3, 4, 5, 6, 7, 8]
        [8, 9, 0, 1, 2, 3, 4, 5, 6, 7]
        [7, 8, 9, 0, 1, 2, 3, 4, 5, 6]
        [6, 7, 8, 9, 0, 1, 2, 3, 4, 5]
        [5, 6, 7, 8, 9, 0, 1, 2, 3, 4]
        [4, 5, 6, 7, 8, 9, 0, 1, 2, 3]
        [3, 4, 5, 6, 7, 8, 9, 0, 1, 2]
        [2, 3, 4, 5, 6, 7, 8, 9, 0, 1]
        [1, 2, 3, 4, 5, 6, 7, 8, 9, 0] 
    Efficiency Test/Comparison: https://chatgpt.com/share/7de702fb-3aef-4f7a-b3f6-2ecc0d2c9fec
    """
    if not shift or not len(x):
        return list(x)
    shift %= len(x)
    return list(x[-shift:]) + list(x[:-shift])


def list_flatten(list_2d):
    """
    Example Speed boost over scoop:
    List size: 21000
       Time taken by list comprehension: 0.032008200883865356 seconds
       Time taken by itertools.chain: 0.016012614592909813 seconds
       Time taken by scoop: 64.72587646916509 seconds
    See https://gist.github.com/SqrtRyan/91dd2edd469c0cef1a545bc576efabf0

    Old, MUCH SLOWER version: list_flatten=lambda list_2d:scoop(lambda old,new:list(old) + list(new),list_2d,[])
    """

    return list(chain.from_iterable(list_2d))

def list_diff(array, *, lazy=False, sub=operator.sub, show_progress=False):
    """ 
    Like np.diff but on general python iterables 
    
    EXAMPLES:
        >>> list_diff([1,2,3,4])                                             # --> [1, 1, 1]
        >>> list_diff(                    [x**2 for x in [0,1,2,3,4,5] ])    # --> [1, 3, 5, 7, 9]
        >>> list_diff(list_diff(          [x**2 for x in [0,1,2,3,4,5] ]))   # --> [2, 2, 2, 2]
        >>> list_diff(list_diff(list_diff([x**2 for x in [0,1,2,3,4,5] ])))  # --> [0, 0, 0]

    """
    def helper():
        items = array

        items = iter(items)
        prev_item = next(items)

        for item in items:
            yield sub(item, prev_item)
            prev_item = item

    if show_progress:
        array = eta(array, "rp.list_diff")
    output = helper()
    if not lazy:
        output = list(output)

    return output


list_pop=list_flatten#Just because I'm used to list_pop, even though it doesn't make much sense lol. Thought of 'popping' the inner brackets of [[a,b],[c,d]] to [a,b,c,d] as if the inner brackets looked like bubbles to be popped. Has no relationship to popping an item off a stack or queue lol
# endregion
# region ［summation，product］
def product(x):
    # Useful because this literally uses the '*' operator over and over again instead of necessarily treating the elements as numbers.
    return scoop(lambda 𝓍,𝓎:𝓍 * 𝓎,x,x[0]) if len(x) else 1
    # assert is_iterable(x)
    # try:
    #     out=x[0]
    # except Exception:
    #     return 1# x has no indices
    # for y in x[1:]:
    #     out*=y
    # return out

def list_set(x):
    """
    Similar to performing list(set(x)), except that it preserves the original order of the items.
    You could also think of it as list_set≣remove_duplicates
    Demo:
          >>> l=[5,4,4,3,3,2,1,1,1]
          >>> list(set(l))
          ans=[1,2,3,4,5]
          >>> list_set(l)  ⟵ This method
          ans=[5,4,3,2,1]
    """
    from more_itertools import unique_everseen  # http://stackoverflow.com/questions/480214/how-do-you-remove-duplicates-from-a-list-in-whilst-preserving-order
    return list(unique_everseen(x))

def unique(iterable, *, key=identity, lazy=False):
    """
    Removes duplicates but preserves order
    Works with things that aren't conventionally hashable, like numpy arrays
       (this is because it uses handy_hash)
    EXAMPLE:
        >>> list(unique([4,3,5,4,3,2]))
       ans = [4, 3, 5, 2]
    EXAMPLE:
        >>> list(unique('alpha beta delta alpha'.split()))
       ans = ['alpha', 'beta', 'delta']
        >>> list(unique('alpha beta delta alpha'.split(),key=len))
       ans = ['alpha', 'beta']
    """
    def helper():
        seen = set()
        for item in iterable:
            tag = handy_hash(key(item))
            if tag not in seen:
                seen.add(tag)
                yield item
    output = helper()
    if not lazy:
        output = list(output)
    return output
    
# endregion
# endregion
# region  Time:［gtoc，tic‚ toc‚ ptoc‚ ptoctic‚ millis，micros，nanos］
_global_tic=time.time()
gtoc=time.time  # global toc
def tic() -> callable:
    """Creates a timer function that returns elapsed time when called.

    Each tic() call creates an independent timer. The returned function
    has a .tic() method to reset the timer.

    Returns:
        callable: A timer function that returns elapsed seconds.
    """
    global _global_tic
    _global_tic=local_tic=time.time()
    def local_toc():  # Gives a permanent toc to this tic, specifically
        return gtoc() - local_tic
    def reset_timer():
        nonlocal local_tic
        local_tic=time.time()
    local_toc.tic=reset_timer
    return local_toc  # Returns a method so you can do a=tic();a.toc() ⟵ Gives a local (not global) toc value so each tic can be used as a new timer
def toc() -> float:
    return gtoc() - _global_tic
def ptoc(title='',*,new_line=True) -> None:
    print(str(title) + ": %05f seconds" % toc(), end="\n" if new_line else "")
def ptoctic(label='') -> None:
    ptoc(label)
    tic()
#                                         ⎧                                      ⎫
#                                         ⎪     ⎧                               ⎫⎪
#                                         ⎪     ⎪⎧                         ⎫    ⎪⎪
_milli_micro_nano_converter=lambda s,n:int(round((s() if callable(s) else s) * n))
#                                         ⎪     ⎪⎩                         ⎭    ⎪⎪
#                                         ⎪     ⎩                               ⎭⎪
#                                         ⎩                                      ⎭
# You can do millis(tic()) ⟵ Will probably be about 0， millis(toc)， millis(1315)， millis() ⟵ Gets global time by default
def seconds(seconds=gtoc) -> int:
    """ Return seconds since common epoch (rounded to the nearest integer) """
    return _milli_micro_nano_converter(seconds,10 ** 0)
def millis(seconds=gtoc) -> int:
    """ Return milliseconds since common epoch (rounded to the nearest integer) """
    return _milli_micro_nano_converter(seconds,10 ** 3)
def micros(seconds=gtoc) -> int:
    """ Return microseconds since common epoch (rounded to the nearest integer) """
    return _milli_micro_nano_converter(seconds,10 ** 6)
def nanos(seconds=gtoc) -> int:
    """ Return nanoseconds since common epoch (rounded to the nearest integer) """
    return _milli_micro_nano_converter(seconds,10 ** 9)

# endregion
# region  Files and such: ［get_current_directory‚ get_all_file_names］
def get_process_cwd(pid):
    pip_import('psutil')
    import psutil
    process = psutil.Process(pid)
    cwd = process.cwd()
    return cwd

def get_current_directory(pid=None):
    """Returns the current working directory of the Python process or a specified process.

    Args:
        pid (int, optional): Process ID to get working directory for. If None,
            gets current process.

    Returns:
        str: Absolute path to the current working directory. Returns '.' if
            current directory was deleted.
    """
    if pid is not None:
        assert isinstance(pid,int),pid
        return _get_process_cwd(pid)
    try:
        import os
        return os.getcwd()
    except FileNotFoundError as e:
        return '.' #A simple, but technically correct way to answer this question...will prevent errors in other places when the current directory is deleted...
        raise FileNotFoundError(str(e)+"\nPerhaps the directory you're working in no longer exists?")

def set_current_directory(path):
    """Changes the current working directory to the specified path.

    Args:
        path (str or Path-like): The directory path to change to.
    """
    import os
    os.chdir(path)

class SetCurrentDirectoryTemporarily:
    """
    Temporarily CD into a directory
    Example:
       print(get_current_directory())
       with SetCurrentDirectoryTemporarily('/home'):
           print(get_current_directory())
       print(get_current_directory())
    """
    def __init__(self,directory:str=None):
        self.directory=directory
        
    def __enter__(self, directory:str=None):
        self.original_dir=get_current_directory()
        if self.directory is not None:
            set_current_directory(self.directory)
            
    def __exit__(self,*args):
        set_current_directory(self.original_dir)


class TemporarilySetAttr:
    """
    A context manager for temporarily setting attributes on an object.

    Usage:
        with TemporarilySetAttr(obj, attr1=value1, attr2=value2):
            # do something with obj that requires temporary attribute values

    # Other examples and testable examples are included below:

    Example 1 (Graphics - Drawing shapes with temporary styles):
        # Instead of doing this:
        old_fill_color = shape.fill_color
        old_stroke_width = shape.stroke_width
        shape.fill_color = 'red'
        shape.stroke_width = 3
        draw_shape(shape)
        shape.fill_color = old_fill_color
        shape.stroke_width = old_stroke_width

        # Use TemporarilySetAttr like this:
        with TemporarilySetAttr(shape, fill_color='red', stroke_width=3):
            draw_shape(shape)

    Example 2 (Scientific Computing - Using temporary units in a physics simulation):
        # Instead of doing this:
        old_unit_system = physics_object.unit_system
        physics_object.unit_system = 'imperial'
        compute_gravitational_force(physics_object)
        physics_object.unit_system = old_unit_system

        # Use TemporarilySetAttr like this:
        with TemporarilySetAttr(physics_object, unit_system='imperial'):
            compute_gravitational_force(physics_object)

    Example 3 (Web Scraping - Temporarily changing request headers):
        # Instead of doing this:
        old_headers = request.headers
        request.headers = {'User-Agent': 'Custom-UA'}
        response = fetch_data(request)
        request.headers = old_headers

        # Use TemporarilySetAttr like this:
        with TemporarilySetAttr(request, headers={'User-Agent': 'Custom-UA'}):
            response = fetch_data(request)

    Testable examples:
        class Test:
            def __init__(self):
                self.attr = 0

        instance = Test()

        with TemporarilySetAttr(instance, attr=42):
            assert instance.attr == 42
        assert instance.attr == 0

        with TemporarilySetAttr(instance, attr=42, new_attr=100):
            assert instance.attr == 42
            assert instance.new_attr == 100
        assert instance.attr == 0
        assert not hasattr(instance, 'new_attr')

    Written with the aid of GPT4: https://sharegpt.com/c/ZXG65TG
    """

    def __init__(self, instance, **kwargs):
        self.instance = instance
        self.old_attrs = {}
        self.new_attrs = kwargs

    def __enter__(self):
        for attr, new_value in self.new_attrs.items():
            if hasattr(self.instance, attr):
                self.old_attrs[attr] = getattr(self.instance, attr)
            setattr(self.instance, attr, new_value)

    def __exit__(self, exc_type, exc_value, traceback):
        for attr, old_value in self.old_attrs.items():
            setattr(self.instance, attr, old_value)
        for attr in self.new_attrs:
            if attr not in self.old_attrs:
                delattr(self.instance, attr)

class TemporarilySetItem:
    """
    A context manager for temporarily setting items in a container (list, dict, etc.).
    
    Usage:
        with TemporarilySetItem(container, {key1: value1, key2: value2}):
            # do something with the container that requires temporary values

    # Other examples and testable examples are included below:

    Example 1 (Text Processing - Temporarily changing specific words in a list):
        # Instead of doing this:
        old_word_1 = words[1]
        old_word_2 = words[2]
        words[1] = 'slow'
        words[2] = 'red'
        process_text(words)
        words[1] = old_word_1
        words[2] = old_word_2

        # Use TemporarilySetItem like this:
        words = ['The', 'quick', 'brown', 'fox']
        with TemporarilySetItem(words, {1: 'slow', 2: 'red'}):
            process_text(words)

    Example 2 (Data Analysis - Temporarily modifying data points in a dataset):
        # Instead of doing this:
        old_a = data['a']
        old_b = data['b']
        data['a'] = 100
        data['b'] = 5
        analyze_outliers(data)
        data['a'] = old_a
        data['b'] = old_b

        # Use TemporarilySetItem like this:
        data = {'a': 42, 'b': 7, 'c': 15}
        with TemporarilySetItem(data, {'a': 100, 'b': 5}):
            analyze_outliers(data)

    Example 3 (Configuration - Temporarily changing settings in a configuration dictionary):
        # Instead of doing this:
        old_mode = config['mode']
        old_log_level = config['log_level']
        config['mode'] = 'development'
        config['log_level'] = 'debug'
        run_tests(config)
        config['mode'] = old_mode
        config['log_level'] = old_log_level

        # Use TemporarilySetItem like this:
        config = {'mode': 'production', 'log_level': 'info'}
        with TemporarilySetItem(config, {'mode': 'development', 'log_level': 'debug'}):

    Testable examples:

        my_list = [1, 2, 3, 4]

        with TemporarilySetItem(my_list, {2: 42}):
            assert my_list[2] == 42
        assert my_list[2] == 3

        my_dict = {'a': 1, 'b': 2}

        with TemporarilySetItem(my_dict, {'a': 42, 'c': 3}):
            assert my_dict['a'] == 42
            assert my_dict['c'] == 3
        assert my_dict['a'] == 1
        assert 'c' not in my_dict

    Written with the aid of GPT4: https://sharegpt.com/c/ZXG65TG
    """

    def __init__(self, container, mapping):
        self.container = container
        self.old_items = {}
        self.new_items = mapping
        
    def __enter__(self):
        for key, new_value in self.new_items.items():
            if key in self.container:
                self.old_items[key] = self.container[key]
            self.container[key] = new_value

    def __exit__(self, exc_type, exc_value, traceback):
        for key in self.new_items:
            if key in self.old_items:
                self.container[key] = self.old_items[key]
            else:
                del self.container[key]


def ConditionalContext(condition, context_manager, *args, **kwargs):
    """
    A context manager to conditionally enter another context based on a given condition.

    This utility facilitates cleaner and concise management of conditional contexts without deep nesting.

    Parameters:
    - condition (bool or callable): A flag or a function returning a bool to determine 
      if the context should be entered.
    - context_manager (callable): A function, lambda, or class instance that returns/provides a context manager.
    - *args, **kwargs: Arguments and keyword arguments to be passed to the context_manager.

    Usage:
    ```python
    with conditional_context(some_condition, some_context, arg1, arg2, key=value):
        # Your code here...
    ```

    This usage is equivalent to the following, but with less branching and nesting:
    ```python
    if some_condition:
        with some_context(arg1, arg2, key=value):
            # Your code here...
    else:
        # Your code here... (Same as inside the context)
    ```

    The `conditional_context` streamlines the structure, making the code more readable and easier to maintain.

    Note: The context managers are instantiated and arguments are passed only if their respective conditions are True (or evaluate to True).

    Returns:
    - Context manager based on the condition.

    EXAMPLES:
    ```python
    def test_conditional_context():
        import random
        from contextlib import contextmanager

        @contextmanager
        def sample_context():
            print("Entered the context!")
            yield
            print("Exiting the context!")

        # Condition set randomly
        random_condition = random.choice([True, False])
        print(f"Random condition: {random_condition}")

        # Using conditional_context
        with conditional_context(random_condition, sample_context):
            print("Inside the conditional context.")
        print()

        # Equivalent traditional method
        if random_condition:
            with sample_context():
                print("Inside the traditional context.")
        else:
            print("Inside the traditional context.")

    #This will print the same thing twice, because it works!
    test_conditional_context()
    ```

    This test showcases how the conditional context manager works equivalently to the traditional method while providing a cleaner structure. The output for both scenarios will match, demonstrating its effectiveness.
    
    Aided by GPT4: https://chat.openai.com/share/36186fdf-fc23-4c82-8394-d29e5cbbd32d
    """
    from contextlib import contextmanager

    @contextmanager
    def wrapper():
        if callable(condition):
            condition_val = condition()
        else:
            condition_val = condition

        if condition_val:
            with context_manager(*args, **kwargs):
                yield
        else:
            yield

    return wrapper()

class PrintBeforeAfter:
    """

    A context manager that prints the value of an expression or callable before and after execution.

    Args:
        target: Either a string representing an expression or a callable function.

    Example:
        >>> x = 10
        ... with PrintBeforeAfter("x"):
        ...     x += 5
        ...
        ... def get_value():
        ...     return x * 2
        ...
        ... with PrintBeforeAfter(get_value):
        ...     x *= 3
        --> BEFORE: x  ==  10
        <-- AFTER:  x  ==  15
        --> BEFORE: 30
        <-- AFTER:  90

    Example:
        >>> with rp.PrintBeforeAfter('transformer.patch_embed.proj'):
        ...     transformer.patch_embed.proj = rp.libs.torch_tools.resize_conv2d_channels(
        ...         transformer.patch_embed.proj,
        ...         in_channels=64,
        ...     )
        --> BEFORE: transformer.patch_embed.proj  ==  Conv2d(32, 3072, kernel_size=(2, 2), stride=(2, 2))
        <-- AFTER:  transformer.patch_embed.proj  ==  Conv2d(64, 3072, kernel_size=(2, 2), stride=(2, 2))

    """

    def __init__(self, target):
        self._scope = get_scope(frames_back=1)
        self._target = target

        if not (isinstance(target, str) or callable(target)):
            raise AssertionError("Target must be a string or callable")

    def _get_value(self):
        if isinstance(self._target, str):
            out = exeval(self._target, self._scope)
        elif callable(self._target):
            out = self._target()
        else:
            assert False
        return str(out)

    def _get_name(self):
        if isinstance(self._target, str):
            return self._target + "  ==  "
        elif callable(self._target):
            return ""
        else:
            assert False

    def __enter__(self):
        fansi_print(
            " --> BEFORE: " + self._get_name() + self._get_value(),
            "bold green",
        )

    def __exit__(self, exc_type, exc_val, exc_tb):
        fansi_print(
            " <-- AFTER:  " + self._get_name() + self._get_value(),
            "bold green",
        )

        
#THIS IS DEPRECATED IN FAVOR OF get_all_paths


# endregion
# region String ⟷ Integer List:  ［int_list_to_string‚ string_to_int_list］
int_list_to_string=lambda int_list:"".join(list(chr(i) for i in int_list))
string_to_int_list=lambda string:list(ord(i) for i in string)
# USAGE EXAMPLE:
#   print((lambda x:int_list_to_string(range(ord(x)-500,ord(x)+500)))("⚢"))
#   print(int_list_to_string([*(a+1 for a in string_to_int_list("♔"))]))
#   #♈♉♊♋♌♍♎♏♐♑♒♓ ♔♕♖♗♘♙♚♛♜♝♞♟ gen
#   #⟦⟧⟨⟩⟪⟫⟬⟭⟮⟯ ❨❩❪❫❬❭❮❯❰❱❲❳❴❵ ⚀⚁⚂⚃⚄⚅ ♔♕♖♗♘♙♚♛♜♝♞♟
# endregion
# region Fansi:［fansi，fansi_print，print_fansi_reference_table，fansi_syntax_highlighting］   (Format-ANSI colors and styles for the console)
# noinspection PyShadowingBuiltins
def currently_running_windows():
    """Checks if the current Python process is running on a Windows operating system."""
    import os
    return os.name=='nt'

running_in_windows = currently_running_windows  # Alias

def currently_running_posix():
    """
    Check if running on POSIX system (Linux, macOS, Unix).
    
    Alias: running_in_posix
    """
    import os
    return os.name=='posix'

running_in_posix = currently_running_posix

def currently_running_mac():
    """Checks if the current Python process is running on macOS."""
    import platform
    return platform.system()=='Darwin'

running_in_mac = currently_running_mac

def currently_running_linux():
    """
    Check if running on Linux system. Returns bool.
    
    Alias: rp.running_in_linux
    """
    import platform
    return platform.system()=='Linux'

running_in_linux = currently_running_linux

currently_running_unix=currently_running_posix#Technically posix!=unix, but realistically we don't care...i mean what OS is posix and not unix that somebody's likely to run rp on?
def terminal_supports_ansi():
    """Checks if the current terminal supports ANSI escape sequences for colors and text styling."""
    if currently_running_windows():
        try:
            from colorama import init
            init()  # Trying to enable ANSI coloring on windows console
            return True
        except Exception:
            return False
    return True
    # return sys.stdout.isatty()# There are probably more sophistacated, better ways to check, but I don't know them.
def terminal_supports_unicode():
    """Checks if the current terminal supports Unicode characters for display."""
    if currently_running_windows():# Try to enable unicode, but fail if we can't
        try:
            from win_unicode_console import enable
            enable()  # Trying to enable unicode characters on windows console
            return True
        except Exception:
            return False
    # ∴ we are not running Windows
    return True# I don't know how to check whether you can render characters such as ⮤, ✔, or ⛤ etc


def fansi_is_enabled():
    """ Returns true IFF fansi is enabled """
    return not _disable_fansi
def fansi_is_disabled():
    """ Returns true IFF fansi is disabled """
    return _disable_fansi
_disable_fansi=False
def disable_fansi():
    global _disable_fansi
    _disable_fansi=True
def enable_fansi():
    global _disable_fansi
    _disable_fansi=False

@contextmanager
def without_fansi():
    """
    Context to run a block of code without using fansi.
    Example:
      f=lambda:fansi_print("Hello World",'cyan','bold','red')
      f()#With fansi
      with without_fansi():
          f()#Without fansi
    """
    global _disable_fansi
    old_disable_fansi=_disable_fansi
    _disable_fansi=True
    try:
        yield
    finally:
        _disable_fansi=old_disable_fansi

_fansi_styles = {
    "normal": 0,
    "bold": 1,
    "faded": 2,
    "italic": 3,
    "underlined": 4,
    "blinking": 5,
    "fastblink": 6,
    "invert": 7,
    "hide": 8,
    "strike": 9,
    "sub": 74,
    "super": 73,
    "overlined": 53,
    "thin": 2,

    #https://ryantravitz.com/blog/2023-02-18-pull-of-the-undercurl/
    'underline'  : '4:1', #Same as underlined, but supports custom colors! 
    'underdouble': '4:2',
    'undercurl'  : '4:3',
    'underdots'  : '4:4', 
    'underdash'  : '4:5',
}

def _transform_fansi_arg(spec,style_keywords=None):
    """ Allow for 'yellow green underlined on blue bold' """
    style_keywords = style_keywords or _fansi_styles
    spec = spec.lower()
    style = []
    color = []
    background = []
    on = False
    for x in spec.split():
        if x == 'on':
            on = True
        elif x in style_keywords:
            style.append(x)
        elif on:
            background.append(x)
        else:
            color.append(x)

    style = ' '.join(style) or None
    color = ' '.join(color) or None
    background = ' '.join(background) or None

    return color, style, background


def fansi(
    text_string="",
    text_color=None,
    style=None,
    background_color=None,
    underline_color=None,
    *,
    per_line=True,
    reset=True,
    truecolor=None,
    link=None
):
    """
    'fansi' is a pun, referring to ANSI and fancy
    Uses ANSI formatting to give the terminal styled color outputs.

    Parameters:
        text_string (str): Text to format with ANSI codes. Defaults to empty string for reset-only usage.
        text_color (str|tuple|int|None): Foreground color. Accepts color names ('red'), RGB tuples (0.1,0.5,0.8), hex codes ('#FF0000'), ANSI256 integers, or None.
        style (str|None): Text styling. Space-separated combinations: 'bold', 'italic', 'underlined', 'blinking', 'invert', 'strike', 'faded', 'hide', 'super', 'sub', 'normal'.
        background_color (str|tuple|int|None): Background color. Same format as text_color.
        underline_color (str|tuple|int|None): Underline color independent of text color. Same format as text_color. None by default, meaning underline will inherit text color.
        per_line (bool): Apply formatting to each line separately for multi-line strings. Defaults to True.
        reset (bool): Add ANSI reset code at the end to clear formatting. Defaults to True.
        truecolor (bool|None): Enable 24-bit color support. Auto-detects from pseudo_terminal if None.
        link (str|None): Create hyperlink to URL if not None. Terminal support varies. None by default.
            Note on terminal hyperlink support:
            - iTerm2, GNOME Terminal, Konsole: Directly clickable hyperlinks
            - Wezterm: Requires Ctrl+click or similar modifier (configurable)
            - Alacritty: Highlights links, but requires additional configuration for clicking
              (Check .alacritty.yml documentation for mouse.url settings)

    Returns:
        str: Text wrapped in ANSI escape sequences for terminal formatting. Can be concatenated, printed, or stored.

    STYLES:
                                                               Alacritty   Terminal.app   Wezterm 
        - 'normal': No styling (default)                     |  yes      |    yes       |   yes   |
        - 'bold': Bold text                                  |  yes      |    yes       |   yes   |
        - 'faded': Faint text                                |  yes      |    yes       |   yes   |
        - 'italic': Italic text                              |  yes      |    yes       |   yes   |
        - 'blinking': Blinking text                          |       no  |    yes       |   yes   |
        - 'invert': Swap foreground and background colors    |  yes      |    yes       |   yes   |
        - 'hide': Hidden text (useful for passwords)         |  yes      |    yes       |   yes   |
        - 'strike': Strikethrough text                       |  yes      |         no   |   yes   |
        - 'super': Superscript text                          |       no  |         no   |   yes   |
        - 'sub': Subscript text                              |       no  |         no   |   yes   |
        - 'underlined': Underlined text                      |  yes      |    yes       |   yes   |
        - 'undercurl': Wiggly underline                      |  yes      |         no   |   yes   |
        - 'underdots': Dotted underline                      |  yes      |         no   |   yes   |
        - 'underdash': Dashed underline                      |  yes      |         no   |   yes   |
        - 'underdouble': Double-underline                    |  yes      |         no   |   yes   |
        - 'overlined': Line over text                        |       no  |         no   |   yes   |
        - 'fastblink': Blinking text, but faster             |       no  |         no   |   yes   |
        - 'thin': Opposite of bold                           |       no  |         no   |   yes   |


    COLORS:
        Fansi supports the basic 8-colors windows supports, as well as 256-bit color and truecolor

        The basic color options for text_color and background_color are:
            - 'black': ANSI color 0
            - 'red': ANSI color 1 
            - 'green': ANSI color 2
            - 'yellow': ANSI color 3
            - 'blue': ANSI color 4
            - 'magenta': ANSI color 5
            - 'cyan': ANSI color 6 
            - 'gray'/'grey': ANSI color 7
            - 'white': ANSI color 8

        Any other colors will be displayed in either 256-color form, or 24-bit color form if truecolor==True
            If text_color or background_color is given as an integer, it will be interpreted as a 256-color code.
            Any color compatible with rp.as_rgba_float_color will work too, and will be mapped to the nearest 256-color code.
            If truecolor=True, assumes terminal has 24-bit color support. Otherwise, 256 color support will be assumed.
            See the below examples for how to specify colors - you have quite a lot of freedom!
            Colors like "dark dark green", "light cyan blue", "purple hotpink", "yellow green" and even "randomhue" etc all work

    LINK:
        - If link is provided, the text becomes a clickable hyperlink in terminals that support hyperlinks
        - Example: fansi("Click me", "blue", "underlined", link="https://example.com")

    SHORTHAND:
        - Instead of specifying the style, color, and background color all in different fields you can also specify it in a single string
          For example, "bold italic blue on black" sets text_color="blue", style="bold italic", and background_color="black"
          See below examples for more!

    EXAMPLES:

        >>> #Shorthand: You can combine multiple styles together, foreground and background separated by 'on'!
        ... fansi_print("HELLO WORLD!",'bold yellow green on red')
        ... fansi_print("HELLO WORLD!",'bold yellow green on red red underlined blinking')
        ... fansi_print("HELLO WORLD!",'yellow on white')
        ... fansi_print("HELLO WORLD!",'bold green')
        ... fansi_print("HELLO WORLD!",'on blue cyan')
        ... fansi_print("HELLO WORLD!",'on blue cyan bold')
        ... fansi_print("HELLO WORLD!",'on bold')
        ... fansi_print("HELLO WORLD!",'bold')

        >>> #Adding styles together via setting reset=False
        ... print(
        ...     fansi("hello ", "red", "sub", reset=False)
        ...     + fansi("underline ", "green", "underlined", reset=False)
        ...     + fansi("blinking ", "blue", "blinking", reset=False)
        ...     + fansi("italic ", "yellow", "italic", reset=False)
        ...     + fansi("strike ", "cyan", "strike", reset=False)
        ...     + fansi("bold ", "magenta", "bold", reset=False)
        ...     + fansi("invert ", "orange", "invert", reset=False)
        ...     + fansi(reset=True)
        ...     + "After reset..."
        ...     + fansi("All at once!", 'hot pink', 'underlined blinking italic strike bold super')
        ... )

        >>> #Using hyperlinks
        ... print(fansi("Click here to visit example.com", "blue", "underlined", link="https://example.com"))
        ... print(fansi("Documentation", "green", link="https://docs.python.org"))

        >>> #Display an image (display_image_in_terminal_color is faster - but this is to show how fansi works)
        ... for truecolor in [True,False]:
        ...     image=load_image('https://images.unsplash.com/photo-1507146426996-ef05306b995a?fm=jpg&q=60&w=3000&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxzZWFyY2h8Mnx8cHVwcHl8ZW58MHx8MHx8fDA%3D')
        ...     image=resize_image_to_fit(image,100,100)
        ...     string=""
        ...     for row in image:
        ...         for pixel in row:
        ...             string+=fansi('██',text_color=tuple(pixel/255),truecolor=truecolor)
        ...         string+='\n'
        ...     print('truecolor = ',truecolor)
        ...     print(string)

        >>> #Adding styles together in a single call 
        ... for style_a, style_b in all_combinations("normal bold italic underlined invert strike".split(), 2):
        ...         print(fansi("\tCombined Style: " + style_a + " " + style_b, style=style_a + " " + style_b))
        
        >>> #An overview of what you can do with fansi
        ... fansi_print("Fansi Styles:", style="underlined")
        ... for style in 'normal bold faded italic underlined blinking invert hide strike'.split():
        ...     print(fansi("\tStyle: "+style, style=style))
        ... 
        ... fansi_print("Traditional Terminal Colors", style="underlined")
        ... for color in "black red green yellow blue magenta cyan gray".split():
        ...     for style in [None, "bold"]:
        ...         print(fansi("\t█████ " + color + " " + str(style), color, style))
        ... 
        ... fansi_print("Ansi256 Terminal Colors", style="underlined")
        ... print("\tPerfect RGB matches")
        ... for color in ["green green", "blue blue", "red red", "yellow yellow", "cyan cyan", "magenta magenta", "gray gray", "black black", "white white"]:
        ...     print(fansi("\t█████ " + str(color) + " ", color))
        ... 
        ... print("\tSpecial color names")
        ... for color in ["green cyan", "blue cyan", "navy blue", "hot pink"]:
        ...     print(fansi("\t█████ " + str(color) + " ", color))
        ... 
        ... print("\tHex codes")
        ... for color in ["#0055AB"]:
        ...     print(fansi("\t█████ " + str(color) + " ", color))
        ... 
        ... print("\tGrayscale floats")
        ... for color in [0.9, 0.8, 0.7, 0.6, 0.5, 0.4, 0.3, 0.2, 0.1]:
        ...     print(fansi("\t█████ " + str(color) + " ", color))
        ... 
        ... print("\tAnsi256 integer color codes")
        ... for color in [12, 34, 56, 78, 90]:
        ...     print(fansi("\t█████ " + str(color) + " ", color))
        ... 
        ... print("\tRGB float tuples (truecolor=True)")
        ... for color in [random_rgb_float_color() for _ in range(10)]:
        ...     print(fansi("\t█████ " + str(color) + " ", color, truecolor=True))
        ...
        ... fansi_print("Background Colors:", style="underlined")
        ... for color in ["green cyan", "blue cyan", "navy blue", "hot pink"]:
        ...     print(fansi("\tXXXXX " + str(color) + " ", background_color=color))
        
        ... fansi_print("Hyperlink Examples:", style="underlined")
        ... print(fansi("\tPython Documentation", "blue", "underlined", link="https://docs.python.org"))
        ... print(fansi("\tGoogle Search", "green", link="https://google.com"))
        ... print(fansi("\tGitHub Repository", "magenta", "bold", link="https://github.com"))
        ... print("Note: In Wezterm, use Ctrl+click on links. In Alacritty, hyperlinks may need configuration.")

    See Also: fansi_print() for direct printing, fansi_highlight_path() for file paths, fansi_syntax_highlighting() for code

    Tags: ansi, terminal, formatting, colors, styles, hyperlinks, core-utility
    """

    if truecolor is None and 'pyin' in globals() and hasattr(pyin, 'true_color'):
        #If not specified, grab the truecolor value from pseudo-terminal
        truecolor = pyin.true_color

    if (
            #If exactly two of them are None and one is a string...
            sum(isinstance(x, str) for x in [text_color, style, background_color]) == 1
        and sum(x is None          for x in [text_color, style, background_color]) == 2
    ):
        text_color       = text_color       or ''
        style            = style            or ''
        background_color = background_color or ''
        text_color, style, background_color = _transform_fansi_arg(text_color + style + background_color)

    # Ensure text_string is a string
    text_string = str(text_string)

    # Handle per_line option
    if per_line and text_string:
        lines = text_string.splitlines(keepends=True)
        lines = [fansi(line, text_color, style, background_color, underline_color=underline_color, per_line=False, reset=reset, truecolor=truecolor, link=link) for line in lines]
        return ''.join(lines)

    # Check if ANSI formatting is disabled
    if globals().get('_disable_fansi', False):
        return text_string

    # Check if terminal supports ANSI codes
    if not terminal_supports_ansi():
        return text_string

    # Define color and style mappings
    color_codes = {'black': 0, 'red': 1, 'green': 2, 'yellow': 3,
                   'blue': 4, 'magenta': 5, 'cyan': 6, 'gray': 7, 'grey': 7}
    styles = _fansi_styles
    #To see all styles supported for your terminal:
    #   >>> for style in range(100):  print(fansi('Hello World! '+str(style),style=style))
    #   ... #Reference: https://en.wikipedia.org/wiki/ANSI_escape_code

    legacy_styles = {'outlined':7} #Older versions of RP used these keys instead
    styles.update(legacy_styles)

    format_codes = []

    # Convert style from string to code
    if isinstance(style, str):
        for style_lower in style.lower().split():
            if style_lower in styles:
                style = styles[style_lower]

                #You can try a custom integer and see what happens in your terminal! Not all terminals respond the same to style codes - some implement more than others.
                #For example, terminal.app doesn't support 'strike' but does support 'blinking', which Alacritty doesn't
                format_codes.append(str(style))
            else:
                print("ERROR: fansi: Invalid style '{}'. Valid options are: {}".format(style, list(styles.keys())))
                style = None

    if isinstance(text_color, str):
        text_color = text_color.lower()

    # Handle text_color
    if text_color is not None:
        if isinstance(text_color,str) and text_color in color_codes:
            color_code = color_codes[text_color] + 30
            format_codes.append(str(color_code))
        else:
            try:
                text_color=as_rgb_float_color(text_color)
                if truecolor and not isinstance(text_color,int):
                    r, g, b = float_color_to_byte_color(text_color)
                    format_codes.append('38;2;%i;%i;%i'%(r,g,b))
                else:
                    color_code = text_color if isinstance(text_color, int) else float_color_to_ansi256(text_color)
                    format_codes.append('38;5;'+str(color_code))
            except Exception:    
                print("ERROR: fansi: Invalid text_color '%s'. Valid options are: %s, or any RGB float color compatible with rp.as_rgb_float_color" % (text_color, list(color_codes.keys())))

    # Handle background_color
    if background_color is not None:
        if isinstance(background_color,str) and background_color in color_codes:
            bg_color_code = color_codes[background_color] + 40
            format_codes.append(str(bg_color_code))
        else:
            try:
                background_color=as_rgb_float_color(background_color)
                if truecolor and not isinstance(background_color,int):
                    r, g, b = float_color_to_byte_color(background_color)
                    format_codes.append('48;2;%i;%i;%i'%(r,g,b))
                else:
                    color_code = background_color if isinstance(background_color, int) else float_color_to_ansi256(background_color)
                    format_codes.append('48;5;'+str(color_code))
            except Exception:
                print("ERROR: fansi: Invalid background_color '%s'. Valid options are: %s, or any RGB float color compatible with rp.as_rgb_float_color" % (background_color, list(color_codes.keys())))

    # Apply hyperlink if provided
    hyperlink_start = ""
    hyperlink_end = ""
    if link is not None:
        # OSC 8 hyperlink format: ESC]8;params;URI\BEL text ESC]8;;\BEL
        # Where ESC is \033, and BEL is \007
        # The empty string before the URI is where additional parameters can go
        # For maximum compatibility across terminals (Wezterm/Alacritty/iTerm/etc.)
        hyperlink_params = ""  # Could add params like "id=identifier" here if needed
        hyperlink_start = "\033]8;" + hyperlink_params + ";" + str(link) + "\007"
        hyperlink_end = "\033]8;;\007"

    # Handle underline_color if provided
    underline_color_code = ""
    if underline_color is not None:
        if isinstance(underline_color, str) and underline_color in color_codes:
            color_code = color_codes[underline_color] 
            underline_color_code = '\033[58;5;{}m'.format(color_code)
        else:
            try:
                underline_color = as_rgb_float_color(underline_color)
                if truecolor and not isinstance(underline_color, int):
                    r, g, b = float_color_to_byte_color(underline_color)
                    underline_color_code = '\033[58;2;{};{};{}m'.format(r, g, b)
                else:
                    color_code = underline_color if isinstance(underline_color, int) else float_color_to_ansi256(underline_color)
                    underline_color_code = '\033[58;5;{}m'.format(color_code)
            except Exception:
                print("ERROR: fansi: Invalid underline_color '{}'. Valid options are: {}, or any RGB float color compatible with rp.as_rgb_float_color".format(
                    underline_color, list(color_codes.keys())))

    # Apply ANSI formatting
    format_sequence = ';'.join(format_codes)
    # Insert underline color after the format codes if needed
    output = "\x1b[{}m{}{}{}{}".format(format_sequence, underline_color_code, hyperlink_start, text_string, hyperlink_end)
    if reset:
        output += '\x1b[0m'
    return output

def _fansi_fix(string):
    """
    Fixes nested ANSI formatting issues in a string by restoring outer formatting after inner resets.
    
    When nested fansi calls are used, the inner reset code (\x1b[0m) cancels all formatting.
    This function ensures that after an inner reset, the outer formatting is restored.
    
    Example:
        fansi("Hello "+fansi("World",'yellow')+"!",'green')
        Original output: \x1b[32mHello \x1b[33mWorld\x1b[0m!\x1b[0m
        Fixed output: \x1b[32mHello \x1b[33mWorld\x1b[0m\x1b[32m!\x1b[0m
    
    Args:
        string (str): The string with ANSI formatting to fix
        
    Returns:
        str: The fixed string with proper nested formatting
    """
    import re
    
    # Regular expression to find ANSI escape sequences
    ansi_pattern = re.compile(r'\x1b\[((?:\d+;)*\d+)m')
    
    # Process the string
    result = []
    last_pos = 0
    format_stack = []  # Stack to track active format codes
    
    for match in ansi_pattern.finditer(string):
        # Add text before this sequence
        if match.start() > last_pos:
            result.append(string[last_pos:match.start()])
        
        code = match.group(1)
        
        if code == '0':  # Reset code
            # Add the reset
            result.append('\x1b[0m')
            
            # Pop from format stack
            if format_stack:
                format_stack.pop()
                
                # Restore previous format if there's any
                if format_stack:
                    result.append('\x1b[%sm'%format_stack[-1])
        else:
            # Add the format code
            result.append(match.group(0))
            
            # Push to format stack
            format_stack.append(code)
        
        # Update last position
        last_pos = match.end()
    
    # Add remaining text
    if last_pos < len(string):
        result.append(string[last_pos:])
    
    return ''.join(result)


def _legacy_fansi(text_string,text_color=None,style=None,background_color=None,*,per_line=True):
    """
    TODO: Fix bug: PROBLEM is that '\n' not in fansi('Hello\n','gray')
    This function uses ANSI escape sequnces to make colored text in a terminal.
    It can also make bolded, underlined, or highlighted text.
    It uses ANSI escape sequences to do this...
       ...and so calling it 'fansi' is a pun on 'fancy' and 'ansi'
    'fansi' is a pun, referring to ANSI and fancy
    Uses ANSI formatting to give the terminal color outputs.
    There are only 8 possible choices from each category, in ［０‚７］⋂ ℤ
    Adding 0,30,and 40 because of the ANSI codes. Subtracting 1 later on because the syntax
    of this def says that '0' is the absence of any style etc, whereas 1-8 are active styles.
    The 'per_line' option applies fansi to every line, which is useful when trying to draw tables and such
    Some terminals cant handle ansi escape sequences and just print garbage, so if _disable_fansi is turned on this function just returns unformatted text.
      (This is usually only the case with more obscure terminals, such as one I have for ssh'ing on my phone. But they do exist)
    To undo the effect of this function on a string (aka to un-format a string) use rp.strip_ansi_escapes()  (see its documentation for more details)
    EXAMPLE: print(fansi('ERROR:','red','bold')+fansi(" ATE TOO MANY APPLES!!!",'blue','underlined','yellow'))
    """
    #This function was replaced by fansi at 12:00AM Oct28 2024 - and now has better color functionality and cleaner code!
    text_string=str(text_string)
    if per_line:
        lines=line_split(text_string)
        lines=[fansi(line,text_color,style,background_color,per_line=False) for line in lines]
        return line_join(lines)
    if _disable_fansi:
        return text_string#This is for terminals that dont support colors. I don't have a method wrapper for this yet, though.
    if not terminal_supports_ansi():# We cannot guarentee we have ANSI support; we might get ugly crap like '\[0Hello World\[0' or something ugly like that!
        return text_string# Don't format it; just leave it as-is
    if text_string=='':# Without this, print(fansi("",'blue')+'Hello World'
        return ''
    if isinstance(text_color,str):  # if text_color is a string, convert it into the correct integer and handle the associated exceptions
        text_colors={'black':0,'red':1,'green':2,'yellow':3,'blue':4,'magenta':5,'cyan':6,'gray':7,'grey':7}
        try:
            text_color=text_colors[text_color.lower()]
        except Exception:
            print("ERROR: def fansi: input-error: text_color = '{0}' BUT '{0}' is not a valid key! Replacing text_color as None. Please choose from {1}".format(text_color,str(list(text_colors))))
            text_color=None
    if isinstance(style,str):  # if background_color is a string, convert it into the correct integer
        styles={'bold':1,'faded':2,'underlined':4,'blinking':5,'outlined':7}
        try:
            style=styles[style.lower()]  # I don't know what the other integers do.
        except Exception:
            print("ERROR: def fansi: input-error: style = '{0}' BUT '{0}' is not a valid key! Replacing style as None. Please choose from {1}".format(style,str(list(styles))))
            style=None
    if isinstance(background_color,str):  # if background_color is a string, convert it into the correct integer
        background_colors={'black':0,'red':1,'green':2,'yellow':3,'blue':4,'magenta':5,'cyan':6,'gray':7,'grey':7}
        try:
            background_color=background_colors[background_color.lower()]
        except Exception:
            print("ERROR: def fansi: input-error: background_color = '{0}' BUT '{0}' is not a valid key! Replacing background_color as None. Please choose from {1}".format(background_color,str(list(background_colors))))
            background_color=None

    format=[]
    if style is not None:
        assert 0 <= style <= 7,"style == " + str(style) + " ∴ ¬﹙0 <= style <= 7﹚ ∴ AssertionError"
        style+=0
        format.append(str(style))
    if text_color is not None:
        assert 0 <= text_color <= 7,"text_color == " + str(text_color) + " ∴ ¬﹙0 <= text_color <= 7﹚ ∴ AssertionError"
        text_color+=30
        format.append(str(text_color))
    if background_color is not None:
        assert 0 <= background_color <= 7,"background_color == " + str(background_color) + " ∴ ¬﹙0 <= background_color <= 7﹚ ∴ AssertionError"
        background_color+=40
        format.append(str(background_color))

    return "\x1b[%sm%s\x1b[0m" % (';'.join(format),str(text_string))  # returns a string with the appropriate formatting applied
# region fansi Examples
# print(fansi('ERROR:','red','bold')+fansi(" ATE TOO MANY APPLES!!!",'blue','underlined','yellow'))
# from random import randint
# print(seq([lambda old:old+fansi(chr(randint(0,30000)),randint(0,7),randint(0,7),randint(0,7))]*100,''))
# endregion
def fansi_print(
    text_string: object,
    text_color: object = None,
    style: object = None,
    background_color: object = None,
    underline_color=None,
    *,
    link=None,
    new_line=True,
    reset=True,
    truecolor=None
):
    """

    Prints colored and styled text to the terminal.
    It can also print bolded, underlined, or highlighted text.
    It uses ANSI escape sequences to do this...
       ...and so calling it 'fansi' is a pun on 'fancy' and 'ansi'

    EXAMPLE: 

        >>> fansi_print("Hello World!", 'bold blue on dark orange')
        >>> fansi_print("ERROR: THE WORLD IS ENDING!!!", 'red red bold on black black')
        >>> fansi_print("Every\nLine\nIs\nA\nDifferent\nColor!", 'randomhue bold on black altbw blue randomhue',truecolor=True)

        >>> #Or, without fansi_print and just using fansi
        >>> print(
        ...      fansi("ERROR:", "red", "bold")
        ...    + fansi(" ATE TOO MANY APPLES!!!", "blue", "underlined", "yellow")
        ... )

    A wrapper around `fansi()` and `print()` for direct output.

    Args:
        text_string (object): The text to print.
        text_color (object, optional): Text color. Defaults to None.
        style (object, optional): Text style. Defaults to None.
        background_color (object, optional): Background color. Defaults to None.
        underline_color (optional): Underline color. Defaults to None.
        link (optional): URL for hyperlink. Defaults to None.
        new_line (bool, optional): Whether to add a newline. Defaults to True.
        reset (bool, optional): Whether to reset style after printing. Defaults to True.
        truecolor (optional): Truecolor override. Defaults to None.
    """
    print(
        fansi(
            text_string,
            text_color=text_color,
            style=style,
            background_color=background_color,
            reset=reset,
            truecolor=truecolor,
            underline_color=underline_color,
            link=link,
        ),
        end="\n" if new_line else "",
        flush=True,
    )

def fansi_printed(x, *args, **kwargs):
    """
    Print styled text and return the original text unchanged.
    See `fansi_print` for parameter details.
    """
    fansi_print(x, *args, **kwargs)
    return x

# noinspection PyShadowingBuiltins
def print_fansi_reference_table() -> None:
    """
    prints table of formatted text format options for fansi. For reference
    """
    for style in range(8):
        for fg in range(30,38):
            s1=''
            for bg in range(40,48):
                format=';'.join([str(style),str(fg),str(bg)])
                s1+='\x1b[%sm %s \x1b[0m' % (format,format)
            print(s1)
    if currently_running_unix():
        print("ALSO PRINTING ALL 256 COLORS")
        #From https://superuser.com/questions/285381/how-does-the-tmux-color-palette-work/285400
        os.system('bash -c \'for i in {0..255}; do  printf "\\x1b[38;5;${i}mcolor%-5i\\x1b[0m" $i ; if ! (( ($i + 1 ) % 8 )); then echo ; fi ; done\'')

def _old_fansi_syntax_highlighting(code: str,namespace=(),style_overrides={}):
    """
    PLEASE NOTE THAT I DID NOT WRITE SOME OF THIS CODE!!! IT CAME FROM https://github.com/akheron/cpython/blob/master/Tools/scripts/highlight.py
    Assumes code was written in python.
    Method mainly intended for rinsp.
    I put it in the r class for convenience.
    Works when I paste methods in but doesn't seem to play nicely with rinsp. I don't know why yet.
    See the highlight_sourse_in_ansi module for more stuff including HTML highlighting etc.
    """
    default_ansi={
        'comment':('\033[0;31m','\033[0m'),
        'string':('\033[0;32m','\033[0m'),
        'docstring':('\033[0;32m','\033[0m'),
        'keyword':('\033[0;33m','\033[0m'),
        'builtin':('\033[0;35m','\033[0m'),
        'definition':('\033[0;33m','\033[0m'),
        'defname':('\033[0;34m','\033[0m'),
        'operator':('\033[0;33m','\033[0m'),
    }
    default_ansi.update(style_overrides)
    try:
        import keyword,tokenize,cgi,re,functools
        try:
            import builtins
        except ImportError:
            import builtins as builtins
        def is_builtin(s):
            'Return True if s is the name of a builtin'
            return hasattr(builtins,s) or s in namespace
        def combine_range(lines,start,end):
            'Join content from a range of lines between start and end'
            (srow,scol),(erow,ecol)=start,end
            if srow == erow:
                return lines[srow - 1][scol:ecol],end
            rows=[lines[srow - 1][scol:]] + lines[srow: erow - 1] + [lines[erow - 1][:ecol]]
            return ''.join(rows),end
        def analyze_python(source):
            '''Generate and classify chunks of Python for syntax highlighting.
               Yields tuples in the form: (category, categorized_text).
            '''
            lines=source.splitlines(True)
            lines.append('')
            readline=functools.partial(next,iter(lines),'')
            kind=tok_str=''
            tok_type=tokenize.COMMENT
            written=(1,0)
            for tok in tokenize.generate_tokens(readline):
                prev_tok_type,prev_tok_str=tok_type,tok_str
                tok_type,tok_str,(srow,scol),(erow,ecol),logical_lineno=tok
                kind=''
                if tok_type == tokenize.COMMENT:
                    kind='comment'
                elif tok_type == tokenize.OP and tok_str[:1] not in '{}[](),.:;@':
                    kind='operator'
                elif tok_type == tokenize.STRING:
                    kind='string'
                    if prev_tok_type == tokenize.INDENT or scol == 0:
                        kind='docstring'
                elif tok_type == tokenize.NAME:
                    if tok_str in ('def','class','import','from'):
                        kind='definition'
                    elif prev_tok_str in ('def','class'):
                        kind='defname'
                    elif keyword.iskeyword(tok_str):
                        kind='keyword'
                    elif is_builtin(tok_str) and prev_tok_str != '.':
                        kind='builtin'
                if kind:
                    if written != (srow,scol):
                        text,written=combine_range(lines,written,(srow,scol))
                        yield '',text
                    text,written=tok_str,(erow,ecol)
                    yield kind,text
            line_upto_token,written=combine_range(lines,written,(erow,ecol))
            yield '',line_upto_token
        def ansi_highlight(classified_text,colors=default_ansi):
            'Add syntax highlighting to source code using ANSI escape sequences'
            # http://en.wikipedia.org/wiki/ANSI_escape_code
            result=[]
            for kind,text in classified_text:
                opener,closer=colors.get(kind,('',''))
                result+=[opener,text,closer]
            return ''.join(result)
        return ansi_highlight(analyze_python(code))
    except Exception:
        return code  # Failed to highlight code, presumably because of an import error.

def fansi_syntax_highlighting(code: str,
                              namespace=(),
                              style_overrides:dict={},
                              line_wrap_width:int=None,
                              show_line_numbers:bool=False,
                              lazy:bool=False,
                              ):
    """ Apply syntax highlighting to 'code', a given string of python code. Returns an ANSI-styled string for printing in a terminal. Provides extra arguments such as including line numbers, line wrapping stuff, custom styling via style_overrides, and lazy for processing super large amounts of code without having to wait for it to all finish.
        
    TODO: Because of the way it was programmed, it now included an extraneous new empty line on the top of the output. Feel free to remove that later brutishly lol (just lob it off the final output)
    If lazy==True, this function returns a generator of strings that should be printed sequentially without new lines
    If line_wrap_width is an int, it will wrap the whole output to that width - this is suprisingly tricky to do because of the ansi escape codes
    show_line_numbers, if true, will also display a line number gutter on the side

    EXAMPLE USING LAZY:
       #Lazy can make syntax highlighting of things like rp start instantly
       code=get_source_code(r)
       for chunk in fansi_syntax_highlighting(code,lazy=True,show_line_numbers=True,line_wrap_width=get_terminal_width()):
           print(end=chunk)
       print()
    The result is that it has a shorter delay to start ; but it also might take longer in total

    EXAMPLE:
       print(fansi_syntax_highlighting(get_source_code(load_image),line_wrap_width=30,show_line_numbers=False))

    PLEASE NOTE THAT I DID NOT WRITE SOME OF THIS CODE!!! IT CAME FROM https://github.com/akheron/cpython/blob/master/Tools/scripts/highlight.py
    Assumes code was written in python.
    Method mainly intended for rinsp.
    I put it in the r class for convenience.
    Works when I paste methods in but doesn't seem to play nicely with rinsp. I don't know why yet.
    See the highlight_sourse_in_ansi module for more stuff including HTML highlighting etc.
    """

    if not lazy and not show_line_numbers and not line_wrap_width:
        return _old_fansi_syntax_highlighting(code,namespace,style_overrides) #This one is less glitchy. Use it when we can until the new one is fixed.
    default_ansi={
        'comment':('\033[0;31m','\033[0m'),
        'string':('\033[0;32m','\033[0m'),
        'docstring':('\033[0;32m','\033[0m'),
        'keyword':('\033[0;33m','\033[0m'),
        'builtin':('\033[0;35m','\033[0m'),
        'definition':('\033[0;33m','\033[0m'),
        'defname':('\033[0;34m','\033[0m'),
        'operator':('\033[0;33m','\033[0m'),
    }
    default_ansi.update(style_overrides)
    try:
        import keyword,tokenize,cgi,re,functools
        try:
            import builtins
        except ImportError:
            import builtins as builtins
        def is_builtin(s):
            'Return True if s is the name of a builtin'
            return hasattr(builtins,s) or s in namespace
        def combine_range(lines,start,end):
            'Join content from a range of lines between start and end'
            (srow,scol),(erow,ecol)=start,end
            if srow == erow:
                return lines[srow - 1][scol:ecol],end
            rows=[lines[srow - 1][scol:]] + lines[srow: erow - 1] + [lines[erow - 1][:ecol]]
            return ''.join(rows),end
        def analyze_python(source):
            '''Generate and classify chunks of Python for syntax highlighting.
               Yields tuples in the form: (category, categorized_text).
            '''
            lines=source.splitlines(True)
            lines.append('')
            readline=functools.partial(next,iter(lines),'')
            kind=tok_str=''
            tok_type=tokenize.COMMENT
            written=(1,0)
            for tok in tokenize.generate_tokens(readline):
                prev_tok_type,prev_tok_str=tok_type,tok_str
                tok_type,tok_str,(srow,scol),(erow,ecol),logical_lineno=tok
                kind=''
                if tok_type == tokenize.COMMENT:
                    kind='comment'
                elif tok_type == tokenize.OP and tok_str[:1] not in '{}[](),.:;@':
                    kind='operator'
                elif tok_type == tokenize.STRING:
                    kind='string'
                    if prev_tok_type == tokenize.INDENT or scol == 0:
                        kind='docstring'
                elif tok_type == tokenize.NAME:
                    if tok_str in ('def','class','import','from'):
                        kind='definition'
                    elif prev_tok_str in ('def','class'):
                        kind='defname'
                    elif keyword.iskeyword(tok_str):
                        kind='keyword'
                    elif is_builtin(tok_str) and prev_tok_str != '.':
                        kind='builtin'
                if kind:
                    if written != (srow,scol):
                        text,written=combine_range(lines,written,(srow,scol))
                        yield '',text
                    text,written=tok_str,(erow,ecol)
                    yield kind,text
            line_upto_token,written=combine_range(lines,written,(erow,ecol))
            yield '',line_upto_token
        def ansi_highlight(classified_text,colors=default_ansi):
            'Add syntax highlighting to source code using ANSI escape sequences'
            # http://en.wikipedia.org/wiki/ANSI_escape_code

            nonlocal line_wrap_width, show_line_numbers

            if line_wrap_width is None:
                line_wrap_width = 9999999
            
            num_code_lines = code.count('\n')+1
            num_digits = len(str(num_code_lines)) #Max number of digits in the line numbers

            max_width = line_wrap_width
            if show_line_numbers:
                #Should always return strings of the same width if done correctly
                #TODO: Make this customizable through the args
                def line_number_prefix_generator( line_number):
                    return (('%%%ii│ ')%num_digits)%line_number 

                line_prefix_length = len(line_number_prefix_generator(num_code_lines))

                if line_wrap_width >= line_prefix_length:
                    max_width = line_wrap_width-line_prefix_length
                else:
                    #We have to not show line numbers, or else we'd be showing literally nothing but them!
                    show_line_numbers=False
                    

            def wrapped_line_tokens(tokens,max_width):
                #Wrap the string, respecting token boundaries when possible
                #Tokens is a list of [(kind,text), (kind,text), ... ] tuples
                #Output is a generator of [(kind,text,line_number) ... ] tuples
                #EXAMPLE TEST:
                #   
                #    >>> list(wrapped_line_tokens([(11,'Hello\nWorld!\n123\nab\nc'),(22,'d'),(33,'e'),(44,'f')],2))
                #    [
                #        (11  , 'He', 0),
                #        (None, '\n', 1),
                #        (11  , 'll', 1),
                #        (None, '\n', 2),
                #        (11  , 'o' , 2),
                #        (None, '\n', 3),
                #        (11  , 'Wo', 3),
                #        (None, '\n', 4),
                #        (11  , 'rl', 4),
                #        (None, '\n', 5),
                #        (11  , 'd!', 5),
                #        (None, '\n', 6),
                #        (11  , '12', 6),
                #        (None, '\n', 7),
                #        (11  , '3' , 7),
                #        (None, '\n', 8),
                #        (11  , 'ab', 8),
                #        (None, '\n', 9),
                #        (11  , 'c' , 9),
                #        (22  , 'd' , 9),
                #        (33  , ''  , 9),
                #        (None, '\n', 10),
                #        (33  , 'e' , 10),
                #        (44  , 'f' , 10)
                #    ]
                #
                line_length=0
                line_number=0
                line_skip=0
                for kind,text in tokens:
                    subtokens=split_including_delimiters(text,'\n')
                    subtokens=subtokens[::-1]
                    while subtokens:
                        assert max_width>=line_length
                        subtoken=subtokens.pop()
                        if subtoken=='\n':
                            if not line_skip:
                                line_number+=1
                            line_skip=max(0,line_skip-1)
                            line_length=0
                            #Probably can eliminate typehere....
                            yield None,subtoken,line_number
                        elif line_length+len(subtoken)>max_width:
                            index=max_width-line_length
                            token_right=subtoken[index:]
                            subtoken   =subtoken[:index]
                            line_length=0
                            subtokens.append(token_right)
                            subtokens.append('\n')
                            yield kind,subtoken,line_number
                            line_skip+=1
                        else:
                            line_length+=len(subtoken)
                            yield kind,subtoken,line_number

            digit_remover=str.maketrans('0123456789', '          ')
            
            prev_line_number=None
            for kind,text,line_number in chain([[None,'\n',0]],wrapped_line_tokens(classified_text,max_width=max_width)):
                opener,closer=colors.get(kind,('',''))
                if show_line_numbers and text.endswith('\n'):
                    prefix=line_number_prefix_generator(line_number+1)
                    prefix=fansi(prefix,'cyan','bold')#,'black')
                    if line_number==prev_line_number:
                        #https://stackoverflow.com/questions/19084443/replacing-digits-with-str-replace
                        prefix=prefix.translate(digit_remover)
                    text=text+prefix
                yield from [opener,text,closer]
                prev_line_number=line_number
        output=(ansi_highlight(analyze_python(code)))
        if lazy:
            return output
        else:
            return ''.join(output)
    
    except Exception:
        raise
        return code  # Failed to highlight code, presumably because of an import error.


def fansi_highlight_path(path):
    """ Syntax-highlights a path like "/path/to/thing/" - it colors the /'s differently from the rest. Returns a string with ansi escapes for printing in a terminal. """
    color='cyan'
    path=strip_ansi_escapes(path)
    path=path.split('/')
    return fansi('/','blue','bold').join(fansi(x,color) for x in path)
    return fansi('/',color,'bold').join(fansi(x,color) for x in path)

_fansi_highlight_path = fansi_highlight_path


def fansi_pygments(
    code,
    language=None,
    *,
    style=None,
    color_mode=None
):
    """
    Highlight code using pygments and return a string with ANSI escape codes for colors.
    If language is not provided, it will attempt to autodetect the language.
    The style parameter allows specifying a color scheme for the highlighting.
    The color_mode parameter specifies the color mode for the output (basic, 256, or truecolor).
    """
    if fansi_is_disabled():
        return code
    
    pip_import("pygments")
    from pygments import highlight
    from pygments.lexers import get_lexer_by_name
    from pygments.formatters import Terminal256Formatter, TerminalTrueColorFormatter, TerminalFormatter
    from pygments.lexers import guess_lexer
    from pygments.styles import get_style_by_name, get_all_styles
    from pygments.lexers import get_all_lexers

    #Handle defaults
    if currently_running_windows():
        if color_mode is None: color_mode = 'basic'
        if style is None:style='default'
    else:
        if color_mode is None:color_mode='256'
        if style is None:style='monokai'

    if language:
        try:
            lexer = get_lexer_by_name(language)
        except:
            available_languages = [lexer[0] for lexer in get_all_lexers()]
            raise ValueError("Invalid language '{}' specified. Available languages: {}".format(language, ', '.join(available_languages)))
    else:
        # Custom detection for specific patterns before falling back to guess_lexer
        code_stripped = code.strip()
        if code_stripped.startswith('#!/bin/bash') or code_stripped.startswith('#!/usr/bin/bash'):
            lexer = get_lexer_by_name('bash')
        elif code_stripped.startswith('<!DOCTYPE html>') or code_stripped.lower().startswith('<!doctype html>'):
            lexer = get_lexer_by_name('html')
        else:
            lexer = guess_lexer(code)
    
    try:
        style = get_style_by_name(style)
    except:
        available_style = list(get_all_styles())
        raise ValueError("Invalid color style '{}' specified. Available style: {}".format(style, ', '.join(available_style)))
    
    if color_mode == 'basic':
        formatter = TerminalFormatter()
    elif color_mode == '256':
        formatter = Terminal256Formatter(style=style)
    elif color_mode == 'true':
        formatter = TerminalTrueColorFormatter(style=style)
    else:
        raise ValueError("Invalid color mode '{}' specified. Available modes: basic, 256, true".format(color_mode))
    
    highlighted_code = highlight(code, lexer, formatter)

    #Trying to solve a proble with newlines at the end...
    assert highlighted_code.endswith('\n')
    highlighted_code = highlighted_code[:-1]

    return highlighted_code

def fansi_pygments_demo(code=None):
    """ Displays all themes for fansi_pygments """
    if code is None:
        code=unindent("""
        @decorator
        def f(x, *y):
            print("HELLO", 1+2.3 <= [])
        """).strip()
    pip_import('pygments')
    from pygments.styles import  get_all_styles
    for style in get_all_styles():
        print(style.center(string_width(code)+10,'·'))
        print(fansi_pygments(code,'python',style=style))


# endregion
# region  Copy/Paste: ［string_to_clipboard，string_from_clipboard］
_local_clipboard_string=''#if we can't access a system OS clipboard, try and fake it with a local clipboard istead. Of course, you need to use the string_to_clipboard and clipboard_to_string functions to make this work, but that's ok

def _get_local_clipboard_string():
    #Try to get the string from a file (so we can share our fake clipboard across processes. THis is important over SSH into headless systems that don't support clipboards, but we still want to keep our clipboard between sessions).
    #If we can't write or read from a file, just keep _local_clipboard_string as a local variable in this process as a last resort.
    try:
        return text_file_to_string(_local_clipboard_string_path)
    except OSError:
        return _local_clipboard_string
def _set_local_clipboard_string(string):
    global _local_clipboard_string
    _local_clipboard_string=string
    string_to_text_file(_local_clipboard_string_path,string)
def string_to_clipboard(string):
    """
    Copies a string to your system clipboard so you can paste it later
    First tries to copy the string to the system clipboard, as well as via OSC52 to copy to your clipboard if over a terminal connection.
    If that doesn't work, it falls back to writing your string to a local file called '.rp_local_clipboard', and uses that to copy/paste along with the string_from_clipboard function. This is useful over SSH where pyperclip fails on linux systems. Because it uses a file, it's synced across rp processes and is persistent even after we close and reopen rp, even while over ssh on a system whose clipboard we can't modify for some reason.
    If that doesn't work, it falls back to reading/writing to a global variable called _local_clipboard_string. This string is lost if rp is closed.
    I decided not to label this function 'copy' because 'copy' could refer to copying objects such as lists etc, like [1,2,3].copy()

    Enhanced Documentation:
    
    Multi-fallback clipboard system that works across different environments including SSH.
    Tries system clipboard first, then file-based clipboard, then in-memory fallback.
    
    Parameters:
        string (str): Text content to copy to clipboard
    
    Usage Examples:
        >>> string_to_clipboard("Hello, clipboard!")
        >>> # Now can paste in any application
        
    See also:
        - rp.string_from_clipboard(): Paste text into python
        - rp.accumulate_clipboard_text(): Copy multiple strings to clipboard at once
    
    TODO: Make this work in Jupyter and Marimo too

    Tags: clipboard, copy, text, ssh, cross-platform, fallback
    """
    global _local_clipboard_string
    
    
    _copy_text_over_terminal(string) #Experimental

    _set_local_clipboard_string(string)
    try:
        try:
            from rp.libs.Pyperclip import paste,copy
            assert not running_in_ssh() #This is a patch for Ryan Burgert's desktop computer, which doesn't like using the clipboard over ssh for some reason. 
            copy(string)
        except Exception:
            assert currently_running_mac()
            os.system("echo '%_s' | pbcopy" % string)
    except Exception:
        return
        fansi_print("string_to_clipboard: error: failed to copy a string to the clipboard",'red')

def _copy_text_over_terminal(string):
    """
    Encodes a given string in base64 and sends it to the terminal to be copied to the clipboard via OSC 52 ANSI escape codes.
    Does this via "OSC52" (you can google that)

    Doesn't work in all terminals - for example TMUX blocks it with default settings, and it doesn't work in Jupyter's terminals.
    However, it does work when TMUX is configured with "set -g set-clipboard on", and works in Alacritty
    
    Args:
    string (str): The string to be copied to the clipboard.
    
    Raises:
    TypeError: If the input is not a string.
    
    Note:
    This function relies on the terminal's ability to interpret OSC 52 escape sequences.
    """
    if not isinstance(string, str):
        raise TypeError("Input must be a string")
    
    # Import necessary modules
    import sys
    import base64
    
    # Base64 encode the string
    encoded_string = base64.b64encode(string.encode()).decode()
    
    # Create the OSC 52 ANSI escape sequence
    escape_sequence = "\033]52;c;{}\a".format(encoded_string)
    
    # Write the escape sequence to stdout directly
    sys.stdout.write(escape_sequence)
    sys.stdout.flush()




def string_from_clipboard():
    """
    Pastes the string from the clipboard and returns that value
    First tries to paste the string from the system clipboard.
    If that doesn't work, it falls back to reading your string from a local file called '.rp_local_clipboard'
    If that doesn't work, it falls back to writing to a global variable called _local_clipboard_string
    """
    try:
        from rp.libs.Pyperclip import paste,copy
        assert not running_in_ssh() #This is a patch for Ryan Burgert's desktop computer, which doesn't like using the clipboard over ssh for some reason. 
        return paste()
    except Exception:
        return _get_local_clipboard_string()
        fansi_print("string_from_clipboard: error: failed to get a string from clipboard",'red')

def accumulate_clipboard_text(*, wipe=False, unique=False):
    """
    Automatically accumulates and combines text copied to the clipboard.

    This function continuously monitors your clipboard for any new text that you copy.
    Run this function, then start copying text using your system clipboard. The copied text will get longer and longer.

    Whenever you copy a piece of text, it will be automatically added to a running collection
    of text items. All the accumulated text items will be combined together and copied back
    to your clipboard, allowing you to easily collect and combine multiple pieces of text.

    Parameters:
        wipe (bool, optional): If True, will clear your clipboard before running.
        unique (bool, optional): If set to True, duplicate items will be ignored.
                                 Defaults to False.

    Returns:
        list: A list containing all the accumulated text items.

    Notes:
        - The function will continue running until you manually interrupt it using Ctrl+C (KeyboardInterrupt).
        - The accumulated text items will be combined using a newline character as the separator.
        - Each time a new text item is added, the updated collection will be copied back to your clipboard.
        - Upon exiting, the function will print a message indicating that it is terminating.

    Example:
        >>> accumulation = run_clipboard_text_accumulator(unique=True)
        # Start the clipboard text accumulator
        # Copy various pieces of text to accumulate them
        # Press Ctrl+C to stop the accumulator
        # The accumulated text will be available on your clipboard, with duplicates removed
        # The function will return a list containing all the accumulated text items

    """
    if wipe:    
        string_to_clipboard('')

    accumulation = []
    separator = "\n"

    def get_accumulation_string():
        items = accumulation
        return separator.join(items)

    colors = ["yellow", "green"]
    try:
        while True:
            cum = get_accumulation_string()
            clip = string_from_clipboard()
            if clip != cum:
                if not unique or clip not in accumulation:
                    accumulation.append(clip)
                cum = get_accumulation_string()
                string_to_clipboard(cum)
                
                # sleep(.1) #We might need this if it breaks in the future if system lags...so far so good though.
                
                fansi_print(clip, colors[len(accumulation) % len(colors)])
    except KeyboardInterrupt:
        fansi_print("rp.run_clipboard_text_accumulator: Exiting", "cyan", "bold")
    return accumulation

# endregion
# region pseudo_terminal
# EXAMPLE CODE TO USE pseudo_terminal:
# The next 3 lines are used to import pseudo_terminal
# region pseudo_terminal definition
# #from r import make_pseudo_terminal
# def pseudo_terminal():pass # Easiest way to let PyCharm know that this is a valid def. The next line redefines it.
# exec(make_pseudo_terminal)
# endregion
# NOTE: In my PyCharm Live Templates, I made a shortcut to create the above three lines.
# make pseudo terminal     ⟵ The template keyword.


#   print("Result = "+str(pseudo_terminal()))
# endregion
# region 2d Methods:［width，height，_rgb_to_grayscale，gauss_blur，flat_circle_kernel，med_filter，med_filter，med_filter，grid2d，grid2d_map，resize_image］
# noinspection PyShadowingNames

#The following functions are very, very deprecated. Please don't use them.

def _rgb_to_grayscale(image):  # A demonstrative implementation of this pair
    """
    Takes an image with multiple color channels
    Takes a 3d tensor as an input (X,Y,RGB)
    Outputs a matrix (X,Y ⋀ Grayscale value)
    Calculated by taking the average of the three channels.
    """
    try:
        image=as_numpy_array(image)
        assert image.ndim==3 #HWC
        return image.mean(2).astype(image.dtype)
        # return np.average(image,2).astype(image.dtype)  # Very fast if possible
    except Exception:
        # The old way, when I used nested lists to represent images
        # (Only doing this if the numpy way fails so my older scripts don't break)
        # 'z' denotes the grayscale channel.
        # z ﹦﹙r﹢g﹢b﹚÷３
        x,y,r,g,b=image_to_xyrgb_lists(image)
        # z=[*map(lambda a,b,c:(a+b+c)/3.,r,g,b)] ⟵ Got overflow errors!
        z=list(range(assert_equality(len(x),len(y),len(r),len(g),len(b))))
        for i in z:
            z[i]=(float(r[i]) / 256 + float(g[i]) / 256 + float(b[i]) / 256) / 3
        return xyrgb_lists_to_image(x,y,z.copy(),z.copy(),z.copy())
def grayscale_to_rgb(matrix,number_of_channels=3):
    return np.stack((matrix,) * number_of_channels,-1)
def gauss_blur(image,σ,single_channel: bool = False,mode: str = 'reflect',shutup: bool = False):
    """Apply Gaussian blur to image. Accepts any image type, returns NumPy array.
    
    Args:
        image: Any valid image (NumPy, PIL, etc)
        σ: Standard deviation for Gaussian kernel (0 = no blur)
        single_channel: If False, blurs each channel separately (preserves colors)
        mode: Edge handling: 'reflect', 'constant', 'nearest', 'mirror', or 'wrap'
    """
    # NOTE: order refers to the derivative of the gauss curve; for edge detection etc.
    if σ == 0:
        return image
    mode=mode.lower()
    assert mode in {'constant','nearest','reflect','mirror','wrap'},"r.med_filter: Invalid mode for blurring edge-areas of image. mode=" + str(mode)
    # single_channel: IMPORTANT: This determines the difference between
    #       [1,2,3,4,5]
    #  and
    #       [[1],[2],[3],[4],[5]] (when False)
    # Works in RGB, RGBA, or any other number of color channels!
    from scipy.ndimage.filters import gaussian_filter
    gb=lambda x:gaussian_filter(x,sigma=σ,mode=mode)
    tp=np.transpose
    # noinspection PyTypeChecker
    sh=np.shape(image)
    assert isinstance(sh,tuple)
    if not single_channel and not sh[-1] <= 4 and not shutup:  # Generally if you have more than 4 channels you are using a single_channel image.
        fansi_print("r.gauss_blur: Warning: Last channel has length of " + str(sh[-1]) + "; you results might be weird. Consider setting optional parameter 'single_channel' to True?",'red')
    s=list(range(len(sh)))
    if len(s) == 1 or single_channel:  # We don't have channels of colors, we only have 1 color channel (AKA we extracted the red of an image etc)
        return gb(image)

    #        ⎛                                                                      ⎞
    #        ⎜⎛                                               ⎞                     ⎟
    #        ⎜⎜                 ⎛                            ⎞⎟                     ⎟
    #        ⎜⎜                 ⎜      ⎛     ⎞       ⎛      ⎞⎟⎟     ⎛     ⎞   ⎛    ⎞⎟
    return tp([gb(x) for x in tp(image,[s[-1]] + list(s[:-1]))],list(s[1:]) + [s[0]])  # Blur each channel individually.
    #        ⎜⎜                 ⎜      ⎝     ⎠       ⎝      ⎠⎟⎟     ⎝     ⎠   ⎝    ⎠⎟
    #        ⎜⎜                 ⎝                            ⎠⎟                     ⎟
    #        ⎜⎝                                               ⎠                     ⎟
    #        ⎝                                                                      ⎠

    # NOTE:
    #     >>> _s=(0,1,2)
    #     >>> [_s[-1]] + list(_s[:-1])
    # ans=[2,0,1]
    #     >>> list(_s[1:]) + [_s[0]]
    # ans=[1,2,0]

    # region Works with RGB but fails on single channels
    # cv2=pip_import('cv2')
    # # noinspection PyUnresolvedReferences
    # return cv2.GaussianBlur(image,(radius,radius),0)
    # endregion
    # def med_filter(image,σ):
    #     # Works in RGB, RGBA, or any other number of color channels!
    #     from scipy.ndimage.filters import gaussian_filter as gb
    #     tp=np.transpose
    #     return tp([gb(x,σ) for x in tp(image,[2,0,1])],[1,2,0])# Blur each channel individually.
    #     # region Works with RGB but fails on single channels
    #     # cv2=pip_import('cv2')
    #     # # noinspection PyUnresolvedReferences
    #     # return cv2.GaussianBlur(image,(radius,radius),0)
    #     # endregion
_flat_circle_kernel_cache={}
def flat_circle_kernel(diameter):
    """ Returns a binary grayscale image (aka boolean matrix) with a circle in the middle with the given diameter """
    if diameter not in _flat_circle_kernel_cache:
        d=int(diameter)
        v=np.linspace(-1,1,d) ** 2
        m=np.zeros([d,d])
        m+=v
        m=np.transpose(m)
        m+=v
        m=m<=1
        _flat_circle_kernel_cache[diameter]=m
    return _flat_circle_kernel_cache[diameter]

_gaussian_circle_kernel_cache={}
def gaussian_kernel(size=21, sigma=3,dim=2):
    """Returns a normalized 2D Gaussian kernel.
    Please note that increasing 'size' does NOT increase 'sigma': you must manually increase sigma proportionally if you want a bigger blur!
    Parameters
    ----------
    size : float, the kernel size (will be square)

    sigma : float, the sigma Gaussian parameter

    Returns
    -------
    out : array, shape = (size, size)
      an array with the centered gaussian kernel
    """
    args=size,sigma,dim
    if args not in _gaussian_circle_kernel_cache:
        x = np.linspace(- (size // 2), size // 2,num=size)
        x /= np.sqrt(2)*sigma
        x2 = x**2
        assert dim==2 or dim==1,'Only 1d and 2d gaussians are supported right now'
        kernel = np.exp(- x2[:, None] - x2[None, :]) if dim==2 else np.exp(-x2)
        _gaussian_circle_kernel_cache[args]=kernel / kernel.sum()
    return _gaussian_circle_kernel_cache[args]

def get_max_image_dimensions(*images):
    """ 
    Given a set of images, return the maximum height and width seen across all of them 
    
    Args:
        *images: Variable number of images in any supported format (np.ndarray, PIL.Image, torch.Tensor, etc)
        
    Returns:
        tuple[int, int]: (max_height, max_width)
        
    Examples:
        >>> import numpy as np
        >>> img1 = np.zeros((100, 200, 3))
        >>> img2 = np.zeros((150, 180, 3))  
        >>> get_max_image_dimensions(img1, img2) #You can pass each image as an argument
        (150, 200)
        >>> get_max_image_dimensions([img1, img2]) #You can also pass an iterable of images
        (150, 200)
        
    See also:
        - rp.get_min_image_dimensions: Opposite of this function
        - rp.get_image_dimensions: Get height and width of a single image
        - rp.resize_images_to_max_size: Resize images to max size
        
    Tags: image, dimensions, maximum, batch, multiple
    """
    images = detuple(images)

    if is_numpy_array(images) or is_torch_tensor(images): return get_image_dimensions(images[0]) #Efficiency shortcut: if given video is a tensor, all heights and widths will be the same

    heights=[get_image_height(x) for x in images]
    widths =[get_image_width (x) for x in images]
    return max(heights),max(widths)

def get_max_video_dimensions(*images):
    """ Given a set of videos, return the maximum height and width seen across all of them """
    images = detuple(images)

    if is_numpy_array(images) or is_torch_tensor(images): return get_image_dimensions(images[0,0]) #Efficiency shortcut: if given video is a tensor, all heights and widths will be the same

    heights=[get_video_height(x) for x in images]
    widths =[get_video_width (x) for x in images]
    return max(heights),max(widths)

def get_min_video_dimensions(*images):
    """ Given a set of videos, return the minimum height and width seen across all of them """
    images = detuple(images)

    if is_numpy_array(images) or is_torch_tensor(images): return get_image_dimensions(images[0,0]) #Efficiency shortcut: if given video is a tensor, all heights and widths will be the same

    heights=[get_video_height(x) for x in images]
    widths =[get_video_width (x) for x in images]
    return min(heights),min(widths)

def get_min_image_dimensions(*images):
    """ Given a set of images, return the minimum height and width seen across all of them """
    images = detuple(images)

    if is_numpy_array(images) or is_torch_tensor(images): return get_image_dimensions(images[0]) #Efficiency shortcut: if given video is a tensor, all heights and widths will be the same

    heights=[get_image_height(x) for x in images]
    widths =[get_image_width (x) for x in images]
    return min(heights),min(widths)

get_video_dimensions = get_max_image_dimensions

def uniform_float_color_image(height:int,width:int,color:tuple=(0,0,0,0)):
    """
    Returns an image with the given height and width, where all pixels are the given color
    If the given color is a number, it returns a grayscale image
    Otherwise, the given color must be either an RGB or RGBA float color (a tuple with 3 or 4 floats between 0 and 1)
    
    EXAMPLE:
        for _ in range(16):
            height=randint(10,30)
            width=randint(10,30)
            color=random_rgb_float_color() #Color is like (.1235, .5742, .8652)
            tile=uniform_float_color_image(height,width,color)
            random_color_tiles.append(tile)
        image=tiled_images(random_color_tiles,border_thickness=0)
        display_image(image) #The result will look like https://i.imgur.com/COlmGRT.png 
    """
    
    color = as_rgba_float_color(color)

    assert height>=0 and width>=0
    assert is_number(color) or is_color_vector(color) and len(color) in {3,4}, 'Color should be a number, an RGB float color, or an RGBA float color'
    
    if is_number(color):
        output = np.ones((height,width),dtype=_float_image_dtype())*color
        assert is_grayscale_image(output)
        return output
    else:
        output = np.ones((height,width,len(color)),dtype=_float_image_dtype())*as_numpy_array([[[*color]]])
        assert len(color)==3 and is_rgb_image(output) or len(color)==4 and is_rgba_image(output)
        return output

def uniform_byte_color_image(height:int,width:int,color:tuple=(0,0,0,0)):
    """ Just like uniform_float_color_image, but with as_byte_image """
    return as_byte_image(gather_args_call(uniform_float_color_image))

def _is_rgb_color_vector(color):
    """Check if color is an RGB color vector (3 numeric values)."""
    return is_color_vector(color) and len(color) == 3

def _assert_is_number(arg_name, arg, func_name=None, frames_back=0):
    """Private helper to assert that an argument is a number with proper rp-style error message."""
    if not is_number(arg):
        if func_name is None:
            try:
                func_name = get_current_function_name(frames_back=1+frames_back)
            except RuntimeError:
                func_name = "rp._assert_is_number"
        raise ValueError(func_name + ": " + arg_name + " must be a number, got " + type(arg).__name__ + ": " + str(arg))

def with_color_green(color, green):
    """Modify the green channel of the color. Returns RGB if input is RGB tuple, otherwise RGBA."""
    _assert_is_number("green", green)
    r, g, b, a = as_rgba_float_color(color)
    return (r, green, b) if _is_rgb_color_vector(color) else (r, green, b, a)

def with_color_red(color, red):
    """Modify the red channel of the color. Returns RGB if input is RGB tuple, otherwise RGBA."""
    _assert_is_number("red", red)
    r, g, b, a = as_rgba_float_color(color)
    return (red, g, b) if _is_rgb_color_vector(color) else (red, g, b, a)

def with_color_blue(color, blue):
    """Modify the blue channel of the color. Returns RGB if input is RGB tuple, otherwise RGBA."""
    _assert_is_number("blue", blue)
    r, g, b, a = as_rgba_float_color(color)
    return (r, g, blue) if _is_rgb_color_vector(color) else (r, g, blue, a)

def with_color_alpha(color, alpha):
    """Modify the alpha channel of the color. Always returns RGBA."""
    _assert_is_number("alpha", alpha)
    r, g, b, a = as_rgba_float_color(color)
    return (r, g, b, alpha)

def with_color_hue(color, hue):
    """Sets the hue of the color. Returns RGB if input is RGB tuple, otherwise RGBA."""
    _assert_is_number("hue", hue)
    r, g, b, a = as_rgba_float_color(color)
    h, s, v = rgb_to_hsv_float_color(r, g, b)
    r, g, b = hsv_to_rgb_float_color(hue % 1, s, v)
    return (r, g, b) if _is_rgb_color_vector(color) else (r, g, b, a)

def with_color_saturation(color, saturation):
    """Sets the saturation of the color. Returns RGB if input is RGB tuple, otherwise RGBA."""
    _assert_is_number("saturation", saturation)
    r, g, b, a = as_rgba_float_color(color)
    h, s, v = rgb_to_hsv_float_color(r, g, b)
    r, g, b = hsv_to_rgb_float_color(h, saturation, v)
    return (r, g, b) if _is_rgb_color_vector(color) else (r, g, b, a)

def with_color_brightness(color, brightness):
    """Sets the brightness of the color. Returns RGB if input is RGB tuple, otherwise RGBA."""
    _assert_is_number("brightness", brightness)
    r, g, b, a = as_rgba_float_color(color)
    h, s, v = rgb_to_hsv_float_color(r, g, b)
    r, g, b = hsv_to_rgb_float_color(h, s, brightness)
    return (r, g, b) if _is_rgb_color_vector(color) else (r, g, b, a)

class DictReader:
    """ Like a read-only EasyDict - with a super simple implementation """
    #Note: This class is similar in functionality to the python libraries "easydict" and "addict". You can find them on pypi.
    #This class, however, is read-only right now.

    def __init__(self,data:dict):
        #This class makes reading nested dicts easier
        #Instead of data['a']['b']['c'] you say data.a.b.c
        #Right now this only reads values from dicts
        #More functionality might be added later if I need it...
        #...but for now let's keep it super simple...
        #In the future this might:
        #    - Extend the dict class (allow for __getitem__, __setitem__, and other operators)
        
        assert isinstance(data,dict)    
    
        self._data=data
        
    def __getattr__(self,key:str):
        assert key in self._data, key
        value = self._data[key]
        return DictReader(value) if isinstance(value,dict) else value

    def __dir__(self):
        #Great for autocompletion in interactive sessions!
        return list(self._data)

    def __contains__(self, key):
        return key in self._data

    def __repr__(self):
        return 'DictReader('+repr(self._data)+')'

    def __getitem__(self, index):
        return self._data[index]

    def __iter__(self):
        return iter(self._data)

    def __len__(self):
        return len(self._data)

    def items(self):
        return self._data.items()

    def keys(self):
        return self._data.keys()

    def values(self):
        return self._data.values()

# Professional gradient presets from real design systems and modern UI trends
gradient_presets = {
    # Tech company gradients
    'instagram_classic': ['#833ab4', '#fd1d1d', '#fcb045'],
    'instagram_sunset': ['#ff7e5f', '#feb47b'], 
    'facebook_blue': ['#3b82f6', '#1d4ed8'],
    'twitter_blue': ['#1da1f2', '#0d8bd9'],
    'linkedin_blue': ['#0077b5', '#005582'],
    'github_dark': ['#0d1117', '#161b22'],
    'discord_blurple': ['#7289da', '#5865f2'],
    'slack_aubergine': ['#4a154b', '#350d36'],
    'slack_green': ['#2eb67d', '#1a9661'],
    'spotify_green': ['#1ed760', '#1db954'],
    'netflix_red': ['#e50914', '#b20710'],
    'youtube_red': ['#ff0000', '#cc0000'],
    'zoom_blue': ['#2d8cff', '#1976d2'],
    'figma_purple': ['#a259ff', '#7c3aed'],
    'notion_grey': ['#2f3437', '#37352f'],
    'stripe_blue': ['#635bff', '#4f46e5'],
    'airbnb_coral': ['#ff5a5f', '#c41e3a'],
    'uber_black': ['#000000', '#1a1a1a'],
    'lyft_pink': ['#ff00bf', '#e619ac'],
    'pinterest_red': ['#bd081c', '#e60023'],

    # Modern UI/UX gradients
    'corporate_blue': ['#2c5aa0', '#5c85d6'],
    'enterprise_grey': ['#667eea', '#764ba2'],
    'fintech_green': ['#11998e', '#38ef7d'],
    'startup_orange': ['#ff6b6b', '#ffa726'],
    'saas_purple': ['#8b5cf6', '#a855f7'],
    'dashboard_blue': ['#0ea5e9', '#0284c7'],
    'analytics_green': ['#10b981', '#059669'],
    'premium_gold': ['#f59e0b', '#d97706'],
    'pro_silver': ['#6b7280', '#4b5563'],
    'enterprise_black': ['#111827', '#1f2937'],

    # Nature & organic
    'mountain_mist': ['#bdc3c7', '#2c3e50'],
    'tropical_waters': ['#00b09b', '#96c93d'],
    'arctic_aurora': ['#667eea', '#764ba2'],
    'sahara_dunes': ['#deb887', '#f4a460'],
    'amazon_canopy': ['#228b22', '#32cd32'],
    'coral_reef': ['#ff9a9e', '#fecfef'],
    'himalayan_peaks': ['#708090', '#b0c4de'],
    'bamboo_forest': ['#7cb342', '#558b2f'],
    'cherry_blossom': ['#ffb7c5', '#ffc0cb'],
    'autumn_leaves': ['#d2691e', '#cd853f'],
    'spring_meadow': ['#98fb98', '#00fa9a'],
    'winter_ice': ['#b0e0e6', '#f0f8ff'],
    'summer_sky': ['#87ceeb', '#4682b4'],

    # Vibrant & energetic
    'electric_violet': ['#4facfe', '#00f2fe'],
    'neon_pink': ['#ff0099', '#493240'],
    'cyber_blue': ['#00d2ff', '#3a7bd5'],
    'laser_green': ['#56ab2f', '#a8e6cf'],
    'plasma_orange': ['#f093fb', '#f5576c'],
    'synthwave': ['#f72585', '#7209b7'],
    'vaporwave': ['#a663cc', '#4cc9f0'],
    'neon_city': ['#ff006e', '#8338ec', '#3a86ff'],
    'electric_dreams': ['#667eea', '#764ba2'],
    'digital_rain': ['#00ff41', '#00b8a9'],
    'hologram': ['#89f7fe', '#66a6ff'],
    'quantum_leap': ['#667eea', '#764ba2'],
    'binary_sunset': ['#ff6b6b', '#4ecdc4'],
    'matrix_code': ['#00ff00', '#008000'],

    # Pastel & soft
    'cotton_candy': ['#ffecd2', '#fcb69f'],
    'lavender_dream': ['#e0c3fc', '#9bb5ff'],
    'peach_cream': ['#ffeaa7', '#fab1a0'],
    'mint_chocolate': ['#a8e6cf', '#dcedc1'],
    'powder_blue': ['#a8edea', '#fed6e3'],
    'baby_pink': ['#ffc0cb', '#ffb6c1'],
    'soft_yellow': ['#fff8dc', '#fffacd'],
    'gentle_green': ['#f0fff0', '#e0ffe0'],
    'cloud_white': ['#f8f8ff', '#ffffff'],
    'sunset_pink': ['#ff9a9e', '#fecfef'],
    'twilight_blue': ['#b0c4de', '#add8e6'],
    'pearl_white': ['#f8f8ff', '#fffafa'],
    'silk_purple': ['#dda0dd', '#e6e6fa'],

    # Dark & sophisticated
    'midnight_city': ['#232526', '#414345'],
    'charcoal_fade': ['#2c3e50', '#3498db'],
    'obsidian': ['#0f0f23', '#24243e'],
    'carbon_fiber': ['#2c2c54', '#40407a'],
    'deep_space': ['#0f3460', '#16213e'],
    'noir_black': ['#000000', '#434343'],
    'gunmetal': ['#2a3439', '#1e2426'],
    'slate_grey': ['#2f4f4f', '#696969'],
    'storm_clouds': ['#2c3e50', '#34495e'],
    'shadow_black': ['#1a1a2e', '#16213e'],
    'eclipse': ['#0c0c0c', '#1a1a1a'],
    'void': ['#000000', '#0d1117'],
    'onyx': ['#353839', '#2c2c2c'],
    'graphite': ['#41424c', '#2e2e2e'],
    'charcoal': ['#36454f', '#2f4f4f'],

    # Metallic & luxury
    'gold_rush': ['#f7971e', '#ffd200'],
    'silver_lining': ['#bdc3c7', '#2c3e50'],
    'copper_tone': ['#b87333', '#cd853f'],
    'rose_gold': ['#f093fb', '#f5576c'],
    'platinum': ['#e5e4e2', '#d5d4d0'],
    'bronze_age': ['#cd7f32', '#a0522d'],
    'titanium': ['#878681', '#c0c0c0'],
    'chrome_shine': ['#c0c0c0', '#808080'],
    'brass_glow': ['#b5651d', '#daa520'],
    'pewter_grey': ['#96a8a1', '#8b9dc3'],
    'mercury_silver': ['#e5e5e5', '#c0c0c0'],
    'golden_hour': ['#ff7f50', '#ffd700'],
    'copper_patina': ['#7fb069', '#b87333'],
    'steel_blue': ['#4682b4', '#708090'],
    'iron_grey': ['#71716f', '#808080'],

    # Food & beverage
    'espresso_bean': ['#3c1053', '#ad5389'],
    'wine_cellar': ['#c31432', '#240b36'],
    'citrus_burst': ['#fa709a', '#fee140'],
    'mint_mojito': ['#56ab2f', '#a8e6cf'],
    'chocolate_truffle': ['#8b4513', '#daa520'],
    'strawberry_shake': ['#ff69b4', '#ffc0cb'],
    'blueberry_pie': ['#4169e1', '#6495ed'],
    'lemon_drop': ['#ffff00', '#fffacd'],
    'grape_juice': ['#8b008b', '#9932cc'],
    'orange_creamsicle': ['#ff8c00', '#ffd700'],
    'vanilla_cream': ['#f5f5dc', '#fffaf0'],
    'caramel_swirl': ['#d2691e', '#daa520'],
    'berry_smoothie': ['#dc143c', '#ff69b4'],
    'green_tea': ['#9acd32', '#adff2f'],
    'coffee_cream': ['#a0522d', '#deb887'],

    # Seasonal
    'spring_bloom': ['#98fb98', '#00fa9a', '#32cd32'],
    'summer_heat': ['#ff4500', '#ff6347', '#ffa500'],
    'autumn_harvest': ['#d2691e', '#cd853f', '#daa520'],
    'winter_chill': ['#708090', '#b0c4de', '#f0f8ff'],
    'valentine_love': ['#ff1493', '#ff69b4'],
    'easter_pastel': ['#dda0dd', '#98fb98'],
    'halloween_spook': ['#ff4500', '#8b0000'],
    'christmas_joy': ['#dc143c', '#228b22'],
    'new_years_gold': ['#ffd700', '#ffff00'],
    'thanksgiving_warm': ['#d2691e', '#b8860b'],

    # Time of day
    'sunrise_glow': ['#ff7e5f', '#feb47b'],
    'morning_fresh': ['#a8edea', '#fed6e3'],
    'midday_bright': ['#fee140', '#fa709a'],
    'afternoon_warm': ['#ff9500', '#ffb347'],
    'golden_hour': ['#f7971e', '#ffd200'],
    'sunset_magic': ['#ff416c', '#ff4b2b'],
    'dusk_purple': ['#667eea', '#764ba2'],
    'night_dark': ['#0f3460', '#16213e'],
    'midnight_deep': ['#2c5aa0', '#5c85d6'],
    'predawn_grey': ['#708090', '#a9a9a9'],
    'dawn_pink': ['#ff69b4', '#ffc0cb'],

    # Abstract concepts
    'velocity_rush': ['#667eea', '#764ba2'],
    'infinite_loop': ['#4facfe', '#00f2fe'],
    'zen_harmony': ['#56ab2f', '#a8e6cf'],
    'cosmic_energy': ['#667eea', '#764ba2'],
    'liquid_flow': ['#bdc3c7', '#2c3e50'],
    'solar_wind': ['#ff6b35', '#f7931e'],
    'stellar_dust': ['#a8edea', '#fed6e3'],
    'quantum_field': ['#667eea', '#764ba2'],
    'gravity_wave': ['#4facfe', '#00f2fe'],
    'time_warp': ['#ff006e', '#8338ec'],

    # Geographic inspirations
    'sahara_heat': ['#deb887', '#f4a460', '#d2b48c'],
    'amazon_green': ['#134e5e', '#71b280'],
    'arctic_white': ['#b0e0e6', '#f0f8ff'],
    'pacific_blue': ['#0077be', '#4169e1'],
    'himalayan_snow': ['#f0f8ff', '#fffafa'],
    'mediterranean_blue': ['#4682b4', '#87ceeb'],
    'caribbean_teal': ['#008b8b', '#20b2aa'],
    'alaskan_ice': ['#b0e0e6', '#e0ffff'],
    'scottish_highlands': ['#556b2f', '#8fbc8f'],
    'australian_sunset': ['#ff4500', '#ff8c00'],
    'african_savanna': ['#daa520', '#b8860b'],
    'asian_monsoon': ['#4682b4', '#708090'],
    'european_autumn': ['#cd853f', '#d2691e'],
    'american_plains': ['#daa520', '#f0e68c'],
    'antarctic_ice': ['#f0f8ff', '#ffffff'],

    # Complex multi-color gradients (3+ colors)
    # Rainbow and spectrum gradients
    'rainbow_classic': ['#ff0000', '#ff8000', '#ffff00', '#80ff00', '#00ff00', '#00ff80', '#00ffff', '#0080ff', '#0000ff', '#8000ff', '#ff00ff', '#ff0080'],
    'pride_rainbow': ['#e40303', '#ff8c00', '#ffed00', '#008018', '#0066ff', '#8b00ff'],
    'fire_to_ice': ['#ff0000', '#ff4500', '#ffa500', '#ffff00', '#adff2f', '#00ffff', '#0080ff', '#0000ff'],
    'sunset_spectrum': ['#ff6b35', '#f7931e', '#ffd200', '#ffb347', '#ff69b4', '#8a2be2'],

    # Complex brand gradients  
    'google_colors': ['#4285f4', '#34a853', '#fbbc04', '#ea4335'],
    'microsoft_rainbow': ['#00bcf2', '#40e0d0', '#7b68ee', '#ff69b4'],
    'adobe_spectrum': ['#ff0000', '#ff8c00', '#0099cc', '#9933ff'],
    'slack_full': ['#4a154b', '#2eb67d', '#ece01a', '#e01e5a'],

    # Nature multi-gradients
    'tropical_sunset': ['#ff6b6b', '#ff8e53', '#ff6b9d', '#c44569'],
    'forest_depths': ['#0f3460', '#16537e', '#1e6091', '#369681', '#53a08c'],
    'ocean_layers': ['#000080', '#0000cd', '#4169e1', '#00bfff', '#87ceeb', '#e0f6ff'],
    'mountain_range': ['#2c3e50', '#34495e', '#7f8c8d', '#95a5a6', '#bdc3c7', '#ecf0f1'],
    'aurora_borealis': ['#0f4c75', '#3282b8', '#bbe1fa', '#1ba1f2', '#6a5acd', '#8a2be2'],

    # Vibrant multi-color
    'neon_spectrum': ['#ff0080', '#ff4000', '#ffff00', '#40ff00', '#00ffff', '#4000ff'],
    'electric_rainbow': ['#ff006e', '#fb5607', '#ffbe0b', '#8338ec', '#3a86ff'],
    'cyber_gradient': ['#00ff41', '#00d4ff', '#ff0080', '#8338ec', '#3a86ff'],
    'synthwave_full': ['#ff006e', '#fb5607', '#ffbe0b', '#8338ec', '#3a86ff', '#06ffa5'],
    'retro_computer': ['#ff0080', '#ff4000', '#ffff00', '#40ff00', '#00ffff', '#8000ff'],

    # Pastel multi-color
    'cotton_candy_dream': ['#ffccd5', '#ffb3d1', '#ff99cc', '#e6ccff', '#ccccff'],
    'spring_garden': ['#f8bbd9', '#f2d7d5', '#d5dbdb', '#a9dfbf', '#85c1e9'],
    'easter_palette': ['#fdeaa7', '#f8c471', '#f1948a', '#bb8fce', '#85c1e9'],
    'soft_rainbow': ['#fadbd8', '#f5cba7', '#fff2cc', '#d5f4e6', '#d6eaf8', '#e8daef'],

    # Dark sophisticated multi-color
    'midnight_spectrum': ['#0c0c0c', '#1a1a2e', '#16213e', '#0f3460', '#533483'],
    'shadow_depths': ['#000000', '#1a1a1a', '#2d2d2d', '#404040', '#545454'],
    'noir_gradient': ['#0d1117', '#161b22', '#21262d', '#30363d', '#484f58'],
    'charcoal_layers': ['#2c3e50', '#34495e', '#566573', '#717d7e', '#85929e'],

    # Metallic multi-color
    'precious_metals': ['#ffd700', '#c0c0c0', '#cd7f32', '#e5e4e2', '#b87333'],
    'chrome_rainbow': ['#c0c0c0', '#d3d3d3', '#e5e5e5', '#f0f0f0', '#dcdcdc'],
    'golden_spectrum': ['#b8860b', '#daa520', '#ffd700', '#ffed4e', '#fffacd'],
    'copper_patina_full': ['#b87333', '#cd853f', '#d2691e', '#7fb069', '#20b2aa'],

    # Time-based multi-color
    'full_day_cycle': ['#191970', '#4b0082', '#8b008b', '#ff1493', '#ff4500', '#ffa500', '#ffff00', '#adff2f', '#00ff7f', '#0080ff'],
    'seasons_cycle': ['#ffffff', '#98fb98', '#ffa500', '#8b4513'],
    'golden_hour_full': ['#191970', '#483d8b', '#8b008b', '#ff1493', '#ff4500', '#ffa500', '#ffd700'],

    # Geographic multi-color
    'sahara_layers': ['#8b4513', '#cd853f', '#daa520', '#f4a460', '#deb887', '#f5deb3'],
    'amazon_canopy_full': ['#013220', '#0d4f2c', '#228b22', '#32cd32', '#7cfc00'],
    'coral_reef_spectrum': ['#008b8b', '#20b2aa', '#48d1cc', '#ff7f50', '#ff6347', '#ff1493'],
    'himalayan_gradient': ['#2f4f4f', '#696969', '#778899', '#b0c4de', '#f0f8ff', '#fffafa'],

    # Food multi-color
    'fruit_bowl': ['#ff0000', '#ffa500', '#ffff00', '#32cd32', '#8a2be2', '#ff1493'],
    'spice_market': ['#8b4513', '#d2691e', '#cd853f', '#daa520', '#ff6347'],
    'candy_shop': ['#ff69b4', '#ff1493', '#ffd700', '#32cd32', '#00bfff', '#8a2be2'],
    'wine_tasting': ['#722f37', '#8b0000', '#a0522d', '#cd853f', '#f4a460'],

    # Abstract multi-color using dict positioning
    'velocity_field': {0.0: '#ff0080', 0.25: '#ff4000', 0.5: '#ffff00', 0.75: '#40ff00', 1.0: '#00ffff'},
    'quantum_superposition': {0.0: '#000080', 0.2: '#4169e1', 0.4: '#00bfff', 0.6: '#ff1493', 0.8: '#ff4500', 1.0: '#ffd700'},
    'time_distortion': {-0.2: '#000000', 0.0: '#4b0082', 0.3: '#8b008b', 0.6: '#ff1493', 0.9: '#ffa500', 1.2: '#ffff00'},
    'gravity_well': {0.0: '#000000', 0.1: '#191970', 0.3: '#4b0082', 0.6: '#8b008b', 0.8: '#ff1493', 1.0: '#ffffff'},
    'wormhole': {-0.5: '#000000', 0.0: '#4b0082', 0.2: '#8b008b', 0.5: '#ff1493', 0.8: '#ffa500', 1.0: '#ffffff', 1.5: '#000000'},

    # Extended range gradients using dict
    'extended_fire': {-0.3: '#000000', 0.0: '#8b0000', 0.3: '#ff0000', 0.6: '#ff4500', 0.9: '#ffa500', 1.0: '#ffff00', 1.3: '#ffffff'},
    'extended_ice': {-0.2: '#000080', 0.0: '#0000ff', 0.4: '#00bfff', 0.7: '#87ceeb', 1.0: '#f0f8ff', 1.2: '#ffffff'},
    'infinity_gradient': {-1.0: '#ff0080', -0.5: '#ff4000', 0.0: '#ffff00', 0.5: '#40ff00', 1.0: '#00ffff', 1.5: '#8000ff', 2.0: '#ff0080'},

    # Web-sourced gradients from popular design collections
    # From webgradients repository - complex multi-color
    'warm_flame': ['#ff9a9e', '#fad0c4', '#fad0c4'],
    'above_the_sky': ['#BCBCBC', '#D9D9D9', '#e0e0e0', '#efefef', '#d9d9d9', '#bcbcbc'],
    'wide_matrix': ['#fcc5e4', '#fda34b', '#ff7882', '#c8699e', '#7046aa', '#0c1db8', '#020f75'],
    'space_shift': ['#3d3393', '#2b76b9', '#2cacd1', '#35eb93'],
    'winter_neva': ['#a1c4fd', '#c2e9fb'],
    'dusty_grass': ['#d4fc79', '#96e6a1'],
    'night_fade': ['#a18cd1', '#fbc2eb'],
    'aqua_splash': ['#13547a', '#80d0c7'],
    'plum_bath': ['#cc208e', '#6713d2'],

    # Popular CSS gradient collections
    'fire_watch': ['#cb2d3e', '#ef473a'],
    'sherbert': ['#f79d00', '#64f38c'],
    'bloody_mary': ['#ff512f', '#dd2476'],
    'lush': ['#56ab2f', '#a8e6cf'],
    'sunrise': ['#ff512f', '#f09819'],
    'purple_paradise': ['#1d2b64', '#f8cdda'],
    'aqua_marine': ['#1a2980', '#26d0ce'],
    'orange_coral': ['#ff9966', '#ff5e62'],
    'blue_lagoon': ['#43c6ac', '#191654'],
    'pink_flavour': ['#800080', '#ffc0cb'],

    # Mesh gradients (trending multi-stops)
    'mesh_purple': ['#667eea', '#764ba2', '#f093fb', '#f5576c'],
    'mesh_ocean': ['#2196f3', '#21cbf3', '#2196f3', '#0288d1'],
    'mesh_sunset': ['#fa709a', '#fee140', '#fa709a', '#ffd200'],
    'mesh_forest': ['#134e5e', '#71b280', '#134e5e', '#2d5016'],
    'mesh_candy': ['#ff9a9e', '#fecfef', '#ff9a9e', '#fad0c4'],

    # Popular Instagram/social media gradients
    'insta_warmth': ['#833ab4', '#fd1d1d', '#fcb045'],
    'insta_purple': ['#405de6', '#5851db', '#833ab4', '#c13584', '#e1306c', '#fd1d1d'],
    'tiktok_vibes': ['#ff0050', '#00f2ea'],
    'snapchat_yellow': ['#fffc00', '#ffffff'],
    'discord_gradient': ['#7289da', '#99aab5'],
    'whatsapp_green': ['#075e54', '#25d366'],
    'telegram_blue': ['#0088cc', '#1ea3d7'],

    # Gaming gradients (popular in UI)
    'valorant_red': ['#ff4655', '#0f1419'],
    'cyberpunk_yellow': ['#fcee21', '#d508b7'],
    'overwatch_orange': ['#f99e1a', '#ef4123'],
    'fortnite_blue': ['#00d4ff', '#5b247a'],
    'minecraft_green': ['#7cb342', '#357a35'],
    'steam_blue': ['#1b2838', '#2a475e', '#66c0f4'],

    # Dribbble/Behance trending gradients
    'dribbble_pink': ['#ea4c89', '#f082ac'],
    'behance_blue': ['#053eff', '#4d9de0'],
    'figma_purple': ['#9747ff', '#651fff'],
    'sketch_yellow': ['#fdbb2d', '#22c1c3'],
    'adobe_red': ['#ff0000', '#ff6600', '#ffcc00'],

    # UI/UX trend gradients from 2023-2024
    'glassmorphism': ['#ffffff', '#ffffff', '#f0f0f0', '#e0e0e0'],
    'neomorphism': ['#e6e7ee', '#ffffff', '#d1d9e6', '#ffffff'],
    'cyberpunk_neon': ['#08fdd8', '#09fbd3', '#08f5d2', '#07f8d1'],
    'retrowave_80s': ['#f7971e', '#ff006e', '#8338ec', '#3a86ff'],
    'vaporwave_aesthetic': ['#ff006e', '#8338ec', '#3a86ff', '#06ffa5'],
    'synthwave_nights': ['#2d1b69', '#11998e', '#38ef7d', '#fcb045'],

    # Natural phenomenon gradients
    'northern_lights_full': ['#00c9ff', '#92fe9d', '#00c9ff', '#a8edea', '#fed6e3'],
    'volcano_eruption': ['#8b0000', '#ff0000', '#ff4500', '#ffa500', '#ffff00'],
    'deep_ocean_trenches': ['#000428', '#004e92', '#009ffd', '#00d2ff'],
    'sahara_sunset_layers': ['#8b4513', '#cd853f', '#daa520', '#ff8c00', '#ff4500'],
    'amazon_canopy_depths': ['#013220', '#0d4f2c', '#228b22', '#32cd32', '#7cfc00', '#adff2f'],
    'arctic_aurora_dance': ['#0f4c75', '#3282b8', '#bbe1fa', '#00d4aa', '#6a5acd', '#8a2be2'],

    # Artistic/creative gradients
    'watercolor_splash': ['#667eea', '#764ba2', '#f093fb', '#f5576c', '#4facfe', '#00f2fe'],
    'oil_painting': ['#8b4513', '#d2691e', '#daa520', '#f4a460', '#deb887'],
    'acrylic_pour': ['#ff006e', '#fb5607', '#ffbe0b', '#8338ec', '#3a86ff', '#06ffa5'],
    'neon_nights': ['#ff0080', '#ff4000', '#ffff00', '#40ff00', '#00ffff', '#4000ff', '#8000ff'],

    # Food & beverage inspired (complex)
    'tropical_smoothie': ['#ff6b6b', '#ee5a24', '#f0932b', '#eb4d4b', '#6c5ce7', '#a29bfe'],
    'coffee_layers': ['#3c1053', '#ad5389', '#8b4513', '#d2691e', '#daa520'],
    'wine_tasting_full': ['#722f37', '#8b0000', '#a0522d', '#cd853f', '#f4a460', '#deb887'],
    'fruit_punch': ['#ff0000', '#ff4500', '#ffa500', '#ffff00', '#adff2f', '#00ff7f', '#ff1493'],
    'spice_bazaar': ['#8b4513', '#d2691e', '#cd853f', '#daa520', '#ff6347', '#ff4500'],

    # Seasonal complex gradients
    'autumn_forest_full': ['#8b4513', '#d2691e', '#cd853f', '#daa520', '#ff8c00', '#ff4500'],
    'spring_meadow_full': ['#98fb98', '#00fa9a', '#32cd32', '#adff2f', '#7fff00', '#9aff9a'],
    'summer_beach_full': ['#87ceeb', '#4682b4', '#1e90ff', '#00bfff', '#87cefa', '#b0e0e6'],
    'winter_wonderland_full': ['#708090', '#b0c4de', '#f0f8ff', '#fffafa', '#ffffff', '#f8f8ff'],

    # Tech/digital complex
    'matrix_rain_full': ['#000000', '#001100', '#002200', '#003300', '#004400', '#00ff00'],
    'holographic_display': ['#89f7fe', '#66a6ff', '#45a3ff', '#2196f3', '#1976d2'],
    'digital_glitch': ['#ff0080', '#ff4000', '#ffff00', '#40ff00', '#00ffff', '#8000ff', '#ff0080'],
    'cyber_security': ['#0f3460', '#16213e', '#0e4b99', '#2e8bc0', '#b1d4e0'],
    'quantum_computing': ['#000080', '#0000cd', '#4169e1', '#00bfff', '#87ceeb', '#e0f6ff', '#ffffff'],

    # Extended range dict gradients (advanced web-sourced)
    'time_portal': {-0.5: '#000000', 0.0: '#4b0082', 0.2: '#8b008b', 0.5: '#ff1493', 0.8: '#ffa500', 1.0: '#ffffff', 1.5: '#000000'},
    'dimensional_rift': {-1.0: '#000000', -0.3: '#1a1a2e', 0.0: '#16213e', 0.3: '#0f3460', 0.7: '#533483', 1.0: '#8b008b', 1.3: '#000000'},
    'energy_field': {0.0: '#ff0080', 0.15: '#ff4000', 0.3: '#ffff00', 0.45: '#40ff00', 0.6: '#00ffff', 0.75: '#4000ff', 0.9: '#8000ff', 1.0: '#ff0080'},
    'reality_distortion': {-0.3: '#000000', 0.0: '#330867', 0.25: '#30cfd0', 0.5: '#a8edea', 0.75: '#fed6e3', 1.0: '#ffffff', 1.3: '#000000'},
    'cosmic_wormhole': {-0.8: '#000000', -0.4: '#000428', 0.0: '#004e92', 0.3: '#009ffd', 0.6: '#00d2ff', 0.8: '#ffffff', 1.2: '#000000'},

    # Step function gradients (sharp transitions for flags, etc.)
    # Pride flags with precise positioning for clean stripes
    'pride_flag': {
            0.0: '#e40303',      # Red
            0.166: '#e40303', 0.1661: '#ff8c00',  # Red->Orange
            0.333: '#ff8c00', 0.3331: '#ffed00',  # Orange->Yellow  
            0.5: '#ffed00', 0.5001: '#008018',    # Yellow->Green
            0.666: '#008018', 0.6661: '#0066ff',  # Green->Blue
            0.833: '#0066ff', 0.8331: '#8b00ff',  # Blue->Purple
            1.0: '#8b00ff'       # Purple
            },

    'trans_flag': {
            0.0: '#55cdfc',      # Light blue
            0.199: '#55cdfc', 0.2: '#f7a8b8',     # Light blue->Pink
            0.399: '#f7a8b8', 0.4: '#ffffff',     # Pink->White  
            0.599: '#ffffff', 0.6: '#f7a8b8',     # White->Pink
            0.799: '#f7a8b8', 0.8: '#55cdfc',     # Pink->Light blue
            1.0: '#55cdfc'       # Light blue
            },

    'bi_flag': {
            0.0: '#d60270',      # Magenta
            0.399: '#d60270', 0.4: '#9b59b6',     # Magenta->Purple
            0.599: '#9b59b6', 0.6: '#0038a8',     # Purple->Blue
            1.0: '#0038a8'       # Blue
            },

    'pan_flag': {
            0.0: '#ff218c',      # Pink
            0.333: '#ff218c', 0.3331: '#ffd800', # Pink->Yellow
            0.666: '#ffd800', 0.6661: '#21b1ff', # Yellow->Blue
            1.0: '#21b1ff'       # Blue
            },

    'lesbian_flag': {
            0.0: '#d52d00',      # Dark orange
            0.199: '#d52d00', 0.2: '#ef7627',     # Dark orange->Orange
            0.399: '#ef7627', 0.4: '#ff9a56',     # Orange->Light orange
            0.599: '#ff9a56', 0.6: '#d162a4',     # Light orange->Pink
            0.799: '#d162a4', 0.8: '#a20160',     # Pink->Dark pink
            1.0: '#a20160'       # Dark pink
            },

    'nb_flag': {
            0.0: '#fcf434',      # Yellow
            0.249: '#fcf434', 0.25: '#ffffff',    # Yellow->White
            0.499: '#ffffff', 0.5: '#9c59d1',     # White->Purple  
            0.749: '#9c59d1', 0.75: '#2c2c2c',    # Purple->Black
            1.0: '#2c2c2c'       # Black
            },

    # International flags with step transitions
    'usa_flag_stripes': {
            0.0: '#b22234',      # Red
            0.076: '#b22234', 0.0761: '#ffffff',  # Red->White
            0.153: '#ffffff', 0.1531: '#b22234',  # White->Red
            0.230: '#b22234', 0.2301: '#ffffff',  # Red->White
            0.307: '#ffffff', 0.3071: '#b22234',  # White->Red
            0.384: '#b22234', 0.3841: '#ffffff',  # Red->White
            0.461: '#ffffff', 0.4611: '#b22234',  # White->Red
            0.538: '#b22234', 0.5381: '#ffffff',  # Red->White
            0.615: '#ffffff', 0.6151: '#b22234',  # White->Red
            0.692: '#b22234', 0.6921: '#ffffff',  # Red->White
            0.769: '#ffffff', 0.7691: '#b22234',  # White->Red
            0.846: '#b22234', 0.8461: '#ffffff',  # Red->White
            0.923: '#ffffff', 0.9231: '#b22234',  # White->Red
            1.0: '#b22234'       # Red
            },

    'german_flag': {
            0.0: '#000000',      # Black
            0.333: '#000000', 0.3331: '#dd0000', # Black->Red
            0.666: '#dd0000', 0.6661: '#ffce00', # Red->Gold
            1.0: '#ffce00'       # Gold
            },

    'french_flag': {
            0.0: '#0055a4',      # Blue
            0.333: '#0055a4', 0.3331: '#ffffff', # Blue->White
            0.666: '#ffffff', 0.6661: '#ef4135', # White->Red
            1.0: '#ef4135'       # Red
            },

    'italian_flag': {
            0.0: '#009246',      # Green
            0.333: '#009246', 0.3331: '#ffffff', # Green->White
            0.666: '#ffffff', 0.6661: '#ce2b37', # White->Red
            1.0: '#ce2b37'       # Red
            },

    'japanese_flag': {
            0.0: '#ffffff',      # White
            0.4: '#ffffff', 0.401: '#bc002d',     # White->Red (sun)
            0.6: '#bc002d', 0.601: '#ffffff',     # Red->White
            1.0: '#ffffff'       # White
            },

    # Sports team step gradients
    'lakers_stripes': {
            0.0: '#552583',      # Purple
            0.499: '#552583', 0.5: '#fdb927',     # Purple->Gold
            1.0: '#fdb927'       # Gold
            },

    'yankees_stripes': {
            0.0: '#132448',      # Navy
            0.499: '#132448', 0.5: '#c4ced4',     # Navy->Gray
            1.0: '#c4ced4'       # Gray
            },

    # Warning/signal step patterns
    'hazard_stripes': {
            0.0: '#ffff00',      # Yellow
            0.124: '#ffff00', 0.125: '#000000',   # Yellow->Black
            0.249: '#000000', 0.25: '#ffff00',    # Black->Yellow
            0.374: '#ffff00', 0.375: '#000000',   # Yellow->Black
            0.499: '#000000', 0.5: '#ffff00',     # Black->Yellow
            0.624: '#ffff00', 0.625: '#000000',   # Yellow->Black
            0.749: '#000000', 0.75: '#ffff00',    # Black->Yellow
            0.874: '#ffff00', 0.875: '#000000',   # Yellow->Black
            1.0: '#000000'       # Black
            },

    'caution_tape': {
            0.0: '#ffff00',      # Yellow
            0.199: '#ffff00', 0.2: '#000000',     # Yellow->Black
            0.399: '#000000', 0.4: '#ffff00',     # Black->Yellow
            0.599: '#ffff00', 0.6: '#000000',     # Yellow->Black
            0.799: '#000000', 0.8: '#ffff00',     # Black->Yellow
            1.0: '#ffff00'       # Yellow
            },

    # Retro/pixel art step gradients
    'pixel_fire': {
            0.0: '#000000',      # Black
            0.249: '#000000', 0.25: '#8b0000',    # Black->Dark red
            0.499: '#8b0000', 0.5: '#ff0000',     # Dark red->Red
            0.749: '#ff0000', 0.75: '#ffa500',    # Red->Orange
            1.0: '#ffa500'       # Orange
            },

    'pixel_water': {
            0.0: '#000080',      # Navy
            0.249: '#000080', 0.25: '#0000ff',    # Navy->Blue
            0.499: '#0000ff', 0.5: '#4169e1',     # Blue->Royal blue
            0.749: '#4169e1', 0.75: '#87ceeb',    # Royal blue->Sky blue
            1.0: '#87ceeb'       # Sky blue
            },

    'minecraft_grass': {
            0.0: '#654321',      # Brown (dirt)
            0.749: '#654321', 0.75: '#228b22',    # Brown->Green
            1.0: '#228b22'       # Green
            },

    'turbo':  #https://research.google/blog/turbo-an-improved-rainbow-colormap-for-visualization/  -->  https://gist.github.com/mikhailov-work/ee72ba4191942acecc03fe6da94fc73f
        [[.18995,.07176, .23217], [.19483, .08339, .26149], [.19956, .09498, .29024], [.20415, .10652, .31844], [.2086,  .11802, .34607], [.21291, .12947, .37314], [.21708, .14087, .39964], [.22111, .15223, .42558], [.225,   .16354, .45096], [.22875, .17481, .47578],
         [.23236, .18603, .50004], [.23582, .1972,  .52373], [.23915, .20833, .54686], [.24234, .21941, .56942], [.24539, .23044, .59142], [.2483,  .24143, .61286], [.25107, .25237, .63374], [.25369, .26327, .65406], [.25618, .27412, .67381], [.25853, .28492, .693],
         [.26074, .29568, .71162], [.2628,  .30639, .72968], [.26473, .31706, .74718], [.26652, .32768, .76412], [.26816, .33825, .7805],  [.26967, .34878, .79631], [.27103, .35926, .81156], [.27226, .3697,  .82624], [.27334, .38008, .84037], [.27429, .39043, .85393],
         [.27509, .40072, .86692], [.27576, .41097, .87936], [.27628, .42118, .89123], [.27667, .43134, .90254], [.27691, .44145, .91328], [.27701, .45152, .92347], [.27698, .46153, .93309], [.2768,  .47151, .94214], [.27648, .48144, .95064], [.27603, .49132, .95857],
         [.27543, .50115, .96594], [.27469, .51094, .97275], [.27381, .52069, .97899], [.27273, .5304,  .98461], [.27106, .54015, .9893],  [.26878, .54995, .99303], [.26592, .55979, .99583], [.26252, .56967, .99773], [.25862, .57958, .99876], [.25425, .5895,  .99896],
         [.24946, .59943, .99835], [.24427, .60937, .99697], [.23874, .61931, .99485], [.23288, .62923, .99202], [.22676, .63913, .98851], [.22039, .64901, .98436], [.21382, .65886, .97959], [.20708, .66866, .97423], [.20021, .67842, .96833], [.19326, .68812, .9619],
         [.18625, .69775, .95498], [.17923, .70732, .94761], [.17223, .7168,  .93981], [.16529, .7262,  .93161], [.15844, .73551, .92305], [.15173, .74472, .91416], [.14519, .75381, .90496], [.13886, .76279, .8955],  [.13278, .77165, .8858],  [.12698, .78037, .8759],
         [.12151, .78896, .86581], [.11639, .7974,  .85559], [.11167, .80569, .84525], [.10738, .81381, .83484], [.10357, .82177, .82437], [.10026, .82955, .81389], [.0975,  .83714, .80342], [.09532, .84455, .79299], [.09377, .85175, .78264], [.09287, .85875, .7724],
         [.09267, .86554, .7623],  [.0932,  .87211, .75237], [.09451, .87844, .74265], [.09662, .88454, .73316], [.09958, .8904,  .72393], [.10342, .896,   .715],   [.10815, .90142, .70599], [.11374, .90673, .69651], [.12014, .91193, .6866],  [.12733, .91701, .67627],
         [.13526, .92197, .66556], [.14391, .9268,  .65448], [.15323, .93151, .64308], [.16319, .93609, .63137], [.17377, .94053, .61938], [.18491, .94484, .60713], [.19659, .94901, .59466], [.20877, .95304, .58199], [.22142, .95692, .56914], [.23449, .96065, .55614],
         [.24797, .96423, .54303], [.2618,  .96765, .52981], [.27597, .97092, .51653], [.29042, .97403, .50321], [.30513, .97697, .48987], [.32006, .97974, .47654], [.33517, .98234, .46325], [.35043, .98477, .45002], [.36581, .98702, .43688], [.38127, .98909, .42386],
         [.39678, .99098, .41098], [.41229, .99268, .39826], [.42778, .99419, .38575], [.44321, .99551, .37345], [.45854, .99663, .3614],  [.47375, .99755, .34963], [.48879, .99828, .33816], [.50362, .99879, .32701], [.51822, .9991,  .31622], [.53255, .99919, .30581],
         [.54658, .99907, .29581], [.56026, .99873, .28623], [.57357, .99817, .27712], [.58646, .99739, .26849], [.59891, .99638, .26038], [.61088, .99514, .2528],  [.62233, .99366, .24579], [.63323, .99195, .23937], [.64362, .98999, .23356], [.65394, .98775, .22835],
         [.66428, .98524, .2237],  [.67462, .98246, .2196],  [.68494, .97941, .21602], [.69525, .9761,  .21294], [.70553, .97255, .21032], [.71577, .96875, .20815], [.72596, .9647,  .2064],  [.7361,  .96043, .20504], [.74617, .95593, .20406], [.75617, .95121, .20343],
         [.76608, .94627, .20311], [.77591, .94113, .2031],  [.78563, .93579, .20336], [.79524, .93025, .20386], [.80473, .92452, .20459], [.8141,  .91861, .20552], [.82333, .91253, .20663], [.83241, .90627, .20788], [.84133, .89986, .20926], [.8501,  .89328, .21074],
         [.85868, .88655, .2123],  [.86709, .87968, .21391], [.8753,  .87267, .21555], [.88331, .86553, .21719], [.89112, .85826, .2188],  [.8987,  .85087, .22038], [.90605, .84337, .22188], [.91317, .83576, .22328], [.92004, .82806, .22456], [.92666, .82025, .2257],
         [.93301, .81236, .22667], [.93909, .80439, .22744], [.94489, .79634, .228],   [.95039, .78823, .22831], [.9556,  .78005, .22836], [.96049, .77181, .22811], [.96507, .76352, .22754], [.96931, .75519, .22663], [.97323, .74682, .22536], [.97679, .73842, .22369],
         [.98,    .73,    .22161], [.98289, .7214,  .21918], [.98549, .7125,  .2165],  [.98781, .7033,  .21358], [.98986, .69382, .21043], [.99163, .68408, .20706], [.99314, .67408, .20348], [.99438, .66386, .19971], [.99535, .65341, .19577], [.99607, .64277, .19165],
         [.99654, .63193, .18738], [.99675, .62093, .18297], [.99672, .60977, .17842], [.99644, .59846, .17376], [.99593, .58703, .16899], [.99517, .57549, .16412], [.99419, .56386, .15918], [.99297, .55214, .15417], [.99153, .54036, .1491],  [.98987, .52854, .14398],
         [.98799, .51667, .13883], [.9859,  .50479, .13367], [.9836,  .49291, .12849], [.98108, .48104, .12332], [.97837, .4692,  .11817], [.97545, .4574,  .11305], [.97234, .44565, .10797], [.96904, .43399, .10294], [.96555, .42241, .09798], [.96187, .41093, .0931],
         [.95801, .39958, .08831], [.95398, .38836, .08362], [.94977, .37729, .07905], [.94538, .36638, .07461], [.94084, .35566, .07031], [.93612, .34513, .06616], [.93125, .33482, .06218], [.92623, .32473, .05837], [.92105, .31489, .05475], [.91572, .3053,  .05134],
         [.91024, .29599, .04814], [.90463, .28696, .04516], [.89888, .27824, .04243], [.89298, .26981, .03993], [.88691, .26152, .03753], [.88066, .25334, .03521], [.87422, .24526, .03297], [.8676,  .2373,  .03082], [.86079, .22945, .02875], [.8538,  .2217,  .02677],
         [.84662, .21407, .02487], [.83926, .20654, .02305], [.83172, .19912, .02131], [.82399, .19182, .01966], [.81608, .18462, .01809], [.80799, .17753, .0166],  [.79971, .17055, .0152],  [.79125, .16368, .01387], [.7826,  .15693, .01264], [.77377, .15028, .01148],
         [.76476, .14374, .01041], [.75556, .13731, .00942], [.74617, .13098, .00851], [.73661, .12477, .00769], [.72686, .11867, .00695], [.71692, .11268, .00629], [.7068,  .1068,  .00571], [.6965,  .10102, .00522], [.68602, .09536, .00481], [.67535, .0898,  .00449],
         [.66449, .08436, .00424], [.65345, .07902, .00408], [.64223, .0738,  .00401], [.63082, .06868, .00401], [.61923, .06367, .0041],  [.60746, .05878, .00427], [.5955,  .05399, .00453], [.58336, .04931, .00486], [.57103, .04474, .00529], [.55852, .04028, .00579],
         [.54583, .03593, .00638], [.53295, .03169, .00705], [.51989, .02756, .0078],  [.50664, .02354, .00863], [.49321, .01963, .00955], [.4796,  .01583, .01055],],

    #
    # # CARTOColors — sequential (curated for maps/UX)
    # "carto_sunset_7": [
    #     "#F3E79BFF", "#FAC484FF", "#F8A07EFF",
    #     "#EB7F86FF", "#CE6693FF", "#A059A0FF", "#5C53A5FF"
    # ],
    # "carto_darkmint_7": [
    #     "#D2FBD4FF", "#A5DBC2FF", "#7BBCB0FF",
    #     "#559C9EFF", "#3A7C89FF", "#235D72FF", "#123F5AFF"
    # ],
    # "carto_emrld_7": [
    #     "#D3F2A3FF", "#97E196FF", "#6CC08BFF",
    #     "#4C9B82FF", "#217A79FF", "#105965FF", "#074050FF"
    # ],
    # "carto_ag_sunset_7": [
    #     "#4B2991FF", "#872CA2FF", "#C0369DFF",
    #     "#EA4F88FF", "#FA7876FF", "#F6A97AFF", "#EDD9A3FF"
    # ],
    #
    # # ColorBrewer — diverging (great for dramatic hero fades & backgrounds)
    # "brewer_rdylbu_11": [
    #     "#A50026FF", "#D73027FF", "#F46D43FF", "#FDAE61FF",
    #     "#FEE090FF", "#FFFFBFFF", "#E0F3F8FF", "#ABD9E9FF",
    #     "#74ADD1FF", "#4575B4FF", "#313695FF"
    # ],
    "brewer_spectral_11": [
            "#9e0142", "#d53e4f", "#f46d43", "#fdae61", "#fee08b",
            "#ffffbf", "#e6f598", "#abdda4", "#66c2a5", "#3288bd", "#5e4fa2"
            ],
    "sunset_horizon": [
            "#F3E79B", "#FAC484", "#F8A07E",
            "#EB7F86", "#CE6693", "#A059A0", "#5C53A5"
            ],  # CARTO Sunset 7

    "desert_glow": [
            "#f46d43", "#fdae61", "#fee08b",
            "#ffffbf", "#e6f598", "#abdda4", "#66c2a5"
            ],  # ColorBrewer Spectral midrange

    "autumn_spice": [
            "#7f0000", "#b30000", "#d7301f",
            "#ef6548", "#fc8d59", "#fdbb84", "#fdd49e", "#fee8c8", "#fff7ec"
            ],  # Brewer Reds 9

    "ocean_depths": [
            "#D2FBD4", "#A5DBC2", "#7BBCB0",
            "#559C9E", "#3A7C89", "#235D72", "#123F5A"
            ],  # CARTO DarkMint 7

    "icy_fjord": [
            "#f7fbff", "#deebf7", "#c6dbef",
            "#9ecae1", "#6baed6", "#4292c6", "#2171b5", "#08519c", "#08306b"
            ],  # Brewer Blues 9
    "forest_trail": [
            "#00441b", "#006d2c", "#238b45",
            "#41ae76", "#66c2a4", "#99d8c9", "#ccece6", "#e5f5f9", "#f7fcfd"
            ],  # Brewer Greens 9


    "viridis": [
            "#440154", "#482878", "#3E4989",
            "#31688E", "#26828E", "#1F9E89",
            "#35B779", "#6DCD59", "#B4DE2C", "#FDE725"
            ],

    "inferno": [
            "#000004", "#1B0C41", "#4A0C6B",
            "#781C6D", "#A52C60", "#CF4446",
            "#ED6925", "#FB9A06", "#F7D13D", "#FCFFA4"
            ],

    "magma": [
            "#000004", "#140E36", "#3B0F70",
            "#641A80", "#8C2981", "#B5367A",
            "#DE4968", "#F7705C", "#FE9F6D", "#FEC98D", "#FCFDBF"
            ],

    "plasma": [
            "#0D0887", "#41049D", "#6A00A8",
            "#8F0DA4", "#B12A90", "#CC4778",
            "#E16462", "#F2844B", "#FCA636", "#FCCC25", "#F0F921"
            ],

    "hot_spring": [
            "#7F0000","#B30000","#D7301F","#EF6548","#FC8D59",
            "#FDBB84","#FDD49E","#FEE8C8","#FFF7EC"
            ],  # Brewer Reds warm

    "festival_fire": [
            "#40004B","#762A83","#9970AB","#C2A5CF",
            "#E7D4E8","#D9F0D3","#A6DBA0","#5AAE61","#1B7837","#00441B"
            ],  # Brewer PRGn 10

    "retro_wave": [
            "#3F007D","#54278F","#6A51A3","#807DBA",
            "#9E9AC8","#BCBDDC","#DADAEB","#EFEDF5","#FCFBFD"
            ],  # Brewer Purples

    "pastel_dreams": [
            "#FEE0D2","#FCBBA1","#FC9272","#FB6A4A",
            "#EF3B2C","#CB181D","#A50F15","#67000D"
            ],  # Brewer Reds soft

    "lavender_fields": [
            "#F7F4F9","#E7E1EF","#D4B9DA","#C994C7",
            "#DF65B0","#E7298A","#CE1256","#980043","#67001F"
            ],  # Brewer PuRd 9

    "morning_mist": [
            "#F7FBFF","#DEEBF7","#C6DBEF","#9ECAE1",
            "#6BAED6","#4292C6","#2171B5","#08519C","#08306B"
            ],  # Brewer Blues 9
    "emerald_valley": [
            "#D3F2A3","#97E196","#6CC08B","#4C9B82","#217A79","#105965","#074050"
            ],  # CARTO Emrld

    "forest_canopy": [
            "#00441B","#006D2C","#238B45","#41AE76",
            "#66C2A4","#99D8C9","#CCECE6","#E5F5F9","#F7FCFD"
            ],  # Brewer Greens

    "glacier_melt": [
            "#313695","#4575B4","#74ADD1","#ABD9E9","#E0F3F8",
            "#FFFFBF","#FEE090","#FDAE61","#F46D43","#D73027","#A50026"
            ],  # Brewer RdYlBu 11

    "earth_terrain": [
            "#543005","#8C510A","#BF812D","#DFC27D",
            "#F6E8C3","#C7EAE5","#80CDC1","#35978F","#01665E","#003C30"
            ],  # Brewer BrBG 10


    "desert_mirage": [
            "#7F3B08","#B35806","#E08214","#FDB863",
            "#FEE0B6","#D8DAEB","#B2ABD2","#8073AC","#542788","#2D004B"
            ],  # Brewer PuOr 10

    "aurora_night": [
            "#1B1C3A","#28365C","#335C7D","#2A9D8F","#8AB17D",
            "#E9C46A","#F4A261","#E76F51","#C44569"
            ],

    "tropical_lagoon": [
            "#00429D","#4771B2","#73A2B6","#A5D0A1",
            "#FFCA66","#F8894F","#ED4E33","#9B0000"
            ],  # Brewer RdYlBu variation

    "rose_quartz": [
            "#67001F","#B2182B","#D6604D","#F4A582",
            "#FDDBC7","#E0E0E0","#BABABA","#878787","#4D4D4D","#1A1A1A"
            ],  # Brewer RdGy 10

    "cosmic_dawn": [
            "#0B0033","#320A64","#65109A","#A01ECF","#E23CEB","#F3A0F5",
            "#B8CFFF","#7FD3E2","#4BC8CD","#26BFAF","#02B390","#00A271",
            "#008354","#00653E","#00462C","#00241A","#000500",
            "#2A0033","#540066","#7E0099","#A900CC","#D400FF","#FF6AFF"
            ],
    "tropical_twilight": [
            "#FF9300","#FFC300","#FFED66","#FEFEAC","#FFB768",
            "#FF8C69","#FF5E62","#FF006E","#D60087","#AD009E",
            "#8400A6","#5800A1","#350087","#1A0057","#00002E",
            "#320040","#600060","#900080","#C000A0","#FF00C0"
            ],
    "digital_rose": [
            "#F8EDEB","#F5D7E3","#F2C1DC","#EFAACF","#EC94C3","#EB6AB7",
            "#EA3FA7","#E11E99","#CA187F","#B31267","#960C4F","#750839",
            "#54042A","#33041A","#11020A","#330119","#55022A","#77023D",
            "#990250","#BB0640","#DD0640","#FF0640","#FF2E60","#FF4E80"
            ],
    "monet_frost": [
            "#F7F0E6","#ECE3D9","#E0D7CC","#D4CABF","#C8BEB2","#BCB2A5",
            "#AFA597","#A39A8A","#978D7D","#8B816F","#7F7461","#736853",
            "#665C46","#59503A","#4D4430","#403725","#342B1E","#281F16",
            "#1C130F","#10080A","#040103","#1A0910","#361321","#521D38",
            "#6E2955","#8A3373","#A63D90","#C147AE","#DF51CB","#FC5BFD"
            ],
    "surreal_swirl": [
            "#29001A","#3F0036","#560053","#6D006F","#84008B","#9B00A7",
            "#B200C3","#C900DF","#E000FB","#FF00F3","#FF00DB","#FF00C3",
            "#FF009B","#FF0083","#FF006B","#FF0053","#FF003B","#FF0023",
            "#FF000B","#E60000","#CC0000","#B30000","#990000","#800000",
            "#660000","#4D0000","#330000","#1A0000","#000000"
            ],
    "turner_sunset": [
            '#FFD34D', '#FFD24D', '#FED04B', '#FCCC4A', '#FAC747', '#F7C045', '#F4B841', '#F1A936', '#EC992D', '#E98827',
            '#E87727', '#E3642C', '#DA512F', '#CD3C33', '#BC2934', '#A91C36', '#951839', '#841C47', '#722257', '#652968',
            '#583177', '#4A367F', '#3D3A83', '#303E84', '#293C79', '#253A70', '#223869', '#203763', '#1F3660', '#1F365F'
            ],
    "hokusai_prussian_wave": [
            '#EAF7FF', '#E9F7FF', '#E7F6FF', '#E3F5FF', '#DEF3FF', '#D7F1FF', '#CEEFFF', '#C0E7FD', '#B1DEFB', '#A0D4F8',
            '#8BC9F2', '#75BCEC', '#5FAEE5', '#49A0DB', '#3892CE', '#2A83C0', '#2274B3', '#1C69A7', '#175F9C', '#125692',
            '#0E4D8A', '#0B4781', '#094179', '#083C72', '#08396D', '#073768', '#073564', '#073461', '#073360', '#07335F'
            ],
    "mucha_poster_pastel": [
            '#F6D5C3', '#F6D4C1', '#F5D1BB', '#F3CCB0', '#EFC7A1', '#E8C08D', '#DFB679', '#D4AA62', '#CA9E50', '#BF923E',
            '#B68A33', '#A1A562', '#A3C4AB', '#BBD3BE', '#D2E0CE', '#E3E9DB', '#EEF0E4', '#F4F4EC', '#C6E3DF', '#A8C9E3',
            '#91B8D7', '#7BA6CC', '#6895C1', '#5A87B8', '#4E7BB0', '#4571A7', '#3F69A0', '#3A649B', '#376098', '#365F97'
            ],
    "monet_water_garden": [
            '#0E3A75', '#0F3B77', '#103D7C', '#134286', '#184892', '#1E4FA3', '#285AAF', '#3366BE', '#4072C5', '#4F7FCC',
            '#628DD2', '#749BD7', '#78AAD7', '#64B6AA', '#4EA689', '#349776', '#2E916E', '#3D9F78', '#55AE83', '#6FBD8F',
            '#89CA9D', '#A2D5AB', '#B7DFB9', '#C9E5C4', '#D7EBCF', '#E0EFD4', '#E7F2D8', '#ECF4DC', '#EFF6DE', '#F0F6DF'
            ],
    "matisse_fauve_heat": [
            '#D91A1A', '#DA1C1C', '#DD2121', '#E12929', '#E83333', '#F03E3E', '#F75352', '#FE6868', '#FF7C87', '#FF8BA5',
            '#FF61C5', '#ED46E3', '#CA3CED', '#A43EF1', '#7F47F4', '#5D56F4', '#3B6FF6', '#2A9AF3', '#06B8EE', '#00D1E5',
            '#00E5D4', '#00EDBD', '#2DF0A0', '#4CF187', '#66F06D', '#80EF57', '#92ED42', '#9EEC2E', '#A6EB1B', '#A8EB12'
            ],
    "bauhaus_primary_loop": [
            '#C4002B', '#C5042B', '#C90F29', '#CE1C27', '#D62924', '#E0371E', '#EA5D00', '#EF8300', '#FBAA00', '#FFD313',
            '#FFDE4B', '#F6E29C', '#E3DCD4', '#C8B5F4', '#8196FF', '#536EF4', '#274BD7', '#103099', '#3C4DA6', '#716BB1',
            '#9782A7', '#C08F9B', '#D3A288', '#E0AA7A', '#E6B36B', '#EDBA61', '#F1C059', '#F3C452', '#F5C74E', '#F5C84C'
            ],
    "rothko_red_field": [
            '#3D1212', '#3E1212', '#411313', '#461413', '#4D1514', '#561616', '#611917', '#6E1D18', '#7E2219', '#8E291A',
            '#A03219', '#B34119', '#C54F19', '#D85F19', '#E36D20', '#EB7D2A', '#F28D39', '#F69F4B', '#F9AE5C', '#FABC6A',
            '#FBC77A', '#FBD18A', '#FAD999', '#F8E0A6', '#F7E5B1', '#F6E8B9', '#F5EAC0', '#F4ECC4', '#F4EDC7', '#F4EDC8'
            ],
    "warhol_pop_candy": [
            '#FF0068', '#FF0266', '#FF085F', '#FF1254', '#FF1E43', '#FF2A2A', '#FF4A16', '#FF6601', '#FF8C00', '#FFB400',
            '#FFD600', '#E4F000', '#9FFF00', '#51FF00', '#00FF86', '#00F9D4', '#00DEFF', '#00B0FF', '#007FFF', '#3251FF',
            '#5D2BFF', '#8417FF', '#A600FF', '#C502FF', '#E100FF', '#ED00EC', '#F500DC', '#FB00D1', '#FE00CA', '#FF00C8'
            ],
    "miami_vice_neon": [
            '#00F0FF', '#07F0FF', '#16EFFF', '#25EDFF', '#32EBFF', '#40E9FF', '#59E5FF', '#70E2FF', '#89DCFF', '#A2D6FF',
            '#B5CCFF', '#CABFFF', '#DDAFFF', '#F09BFC', '#FF85ED', '#FF6ED6', '#FF5BBF', '#FF4EA6', '#FF4E8C', '#FF5E70',
            '#FF735A', '#FF8B4B', '#FFA142', '#FFB43C', '#FFC440', '#FFCE4D', '#FFD658', '#FFDC60', '#FFDF64', '#FFE066'
            ],
    "memphis_milano": [
            '#FF77A8', '#FF79A9', '#FF7FAC', '#FF89B1', '#FF96B8', '#FFA6C1', '#FFB8CF', '#FFCCDE', '#FFD8AE', '#E2FF98',
            '#C6FF76', '#74FFBD', '#7BF3FF', '#89CCFF', '#9AB3FF', '#B7A7FF', '#D9ABFE', '#FAC1FB', '#FFBDA0', '#FEC459',
            '#FAA441', '#FC704B', '#E04782', '#7755AB', '#26547C', '#00819E', '#00A7AA', '#00C2A8', '#00D1A3', '#06D6A0'
            ],
}

_matplotlib_gradient_preset_names='Accent Accent_r Blues Blues_r BrBG BrBG_r BuGn BuGn_r BuPu BuPu_r CMRmap CMRmap_r Dark2 Dark2_r GnBu GnBu_r Greens Greens_r Greys Greys_r OrRd OrRd_r Oranges Oranges_r PRGn PRGn_r Paired Paired_r Pastel1 Pastel1_r Pastel2 Pastel2_r PiYG PiYG_r PuBu PuBuGn PuBuGn_r PuBu_r PuOr PuOr_r PuRd PuRd_r Purples Purples_r RdBu RdBu_r RdGy RdGy_r RdPu RdPu_r RdYlBu RdYlBu_r RdYlGn RdYlGn_r Reds Reds_r Set1 Set1_r Set2 Set2_r Set3 Set3_r Spectral Spectral_r Wistia Wistia_r YlGn YlGnBu YlGnBu_r YlGn_r YlOrBr YlOrBr_r YlOrRd YlOrRd_r afmhot afmhot_r autumn autumn_r binary binary_r bone bone_r brg brg_r bwr bwr_r cividis cividis_r cool cool_r coolwarm coolwarm_r copper copper_r cubehelix cubehelix_r flag flag_r gist_earth gist_earth_r gist_gray gist_gray_r gist_heat gist_heat_r gist_ncar gist_ncar_r gist_rainbow gist_rainbow_r gist_stern gist_stern_r gist_yarg gist_yarg_r gnuplot gnuplot2 gnuplot2_r gnuplot_r gray gray_r hot hot_r hsv hsv_r inferno inferno_r jet jet_r magma magma_r nipy_spectral nipy_spectral_r ocean ocean_r pink pink_r plasma plasma_r prism prism_r rainbow rainbow_r seismic seismic_r spring spring_r summer summer_r tab10 tab10_r tab20 tab20_r tab20b tab20b_r tab20c tab20c_r terrain terrain_r viridis viridis_r winter winter_r'.split()

gradient_presets = DictReader(gradient_presets)

def _get_gradient_preset(name):
    """
    Get gradient colors from RP gradient presets or matplotlib colormaps.
    
    Args:
        name (str): Gradient preset name or matplotlib colormap name
        
    Returns:
        dict or list: Gradient specification
        
    Raises:
        TypeError: If name is not a string
        ValueError: If gradient name is not found in either RP presets or matplotlib
    """
    pip_import("cmapy")
    import cmapy
    
    if not isinstance(name, str):
        raise TypeError("Gradient name must be a string, got {0}: {1}. ".format(type(name).__name__, repr(name))+
                        "Expected string colormap name like 'viridis', 'sunset', etc - see rp.gradient_presets for full list")
    
    if name in gradient_presets:
        # Check RP gradient presets first
        return gradient_presets[name]
    
    elif name in _matplotlib_gradient_preset_names:
        # Load matplotlib colormap and cache in gradient_presets
        gradient_presets._data.update({
            x: byte_colors_to_float_colors(cmapy.cmap(x)[:, 0])
            for x in list_flatten([x["colormaps"] for x in cmapy.cmap_groups])
        })
        
        # Now it should be in gradient_presets
        if name in gradient_presets:
            return gradient_presets[name]
        else:
            raise ValueError("Failed to load matplotlib colormap '{0}' via cmapy. ".format(name)+
                             "This should not happen - please report this as a bug.")
    
    rp_presets = sorted(gradient_presets.keys())
    raise ValueError("Unknown gradient/colormap name: '{0}'. ".format(name)+
                    "Available options: {0}".format(' '.join(gradient_presets)))

def _as_gradient_dict(gradient_spec):
    """
    Convert various gradient specifications to a unified position-color dict.
    
    Args:
        gradient_spec: Can be:
            - String: gradient preset name or matplotlib colormap name  
            - List/tuple: list of colors to be evenly spaced
            - Dict: position-color mapping (copied)
            
    Returns:
        dict: Position-color mapping {float: color, ...}
        
    Raises:
        TypeError: If gradient_spec is wrong type
        ValueError: If gradient_spec is invalid (empty, unknown name, etc.)
    """
    # Cascade: string -> replace with preset value, then fall through to list/dict handling
    if isinstance(gradient_spec, str):
        gradient_spec = _get_gradient_preset(gradient_spec)
            
    # Now handle list/tuple -> dict conversion
    if isinstance(gradient_spec, (list, tuple)):
        if len(gradient_spec) < 2:
            raise ValueError('Gradient list must have at least 2 colors, got {0}: {1}. '.format(len(gradient_spec), gradient_spec)+
                             'Example: ["red", "blue"] or ["#ff0000", "#0000ff"]')
        positions = np.linspace(0.0, 1.0, len(gradient_spec))
        gradient_spec = {pos: color for pos, color in zip(positions, gradient_spec)}
        
    # Final dict handling
    if not isinstance(gradient_spec, dict):
        # Should never reach here due to cascade logic
        raise TypeError('Gradient must be a string name, list of colors, or position-color dict. '+
                        'Got {0}: {1}. '.format(type(gradient_spec).__name__, repr(gradient_spec))+
                        'Examples: "viridis", ["red", "blue"], {{0.0: "black", 1.0: "white"}}')

    if len(gradient_spec) < 2:
        raise ValueError('Gradient dict must have at least 2 position-color pairs, got {0}: {1}. '.format(len(gradient_spec), gradient_spec)+
                         'Example: {{0.0: "red", 1.0: "blue"}}')

    return gradient_spec.copy()

def _gradient_dict_to_list(gradient_dict, start=0, end=1, res=256):
    """Convert gradient dict to list of colors with specified resolution using np.interp.
    
    EXAMPLE: _gradient_dict_to_list({0.0: 'red', 1.0: 'blue'}, res=3) 
             # Returns [(1.0, 0.0, 0.0, 1.0), (0.5, 0.0, 0.5, 1.0), (0.0, 0.0, 1.0, 1.0)]
    """
    # Create evenly spaced positions
    target_positions = np.linspace(start, end, res)
    
    # Extract sorted positions and colors from gradient_dict
    sorted_items = sorted(gradient_dict.items())
    gradient_positions = np.array([float(pos) for pos, color in sorted_items])
    gradient_colors = [as_rgba_float_color(color) for pos, color in sorted_items]
    colors_array = np.array(gradient_colors)
    
    # Interpolate each channel separately
    interpolated_colors = []
    for pos in target_positions:
        interpolated_rgba = []
        for channel in range(4):  # RGBA channels
            channel_values = colors_array[:, channel]
            interpolated_value = np.interp(pos, gradient_positions, channel_values)
            interpolated_rgba.append(interpolated_value)
        interpolated_colors.append(tuple(interpolated_rgba))
    
    return interpolated_colors
        
def _apply_colormap_to_image_via_np(values, gradient_dict):
    """Apply colormap using numpy interpolation with floating-point precision."""
    # Extract positions and colors directly in sorted order
    sorted_items = sorted(gradient_dict.items())
    sorted_positions = [float(pos) for pos, color in sorted_items]
    colors_array = as_numpy_array(
        as_rgba_float_colors(
            gather(gradient_dict, sorted_positions)
        )
    )
    
    # Apply colormap using np.interp
    output = np.zeros(values.shape + (4,), dtype=_float_image_dtype())
    values_flat = values.flatten()
    for channel in range(4):  # RGBA channels
        interp_result = np.interp(values_flat, sorted_positions, colors_array[:, channel])
        output[..., channel] = interp_result.reshape(values.shape)
    
    return output

def _apply_colormap_to_image_via_cv2(values, gradient_dict):
    """Apply colormap using OpenCV with byte precision (original implementation)."""
    pip_import('cv2')
    import cv2
    
    # Convert float values back to byte image for cv2
    image = as_rgb_image(as_byte_image(values), copy=False)
    
    # Create colormap using utility function
    gradient_colors = _gradient_dict_to_list(gradient_dict, res=256)
    rgb_colors = [color[:3] for color in gradient_colors]  # Strip alpha channel
    byte_colors = float_colors_to_byte_colors(*rgb_colors)
    colormap = np.array(byte_colors, dtype=np.uint8).reshape(256, 1, 3)
    
    # Apply colormap using cv2
    image_colorized = cv2.applyColorMap(image, colormap)
    
    # Convert BGR to RGB and return as float image
    image_colorized = cv_bgr_rgb_swap(image_colorized, copy=False)
    
    return as_float_image(image_colorized)

def apply_colormap_to_image(image, colormap_name='viridis', *, backend='cv2'):
    """
        Apply a colormap to an image or array of values.
        
        https://stackoverflow.com/questions/52498777/apply-matplotlib-or-custom-colormap-to-opencv-image/52626636
        
        Args:
            image: Input image as defined by rp.is_image
            colormap_name: Name of colormap (gradient preset or matplotlib colormap)
            backend: 'np' (numpy interpolation) or 'cv2' (OpenCV)
                'cv2' backend is *much* faster, but only does RGB byte images and is less precise
                'np' is slower and more precise, and uses floating point RGBA images 
            
        EXAMPLE:
            >>> image=load_image('https://www.gaytimes.co.uk/wp-content/uploads/2018/05/Kim-Petras-Thom-Kerr-header.jpg')
            ... image=horizontally_concatenated_images(full_range(gaussian_kernel(size=get_image_height(image),sigma=50)),image)
            ... image=vertically_concatenated_images(image,linear_gradient_image(height=50,width=get_image_width(image),direction='horizontal',gradient=['white','black']))
            ... styles='trans_flag Accent Accent_r Blues Blues_r BrBG BrBG_r BuGn BuGn_r BuPu BuPu_r CMRmap CMRmap_r Dark2 Dark2_r GnBu GnBu_r Greens Greens_r Greys Greys_r OrRd OrRd_r Oranges Oranges_r PRGn PRGn_r Paired Paired_r Pastel1 Pastel1_r Pastel2 Pastel2_r PiYG PiYG_r PuBu PuBuGn PuBuGn_r PuBu_r PuOr PuOr_r PuRd PuRd_r Purples Purples_r RdBu RdBu_r RdGy RdGy_r RdPu RdPu_r RdYlBu RdYlBu_r RdYlGn RdYlGn_r Reds Reds_r Set1 Set1_r Set2 Set2_r Set3 Set3_r Spectral Spectral_r Wistia Wistia_r YlGn YlGnBu YlGnBu_r YlGn_r YlOrBr YlOrBr_r YlOrRd YlOrRd_r afmhot afmhot_r autumn autumn_r binary binary_r bone bone_r brg brg_r bwr bwr_r cividis cividis_r cool cool_r coolwarm coolwarm_r copper copper_r cubehelix cubehelix_r flag flag_r gist_earth gist_earth_r gist_gray gist_gray_r gist_heat gist_heat_r gist_ncar gist_ncar_r gist_rainbow gist_rainbow_r gist_stern gist_stern_r gist_yarg gist_yarg_r gnuplot gnuplot2 gnuplot2_r gnuplot_r gray gray_r hot hot_r hsv hsv_r inferno inferno_r jet jet_r magma magma_r nipy_spectral nipy_spectral_r ocean ocean_r pink pink_r plasma plasma_r prism prism_r rainbow rainbow_r seismic seismic_r spring spring_r summer summer_r tab10 tab10_r tab20 tab20_r tab20b tab20b_r tab20c tab20c_r terrain terrain_r viridis viridis_r winter winter_r'.split()
            ... styles=shuffled(styles+list(gradient_presets))
            ... for style in styles:
            ...                display_image(cv_bgr_rgb_swap(apply_colormap_to_image(image,style,backend='cv2')))
            ...                input(style)
    """
    # Shared preprocessing
    image = as_numpy_image(image, copy=False)
    values = as_grayscale_image(as_float_image(image, copy=False), copy=False)
    gradient_dict = _as_gradient_dict(colormap_name)
    
    # Dispatch to backend
    if backend == 'np':
        return _apply_colormap_to_image_via_np(values, gradient_dict)
    elif backend == 'cv2':
        return _apply_colormap_to_image_via_cv2(values, gradient_dict)
    else:
        raise ValueError('Invalid backend "{0}". Valid options: "np", "cv2"'.format(backend))

def _gradient_angle_to_points(height,width,angle):
    """ See linear_gradient_image's example for a visualization """
    angle = angle + 90
    h,w=height,width
    a=angle/360*tau
    cx=w/2
    cy=h/2
    r=h+w
    dx=r*_cos(a)
    dy=r*_sin(a)
    seg=[[cx,cy],[cx+dx,cy+dy]]
    rect=[[0,0],[w,0],[w,h],[0,h],[0,0]]
    [[ix,iy]]=as_points_array(path_intersections(seg,rect))
    #m is mirror
    mx=(cx-ix)+cx
    my=(cy-iy)+cy
    return [[mx,my],[ix,iy]]

def linear_gradient_image(height: int, width: int, gradient, direction='vertical', *, backend='np'):
    """Creates an image with a linear gradient between colors.
    
    Args:
        height (int): Height of the output image in pixels
        width (int): Width of the output image in pixels  
        gradient: Color gradient specification, can be:
                 - List of colors: ['red', 'blue', 'green'] - evenly spaced from 0.0 to 1.0
                 - Dict mapping positions to colors: {0.0: 'red', 0.5: 'blue', 1.0: 'green'}
                 - Dict can have negative values or values > 1.0 for extended gradients
                 - Colors can be strings, tuples, hex values (anything as_rgba_float_color accepts)
                 - Preset name from gradient_presets: 'sunset', 'ocean', 'neon_glow', etc.
        direction: Gradient direction specification, can be:
                  - 'vertical': Top to bottom gradient  
                  - 'horizontal': Left to right gradient
                  - float: Angle in degrees (0=horizontal right, 90=vertical down, 180=horizontal left, 270=vertical up)
                  - Length-2 path in pixel coordinates: [(x1,y1), (x2,y2)] for custom direction
        backend: 'np' (numpy interpolation) or 'cv2' (OpenCV)
    
    Returns:
        numpy.ndarray: RGBA float image with linear gradient
        
    EXAMPLES - Basic Usage:
        >>> img = linear_gradient_image(100, 100, ['red', 'blue'], 'vertical')
        >>> display_image(img)
        
        >>> img = linear_gradient_image(50, 200, ['#ff0000', 'green', (0,0,1,0.5)], 'horizontal')
        >>> display_image(img)
        
        >>> gradient_dict = {-0.5: 'black', 0.0: 'red', 0.7: 'yellow', 1.5: 'white'}
        >>> img = linear_gradient_image(100, 100, gradient_dict, 'vertical')
        >>> display_image(img)
        
        >>> img = linear_gradient_image(100, 100, ['purple', 'orange'], [(0, 0), (100, 100)])
        >>> display_image(img)
        
        >>> img = linear_gradient_image(200, 200, gradient_presets['sunset'], 'vertical')
        >>> display_image(img)
        
        >>> img = linear_gradient_image(200, 200, 'ocean', 'horizontal')  # preset by name
        >>> display_image(img)
        
        >>> img = linear_gradient_image(300, 300, ['red', 'blue'], 45.0)  # 45 degree angle
        >>> display_image(img)
        
        >>> img = linear_gradient_image(400, 400, ['magenta', 'cyan'], 112.5)  # 112.5 degree angle
        >>> display_image(img)
        
    EXAMPLES - Animated Demo:

        >>> # Animated two-point gradient demo
        >>> width, height = 512, 512
        >>> frames = []
        >>> circle1 = as_easydict(x=150, y=150, vx=2.1, vy=1.8, color_phase=0)
        >>> circle2 = as_easydict(x=350, y=350, vx=-1.9, vy=-2.3, color_phase=3.14)
        >>> 
        >>> for frame_idx in range(540):
        ...     for circle in [circle1, circle2]:
        ...         circle.x += circle.vx
        ...         circle.y += circle.vy
        ...         if circle.x <= 15 or circle.x >= width - 15: circle.vx *= -1
        ...         if circle.y <= 15 or circle.y >= height - 15: circle.vy *= -1
        ...         circle.x = max(15, min(width - 15, circle.x))
        ...         circle.y = max(15, min(height - 15, circle.y))
        ...         circle.color_phase += 0.02
        ...     
        ...     import numpy as np
        ...     h1, h2 = circle1.color_phase, circle2.color_phase
        ...     color1 = (np.sin(h1)+1)/2, (np.sin(h1+2.09)+1)/2, (np.sin(h1+4.19)+1)/2
        ...     color2 = (np.sin(h2)+1)/2, (np.sin(h2+2.09)+1)/2, (np.sin(h2+4.19)+1)/2
        ...     
        ...     gradient_start = (int(circle1.x), int(circle1.y))
        ...     gradient_end = (int(circle2.x), int(circle2.y))
        ...     frame = linear_gradient_image(height, width, [color1, color2], [gradient_start, gradient_end])
        ...     
        ...     frame = cv_draw_circles(frame, [circle1.x, circle2.x], [circle1.y, circle2.y], 
        ...                           15, [color1, color2], rim=3, rim_color='black')
        ...     frames.append(as_byte_image(frame))
        >>> 
        >>> video = np.array(frames)
        >>> save_video(video, 'gradient_circles_demo.mp4', framerate=30)
        >>> display_video(video)

    EXAMPLES - Gradient Angles Video:

        >>> def draw(image, points):
        ...     h, w = get_image_dimensions(image)
        ...     box = [[0, 0], [w, 0], [w, h], [0, h], [0, 0]]
        ... 
        ...     box = as_numpy_array(box)
        ...     points = as_numpy_array(points)
        ...     xs, ys = as_numpy_array(points.T)
        ... 
        ...     image = cv_draw_contour(image, box, color="black")
        ...     image = cv_draw_arrow(image, *list_flatten(points), color="black", tip_length=0.1)
        ...     image = cv_draw_circles(image, xs, ys, rim=1)
        ...     image = cv_draw_circle(image, w / 2, h / 2, rim=1)
        ...     return image
        ... 
        ... 
        ... def video_demo():
        ...     video = []
        ...     h, w = 400, 200
        ...     gradient = [as_rgba_float_color("light random") for _ in range(50)]
        ...     for a in eta(range(360)):
        ...         points = rp.r._gradient_angle_to_points(h, w, a)
        ...         image = linear_gradient_image(
        ...             h,
        ...             w,
        ...             # gradient_presets.electric_rainbow,
        ...             gradient,
        ...             direction=a,
        ...         )
        ...         image = draw(image, points).
        ...         image = labeled_image(image, "{0}°".format(a), font="Futura")
        ...         video.append(image)
        ...     return video
        ... 
        ... 
        ... video = video_demo()
        ... video_path = save_video_mp4(video, "gradient_angles.mp4")
        ... open_file_with_default_application(video_path)
        
    EXAMPLES - Showcase Grid:

        >>> # Showcase all gradient presets in a tiled layout
        ... size = 180
        ... gradient_images = []
        ... gradient_labels = []
        ... 
        ... for name, colors in gradient_presets.items():
        ...     img = linear_gradient_image(size, size, colors, 'vertical')
        ...     gradient_images.append(img)
        ...     labeled_img = labeled_image(img, name.replace('_', ' ').title(),
        ...                                 size=14, background_color=(0,0,0,0.75), text_color='white',font='R:Futura')
        ...     gradient_labels.append(labeled_img)
        ... 
        ... # Create tiled showcase with dark background
        ... #showcase = tiled_images(gradient_images, border_color='#1a1a1a', border_thickness=8)
        ... gradient_labels=with_corner_radii(gradient_labels,radius=15)
        ... labeled_showcase = tiled_images(gradient_labels, border_color='transparent', border_thickness=8)
        ... labeled_showcase=with_drop_shadow(labeled_showcase,blur=30,x=5,y=5,opacity=.5)
        ... #labeled_showcase=blend_images(1,labeled_showcase)
        ... 
        ... 
        ... 
        ... display_image(labeled_showcase)
        ... path = save_image(labeled_showcase, 'gradient_presets_showcase.png')
        ... open_file_with_default_application(path)
        
    EXAMPLES - Complex Multi-Layer CSS Gradient Recreation with Float Angles and Overlay Blending:

        >>> # Recreate CSS: linear-gradient(112.5deg, rgb(250,109,130) 0%, rgb(250,109,130) 3%, 
        ... # rgb(219,107,130) 3%, rgb(219,107,130) 34%, rgb(187,104,131) 34%, rgb(187,104,131) 46%, 
        ... # rgb(156,102,131) 46%, rgb(156,102,131) 51%, rgb(124,99,132) 51%, rgb(124,99,132) 52%, 
        ... # rgb(93,97,132) 52%, rgb(93,97,132) 76%, rgb(61,94,133) 76%, rgb(61,94,133) 84%, 
        ... # rgb(30,92,133) 84%, rgb(30,92,133) 100%), linear-gradient(157.5deg, [same colors]),
        ... # linear-gradient(45deg, [same colors]), linear-gradient(90deg, rgb(71,69,135), rgb(241,26,171));
        ... # background-blend-mode: overlay,overlay,overlay,normal;
        ... gradient_dict = {
        ...     0.0: byte_color_to_float_color((250, 109, 130)),
        ...     0.029: byte_color_to_float_color((250, 109, 130)),  # Hard stop before 3%
        ...     0.03: byte_color_to_float_color((219, 107, 130)),
        ...     0.339: byte_color_to_float_color((219, 107, 130)),  # Hard stop before 34%
        ...     0.34: byte_color_to_float_color((187, 104, 131)),
        ...     0.459: byte_color_to_float_color((187, 104, 131)),  # Hard stop before 46%
        ...     0.46: byte_color_to_float_color((156, 102, 131)),
        ...     0.509: byte_color_to_float_color((156, 102, 131)),  # Hard stop before 51%
        ...     0.51: byte_color_to_float_color((124, 99, 132)),
        ...     0.519: byte_color_to_float_color((124, 99, 132)),   # Hard stop before 52%
        ...     0.52: byte_color_to_float_color((93, 97, 132)),
        ...     0.759: byte_color_to_float_color((93, 97, 132)),    # Hard stop before 76%
        ...     0.76: byte_color_to_float_color((61, 94, 133)),
        ...     0.839: byte_color_to_float_color((61, 94, 133)),    # Hard stop before 84%
        ...     0.84: byte_color_to_float_color((30, 92, 133)),
        ...     1.0: byte_color_to_float_color((30, 92, 133))
        ... }
        ... base_gradient = {0.0: byte_color_to_float_color((71, 69, 135)), 1.0: byte_color_to_float_color((241, 26, 171))}
        ... width, height = 800, 600
        ... layer_112deg = linear_gradient_image(height, width, gradient_dict, 112.5)  # Float angle support
        ... layer_157deg = linear_gradient_image(height, width, gradient_dict, 157.5)
        ... layer_45deg = linear_gradient_image(height, width, gradient_dict, 45.0)
        ... base_layer = linear_gradient_image(height, width, base_gradient, 90.0)
        ... complex_result = base_layer
        ... complex_result = blend_images(complex_result, layer_45deg, mode="overlay")
        ... complex_result = blend_images(complex_result, layer_157deg, mode="overlay")
        ... complex_result = blend_images(complex_result, layer_112deg, mode="overlay")
        ... display_image(complex_result)
        ... save_image(complex_result, 'complex_css_gradient.png')

    EXAMPLES - Green Multi-Layer CSS Gradient with Hard Color Stops:

        >>> # Recreate CSS: linear-gradient(0deg, rgb(164,211,160) 0%, rgb(164,211,160) 22%, 
        ... # rgb(141,190,127) 22%, rgb(141,190,127) 61%, rgb(117,169,93) 61%, rgb(117,169,93) 68%, 
        ... # rgb(94,147,60) 68%, rgb(94,147,60) 92%, rgb(70,126,26) 92%, rgb(70,126,26) 100%),
        ... # linear-gradient(135deg, [same colors]), linear-gradient(90deg, [same colors]), 
        ... # linear-gradient(90deg, rgb(255,159,173), rgb(82,81,20)); background-blend-mode: overlay,overlay,overlay,normal;
        ... green_gradient_dict = {
        ...     0.0: byte_color_to_float_color((164, 211, 160)),
        ...     0.219: byte_color_to_float_color((164, 211, 160)),  # Hard stop before 22%
        ...     0.22: byte_color_to_float_color((141, 190, 127)),   # 22% transition
        ...     0.609: byte_color_to_float_color((141, 190, 127)),  # Hard stop before 61%
        ...     0.61: byte_color_to_float_color((117, 169, 93)),    # 61% transition
        ...     0.679: byte_color_to_float_color((117, 169, 93)),   # Hard stop before 68%
        ...     0.68: byte_color_to_float_color((94, 147, 60)),     # 68% transition
        ...     0.919: byte_color_to_float_color((94, 147, 60)),    # Hard stop before 92%
        ...     0.92: byte_color_to_float_color((70, 126, 26)),     # 92% transition
        ...     1.0: byte_color_to_float_color((70, 126, 26))       # 100% end
        ... }
        ... green_base_gradient = {0.0: byte_color_to_float_color((255, 159, 173)), 1.0: byte_color_to_float_color((82, 81, 20))}
        ... width, height = 800, 600  # Self-contained dimensions
        ... green_layer_0deg = linear_gradient_image(height, width, green_gradient_dict, 0.0)    # 0° horizontal
        ... green_layer_135deg = linear_gradient_image(height, width, green_gradient_dict, 135.0)  # 135° diagonal
        ... green_layer_90deg = linear_gradient_image(height, width, green_gradient_dict, 90.0)   # 90° vertical
        ... green_base_layer = linear_gradient_image(height, width, green_base_gradient, 90.0)     # Base layer
        ... green_final_result = green_base_layer
        ... green_final_result = blend_images(green_final_result, green_layer_90deg, mode="overlay")
        ... green_final_result = blend_images(green_final_result, green_layer_135deg, mode="overlay")
        ... green_final_result = blend_images(green_final_result, green_layer_0deg, mode="overlay")
        ... display_image(green_final_result)
        ... save_image(green_final_result, 'exact_green_gradient.png')

    EXAMPLES - Six-Layer Purple-Blue CSS Gradient Recreation (Fully Self-Contained):

        >>> # EXACT CSS: linear-gradient(155deg, rgb(229,176,124) 0%, rgb(229,176,124) 41%,
        ... # rgb(198,157,133) 41%, rgb(198,157,133) 42%, rgb(167,139,142) 42%, rgb(167,139,142) 44%,
        ... # rgb(136,120,151) 44%, rgb(136,120,151) 45%, rgb(105,101,159) 45%, rgb(105,101,159) 61%,
        ... # rgb(74,83,168) 61%, rgb(74,83,168) 78%, rgb(43,64,177) 78%, rgb(43,64,177) 100%),
        ... # linear-gradient(322deg, rgb(229,176,124) 0%, rgb(229,176,124) 41%, rgb(198,157,133) 41%,
        ... # rgb(198,157,133) 42%, rgb(167,139,142) 42%, rgb(167,139,142) 44%, rgb(136,120,151) 44%,
        ... # rgb(136,120,151) 45%, rgb(105,101,159) 45%, rgb(105,101,159) 61%, rgb(74,83,168) 61%,
        ... # rgb(74,83,168) 78%, rgb(43,64,177) 78%, rgb(43,64,177) 100%), linear-gradient(33deg,
        ... # rgb(229,176,124) 0%, rgb(229,176,124) 41%, rgb(198,157,133) 41%, rgb(198,157,133) 42%,
        ... # rgb(167,139,142) 42%, rgb(167,139,142) 44%, rgb(136,120,151) 44%, rgb(136,120,151) 45%,
        ... # rgb(105,101,159) 45%, rgb(105,101,159) 61%, rgb(74,83,168) 61%, rgb(74,83,168) 78%,
        ... # rgb(43,64,177) 78%, rgb(43,64,177) 100%), linear-gradient(315deg, rgb(229,176,124) 0%,
        ... # rgb(229,176,124) 41%, rgb(198,157,133) 41%, rgb(198,157,133) 42%, rgb(167,139,142) 42%,
        ... # rgb(167,139,142) 44%, rgb(136,120,151) 44%, rgb(136,120,151) 45%, rgb(105,101,159) 45%,
        ... # rgb(105,101,159) 61%, rgb(74,83,168) 61%, rgb(74,83,168) 78%, rgb(43,64,177) 78%,
        ... # rgb(43,64,177) 100%), linear-gradient(41deg, rgb(229,176,124) 0%, rgb(229,176,124) 41%,
        ... # rgb(198,157,133) 41%, rgb(198,157,133) 42%, rgb(167,139,142) 42%, rgb(167,139,142) 44%,
        ... # rgb(136,120,151) 44%, rgb(136,120,151) 45%, rgb(105,101,159) 45%, rgb(105,101,159) 61%,
        ... # rgb(74,83,168) 61%, rgb(74,83,168) 78%, rgb(43,64,177) 78%, rgb(43,64,177) 100%),
        ... # linear-gradient(90deg, rgb(192,100,79), rgb(56,158,172));
        ... # background-blend-mode: overlay,overlay,overlay,overlay,overlay,normal;
        ... six_layer_gradient = {
        ...     0.0: byte_color_to_float_color((229, 176, 124)),    # Light peach
        ...     0.409: byte_color_to_float_color((229, 176, 124)),  # Hard stop before 41%
        ...     0.41: byte_color_to_float_color((198, 157, 133)),   # Medium peach-brown
        ...     0.419: byte_color_to_float_color((198, 157, 133)),  # Hard stop before 42%
        ...     0.42: byte_color_to_float_color((167, 139, 142)),   # Mauve-brown
        ...     0.439: byte_color_to_float_color((167, 139, 142)),  # Hard stop before 44%
        ...     0.44: byte_color_to_float_color((136, 120, 151)),   # Light purple-grey
        ...     0.449: byte_color_to_float_color((136, 120, 151)),  # Hard stop before 45%
        ...     0.45: byte_color_to_float_color((105, 101, 159)),   # Medium purple
        ...     0.609: byte_color_to_float_color((105, 101, 159)),  # Hard stop before 61%
        ...     0.61: byte_color_to_float_color((74, 83, 168)),     # Blue-purple
        ...     0.779: byte_color_to_float_color((74, 83, 168)),    # Hard stop before 78%
        ...     0.78: byte_color_to_float_color((43, 64, 177)),     # Deep blue
        ...     1.0: byte_color_to_float_color((43, 64, 177))       # Deep blue end
        ... }
        ... base_orange_teal = {0.0: byte_color_to_float_color((192, 100, 79)), 1.0: byte_color_to_float_color((56, 158, 172))}
        ... width, height = 800, 600
        ... # Create all 6 gradient layers with exact angles from CSS
        ... layer_155deg = linear_gradient_image(height, width, six_layer_gradient, 155.0)
        ... layer_322deg = linear_gradient_image(height, width, six_layer_gradient, 322.0)
        ... layer_33deg = linear_gradient_image(height, width, six_layer_gradient, 33.0)
        ... layer_315deg = linear_gradient_image(height, width, six_layer_gradient, 315.0)
        ... layer_41deg = linear_gradient_image(height, width, six_layer_gradient, 41.0)
        ... base_layer = linear_gradient_image(height, width, base_orange_teal, 90.0)
        ... # Apply CSS blend modes in exact order: overlay,overlay,overlay,overlay,overlay,normal
        ... final_result = base_layer  # Start with base (normal blend)
        ... final_result = blend_images(final_result, layer_41deg, mode="overlay")   # 5th overlay
        ... final_result = blend_images(final_result, layer_315deg, mode="overlay")  # 4th overlay  
        ... final_result = blend_images(final_result, layer_33deg, mode="overlay")   # 3rd overlay
        ... final_result = blend_images(final_result, layer_322deg, mode="overlay")  # 2nd overlay
        ... final_result = blend_images(final_result, layer_155deg, mode="overlay")  # 1st overlay (top)
        ... display_image(final_result)
        ... save_image(final_result, 'six_layer_complex_gradient.png')

    EXAMPLES - Five-Layer Semi-Transparent RGBA Gradient with Normal Blending (Fully Self-Contained):

        >>> # EXACT CSS: linear-gradient(90deg, rgba(41,41,41,0.05) 0%, rgba(41,41,41,0.05) 85%,
        ... # rgba(4,4,4,0.05) 85%, rgba(4,4,4,0.05) 96%, rgba(121,121,121,0.05) 96%, rgba(121,121,121,0.05) 99%,
        ... # rgba(230,230,230,0.05) 99%, rgba(230,230,230,0.05) 100%), linear-gradient(90deg, rgba(34,34,34,0.1) 0%,
        ... # rgba(34,34,34,0.1) 3%, rgba(120,120,120,0.1) 3%, rgba(120,120,120,0.1) 13%, rgba(5,5,5,0.1) 13%,
        ... # rgba(5,5,5,0.1) 32%, rgba(180,180,180,0.1) 32%, rgba(180,180,180,0.1) 51%, rgba(2,2,2,0.1) 51%,
        ... # rgba(2,2,2,0.1) 79%, rgba(57,57,57,0.1) 79%, rgba(57,57,57,0.1) 86%, rgba(218,218,218,0.1) 86%,
        ... # rgba(218,218,218,0.1) 100%), linear-gradient(90deg, rgba(206,206,206,0.09) 0%, rgba(206,206,206,0.09) 25%,
        ... # rgba(1,1,1,0.09) 25%, rgba(1,1,1,0.09) 39%, rgba(248,248,248,0.09) 39%, rgba(248,248,248,0.09) 45%,
        ... # rgba(115,115,115,0.09) 45%, rgba(115,115,115,0.09) 49%, rgba(36,36,36,0.09) 49%, rgba(36,36,36,0.09) 60%,
        ... # rgba(106,106,106,0.09) 60%, rgba(106,106,106,0.09) 68%, rgba(102,102,102,0.09) 68%, rgba(102,102,102,0.09) 71%,
        ... # rgba(68,68,68,0.09) 71%, rgba(68,68,68,0.09) 100%), linear-gradient(90deg, rgba(87,87,87,0.08) 0%,
        ... # rgba(87,87,87,0.08) 19%, rgba(9,9,9,0.08) 19%, rgba(9,9,9,0.08) 29%, rgba(243,243,243,0.08) 29%,
        ... # rgba(243,243,243,0.08) 34%, rgba(74,74,74,0.08) 34%, rgba(74,74,74,0.08) 37%, rgba(151,151,151,0.08) 37%,
        ... # rgba(151,151,151,0.08) 63%, rgba(13,13,13,0.08) 63%, rgba(13,13,13,0.08) 85%, rgba(111,111,111,0.08) 85%,
        ... # rgba(111,111,111,0.08) 90%, rgba(125,125,125,0.08) 90%, rgba(125,125,125,0.08) 100%),
        ... # linear-gradient(90deg, rgb(203,229,39), rgb(101,168,200), rgb(79,126,85));
        ... # Layer 1 gradient (0.05 alpha)
        ... layer1_rgba_gradient = {
        ...     0.0: (41/255, 41/255, 41/255, 0.05),      # rgba(41,41,41,0.05)
        ...     0.849: (41/255, 41/255, 41/255, 0.05),    # Hard stop before 85%
        ...     0.85: (4/255, 4/255, 4/255, 0.05),        # rgba(4,4,4,0.05) 85%
        ...     0.959: (4/255, 4/255, 4/255, 0.05),       # Hard stop before 96%
        ...     0.96: (121/255, 121/255, 121/255, 0.05),  # rgba(121,121,121,0.05) 96%
        ...     0.989: (121/255, 121/255, 121/255, 0.05), # Hard stop before 99%
        ...     0.99: (230/255, 230/255, 230/255, 0.05),  # rgba(230,230,230,0.05) 99%
        ...     1.0: (230/255, 230/255, 230/255, 0.05)    # rgba(230,230,230,0.05) 100%
        ... }
        ... # Layer 2 gradient (0.1 alpha)
        ... layer2_rgba_gradient = {
        ...     0.0: (34/255, 34/255, 34/255, 0.1),       # rgba(34,34,34,0.1) 0%
        ...     0.029: (34/255, 34/255, 34/255, 0.1),     # Hard stop before 3%
        ...     0.03: (120/255, 120/255, 120/255, 0.1),   # rgba(120,120,120,0.1) 3%
        ...     0.129: (120/255, 120/255, 120/255, 0.1),  # Hard stop before 13%
        ...     0.13: (5/255, 5/255, 5/255, 0.1),         # rgba(5,5,5,0.1) 13%
        ...     0.319: (5/255, 5/255, 5/255, 0.1),        # Hard stop before 32%
        ...     0.32: (180/255, 180/255, 180/255, 0.1),   # rgba(180,180,180,0.1) 32%
        ...     0.509: (180/255, 180/255, 180/255, 0.1),  # Hard stop before 51%
        ...     0.51: (2/255, 2/255, 2/255, 0.1),         # rgba(2,2,2,0.1) 51%
        ...     0.789: (2/255, 2/255, 2/255, 0.1),        # Hard stop before 79%
        ...     0.79: (57/255, 57/255, 57/255, 0.1),      # rgba(57,57,57,0.1) 79%
        ...     0.859: (57/255, 57/255, 57/255, 0.1),     # Hard stop before 86%
        ...     0.86: (218/255, 218/255, 218/255, 0.1),   # rgba(218,218,218,0.1) 86%
        ...     1.0: (218/255, 218/255, 218/255, 0.1)     # rgba(218,218,218,0.1) 100%
        ... }
        ... # Layer 3 gradient (0.09 alpha)
        ... layer3_rgba_gradient = {
        ...     0.0: (206/255, 206/255, 206/255, 0.09),   # rgba(206,206,206,0.09) 0%
        ...     0.249: (206/255, 206/255, 206/255, 0.09), # Hard stop before 25%
        ...     0.25: (1/255, 1/255, 1/255, 0.09),        # rgba(1,1,1,0.09) 25%
        ...     0.389: (1/255, 1/255, 1/255, 0.09),       # Hard stop before 39%
        ...     0.39: (248/255, 248/255, 248/255, 0.09),  # rgba(248,248,248,0.09) 39%
        ...     0.449: (248/255, 248/255, 248/255, 0.09), # Hard stop before 45%
        ...     0.45: (115/255, 115/255, 115/255, 0.09),  # rgba(115,115,115,0.09) 45%
        ...     0.489: (115/255, 115/255, 115/255, 0.09), # Hard stop before 49%
        ...     0.49: (36/255, 36/255, 36/255, 0.09),     # rgba(36,36,36,0.09) 49%
        ...     0.599: (36/255, 36/255, 36/255, 0.09),    # Hard stop before 60%
        ...     0.60: (106/255, 106/255, 106/255, 0.09),  # rgba(106,106,106,0.09) 60%
        ...     0.679: (106/255, 106/255, 106/255, 0.09), # Hard stop before 68%
        ...     0.68: (102/255, 102/255, 102/255, 0.09),  # rgba(102,102,102,0.09) 68%
        ...     0.709: (102/255, 102/255, 102/255, 0.09), # Hard stop before 71%
        ...     0.71: (68/255, 68/255, 68/255, 0.09),     # rgba(68,68,68,0.09) 71%
        ...     1.0: (68/255, 68/255, 68/255, 0.09)       # rgba(68,68,68,0.09) 100%
        ... }
        ... # Layer 4 gradient (0.08 alpha)
        ... layer4_rgba_gradient = {
        ...     0.0: (87/255, 87/255, 87/255, 0.08),      # rgba(87,87,87,0.08) 0%
        ...     0.189: (87/255, 87/255, 87/255, 0.08),    # Hard stop before 19%
        ...     0.19: (9/255, 9/255, 9/255, 0.08),        # rgba(9,9,9,0.08) 19%
        ...     0.289: (9/255, 9/255, 9/255, 0.08),       # Hard stop before 29%
        ...     0.29: (243/255, 243/255, 243/255, 0.08),  # rgba(243,243,243,0.08) 29%
        ...     0.339: (243/255, 243/255, 243/255, 0.08), # Hard stop before 34%
        ...     0.34: (74/255, 74/255, 74/255, 0.08),     # rgba(74,74,74,0.08) 34%
        ...     0.369: (74/255, 74/255, 74/255, 0.08),    # Hard stop before 37%
        ...     0.37: (151/255, 151/255, 151/255, 0.08),  # rgba(151,151,151,0.08) 37%
        ...     0.629: (151/255, 151/255, 151/255, 0.08), # Hard stop before 63%
        ...     0.63: (13/255, 13/255, 13/255, 0.08),     # rgba(13,13,13,0.08) 63%
        ...     0.849: (13/255, 13/255, 13/255, 0.08),    # Hard stop before 85%
        ...     0.85: (111/255, 111/255, 111/255, 0.08),  # rgba(111,111,111,0.08) 85%
        ...     0.899: (111/255, 111/255, 111/255, 0.08), # Hard stop before 90%
        ...     0.90: (125/255, 125/255, 125/255, 0.08),  # rgba(125,125,125,0.08) 90%
        ...     1.0: (125/255, 125/255, 125/255, 0.08)    # rgba(125,125,125,0.08) 100%
        ... }
        ... # Base colorful gradient (no alpha)
        ... base_colorful_gradient = {
        ...     0.0: byte_color_to_float_color((203, 229, 39)),   # rgb(203,229,39) - lime green
        ...     0.5: byte_color_to_float_color((101, 168, 200)),  # rgb(101,168,200) - light blue  
        ...     1.0: byte_color_to_float_color((79, 126, 85))     # rgb(79,126,85) - forest green
        ... }
        ... width, height = 800, 600
        ... # Create all 5 gradient layers (all 90° horizontal)
        ... rgba_layer1 = linear_gradient_image(height, width, layer1_rgba_gradient, 90.0)  # 90° horizontal
        ... rgba_layer2 = linear_gradient_image(height, width, layer2_rgba_gradient, 90.0)  # 90° horizontal
        ... rgba_layer3 = linear_gradient_image(height, width, layer3_rgba_gradient, 90.0)  # 90° horizontal  
        ... rgba_layer4 = linear_gradient_image(height, width, layer4_rgba_gradient, 90.0)  # 90° horizontal
        ... rgba_base = linear_gradient_image(height, width, base_colorful_gradient, 90.0)   # 90° horizontal base
        ... # Apply normal blending (CSS default for layered backgrounds with alpha)
        ... rgba_final_result = rgba_base  # Start with colorful base
        ... rgba_final_result = blend_images(rgba_final_result, rgba_layer4, mode="normal")  # Layer 4 (0.08 alpha)
        ... rgba_final_result = blend_images(rgba_final_result, rgba_layer3, mode="normal")  # Layer 3 (0.09 alpha)
        ... rgba_final_result = blend_images(rgba_final_result, rgba_layer2, mode="normal")  # Layer 2 (0.1 alpha)
        ... rgba_final_result = blend_images(rgba_final_result, rgba_layer1, mode="normal")  # Layer 1 (0.05 alpha, top)
        ... display_image(rgba_final_result)
        ... save_image(rgba_final_result, 'five_layer_rgba_gradient.png')

    """
    assert height >= 0 and width >= 0, 'Height and width must be non-negative'
    assert gradient is not None, 'Gradient cannot be None'
    
    # Convert gradient specification to position-color dict
    gradient_dict = _as_gradient_dict(gradient)
    
    # Convert direction to pixel path
    direction_strings = dict(vertical=0,horizontal=90)
    if isinstance(direction, str):
        if not direction in direction_strings:
            raise ValueError(
                "Given direction string "
                + repr(direction)
                + " is not in "
                + repr(list(direction_strings))
            )
        direction = direction_strings[direction]

    if is_number(direction):
        # Convert angle in degrees to CSS gradient direction
        # CSS: 0° = vertical up, 90° = horizontal right, 180° = vertical down, 270° = horizontal left
        direction = direction
        path = _gradient_angle_to_points(height, width, direction)
    else:
        path = direction

    path = as_points_array(path)
    if len(path) != 2:
        raise ValueError('Direction path must have exactly 2 points')

    start_point, end_point = path
    direction_vector = end_point - start_point
    direction_length = magnitude(direction_vector)
    
    if direction_length == 0:
        raise ValueError('Direction vector cannot have zero length')
    
    direction_unit = direction_vector / direction_length
    
    # Generate coordinate arrays (optimized with xy_float_images)
    x_coords, y_coords = xy_float_images(
        height, width,
        min_x=0, max_x=width  - 1,
        min_y=0, max_y=height - 1,
    )
    
    # Project each pixel onto gradient direction to get interpolation positions
    gradient_positions = (
          (x_coords - start_point[0]) * direction_unit[0]
        + (y_coords - start_point[1]) * direction_unit[1]
    ) / direction_length
    
    # Use apply_colormap_to_image for colormap application
    output = apply_colormap_to_image(gradient_positions, gradient_dict, backend=backend)
    
    # Convert to RGBA if needed (cv2 backend returns RGB)
    if backend == 'cv2':
        output = as_rgba_image(output)
    
    assert is_rgba_image(output)
    return output


def blend_images(bot, top, alpha=1, mode="normal"):
    """
    Blends two images together using various blending modes.

    Args:
        bot (Union[numpy.ndarray, float, Tuple[float, float, float], Tuple[float, float, float, float]]):
            The bottom image to blend. Can be an image, a float (treated as a grayscale value),
            or a color (RGB or RGBA tuple with values between 0 and 1).

        top (Union[numpy.ndarray, float, Tuple[float, float, float], Tuple[float, float, float, float]]):
            The top image to blend. Can be an image, a float (treated as a grayscale value),
            or a color (RGB or RGBA tuple with values between 0 and 1).

        alpha (Union[numpy.ndarray, float], optional): 
            The alpha mask or value to use for blending.
            If an image, it will be converted to grayscale. Where alpha is closer to 1, top will be
            more opaque. Where alpha is closer to 0, top will be more transparent and bot will show more.
            Defaults to 1.

        mode (str, optional): The blend mode to use. Can be one of:
            - "normal"   : Blends images like in Photoshop
            - "add"      : Adds pixel values of the two images
            - "multiply" : Multiplies pixel values of the two images
            - "subtract" : Subtracts pixel values of top from bot
            - "min"      : Takes the minimum pixel value from top and bot
            - "max"      : Takes the maximum pixel value from top and bot
            - "contrast" : Multiplies the bot image by top, centered at .5. 
                           Here, top can be any floating point such as > 1.0 or even -1.0 to invert the image.
            - "overlay"  : Combines multiply and screen - multiply for dark areas, screen for light areas
            Some modes also support "clip" at the end to force pixel outputs to be between 0 and 1.
                - "add clip"
                - "multiply clip"
                - "subtract clip"
                - "contrast clip"
                - "overlay clip"

            These modes are inspired by photoshop. 
            NOTE:
                The specific implementation of alpha with respect to some of them is subject to change (except 'normal', that's set in stone).
                Don't rely on any blending except 'normal' mode to have a specific calculation with respect to alpha values!

            Defaults to "normal".

    Returns:
        numpy.ndarray: The blended image.
            Will always return a float rgba image as defined by rp.is_float_image and rp.is_rgba_image

    Examples:
        Example 1: Blending multiple images
        >>> dice     ='https://bellard.org/bpg/3.png'
        >>> penguin  ='https://www.gstatic.com/webp/gallery3/2_webp_a.png'
        >>> mountains='https://cdn.britannica.com/67/19367-050-885866B4/Valley-Taurus-Mountains-Turkey.jpg'
        >>> checkerboard='https://static8.depositphotos.com/1176848/894/i/450/depositphotos_8945283-stock-photo-checkerboard-chess-background.jpg'
        >>> dice     =load_image(dice     ) #Has alpha channel
        >>> penguin  =load_image(penguin  ) #Has alpha channel
        >>> mountains=load_image(mountains) #Has no alpha channel
        >>> checkerboard=load_image(checkerboard) #Has no alpha channel
        >>> composite=blend_images(mountains,penguin,.5) #Penguin is slightly transparent
        >>> composite=blend_images(composite,dice,alpha=checkerboard) #Mix the dice on with a checkerboard mask
        >>> display_image(composite) #Result should look like https://i.imgur.com/lF8Sxuc.jpeg

        Example 2: Blending with colors and alpha
        >>> dice = 'https://bellard.org/bpg/3.png'
        >>> dice = load_image(dice)  #Has alpha channel  
        >>> display_image(blend_images((0,1,0),dice))               #Should look like https://i.imgur.com/iu6Z8bk.png
        >>> display_image(blend_images((0,1,0),(1,0,1),alpha=dice)) #Should look like https://i.imgur.com/gxaauuD.png  
        >>> display_image(blend_images(1,1/2,alpha=dice))           #Should look like https://i.imgur.com/f0sKWY5.png

        Example 3: Numeric blending examples
        >>> blend_images(0,.5,.5)
        [[[0.25 0.25 0.25 1.  ]]]
        >>> blend_images(0,(0,1,0),.5) 
        [[[0.  0.5 0.  1. ]]]
        >>> blend_images(1,(0,1,0),.5)
        [[[0.5 1.  0.5 1. ]]] 
        >>> blend_images(1,(0,1,0,.5),.5)
        [[[0.75 1.   0.75 1.  ]]]

        Example 4: Complex blending with text and blur
        >>> dog=load_image('https://i.insider.com/5484d9d1eab8ea3017b17e29?width=600&format=jpeg.jpg')
        >>> nebula=load_image('https://spaceplace.nasa.gov/nebula/en/nebula1.en.jpg')  
        >>> nebula,dog=crop_images_to_min_size(nebula,dog)
        >>> text=cv_text_to_image("OUTER\\nSPACE\\nDOGGO!!",scale=4,thickness=20)
        >>> composite=blend_images(dog,nebula,alpha=text)  
        >>> display_image(composite) #Should look like https://i.imgur.com/wEc1t8e.png
        >>> composite=blend_images(dog,nebula,alpha=cv_gauss_blur(text,sigma=15)) #Should look like https://i.imgur.com/YtPtR1p.png

        Example 5: Different blending modes
        >>> display_image(blend_images(mountains,penguin,mode='add'))     #Add mode example
        >>> display_image(blend_images(mountains,penguin,mode='multiply')) #Multiply mode example

        Example 5: Live webcam demo with multiple blend modes
        >>> lena_image = load_image(
        >>>     "https://upload.wikimedia.org/wikipedia/en/7/7d/Lenna_%28test_image%29.png",
        >>>     use_cache=True,
        >>> )
        >>> while True:
        >>>     cam_image = load_image_from_webcam()
        >>>     display_image(
        >>>         blend_images(
        >>>             blend_images(
        >>>                 cam_image,
        >>>                 rotate_image(lena_image, toc() * 10),
        >>>                 mode="multiply",
        >>>             ),
        >>>             lena_image,
        >>>             mode="add",
        >>>         ),
        >>>     )


    Note:
        Most of the code here is to handle the different types of inputs (top and bot can be floats, images or colors etc)
        If the alpha, top or bot image dimensions don't match - its ok! It will choose the size of the larger one and align their top left corners.
    """
    
    #Input validation
    assert is_image(top) or is_color_vector(top) and len(top) in {3,4} or is_number(top) or isinstance(top,str)
    assert is_image(bot) or is_color_vector(bot) and len(bot) in {3,4} or is_number(bot) or isinstance(bot,str)
    assert is_image(alpha) or is_number(alpha)

    if isinstance(top, str):top=as_rgba_float_color(top)
    if isinstance(bot, str):bot=as_rgba_float_color(bot)

    blend_modes = {
        "normal"  ,
        "min"     ,
        "max"     ,
        "add"     , "add clip"     ,
        "multiply", "multiply clip",
        "subtract", "subtract clip",
        "contrast", "contrast clip",
        "overlay", "overlay clip",
    }
    assert mode in blend_modes, 'Please choose a blend mode from the following options: '+str(blend_modes)

    #Determine the height and width of the output
    input_images = [x for x in (bot,top,alpha) if is_image(x)]
    if input_images:
        #If we have at least one image as an input, the output size is the max of all of them
        height,width = get_max_image_dimensions(input_images)
    else:
        #Otherwise, the output image will have a height and width of 1x1
        height,width = 1, 1

    #Possibly use color names or hex codes
    if isinstance(top, str): top = as_rgba_float_color(top)
    if isinstance(bot, str): top = as_rgba_float_color(bot)
        
    #If top or bot are numbers, turn them into grayscale RGB float colors
    if is_number(top):top=float(top);top=(top,top,top);assert is_float_color(top)
    if is_number(bot):bot=float(bot);bot=(bot,bot,bot);assert is_float_color(bot)

    #If top or bot are colors, turn them into solid-colored images
    if is_color_vector (top  ):top  =uniform_float_color_image(height, width, top  )  
    if is_color_vector (bot  ):bot  =uniform_float_color_image(height, width, bot  )
    if is_number(alpha):alpha=uniform_float_color_image(height, width, alpha)
    
    #Whatever top, bot and alpha started as, by this point they should all be images
    assert is_image(top  ) 
    assert is_image(bot  ) 
    assert is_image(alpha) 
    
    #Make sure all images are now the same size
    bot,top,alpha=crop_images_to_max_size(bot,top,alpha)

    top  =as_rgba_image     (as_float_image(top  ,copy=False),copy=False)
    bot  =as_rgba_image     (as_float_image(bot  ,copy=False),copy=False)
    alpha=as_grayscale_image(as_float_image(alpha,copy=False),copy=False)
    
    top_alpha=top[:,:,3] #The alpha channel of the top image
    bot_alpha=bot[:,:,3] #The alpha channel of the bot image
    alpha*=top_alpha #Take the top image's alpha channel into consideration
    
    output_alpha=1-((1-alpha)*(1-bot_alpha)) #The alpha channel of the output image. The output image is always more opaque than the input

    color_alpha = alpha / np.where(output_alpha == 0, 0.0000001, output_alpha) #Avoid ÷0 errors
    
    color_alpha = color_alpha[:,:,None]
    alpha       = alpha      [:,:,None]

    if   "normal"   in mode.split(): output = bot * (1 - color_alpha) + top * color_alpha
    elif "add"      in mode.split(): output = bot + top * alpha
    elif "multiply" in mode.split(): output = bot * (1 - alpha + top * alpha)
    elif "subtract" in mode.split(): output = bot - top * alpha
    elif "min"      in mode.split(): output = np.minimum(bot, top)
    elif "max"      in mode.split(): output = np.maximum(bot, top)
    elif "contrast" in mode.split(): output = bot * (1 - alpha) + ((bot - 0.5) * top + 0.5) * alpha
    elif "overlay"  in mode.split(): 
        # Overlay blend mode: multiply for dark, screen for light
        # Formula: if base < 0.5: 2 * base * overlay, else: 1 - 2 * (1-base) * (1-overlay)
        overlay_result = np.where(bot < 0.5, 2 * bot * top, 1 - 2 * (1 - bot) * (1 - top))
        output = bot * (1 - alpha) + overlay_result * alpha

    if 'clip' in mode.split():
        #If we specify we want to clip the output, it will be constrained between 0 and 1. Useful for some blend modes such as "add clip" etc
        output = np.clip(output, 0, 1)

    output[:,:,3]=output_alpha
    
    return output

# def blend_videos(bot, top, alpha=1, mode="normal", *, show_progress=False):
#     is_numpy
#     bot, top = trim_videos_to_max_length(bot, top)
#     is_numpy = is_numpy_array(bot) and is_numpy_array(top) and get_video_dimensions(bot)==get_video_dimensions(top)
#     return 



def overlay_images(*images,mode='normal'):
    """
    Blends all the given images on top of one another; the last one being on top
    It takes into consideration any alpha channels, if the images are RGBA
    """
    if len(images)==1:
        images=images[0]
    if is_image(images):
        return images.copy()

    if is_image(images[0]):
        #Bottom is an image
        output=images[0]+0
    else:
        #It's a float or color tuple
        output=images[0]

    for image in images[1:]:
        output=blend_images(output,image,mode=mode)
    return output
    
def laplacian_blend(bot, top, alpha, levels=None):
    """
    Uses laplacian pyramid blending on two images with a given alpha mask.
    Note: Right now only RGB images of equal dimensions are supported (non-rgb will be converted to RGB)
    
    Arguments:
        - levels (int, None): The number of laplacian pyramid levels. Defaults to None, where the max number of levels is selected automatically from the image sizes. No errors will be thrown if you input a level number too high - it will effectively just use the max level.
    
    EXAMPLE:
        >>> top_url = "https://fastly.picsum.photos/id/9/5000/3269.jpg?hmac=cZKbaLeduq7rNB8X-bigYO8bvPIWtT-mh8GRXtU3vPc"
        ... bot_url = "https://fastly.picsum.photos/id/21/3008/2008.jpg?hmac=T8DSVNvP-QldCew7WD4jj_S3mWwxZPqdF0CNPksSko4"
        ... 
        ... S = 512
        ... alpha = get_checkerboard_image(S, S, tile_size=64,first_color=0,second_color=1)
        ... 
        ... bot, top = resize_images_to_fit(crop_images_to_square(load_images(download_urls_to_cache(bot_url, top_url), use_cache=True)), height=S, width=S)
        ... 
        ... bot, top, alpha = as_float_images(as_rgb_images([bot, top, alpha]))
        ... 
        ... ic("FOR DEBUGGING", alpha.shape, alpha.dtype, bot.shape, bot.dtype, top.shape, top.dtype)
        ... 
        ... regular_blended_image = blend_images(bot, top, alpha)
        ... laplacian_blended_image = laplacian_blend(bot, top, alpha)
        ... 
        ... laplacian_levels_anim = [laplacian_blend(bot, top, alpha,levels) for levels in range(10)]
        ... 
        ... display_image(grid_concatenated_images([[bot, top, alpha], [regular_blended_image, laplacian_blended_image]]), block=True)
        ... display_image_slideshow(video_with_progress_bar(laplacian_levels_anim))
    """
    pip_import('cv2')
    pip_import('numpy')

    import numpy as np
    import cv2

    #Make all inputs RGB float images
    bot, top, alpha = as_rgb_images(as_float_images([bot, top, alpha], copy=False), copy=False)

    #Input validation: Make sure all images have same shape
    if not (bot.shape == top.shape == alpha.shape):
        raise ValueError(
            "laplacian_blend: All input images must have same dimensions, but got bot.shape==%s and top.shape==%s and alpha.shape==%s" % 
            (bot.shape, top.shape, alpha.shape)
        )

    # Calculate max possible levels if None is provided
    if levels is None:
        min_dim = min(bot.shape[:2] + top.shape[:2] + alpha.shape[:2])
        levels = int(np.ceil(np.log2(min_dim)))

    # Generate Gaussian pyramids
    gaussian_pyr1 = [bot]
    gaussian_pyr2 = [top]
    gaussian_alpha = [alpha]

    for _ in range(levels):
        gaussian_pyr1.append(cv2.pyrDown(gaussian_pyr1[-1]))
        gaussian_pyr2.append(cv2.pyrDown(gaussian_pyr2[-1]))
        gaussian_alpha.append(cv2.pyrDown(gaussian_alpha[-1]))

    # Generate Laplacian pyramids directly
    laplacian_pyr1 = []
    laplacian_pyr2 = []

    for i in range(levels):
        src_img1 = gaussian_pyr1[i + 1]
        src_img2 = gaussian_pyr2[i + 1]

        dst_size1 = (gaussian_pyr1[i].shape[1], gaussian_pyr1[i].shape[0])
        dst_size2 = (gaussian_pyr2[i].shape[1], gaussian_pyr2[i].shape[0])
        
        level_expanded1 = cv2.pyrUp(src_img1, dstsize=dst_size1)
        level_expanded2 = cv2.pyrUp(src_img2, dstsize=dst_size2)

        laplacian_pyr1.append(cv2.subtract(gaussian_pyr1[i], level_expanded1))
        laplacian_pyr2.append(cv2.subtract(gaussian_pyr2[i], level_expanded2))

    # Add the smallest level directly (Gaussian level)
    laplacian_pyr1.append(gaussian_pyr1[-1])
    laplacian_pyr2.append(gaussian_pyr2[-1])

    # Blend pyramids
    blended_pyramid = []
    for lap1, lap2, alpha_mask in zip(laplacian_pyr1, laplacian_pyr2, gaussian_alpha):
        blended = lap1 * alpha_mask + lap2 * (1 - alpha_mask)
        blended_pyramid.append(blended)

    # Reconstruct the blended image
    blended_image = blended_pyramid[-1]
    for level in reversed(blended_pyramid[:-1]):
        blended_image = cv2.pyrUp(blended_image, dstsize=(level.shape[1], level.shape[0]))
        blended_image = cv2.add(blended_image, level)

    return blended_image

def get_checkerboard_image(height=64,
                           width=64,
                           *,
                           tile_size=8,
                           first_color=(1.0, 1.0, 1.0, 1.0),
                           second_color=(0.0, 0.0, 0.0, 1.0)
                          ):
    """
    Generate a checkerboard image as a numpy array in HWC form.
    Default parameters look like an actual game checkerboard.

    Parameters:
        - height: int
            The height of the output image.
        - width: int
            The width of the output image.
        - tile_size: int or tuple of int (tile_height, tile_width)
            The size of each checkerboard tile. If an int is given, the tiles are square.
        - first_color: tuple of float (r, g, b, a)
            The color of the top left tile (and every other even tile)
        - second_color: tuple of float (r, g, b, a)
            The color of the second tile (and every other odd tile)

    Returns:
        - img: ndarray
            A numpy array of shape (height, width, 4), representing the checkerboard image.
            Each pixel is a 4-tuple of float values representing an RGBA color.

    Example:
        >>> img = get_checkerboard_image(100, 200, (20, 30), (1, 1, 1, 1), (0, 0, 0, 1))
        >>> img.shape
        (100, 200, 4)

    Example:
        # Alpha image with checkerboard background
        rgba_image=load_image('https://bellard.org/bpg/2.png')
        height,width=get_image_dimensions(rgba_image)
        background=get_checkerboard_image(height,width, second_color=.75) #You can use floats as colors
        composite=blend_images(background,rgba_image)
        display_image(composite)

    Example:
        for h in range(1,32):
            for w in range(1,32):
                display_image(get_checkerboard_image(256,256,(h,w)))

    Written with GPT4's help.
    """
    import numpy as np
    import math

    # Handle both int and tuple tile_size
    tile_height, tile_width = (
        (tile_size, tile_size) if isinstance(tile_size, int) else tile_size
    )

    # Create the base tiles
    second_tile = rp.as_rgba_image(uniform_float_color_image(tile_height, tile_width, second_color))
    first_tile  = rp.as_rgba_image(uniform_float_color_image(tile_height, tile_width, first_color ))

    # Create the base tile
    base_tile_row1 = np.hstack((first_tile, second_tile))
    base_tile_row2 = np.hstack((second_tile, first_tile))
    base_tile = np.vstack((base_tile_row1, base_tile_row2))

    # Calculate the number of repetitions needed
    reps_y = math.ceil(height / (2 * tile_height))
    reps_x = math.ceil(width / (2 * tile_width))

    # Repeat the base tile to cover the image size
    img = np.tile(base_tile, (reps_y, reps_x, 1))

    # Crop to the desired image size
    img = img[:height, :width, :]

    return img

def with_drop_shadow(
    image,
    *,
    x=0,
    y=0,
    color=(0, 0, 0, 1),
    blur=10,
    opacity=1,
    kernel='gauss'
):
    """
    Applies a drop shadow to an image
    **DEFAULT ARGUMENT VALUES ARE SUBJECT TO CHANGE**
    """
    
    image=as_numpy_image(image,copy=False)
    image=as_float_image(image,copy=False)
    alpha=get_image_alpha(image)
    shadow_alpha=shift_image(alpha,x,y,allow_growth=False)

    if kernel=='gauss':
        shadow_alpha=cv_gauss_blur(shadow_alpha,sigma=blur)
    elif kernel=='box':
        shadow_alpha=cv_box_blur(shadow_alpha,diameter=blur)
    else:
        assert False, kernel

    height,width=get_image_dimensions(image)
    shadow=with_alpha_channel(uniform_float_color_image(height,width,color),shadow_alpha*opacity,copy=False)
    return blend_images(shadow,image)

def with_drop_shadows(images,**kwargs):
    """Apply with_drop_shadow to multiple images. See with_drop_shadow for details.
    
    Args:
        images: List/iterable of images to add drop shadows to
        **kwargs: All keyword arguments passed to with_drop_shadow
        
    Returns:
        List of images with drop shadows applied
    """
    return [with_drop_shadow(image,**kwargs) for image in images]
    
def with_corner_radius(image, radius, *, antialias=True, background=None):
    """
    Applies an alpha mask to round off the corners of an image
    Radius is, of course, measured in pixels

    EXAMPLE:
        image = load_image(
            "https://upload.wikimedia.org/wikipedia/en/7/7d/Lenna_%28test_image%29.png"
        )
        for radius in resize_list(range(max(get_image_dimensions(image)) // 2), 30):
            display_alpha_image(
                with_drop_shadow(
                    crop_image(
                        with_corner_radius(image, radius),
                        get_image_height(image) + 200,
                        get_image_width(image) + 200,
                        origin="center",
                    ),
                    opacity=0.8,
                    blur=100,
                    x=50,
                    y=50,
                )
            )
    """
    from PIL import Image, ImageDraw

    radius = round(radius)
    assert radius>=0

    mask_size = tuple(get_image_dimensions(image)[::-1])  # PIL uses (width, height)

    if antialias:
        antialias_upsampling_factor = 2
        radius *= antialias_upsampling_factor
        original_mask_size = mask_size
        mask_size = tuple(x * antialias_upsampling_factor for x in mask_size)

    mask = Image.new("L", mask_size, 0)
    draw = ImageDraw.Draw(mask)

    draw.rounded_rectangle([(0, 0), mask_size], radius, fill=255)

    if antialias:
        mask = mask.resize(original_mask_size)

    alpha = as_float_image(get_image_alpha(image),copy=False) * as_float_image(as_numpy_image(mask),copy=False)
    return with_image_alpha(image, alpha)

def with_image_glow(image, *, blur=None, strength=None):
    """
    Adds a bloom effect to an image with a given blur and strength.
    The default values are subject to change - they're purely aesthetic!

    EXAMPLE:
        >>> for i in range(1000):
        ...     display_image(
        ...         with_image_glow(
        ...             resize_image_to_fit(pil_text_to_image(
        ...                 "Hello World\n0123456789\n" + str(i),
        ...                 font="https://github.com/ctrlcctrlv/lcd-font/raw/master/otf/LCD14.otf",
        ...                 color=(.5,.9,.1),
        ...                 size=256,
        ...                 align='center',
        ...             ),height=512sp),
        ...             blur=50,
        ...             strength=1.5,
        ...         )
        ...     )
    
    EXAMPLE:
        >>> url = "https://www.shutterstock.com/shutterstock/videos/1077886106/preview/stock-footage-jun-hong-kong-china-asia-drone-hyperlapse-of-hong-kong-international-financial-centre.webm"
        ... video = load_video(url)
        ... frames = []
        ... for frame in eta(video):
        ...     frame = with_image_glow(frame)
        ...     frames.append(frame)
        ... display_video(
        ...     vertically_concatenated_videos(
        ...         labeled_videos([video, frames], ["Input Video", "with_image_glow"])
        ...     )
        ... )
    """
    if blur is None:
        blur = 10
    if strength is None:
        strength = 1

    image = as_float_image(image)
    image = as_rgba_image(image)

    blurred = cv_gauss_blur(image, blur, alpha_weighted=True)
    glow = as_rgb_image(blurred) * as_rgb_image(get_image_alpha(blurred))

    rgb = as_rgb_image(image) + glow * strength

    return with_image_rgb(image, rgb)


def with_image_glows(*images, blur=None, strength=None):
    """Plural of with_image_glow"""
    images=detuple(images)
    return [with_image_glow(image,blur=blur,strength=strength) for image in images]



def with_corner_radii(*images, radius, antialias=True):
    """Apply with_corner_radius to multiple images. See with_corner_radius for details.
    
    Args:
        *images: Variable number of images to apply corner radius to
        radius: Corner radius in pixels
        antialias: Whether to apply antialiasing
        
    Returns:
        List of images with rounded corners applied
    """
    images = detuple(images)
    return [with_corner_radius(image, radius, antialias=antialias) for image in images]


def get_alpha_outline(image,*,inner_radius=0,outer_radius=0,include_edges=True,allow_growth=False):
    """
    You should set inner_radius>0 or outer_radius>0

    include_edges (bool): Has an effect when inner_radius>0 - if True, it assumes there's 0 alpha outside the image - and as a result can create an outline aronud the edges of the image

    Returns an alpha mask
    """
    if include_edges:
        image=bordered_image_solid_color(image,color=(0,0,0,0))
        
    if allow_growth and outer_radius:
        image=bordered_image_solid_color(image,color=color,thickness=outer_radius)
        
    mask=get_alpha_channel(image)
    mask=as_binary_image(mask)
    dilated=cv_dilate(mask,diameter=outer_radius,circular=True)
    eroded=cv_erode(mask,diameter=inner_radius,circular=True)
    outline=dilated&~eroded

    if include_edges:    
        outline=outline[1:-1,1:-1]

    outline = as_grayscale_image(outline)

    return outline

def with_alpha_outline(image,*,inner_radius=0,outer_radius=0,include_edges=True,color=(1,1,1,1),allow_growth=False):
    """Add colored outline around alpha edges of image. Useful for text, logos, sprites.
    
    Args:
        inner_radius: Outline extends inward from alpha edges
        outer_radius: Outline extends outward from alpha edges  
        color: RGBA color tuple (values 0-1)
        allow_growth: If True, image can grow to accommodate outer outline
    """
    if allow_growth and outer_radius:
        image=bordered_image_solid_color(image,color=(0,0,0,0),thickness=outer_radius)
        
    outline = get_alpha_outline(
        image,
        inner_radius=inner_radius,
        outer_radius=outer_radius,
        include_edges=include_edges,
        allow_growth=False
    )
    color=as_rgba_float_color(color)
        

    return blend_images(image,color,outline)


def with_alpha_outlines(*images,**kwargs):
    """Apply with_alpha_outline to multiple images. See with_alpha_outline for details.
    
    Args:
        *images: Variable number of images to add alpha outlines to
        **kwargs: All keyword arguments passed to with_alpha_outline
        
    Returns:
        List of images with alpha outlines applied
    """
    images=detuple(images)
    return [with_alpha_outline(image,**kwargs) for image in images]


def get_progress_bar_image(
    progress,
    *,
    height=10,
    width=100,
    bar_color="white",
    background_color="black",
    reverse=False
):
    """
    Generate a rectangular RGBA progress bar image.

    Args:
        progress (float): Progress value between 0 and 1.
        height (int): Height of the progress bar image in pixels. Default is 10.
        width  (int): Width  of the progress bar image in pixels. Default is 100.
        bar_color        (str, tuple[float], float): Color of the progress bar.
        background_color (str, tuple[float], float): Color of the background.

    Colors can be given as a string, as a float, or a tuple of RGB or RGBA floats (see as_rgba_float_color)

    Returns:
        numpy.ndarray: RGBA image of the progress bar.

    EXAMPLE:
        >>> N=1000
        ... for i in range(N):
        ...     display_image(get_progress_bar_image(i/N,width=10))
    """

    bar_color = as_rgba_float_color(bar_color)
    background_color = as_rgba_float_color(background_color)

    progress = clamp(progress, 0, 1)

    background = uniform_float_color_image(height, width, background_color)

    alpha = np.zeros((height, width))

    bar_width = progress * width
    bar_floor = int(bar_width)
    bar_remainder = bar_width - bar_floor

    alpha[:, : int(bar_floor)] = 1
    if bar_remainder and bar_floor < width:
        # A bit of antialising
        alpha[:, bar_floor] = bar_remainder

    bar_image = blend_images(background_color, bar_color, alpha)

    if reverse:
        bar_image = horizontally_flipped_image(bar_image)

    return bar_image


def image_with_progress_bar(
    image,
    progress,
    *,
    size=10,
    bar_color="white",
    background_color="black",
    position='top',
    reverse=False
):
    """
    Adds a progress bar to an image.
    See rp.get_progress_bar_image for further documentation.
    
    EXAMPLE:
        >>> image = load_image(
        ...     "https://upload.wikimedia.org/wikipedia/en/7/7d/Lenna_%28test_image%29.png",
        ...     use_cache=True,
        ... )
        ... N = 1000
        ... for i in range(N):
        ...     display_alpha_image(
        ...         labeled_image(
        ...             image_with_progress_bar(
        ...                 image,
        ...                 i / N,
        ...                 bar_color="white",
        ...                 background_color="dark blue",
        ...             ),
        ...             f"{i}",
        ...             font="G:Chivo Mono",
        ...             background_color="dark blue",
        ...         ),
        ...     )

    """

    assert isinstance(position, str)
    assert is_number(size)

    if size==0:
        return as_numpy_image(image, copy=True)

    if position in ['top', 'bottom']:
        bar_width = get_image_width(image)
        bar_height = size

        progress_bar_image = gather_args_call(
            get_progress_bar_image,
            height=abs(bar_height),
            width=bar_width,
        )

        images = progress_bar_image, image

        if bar_height>0:
            if position=='top':
                return vertically_concatenated_images(images)
            else:
                return vertically_concatenated_images(images[::-1])
        else:
            #Overlay the bar over the image
            images = crop_images_to_max_size(
                images,
                origin={
                    "top": "top left",
                    "bottom": "bottom right",
                }[position],
            )
            return blend_images(*images[::-1])

    elif position=='left':
        image = rotate_image(image, 90)
        image = gather_args_call(image_with_progress_bar, position='top', reverse=not reverse)
        image = rotate_image(image, -90)
        return image
    elif position=='right':
        image = rotate_image(image, 90)
        image = gather_args_call(image_with_progress_bar, position='bottom', reverse=not reverse)
        image = rotate_image(image, -90)
        return image
    else:
        assert False, 'rp.image_with_progress_bar: position should be "top", "bottom", "left" or "right", not '+repr(position)


def video_with_progress_bar(
    video,
    *,
    size=10,
    length=None,
    bar_color="white",
    background_color="black",
    reverse=False,
    position='top',
    lazy=False
):
    """
    Adds a progress bar to the top of a video to see how far into it you are.
    See rp.get_progress_bar_image for further documentation.
    
    Parameters:
        video: List of images/frames or iterable video object
        size (int, default=10): Height of progress bar in pixels for top/bottom position,
                                width for left/right position. Negative values mean the bar is drawn over the image.
        length (int, optional): Total length of video. If None, uses len(video)
        bar_color (str, default="white"): Color of the progress bar
        background_color (str, default="black"): Color of the progress bar background
        reverse (bool, default=False): If True, progress bar moves in reverse direction
        position (str, default='top'): Position of progress bar - 'top', 'bottom', 'left', 'right'
        lazy (bool, default=False): If True, returns generator; if False, returns list
                                    Use lazy=True for large videos to avoid memory usage, or for streaming video
    
    Returns:
        List or generator of images with progress bars added. Output frames have alpha channel.
        Shape changes: If size>0, for top/bottom bars, height increases by size. 
                       If size>0, for left/right bars, width increases by size.
                       If size<=0, the output image shape = the input image shape (the bar is drawn over the image)
    
    See also:
        - rp.get_progress_bar_image: Creates individual progress bar images
        - rp.image_with_progress_bar: Adds a progress bar to a single image
        - rp.with_alpha_checkerboards: Often used with progress bar videos for transparency
        - resize_images_to_hold, resize_image_to_fit, resize_images_to_max_size, resize_videos_to_min_size,
          resize_images, crop_images, crop_images_to_max_size, crop_images_to_min_size: Commonly used to standardize frame sizes before adding bars

    EXAMPLE:
       >>> display_video(
       ...     video_with_progress_bar(
       ...         load_video(
       ...             "https://www.shutterstock.com/shutterstock/videos/1070160847/preview/stock-footage-electric-car-drive-on-the-wind-turbines-background-car-drives-along-a-mountain-road-electric-car.webm",
       ...             use_cache=True,
       ...         ),
       ...         lazy=True,
       ...     )
       ... )

    EXAMPLE (slightly crazier):

        >>> video = load_video(
        ...     "https://www.shutterstock.com/shutterstock/videos/1056263531/preview/stock-footage-cctv-ai-facial-recognition-camera-zoom-in-recognizes-person-elevated-security-camera-surveillance.webm",
        ...     use_cache=True,
        ... )
        ... video=resize_list(video,30)
        ... video=resize_images_to_hold(video,height=256)
        ... video = video_with_progress_bar(
        ...     video,
        ...     position="right",
        ...     size=-20,
        ...     background_color="translucent orange",
        ...     lazy=True,
        ... )
        ... video = video_with_progress_bar(
        ...     video,
        ...     position="right",
        ...     size=-5,
        ...     background_color="green",
        ...     bar_color='translucent black',
        ...     lazy=True,
        ...     reverse=True
        ... )
        ... video = video_with_progress_bar(
        ...     video,
        ...     position="top",
        ...     bar_color="white",
        ...     background_color="translucent black",
        ...     size=10,
        ...     lazy=True,
        ... )
        ... video = video_with_progress_bar(
        ...     video,
        ...     position="bottom",
        ...     lazy=True,
        ... )
        ... video = video_with_progress_bar(
        ...     video,
        ...     position="left",
        ...     bar_color='white',
        ...     reverse=False,
        ...     lazy=True,
        ... )
        ... video = video_with_progress_bar(
        ...     video,
        ...     position="left",
        ...     bar_color='blue white',
        ...     reverse=True,
        ...     lazy=True,
        ... )
        ... video = video_with_progress_bar(
        ...     video,
        ...     position="left",
        ...     bar_color='blue blue white',
        ...     reverse=False,
        ...     lazy=True,
        ... )
        ... video = video_with_progress_bar(
        ...     video,
        ...     position="left",
        ...     bar_color='blue blue blue white',
        ...     reverse=True,
        ...     lazy=True,
        ... )
        ... 
        ... display_video(with_alpha_checkerboards(video,lazy=True),loop=True)

    """

    if length is None: 
        length = len(video)
    
    def helper():
        nonlocal size, bar_color, background_color, position, reverse
        assert length > 0, 'Cannot make progress bar on video with only one frame - length='+str(length)
        for index, image in enumerate(video):
            progress = index / (length - 1)
            frame = gather_args_call(image_with_progress_bar)
            yield frame

    output = helper()

    if lazy:
        output = IteratorWithLen(output, length)
    else:
        output = list(output)

    return output

def boomerang_video(video):
    if isinstance(video,str):
        video=load_video(video)

    video = list(video)
    new_video=video[:-1]+video[::-1][:-1]

    return new_video
    

def _ensure_chmod_executable(path:str):
    "Ensures a path has the executable permission bit turned on"
    import os, stat, pathlib
    p = pathlib.Path(path)
    current_mode = p.stat().st_mode
    if not (current_mode & stat.S_IXUSR):
        p.chmod(current_mode | stat.S_IXUSR)

def _get_platform_executable(name, download_urls, executable_name):
    download_dir = make_directory(path_join(_rp_downloads_folder, name))
    url = (
        download_urls["macos"]
        if currently_running_mac()
        else download_urls["linux"]
        if currently_running_linux()
        else download_urls["windows"]
    )
    zip_file = download_url(url, download_dir, skip_existing=True, show_progress=True)
    folder = strip_file_extension(zip_file)
    if not folder_exists(folder):
        unzip_to_folder(zip_file)
    assert folder_exists(folder)
    executable = path_join(folder, executable_name)
    assert file_exists(executable), executable
    
    if currently_running_linux() or currently_running_mac():
        _ensure_chmod_executable(executable) #Fix permissions

    return executable

def _get_rife_executable():
    """Returns the path to the rife-ncnn-vulkan executable or if it doesn't exist in rp downloads it"""
    rife_download_urls = dict(
        # https://github.com/nihui/rife-ncnn-vulkan/releases
        macos="https://github.com/nihui/rife-ncnn-vulkan/releases/download/20221029/rife-ncnn-vulkan-20221029-macos.zip",
        linux="https://github.com/nihui/rife-ncnn-vulkan/releases/download/20221029/rife-ncnn-vulkan-20221029-ubuntu.zip",
        windows="https://github.com/nihui/rife-ncnn-vulkan/releases/download/20221029/rife-ncnn-vulkan-20221029-windows.zip",
    )
    return _get_platform_executable("rife", rife_download_urls, "rife-ncnn-vulkan")


def _get_esrgan_executable():
    """Returns the path to the realesrgan-ncnn-vulkan executable or if it doesn't exist in rp downloads it"""
    #TODO: Make a function to use this on images
    esrgan_download_urls = dict(
        # https://github.com/xinntao/Real-ESRGAN/releases/tag/v0.2.5.0
        macos="https://github.com/xinntao/Real-ESRGAN/releases/download/v0.2.5.0/realesrgan-ncnn-vulkan-20220424-macos.zip",
        linux="https://github.com/xinntao/Real-ESRGAN/releases/download/v0.2.5.0/realesrgan-ncnn-vulkan-20220424-ubuntu.zip",
        windows="https://github.com/xinntao/Real-ESRGAN/releases/download/v0.2.5.0/realesrgan-ncnn-vulkan-20220424-windows.zip",
    )
    return _get_platform_executable("esrgan", esrgan_download_urls, "realesrgan-ncnn-vulkan")

# def slowmo_video_via_rife(video):
#     """ Doubles the framerate of a given video. Can be a list of images as defined by rp.is_image, or a numpy array etc. Anything compatible with save_image."""
#     rife_executable = _get_rife_executable()
#     input_dir = make_directory(temporary_file_path())
#     output_dir = make_directory(temporary_file_path())
#     try:
#         save_images(video, input_dir, show_progress=True)
#         command=shlex.quote(rife_executable) + " -i " + shlex.quote(input_dir) + " -o " + shlex.quote(output_dir)
#         fansi_print(command, 'yellow')
#         _run_sys_command(command)
#         new_video = load_images(output_dir, show_progress=True)
#         new_video = as_numpy_array(new_video)
#     finally:
#         if folder_exists(input_dir ): delete_folder(input_dir )
#         if folder_exists(output_dir): delete_folder(output_dir)
#
#     #I found this created a duplicate last frame
#     new_video=new_video[:-1]
#
#     return new_video

def slowmo_video_via_rife(video, *, device=None, verbose=True):
    """
    Run RIFE frame interpolation on a given video

    Args:
        video: Input video (numpy array, list of images, video file path, or path with glob pattern)
        device: Device selection:
               - None: Use rp.select_torch_device(prefer_used=True) or auto-detect
                       On Mac, will use internal GPU
               - int: GPU index (0, 1, 2, etc.)
               - 'cpu': Force CPU mode
        verbose: Show progress

    Returns:
        Interpolated video with 2N-1 frames

    TEXT MORPHING EXAMPLE:
        
        >>> t2i=partial(skia_text_to_image,background_color='blue')
        ... video=[t2i('Hello 💁‍♀️'),t2i('Clara 🙋‍♀️'),t2i('and 🐶'),t2i('Emmy 🐶'),t2i('Hello 💁‍♀️')]
        ... video=crop_images_to_max_size(video,origin='center')
        ... for _ in range(4):
        ...     video=slowmo_video_via_rife(video)
        ... display_video(video,loop=True)

    BASIC TEST EXAMPLE:

        >>> test = np.random.rand(5, 64, 64, 3)
        ... result = slowmo_video_via_rife(test, device="cpu", verbose=False)

    """
    rife_executable = _get_rife_executable()
    input_dir, output_dir = make_temporary_folders(2)

    command = [rife_executable, "-i", input_dir, "-o", output_dir]

    if not currently_running_mac():
        # Add a GPU flag to RIFE

        if currently_running_linux():
            _ensure_libvulkan_installed()
        
        #Determine the flag from the args
        if device is None:
            device = select_torch_device(prefer_used=True)
            assert _is_torch_device(device)
        if _is_torch_device(device):
            device = device.index
            assert isinstance(device, int)
        #
        if device == "cpu":
            gpu_flag = "-1"
        elif isinstance(device, int):
            gpu_flag = str(device)
        else:
            raise ValueError(
                "Bad device argument: "
                + repr(device if isinstance(device, (str, int)) else type(device))
            )

        command += ["-g", gpu_flag]

    try:
        save_images(video, input_dir, show_progress=verbose)
        _run_sys_command(*command)
        new_video = load_images(output_dir, show_progress=verbose)
        new_video = as_numpy_array(new_video)
    finally:
        delete_folders(input_dir, output_dir, strict=False)

    # I found this created a duplicate last frame
    new_video = new_video[:-1]
    return new_video



def _crop_images_to_max_or_min_size(*images,origin='top left',criterion=max,copy=True,do_height=True,do_width=True):
    
    images=detuple(images)

    if is_numpy_array(images) or is_torch_tensor(images): #If its an array skip the extra compute
        if copy: return images+0
        else   : return images

    dimensions=[get_image_dimensions(image) for image in images]
    
    if len(set(dimensions))==1:
        #Save a bit of time. If all the image dimensions are the same, we don't need to bother cropping them.
        return list(images)
    #
    heights,widths=zip(*dimensions)
    max_height=criterion(heights) if do_height else None
    max_width =criterion(widths) if do_width else None
    
    images=[crop_image(image,max_height,max_width,origin=origin, copy=copy) for image in images]
    return images

def crop_images_to_max_size(*images,origin='top left',copy=True):
    """
    Makes sure all images have the same height and width
    Does this by adding additional black space around images if needed
    EXAMPLE:
        ans='https://i.ytimg.com/vi/MPV2METPeJU/maxresdefault.jpg https://i.insider.com/5484d9d1eab8ea3017b17e29?width=600&format=jpeg&auto=webp https://s3.amazonaws.com/cdn-origin-etr.akc.org/wp-content/uploads/2017/11/13002248/GettyImages-187066830.jpg https://hips.hearstapps.com/hmg-prod.s3.amazonaws.com/images/best-small-dog-breeds-cavalier-king-charles-spaniel-1598992577.jpg?crop=0.468xw:1.00xh;0.259xw,0&resize=480:*'.split()
        ans=load_images(ans)
        display_image_slideshow(ans)
        print("DI")
        display_image_slideshow(crop_images_to_max_size(ans))
        display_image_slideshow(crop_images_to_max_size(ans,origin='center'))
    """
    return _crop_images_to_max_or_min_size(*images,origin=origin,criterion=max,copy=copy)

def crop_images_to_min_size(*images,origin='top left',copy=True):
    """
    Makes sure all images have the same height and width
    Does this by cropping out the edges of the images if needed
    EXAMPLE:
        ans='https://i.ytimg.com/vi/MPV2METPeJU/maxresdefault.jpg https://i.insider.com/5484d9d1eab8ea3017b17e29?width=600&format=jpeg&auto=webp https://s3.amazonaws.com/cdn-origin-etr.akc.org/wp-content/uploads/2017/11/13002248/GettyImages-187066830.jpg https://hips.hearstapps.com/hmg-prod.s3.amazonaws.com/images/best-small-dog-breeds-cavalier-king-charles-spaniel-1598992577.jpg?crop=0.468xw:1.00xh;0.259xw,0&resize=480:*'.split()
        ans=load_images(ans)
        display_image_slideshow(ans)
        print("DI")
        display_image_slideshow(crop_images_to_min_size(ans))
        display_image_slideshow(crop_images_to_min_size(ans,origin='center'))
    """
    return _crop_images_to_max_or_min_size(*images,origin=origin,criterion=min,copy=copy)

def crop_images_to_max_height(*images,origin='top left',copy=True):
    """ Crop all given images to the maximum height of all given images using the same extra args as seen in rp.crop_image """
    return _crop_images_to_max_or_min_size(*images,origin=origin,criterion=max,copy=copy,do_width=False)

def crop_images_to_max_width(*images,origin='top left',copy=True):
    """ Crop all given images to the maximum width of all given images using the same extra args as seen in rp.crop_image """
    return _crop_images_to_max_or_min_size(*images,origin=origin,criterion=max,copy=copy,do_height=False)

def crop_images_to_min_height(*images,origin='top left',copy=True):
    """ Crop all given images to the minimum height of all given images using the same extra args as seen in rp.crop_image """
    return _crop_images_to_max_or_min_size(*images,origin=origin,criterion=min,copy=copy,do_width=False)

def crop_images_to_min_width(*images,origin='top left',copy=True):
    """ Crop all given images to the minimum width of all given images using the same extra args as seen in rp.crop_image """
    return _crop_images_to_max_or_min_size(*images,origin=origin,criterion=min,copy=copy,do_height=False)

def crop_image_to_square(image, *, origin="center", grow=False, copy=True):
    """
    Crops an image so that it becomes square.
    If grow==True, the image can become larger instead of smaller
        (this means it pads the image with black transparent pixels)
    """
    assert is_image(image)
    
    if grow:
        size = max(get_image_dimensions(image))
    else:
        size = min(get_image_dimensions(image))
        
    image = crop_image(image, height=size, width=size, origin=origin, copy=copy)
    
    return image

def crop_images_to_square(images, *, origin="center", grow=False, copy=True):
    """
    TODO: Optimize me!
    """
    output = [crop_image_to_square(image, origin=origin, grow=grow, copy=copy) for image in images]
    if is_numpy_array(images):
        output = as_numpy_array(output)
    return output

def crop_image_at_random_position(image, height, width, include_position=False):
    """
    Returns a randomly-positioned cropped version of the input image with the specified height and width.
    
    This function is useful for data augmentation, as it can create multiple different crops from the same image,
    increasing the variability in the training dataset and helping the model generalize better.
    
    Parameters:
        image : numpy.ndarray
            The input image, as defined by rp.is_image
        height : int
            The height of the cropped image.
        width : int
            The width of the cropped image.
        include_position : bool
            If true, will instead output a tuple (cropped_image, (x, y))

    Returns:
        cropped_image : numpy.ndarray
            A randomly-positioned cropped image of the original
        
    Raises:
        ValueError
            If the crop dimensions are larger than the original image dimensions.
        
    Examples:
        >>> img = np.random.randint(0, 256, size=(300, 400, 3), dtype=np.uint8)
        >>> cropped_img = crop_image_at_random_position(img, 100, 100)
        >>> cropped_img.shape
        (100, 100, 3)
        
        >>> cropped_img = crop_image_at_random_position(img, 200, 200)
        >>> cropped_img.shape
        (200, 200, 3)
    """
    assert is_image(image)
    
    img_height, img_width = get_image_dimensions(image)
    
    if height > img_height or width > img_width:
        raise ValueError("Crop dimensions must be smaller than or equal to the original image dimensions.")
    
    y = randint(0, img_height - height)
    x = randint(0, img_width  - width)
    
    cropped_image = image[y:y + height, x:x + width]
    
    if include_position:
        return cropped_image, (x,y)

    return cropped_image

def get_random_crop_bounds(image_dimensions, crop_dimensions):
    """
    Generate random bounds for cropping an image or any n-dimensional array.
    
    Parameters:
        image_dimensions : tuple of int
            The dimensions of the original image or array.
            Can be any number of dimensions (e.g., height, width, time, etc.).
        crop_dimensions : tuple of int
            The dimensions of the crop area.
            Must have the same number of dimensions as image_dimensions.
    
    Returns:
        tuple of tuples
            A tuple of tuples representing the bounds of the crop area.
            Each inner tuple contains (start, end) indices for the corresponding dimension.
    
    Example:
        >>> # 2D image
        >>> height, width = 100, 200
        >>> image = np.random.rand(height, width, 3)  # Dummy image
        >>> crop_dimensions = crop_height, crop_width = 50, 80
        >>> (top, bottom), (left, right) = get_random_crop_bounds((height, width), crop_dimensions)
        >>> # Possible ranges:
        >>> #   top: 0 to 50 (inclusive)
        >>> #   bottom: top + 50, i.e., 50 to 100 (inclusive)
        >>> #   left: 0 to 120 (inclusive)
        >>> #   right: left + 80, i.e., 80 to 200 (inclusive)
        >>> cropped_image = image[top:bottom, left:right]
        
        >>> # 3D image (e.g., video)
        >>> height, width, time = 200, 300, 100
        >>> video = np.random.rand(height, width, time)  # Dummy video
        >>> crop_dimensions = crop_height, crop_width, crop_time = 120, 200, 50
        >>> (top, bottom), (left, right), (start_time, end_time) = get_random_crop_bounds((height, width, time), crop_dimensions)
        >>> # Possible ranges:
        >>> #   top: 0 to 80 (inclusive)
        >>> #   bottom: top + 120, i.e., 120 to 200 (inclusive)
        >>> #   left: 0 to 100 (inclusive)
        >>> #   right: left + 200, i.e., 200 to 300 (inclusive)
        >>> #   start_time: 0 to 50 (inclusive)
        >>> #   end_time: start_time + 50, i.e., 50 to 100 (inclusive)
        >>> cropped_video = video[top:bottom, left:right, start_time:end_time]
    """
    from random import randint

    if len(image_dimensions) != len(crop_dimensions):
        raise ValueError("The number of dimensions in image_dimensions and crop_dimensions must be the same.")

    if any(crop_dim > img_dim for crop_dim, img_dim in zip(crop_dimensions, image_dimensions)):
        raise ValueError("Crop dimensions must be smaller than or equal to the original image dimensions.")

    bounds = []
    for img_dim, crop_dim in zip(image_dimensions, crop_dimensions):
        start = randint(0, img_dim - crop_dim)
        end = start + crop_dim
        bounds.append((start, end))

    return tuple(bounds)

def get_center_crop_bounds(image_dimensions, crop_dimensions):
    """
    Generate bounds for center cropping an image or any n-dimensional array.
    
    Parameters:
        image_dimensions : tuple of int
            The dimensions of the original image or array.
            Can be any number of dimensions (e.g., height, width, time, etc.).
        crop_dimensions : tuple of int
            The dimensions of the crop area.
            Must have the same number of dimensions as image_dimensions.
    
    Returns:
        tuple of tuples
            A tuple of tuples representing the bounds of the crop area.
            Each inner tuple contains (start, end) indices for the corresponding dimension.
    
    Example:
        >>> while True:
        >>>     image = load_image_from_webcam()
        >>>     crop_size = 100, 100

        >>>     (top, bot), (left, right) = get_center_crop_bounds(
        >>>         get_image_dimensions(image),
        >>>         crop_size,
        >>>     )

        >>>     display_image(
        >>>         # These two images should be exactly the same
        >>>         horizontally_concatenated_images(
        >>>             image[top:bot, left:right],
        >>>             crop_image(image, *crop_size, origin="center"),
        >>>         ),
        >>>     )

    Example:
        >>> # 2D image
        >>> height, width = 100, 200
        >>> image = np.random.rand(height, width, 3)  # Dummy image
        >>> crop_dimensions = crop_height, crop_width = 50, 80
        >>> (top, bottom), (left, right) = get_center_crop_bounds((height, width), crop_dimensions)
        >>> # Expected bounds:
        >>> #   top: 25
        >>> #   bottom: 75
        >>> #   left: 60
        >>> #   right: 140
        >>> cropped_image = image[top:bottom, left:right]
        
        >>> # 3D image (e.g., video)
        >>> height, width, time = 200, 300, 100
        >>> video = np.random.rand(height, width, time)  # Dummy video
        >>> crop_dimensions = crop_height, crop_width, crop_time = 120, 200, 50
        >>> (top, bottom), (left, right), (start_time, end_time) = get_center_crop_bounds((height, width, time), crop_dimensions)
        >>> # Expected bounds:
        >>> #   top: 40
        >>> #   bottom: 160
        >>> #   left: 50
        >>> #   right: 250
        >>> #   start_time: 25
        >>> #   end_time: 75
        >>> cropped_video = video[top:bottom, left:right, start_time:end_time]
    """
    if len(image_dimensions) != len(crop_dimensions):
        raise ValueError("The number of dimensions in image_dimensions and crop_dimensions must be the same.")

    if any(crop_dim > img_dim for crop_dim, img_dim in zip(crop_dimensions, image_dimensions)):
        raise ValueError("Crop dimensions must be smaller than or equal to the original image dimensions.")

    bounds = []
    for img_dim, crop_dim in zip(image_dimensions, crop_dimensions):
        start = (img_dim - crop_dim) // 2
        end = start + crop_dim
        bounds.append((start, end))

    return tuple(bounds)

def trim_video(video,length:int,copy=True,mode='extend'):
    """
    This function takes a video and a length, and returns a video with that length
    If the desired length is longer than the video, additional blank frames will be added to the end
    TODO: Add examples for all use-cases, including:
        -Decreasing video length
        -Increasing video length for lists of images
        -Increasing video length for numpy-array videos
    """
    assert length>=0,'Cannot trim a video to a negative length'
    assert is_numpy_array(video) or isinstance(video, list), 'Only list-videos and numpy-videos are supported right now'

    if len(video)==length and not copy:
        return video
    
    if len(video)>=length:
        return video[:length]

    number_of_extra_frames=length-len(video)

    assert len(video),'Cannot extend a video with no frames - we need an example frame to determine the width and height'
    last_frame=video[-1]
    assert is_image(last_frame)

    # extra_frames=np.asarray([np.zeros_like(last_frame)]*number_of_extra_frames)
    if mode=='zeros':
        zero_frame = np.zeros_like(last_frame)
    elif mode=='extend':
        zero_frame = last_frame
    else:
        assert False,'Invalid mode: '+mode

    extra_frames = np.repeat(zero_frame[None], number_of_extra_frames, axis=0)

    if isinstance(video,list):
        if not length:
            return []
        return list(video)+list(extra_frames)
        
    elif isinstance(video,np.ndarray):
        return np.concatenate((video,np.asarray(extra_frames)))
    
    else:
        raise TypeError('Unsupported video type: %s'%type(video))

def trim_videos(*videos, length: int):
    """Plural of rp.trim_video"""
    videos = detuple(videos)

    output = []
    for video in videos:
        video = trim_video(video, length)
        output.append(video)

    return output

def _trim_videos_to_same_length(*videos,mode=max,copy=True):
    """
    If mode = max, adds blank frames to the end of videos to make sure they're all the same number of frames
    If mode = min, cuts off every video to become the shortest of all lengths
    If possible, returns a numpy array instead of a list
    If copy=False, it might return the original tensor without copying
    """
    videos=detuple(videos)

    if is_numpy_array(videos) or is_torch_tensor(videos): #If its an array skip the extra compute
        if copy: return videos+0
        else   : return videos

    lengths = list(map(len, videos))
    out_length = mode(lengths)
    videos = trim_videos(videos, length=out_length)

    if all(map(is_numpy_array, videos)) and set(x.shape for x in videos)==1:
        #If possible, return a numpy array
        videos = as_numpy_array(videos)

    return videos

def trim_videos_to_max_length(*videos, copy=True):
    return _trim_videos_to_same_length(*videos, mode=max, copy=copy)
    
def trim_videos_to_min_length(*videos, copy=True):
    return _trim_videos_to_same_length(*videos, mode=min, copy=copy)

def _concatenated_videos(image_method,videos,origin):
    videos=detuple(videos)
    videos=[video for video in videos if len(video)] #Exclude videos with no frames
    videos=[crop_images_to_max_size(video,copy=False) for video in videos]
    videos=trim_videos_to_max_length(videos,copy=False)
    output=[image_method(*frames,origin=origin) for frames in zip(*videos)]
    return output
     
def horizontally_concatenated_videos(*videos,origin=None):
    #TODO: Optimize this to not use horizontally_concatenated_images (which is slow)
    return _concatenated_videos(horizontally_concatenated_images,videos,origin=origin)
    
def vertically_concatenated_videos(*videos,origin=None):
    #TODO: Optimize this to not use vertically_concatenated_images (which is slow)
    return _concatenated_videos(vertically_concatenated_images,videos,origin=origin)

def max_filter(image,diameter,single_channel: bool = False,mode: str = 'reflect',shutup: bool = False):
    # NOTE: order refers to the derivative of the gauss curve; for edge detection etc.
    if diameter == 0:
        return image
    mode=mode.lower()
    assert mode in {'constant','nearest','reflect','mirror','wrap'},"r.max_filter: Invalid mode for max-filtering edge-areas of image. mode=" + str(mode)
    # single_channel: IMPORTANT: This determines the difference between
    #       [1,2,3,4,5]
    #  and
    #       [[1],[2],[3],[4],[5]] (when False)
    # Works in RGB, RGBA, or any other number of color channels!
    from scipy.ndimage.filters import maximum_filter as filter
    kernel=flat_circle_kernel(diameter)
    f=lambda x:filter(x,footprint=kernel,mode=mode)
    tp=np.transpose
    sh=np.shape(image)
    assert isinstance(sh,tuple)
    if not single_channel and not sh[-1] <= 4 and not shutup:  # Generally if you have more than 4 channels you are using a single_channel image.
        fansi_print("r.med_filter: Warning: Last channel has length of " + str(sh[-1]) + "; you results might be weird. Consider setting optional parameter 'single_channel' to True?",'red')
    s=list(range(len(sh)))
    if len(s) == 1 or single_channel:  # We don't have channels of colors, we only have 1 color channel (AKA we extracted the red of an image etc)
        return f(image)

    #        ⎛                                                                     ⎞
    #        ⎜⎛                                              ⎞                     ⎟
    #        ⎜⎜                ⎛                            ⎞⎟                     ⎟
    #        ⎜⎜                ⎜      ⎛     ⎞       ⎛      ⎞⎟⎟     ⎛     ⎞   ⎛    ⎞⎟
    return tp([f(x) for x in tp(image,[s[-1]] + list(s[:-1]))],list(s[1:]) + [s[0]])  # Blur each channel individually.
    #        ⎜⎜                ⎜      ⎝     ⎠       ⎝      ⎠⎟⎟     ⎝     ⎠   ⎝    ⎠⎟
    #        ⎜⎜                ⎝                            ⎠⎟                     ⎟
    #        ⎜⎝                                              ⎠                     ⎟
    #        ⎝                                                                     ⎠

    # NOTE:
    #     >>> _s=(0,1,2)
    #     >>> [_s[-1]] + list(_s[:-1])
    # ans=[2,0,1]
    #     >>> list(_s[1:]) + [_s[0]]
    # ans=[1,2,0]

def min_filter(image,diameter,single_channel: bool = False,mode: str = 'reflect',shutup: bool = False):
    # NOTE: order refers to the derivative of the gauss curve; for edge detection etc.
    if diameter == 0:
        return image
    mode=mode.lower()
    assert mode in {'constant','nearest','reflect','mir3ror','wrap'},"r.min_filter: Invalid mode for min-filtering edge-areas of image. mode=" + str(mode)
    # single_channel: IMPORTANT: This determines the difference between
    #       [1,2,3,4,5]
    #  and
    #       [[1],[2],[3],[4],[5]] (when False)
    # Works in RGB, RGBA, or any other number of color channels!
    from scipy.ndimage.filters import minimum_filter as filter
    kernel=flat_circle_kernel(diameter)
    f=lambda x:filter(x,footprint=kernel,mode=mode)
    tp=np.transpose
    sh=np.shape(image)
    assert isinstance(sh,tuple)
    if not single_channel and not sh[-1] <= 4 and not shutup:  # Generally if you have more than 4 channels you are using a single_channel image.
        fansi_print("r.med_filter: Warning: Last channel has length of " + str(sh[-1]) + "; you results might be weird. Consider setting optional parameter 'single_channel' to True?",'red')
    s=list(range(len(sh)))
    if len(s) == 1 or single_channel:  # We don't have channels of colors, we only have 1 color channel (AKA we extracted the red of an image etc)
        return f(image)

    #        ⎛                                                                     ⎞
    #        ⎜⎛                                              ⎞                     ⎟
    #        ⎜⎜                ⎛                            ⎞⎟                     ⎟
    #        ⎜⎜                ⎜      ⎛     ⎞       ⎛      ⎞⎟⎟     ⎛     ⎞   ⎛    ⎞⎟
    return tp([f(x) for x in tp(image,[s[-1]] + list(s[:-1]))],list(s[1:]) + [s[0]])  # Blur each channel individually.
    #        ⎜⎜                ⎜      ⎝     ⎠       ⎝      ⎠⎟⎟     ⎝     ⎠   ⎝    ⎠⎟
    #        ⎜⎜                ⎝                            ⎠⎟                     ⎟
    #        ⎜⎝                                              ⎠                     ⎟
    #        ⎝                                                                     ⎠

    # NOTE:
    #     >>> _s=(0,1,2)
    #     >>> [_s[-1]] + list(_s[:-1])
    # ans=[2,0,1]
    #     >>> list(_s[1:]) + [_s[0]]
    # ans=[1,2,0]

def med_filter(image,diameter,single_channel: bool = False,mode: str = 'reflect',shutup: bool = False):
    # NOTE: order refers to the derivative of the gauss curve; for edge detection etc.
    if diameter == 0:
        return image
    mode=mode.lower()
    assert mode in {'constant','nearest','reflect','mirror','wrap'},"r.med_filter: Invalid mode for med-filtering edge-areas of image. mode=" + str(mode)
    # single_channel: IMPORTANT: This determines the difference between
    #       [1,2,3,4,5]
    #  and
    #       [[1],[2],[3],[4],[5]] (when False)
    # Works in RGB, RGBA, or any other number of color channels!
    from scipy.ndimage.filters import median_filter as filter
    kernel=flat_circle_kernel(diameter)
    f=lambda x:filter(x,footprint=kernel,mode=mode)
    tp=np.transpose
    sh=np.shape(image)
    assert isinstance(sh,tuple)
    if not single_channel and not sh[-1] <= 4 and not shutup:  # Generally if you have more than 4 channels you are using a single_channel image.
        fansi_print("r.med_filter: Warning: Last channel has length of " + str(sh[-1]) + "; you results might be weird. Consider setting optional parameter 'single_channel' to True?",'red')
    s=list(range(len(sh)))
    if len(s) == 1 or single_channel:  # We don't have channels of colors, we only have 1 color channel (AKA we extracted the red of an image etc)
        return f(image)

    #        ⎛                                                                     ⎞
    #        ⎜⎛                                              ⎞                     ⎟
    #        ⎜⎜                ⎛                            ⎞⎟                     ⎟
    #        ⎜⎜                ⎜      ⎛     ⎞       ⎛      ⎞⎟⎟     ⎛     ⎞   ⎛    ⎞⎟
    return tp([f(x) for x in tp(image,[s[-1]] + list(s[:-1]))],list(s[1:]) + [s[0]])  # Blur each channel individually.
    #        ⎜⎜                ⎜      ⎝     ⎠       ⎝      ⎠⎟⎟     ⎝     ⎠   ⎝    ⎠⎟
    #        ⎜⎜                ⎝                            ⎠⎟                     ⎟
    #        ⎜⎝                                              ⎠                     ⎟
    #        ⎝                                                                     ⎠

    # NOTE:
    #     >>> _s=(0,1,2)
    #     >>> [_s[-1]] + list(_s[:-1])
    # ans=[2,0,1]
    #     >>> list(_s[1:]) + [_s[0]]
    # ans=[1,2,0]

def range_filter(image,diameter,single_channel: bool = False,mode: str = 'reflect',shutup: bool = False):
    args=image,diameter,single_channel,mode,shutup
    return max_filter(*args) - min_filter(*args)

def grid2d(width: int,height: int,fᆢrowˏcolumn=lambda r,c:None) -> list:
    from copy import deepcopy
    # Perhaps I'll make a future version that extends this to n-dimensions, like rmif in MatLab
    out=deepcopy_multiply([[[None]] * height],width)
    for column in range(height):
        for row in range(width):
            out[row][column]=fᆢrowˏcolumn(row,column)
    return out

def grid2d_map(grid2d_input,value_func=identity) -> list:
    # Similar to rmvf (ryan matrix value function), except restricted to just 2d grids.
    def width(image) -> int:
        return len(image)
    def height(image) -> int:
        return len(image[0])
    #            ⎧                                                                                  ⎫
    #            ⎪                                                              ⎧                  ⎫⎪
    #            ⎪     ⎧            ⎫       ⎧            ⎫                      ⎪            ⎧ ⎫⎧ ⎫⎪⎪
    return grid2d(width(grid2d_input),height(grid2d_input),lambda x,y:value_func(grid2d_input[x][y]))
    #            ⎪     ⎩            ⎭       ⎩            ⎭                      ⎪            ⎩ ⎭⎩ ⎭⎪⎪
    #            ⎪                                                              ⎩                  ⎭⎪
    #            ⎩                                                                                  ⎭

def _auto_interp_for_resize_image(resize_func, image, new_size):
    """
    A private function used by image resizing functions in rp when their interp=='auto'

    'area' interpolation is good for shrinking images
    'bilinear' interpolation is good for growing images
    This function lets you automatically choose the best interp method.
    If one dimension grows whereas the other dimension shrinks, we should use two resizings: one for 'bilinear' and the other 'area'
    
    resize_func: is expected to be an image resizing function from rp, such as rp.resize_image, rp.cv_resize_image or rp.torch_resize_image
                 it should be able to handle sizes such as (None, width) to indicate one dimension should be unchanged
                 This function is meant to be called by those functions, when the interp method is 'auto'
    image      : should be an image type compatible with rp.get_image_dimensions and the given resize_func
    new_size   : is expected to be a tuple containing two integers (height, width)

    EXAMPLE:
        >>> def demo_resize_interp(interp='auto'):
        ...     import numpy as np
        ...     import time
        ...     
        ...     # Assuming get_checkerboard_image returns an image and other functions are available
        ...     c = get_checkerboard_image(128,128)
        ...     original_height = get_image_height(c)
        ...     original_width = get_image_width(c)
        ...     frames = 360  # Total number of frames for a smoother and longer animation
        ...     start_angle = np.pi / 4  # Starting at 45 degrees
        ...     max_multiplier = 3  # Maximum size multiplier
        ...
        ...     for _ in range(2):    
        ...         for frame in range(frames):
        ...             # Calculate the new dimensions using sine and cosine with a 45-degree offset
        ...             # Amplitude is adjusted to vary directly from 1 to max_multiplier times the original size
        ...             new_height = int((np.sin(2 * np.pi * frame / frames + start_angle) + 1) / 2 * (max_multiplier - 1) * original_height + 1)
        ...             new_width = int((np.cos(2 * np.pi * frame / frames + start_angle) + 1) / 2 * (max_multiplier - 1) * original_width + 1)
        ...         
        ...             # Resize and display the image
        ...             
        ...             resized_image = cv_resize_image(c, (new_height, new_width), interp=interp)
        ...             #resized_image = resize_image(c, (new_height, new_width), interp=interp)
        ...             
        ...             resized_image = crop_image(resized_image, height=300, width=300)
        ...             resized_image=blend_images((0,.25,.5),resized_image)
        ...             resized_image=labeled_image(resized_image,interp,background_color=(0,64,128))
        ...             resized_image=shift_image_hue(resized_image,len(interp)/6) #Different background colors so we can easily see when interp changes
        ...             display_image(resized_image)
        ...
        ... demo_resize_interp('auto')
        ... demo_resize_interp('nearest')
        ... demo_resize_interp('bilinear')
        ... demo_resize_interp('area')
    """

    assert isinstance(new_size, tuple) and len(new_size) == 2 and all(isinstance(x,int) for x in new_size)
    old_size = get_image_dimensions(image)

    old_height, old_width = old_size
    new_height, new_width = new_size

    growth_interp = "bilinear"
    shrink_interp = "area"

    out = image

    #In the case that both dimensions grow, or both dimensions shrink, we only need to use the resize_func once
    if   new_height == old_height and new_width == old_width: out = out + 0
    elif new_height >= old_height and new_width >= old_width: out = resize_func(out, size=(new_height, new_width), interp=growth_interp)
    elif new_height <= old_height and new_width <= old_width: out = resize_func(out, size=(new_height, new_width), interp=shrink_interp)
    else:
        #In the case that one dimension grows and the other shrinks, we use the resize_func twice to use both interp methods
        #Do the potential shrink operation first then the growth operation for speed's sake

        if new_height < old_height: out = resize_func(out, size=(new_height, None     ), interp=shrink_interp)
        if new_width  < old_width : out = resize_func(out, size=(None      , new_width), interp=shrink_interp)

        if new_height > old_height: out = resize_func(out, size=(new_height, None     ), interp=growth_interp)
        if new_width  > old_width : out = resize_func(out, size=(None      , new_width), interp=growth_interp)

    return out

def _resize_image_via_skimage(image,scale,interp='bilinear'):
    """Resize using scikit-image (slower but handles more cases)."""
    assert is_image(image)
    pip_import("skimage")
    from skimage.transform import resize
    if not isinstance(scale,tuple):
        height,width=image.shape[:2]
        height=int(height*scale)
        width =int(width *scale)
    else:
        height,width=scale

        if not height or not width:
            #If the user specifies (100,None) it means to rescale the image to a height of 100, and scale the width proportionally
            from math import ceil
            assert height or width
            if not height: height=ceil(get_image_height(image)/get_image_width (image)*width )
            if not width : width =ceil(get_image_width (image)/get_image_height(image)*height)

    order={'nearest':0,'bilinear':1,'bicubic':3}[interp]
    return resize(image,(height,width),order=order)

def resize_image(image,scale,interp='bilinear'):
    """
    resize_image resizes images. Who woulda thunk it? Stretchy-squishy image resizing!
    Now uses cv_resize_image by default (faster). Falls back to skimage if needed.
    :param image: a numpy array, preferably. But it can also handle pure-python list-of-lists if that fails.
    :param scale: can either be a scalar (get it? for SCALE? lol ok yeah that died quickly) or a tuple of integers to specify the new dimensions we want like (128,128)
    :param interp: ONLY APPLIES FOR numpy arrays! interp ∈ {'auto','nearest','bilinear','bicubic','cubic'}
    :return: returns the resized image
    """
    if interp=='auto': interp='bilinear'
    assert interp in {'nearest','bilinear','bicubic'}
    if scale == 1:
        return image
    
    # Try fast OpenCV first
    try:
        return cv_resize_image(image, scale, interp)
    except Exception:
        pass
    
    # Fall back to skimage
    try:
        return _resize_image_via_skimage(image, scale, interp)
    except Exception:
        pass
    
    # Last resort - pure Python (slowest)
    if is_number(scale):
        return grid2d(int(len(image) * scale),int(len(image[0]) * scale),lambda x,y:image[int(x / scale)][int(y / scale)])
# endregion
# region  xyrgb lists ⟷ image:［image_to_xyrgb_lists，xyrgb_lists_to_image，xyrgb_normalize，image_to_all_normalized_xy_rgb_training_pairs，extract_patches］     (Invertible Pair)

# try:from sklearn.feature_extraction.image import extract_patches
# except Exception:pass


def xy_float_images(
    height=256,
    width=256,
    *,
    min_x=0,
    max_x=1,
    min_y=0,
    max_y=1
):
    """
    Returns a pair of grayscale images: x, y
    Where they increase from 0 to 1 on that axis
    
    Args:
        height (int): Height of the output images.
        width (int): Width of the output images.
        min_x, max_x, min_y, max_y (float, optional): The ranges of x and y in the output. Defaults to between 0 and 1.
    
    Returns:
        np.ndarray: A tensor of shape (2, height, width) representing the x and y images

    EXAMPLES:
         >>> #Radius example
         >>> distance = (((2*xy_float_images()-1)**2).sum(0))**.5
         >>> display_image(distance)
         
         >>> #Animation example
         >>> for angle in range(360):
         >>>    angle*=tau/360
         >>>    x,y=2 * xy_float_images(height=256,width=256) - 1
         >>>    rotated=x*np.cos(angle)+y*np.sin(angle)
         >>>    display_image(2*full_range(rotated**5)-1)
    """
    x, y = np.meshgrid(
        np.linspace(min_x, max_x, num=width ),
        np.linspace(min_y, max_y, num=height),
    )
    return np.stack([x,y])

_xy_torch_matrices_cache={}
def xy_torch_matrices(
    height=256,
    width=256,
    *,
    dtype=None,
    device=None,
    min_x=0,
    max_x=1,
    min_y=0,
    max_y=1,
    use_cache=False
):
    """
    Sister function of xy_float_images, but this one uses torch tensors

    Returns a pair of matrices: x, y
    Where they increase from 0 to 1 on that axis
    
    Args:
        height (int): Height of the output images.
        width (int): Width of the output images.
        dtype (torch.dtype, optional): Data type of the output tensors. Defaults to None, corresponding to torch.float32
        device (torch.device, optional): Device to create the output tensors on. Defaults to None, corresponding to "cpu"
        min_x, max_x, min_y, max_y (float, optional): The ranges of x and y in the output. Defaults to between 0 and 1.
        use_cache: Useful when bottlenecked by excessive CPU/GPU transfers (seeing a lot of Tensor.to's in the profiler)
    
    Returns:
        torch.Tensor: A tensor of shape (2, height, width) representing the x and y images.
    
    EXAMPLES:
         >>> #Radius example
         >>> distance = (((2*xy_torch_matrices()-1)**2).sum(0)).sqrt()
         >>> display_image(distance)
         
         >>> #Animation example
         >>> for angle in range(360):
         >>>    angle*=np.pi*2/360
         >>>    x,y=2 * xy_torch_matrices(height=256,width=256) - 1
         >>>    rotated=x*np.cos(angle)+y*np.sin(angle)
         >>>    display_image(2*full_range(rotated**5)-1)
    """

    if use_cache:
        #Prevent excessive transfers between CPU and GPU
        kwargs = gather_vars('height width dtype device min_x max_x min_y max_y')
        kwargs_hash = handy_hash(kwargs)
        if args_hash not in _xy_torch_matrices_cache:
            _xy_torch_matrices_cache[args_hash] = xy_torch_matrices(**kwargs)
        return _xy_torch_matrices_cache[args_hash]

    pip_import('torch')
    import torch

    if dtype  is None: dtype  = torch.float32
    if device is None: device = "cpu"

    y, x = torch.meshgrid(torch.linspace(min_y, max_y, height, dtype=dtype, device=device),
                          torch.linspace(min_x, max_x, width , dtype=dtype, device=device),
                          indexing="ij", #We have to add this for new torch versions
                          )
    return torch.stack([x, y])

def _is_instance_of_module_class(x, module_name: str, class_name: str) -> bool:
    """
    Determines if 'x' (object) is an instance of a class (specified by 'class_name') 
    in a module (specified by 'module_name') efficiently, without importing the module. 
    Ideal for environments where importing large libraries like numpy or torch is costly. 
    Safely returns False, avoiding import errors if the library is not installed.

    Example Usage:
    _is_instance_of_module_class(x,     'numpy',     'ndarray'  ) # Checks if x is a numpy array
    _is_instance_of_module_class(x,     'torch',     'Tensor'   ) # Checks if x is a torch Tensor
    _is_instance_of_module_class(x,     'pandas',    'DataFrame') # Checks if x is a pandas DataFrame
    _is_instance_of_module_class(image, 'PIL.Image', 'Image'    ) # Checks if x is a PIL Image
    """
    if module_name in sys.modules:
        module = sys.modules[module_name]
        class_ = getattr(module, class_name, None)
        return isinstance(x, class_) if class_ is not None else False
    else:
        return False


def is_numpy_array(x):
    """
    Checks if object is NumPy ndarray without requiring numpy import.

    Examples:
        >>> import numpy as np
        >>> is_numpy_array(np.array([1, 2, 3]))
        True
        >>> is_numpy_array([1, 2, 3])  # Regular list
        False

    Tags: numpy, type-check, validation
    """
    return _is_instance_of_module_class(x, 'numpy', 'ndarray')
_is_numpy_array = is_numpy_array #Backwards compatibility with code that expected _is_numpy_array

def is_torch_tensor(x):
    """
    Checks if an object is a PyTorch tensor without requiring torch to be imported.

    Parameters:
        x: Object to test for torch tensor type

    Returns:
        bool: True if x is a PyTorch tensor, False otherwise

    Examples:
        >>> import torch
        >>> tensor = torch.tensor([1, 2, 3])
        >>> is_torch_tensor(tensor)
        True
        >>> is_torch_tensor([1, 2, 3])  # Regular list
        False
        >>> import numpy as np
        >>> is_torch_tensor(np.array([1, 2, 3]))  # NumPy array
        False
        >>> # Works even if torch not imported in current scope:
        >>> is_torch_tensor(some_unknown_object)  # Safe checking

    See also: rp.is_numpy_array, rp.is_torch_image, rp.as_torch_image

    Tags: torch, tensor, type-check, pytorch, validation, isinstance
    """
    return _is_instance_of_module_class(x, 'torch', 'Tensor')

def _is_torch_device(x):
    """
    Checks if an object is a PyTorch device without requiring torch to be imported.
    """
    return _is_instance_of_module_class(x, 'torch', 'device')

def is_torch_image(image):
    """
    Returns True if image could be a CHW torch image
    (Channels-Height-Width). PyTorch typically uses CHW format for images.
    
    Args:
        image: Object to test
        
    Returns:
        bool: True if it's a 3D torch tensor (likely CHW image)
        
    Examples:
        >>> import torch
        >>> tensor_img = torch.zeros(3, 100, 200)  # CHW format
        >>> is_torch_image(tensor_img)
        True
        >>> is_torch_image(torch.zeros(100, 200))  # Not 3D
        False
        
    See also: rp.as_torch_image, rp.as_numpy_image, rp.is_image
        
    Tags: torch, pytorch, image, tensor, validation, CHW
    """
    return is_torch_tensor(image) and image.ndim==3

def is_torch_module(x) -> bool:
    return _is_instance_of_module_class(x, 'torch.nn', 'Module')

def _is_pandas_dataframe(x) -> bool:
    return _is_instance_of_module_class(x, 'pandas', 'DataFrame')

def _is_pandas_series(x) -> bool:
    return _is_instance_of_module_class(x, 'pandas', 'Series')

def _is_pandas_iloc_iterable(x) -> bool:
    return _is_pandas_series(x) or _is_pandas_dataframe(x)

def _is_taichi_field(x) -> bool:
    return _is_instance_of_module_class(x, 'taichi.lang.field', 'Field')

def is_pil_image(image) -> bool:
    """Check if input is a PIL Image instance.
    
    PIL images have limited dtype support compared to NumPy.
    Most RP functions accept PIL but convert to NumPy internally.
    See also: as_pil_image, is_image
    """
    return _is_instance_of_module_class(image, 'PIL.Image', 'Image')

def _is_skia_image(image) -> bool:
    return _is_instance_of_module_class(image, 'skia', 'Image')


def _is_easydict(x) -> bool:
    return _is_instance_of_module_class(x, 'easydict', 'EasyDict')
# region Randomness:［random_index，random_element，random_permutation，randint，random_float，random_chance，random_batch，shuffled，random_parallel_batch］




def random_index(arr_or_len):
    """
    Returns a random index for a given array or array length.

    If the input is a dictionary or a subclass of dict, it returns a random element from the list of values.
    If the input is an integer, it returns a random integer between 0 (inclusive) and the input (exclusive).
    If the input is an array-like object with a __len__ method, it returns a random index within the array.

    :param arr_or_len: The array, array length, or dict-like object.
    :return: A random index or element.
    """
    from random import randint
    
    if isinstance(arr_or_len, dict) or issubclass(type(arr_or_len), dict):
        return random_element(list(arr_or_len.values()))

    if isinstance(arr_or_len, int):
        assert arr_or_len != 0, "Array length cannot be zero."
        return randint(0, arr_or_len - 1)
    
    if has_len(arr_or_len):
        return random_index(len(arr_or_len))
    
    raise TypeError("Input must be an integer, an array with a len, or dict-like object but got type "+str(type(arr_or_len)))

    #OLD VERSION: Works perfectly fine, I just wanted to make the code a bit cleaner to read
    # def random_index(array_length_or_array_itself):
    #     if isinstance(array_length_or_array_itself, dict):
    #         return random_element(list(array_length_or_array_itself))
    #     # Basically a random integer generator suited for generating array indices.
    #     # Returns a random integer ∈ ℤ ⋂ [0‚array_length)
    #     if isinstance(array_length_or_array_itself,int):
    #         assert array_length_or_array_itself != 0
    #         from random import randrange #randrange(0,x) ==== randint(0,x-1)
    #         return randint(0,array_length_or_array_itself - 1)
    #     else:
    #         return random_index(len(array_length_or_array_itself))


def random_element(x):
    """
    Returns a random element from an iterable, dictionary-like, or set-like object.
    
    Parameters:
        x (iterable or Mapping or Set): The collection to choose from.
    
    Returns:
        object: A randomly chosen element from x.
    
    EXAMPLES:
        >>> import numpy as np
        >>> import torch
        >>> import pandas as pd
        >>> from collections import defaultdict, OrderedDict
        >>> from easydict import EasyDict
        >>> 
        >>> assert random_element({'a': 1, 'b': 2}) in [1, 2]
        >>> assert random_element(defaultdict(int, {'a': 1, 'b': 2})) in [1, 2]
        >>> assert random_element(OrderedDict({'a': 1, 'b': 2})) in [1, 2]
        >>> assert random_element(EasyDict({'a': 1, 'b': 2})) in [1, 2]
        >>> assert random_element({1, 2, 3}) in [1, 2, 3]
        >>> assert random_element(frozenset([1, 2, 3])) in [1, 2, 3]
        >>> assert random_element([1, 2, 3]) in [1, 2, 3]
        >>> assert random_element(np.array([1, 2, 3])) in [1, 2, 3]
        >>> assert random_element(torch.tensor([1, 2, 3])) in [1, 2, 3]
        >>> assert random_element(pd.Series([1, 2, 3])) in [1, 2, 3]        
    
    Refactored with GPT4: https://chat.openai.com/share/352d630c-fc34-4f36-b302-e4989c7c98b9

    Original Version:
        def random_element(x):
            if isinstance(x,dict):
                return random_element(list(x.values()))
            if isinstance(x,set):
                x=list(x)
            assert is_iterable(x)
            return x[random_index(len(x))]
    """

    from collections.abc import Mapping, Set, Iterable
    import random

    if isinstance(x, Mapping):
        keys = list(x.keys())
        chosen_key = keys[random_index(keys)]
        return x[chosen_key]

    elif isinstance(x, Set):
        return random_element(list(x))

    elif isinstance(x, Iterable) or has_len(x) and hasattr(x, "__getitem__"):
        index = random_index(x)
        if _is_pandas_iloc_iterable(x):
            x=x.iloc
        return x[index]

    else:
        raise ValueError("Input must be iterable, dictionary-like, or set-like.")





def random_choice(*choices):
    return random_element(choices)

def random_permutation(n) -> list or str:
    """
    Either n is an integer (as a length) OR n is an iterable
    """
    if is_iterable(n):  # random_permutation([1,2,3,4,5]) ⟶ [3, 2, 4, 5, 1]
        return shuffled(n)
    return list(np.random.permutation(n))  # random_permutation(5) ⟶ [3, 2, 1, 4, 0]

def is_a_permutation(permutation):
    """
    A permutation is a list of ints ranging from 0 to len(permutation)-1
    It's used to specify a reordering of an array
    """
    return set(range(len(permutation))) == set(permutation)

def inverse_permutation(permutation):
    """
    Returns the 'undo' of a given permutation
    EXAMPLE:
       a=list(range(100))
       p=random_permutation(100)
       assert a==gather(p,inverse_permutation(p))
    """
    assert is_a_permutation(permutation)
    # Create an empty list of the same length as the permutation
    inverse = [0] * len(permutation)
    # For each index-value pair in the permutation
    for i, p in enumerate(permutation):
        # Set the value of the index at the value in the inverse
        inverse[p] = i
    return inverse


def randint(a_inclusive,b_inclusive=0):
    """Generate a random integer within the specified range(s).
    
    %%%%AAHHH THIS DOCSTRING IS TOOOO LONG
    This function provides flexible random integer generation with automatic 
    range handling. Unlike Python's built-in random.randint, this function 
    automatically handles argument order and has different behavior for 
    single vs double arguments.
    
    Enhanced Documentation:
    
    Usage Patterns:
    - Generate random indices for arrays/lists
    - Create random test data and mock values
    - Simulate dice rolls and game mechanics
    - Generate random IDs or selection choices
    
    Examples:
    >>> # Single argument: random int from [0, a]
    >>> num = randint(10)
    >>> print(f"Random 0-10: {num}")  # e.g., 7
    Random 0-10: 7
    
    >>> # Two arguments: random int from [min(a,b), max(a,b)]
    >>> num = randint(5, 15)
    >>> print(f"Random 5-15: {num}")  # e.g., 12
    Random 5-15: 12
    
    >>> # Arguments in any order
    >>> num1 = randint(20, 10)  # Same as randint(10, 20)
    >>> num2 = randint(10, 20)
    >>> print(f"Both in [10,20]: {num1}, {num2}")
    Both in [10,20]: 18, 14
    
    >>> # Negative ranges work too
    >>> num = randint(-5, 5)
    >>> print(f"Random -5 to 5: {num}")  # e.g., -2
    Random -5 to 5: -2
    
    >>> # Single point range
    >>> num = randint(42, 42)
    >>> print(f"Always 42: {num}")  # Always 42
    Always 42: 42
    
    Args:
        a_inclusive (int): If b_inclusive not provided, upper bound (inclusive)
                          for range [0, a]. If b_inclusive provided, one bound
                          of range [min(a,b), max(a,b)]
        b_inclusive (int, optional): Second bound for range. Defaults to 0.
                                   When provided, creates range [min(a,b), max(a,b)]
        
    Returns:
        int: Random integer within the specified range (inclusive bounds)
        
    Related Functions:
    - random_int: Alias for this function (identical)
    - randints: Generate multiple random integers
    - randint_complex: Generate random complex numbers with integer components
    - random_float: Generate random floating-point numbers
    - random_index: Generate random array index
    
    Implementation Notes:
    - Uses Python's random.randint internally
    - Automatically sorts bounds using min/max
    - Single argument: generates [0, a] not [0, a) like some systems
    - Both bounds are inclusive (different from some random functions)
    
    Original docstring:
    If both a and b are specified, the range is inclusive, choose from range［a，b] ⋂ ℤ
    Otherwise, if only a is specified, choose random element from the range ［a，b) ⋂ ℤ
    
    Tags: random, integer, range, inclusive, generation, dice, selection
    """
    from random import randint
    return randint(min([a_inclusive,b_inclusive]),max([a_inclusive,b_inclusive]))
random_int=randint

def randints(N,a_inclusive=99,b_inclusive=0):
    """
    Generate N random integers
    Example: randints(10)   ====   [9, 36, 82, 49, 13, 9, 62, 81, 80, 66]
    This function exists for convenience when using pseudo_terminal (wasn't really meant for use in long-term code, though it totally could be)
    """
    assert N>=0 and N==int(N),'Cannot have a non-counting-number length: N='+repr(N)
    out=[randint(a_inclusive,b_inclusive) for _ in range(N)]
    try:out=np.asarray(out)#Do this IFF we have numpy for convenience's sake
    except Exception:pass
    return out
random_ints=randints

def randint_complex(*args,**kwargs):
    """
    Arguments passed to this function are passed to 'randint'
    The only difference between this function and randints is that this also generates a complex component
    EXAMPLE:
     >>> randints_complex(100)
    ans = 56.+64.j
     >>> randint_complex(1)
    ans = (1+1j)
     >>> randint_complex(1)
    ans = 0j
     >>> randint_complex(1)
    ans = (1+0j)
     >>> randint_complex(1)
    ans = 0j
    """
    return randint(*args,**kwargs)+randint(*args,**kwargs)*1j
random_int_complex=randint_complex

def randints_complex(*args,**kwargs):
    #Arguments passed to this function are passed to 'randints'
    #The only difference between this function and randints is that this also generates a complex component
    #EXAMPLE:
    # >>> randints_complex(10)
    # ans = [56.+64.j 61. +9.j 58.+42.j 93.+71.j 67.+57.j 67.+67.j 24. +3.j 14.+98.j 92.+96.j 32.+29.j]
    return randints(*args,**kwargs)+randints(*args,**kwargs)*1j
random_ints_complex=randints_complex

def random_float(exclusive_max: float = 1,inclusive_min=0) -> float:
    inclusive_min,exclusive_max=sorted([inclusive_min,exclusive_max])
    return (random.random())*(exclusive_max-inclusive_min)+inclusive_min

def random_float_complex(exclusive_max: float = 1,inclusive_min=0) -> float:
    return random_float(exclusive_max=exclusive_max,inclusive_min=inclusive_min)+1j*random_float(exclusive_max=exclusive_max,inclusive_min=inclusive_min)

def random_floats(N,exclusive_max=1,inclusive_min=0):
    """
    Generate N uniformly distributed random floats
    Example: random_floats(10)   ====   [0.547 0.516 0.421 0.698 0.732 0.885 0.947 0.668 0.857 0.237]
    This function exists for convenience when using pseudo_terminal (wasn't really meant for use in long-term code, though it totally could be)
    """
    assert N>=0 and N==int(N),'Cannot have a non-counting-number length: N='+repr(N)
    inclusive_min,exclusive_max=sorted([inclusive_min,exclusive_max])
    try:return (np.random.rand(N))*(exclusive_max-inclusive_min)+inclusive_min#Do this IFF we have numpy for convenience's sake
    except Exception:pass
    return [random_float(a_inclusive,b_inclusive) for _ in range(N)]

def random_floats_complex(*args,**kwargs):
    """
    Arguments passed to this function are passed to 'random_floats'
    The only difference between this function and randints is that this also generates a complex component
    EXAMPLE:
    >>> random_floats_complex(10)
    ans = [0.611+0.569j 0.371+0.036j 0.469+0.336j 0.615+0.069j 0.329+0.16j  0.896+0.22j  0.22 +0.668j 0.901+0.741j 0.827+0.937j 0.619+0.513j]
    >>> random_floats_complex(10,-1)
    ans = [-0.504-0.998j -0.668-0.345j -0.104-0.952j -0.532-0.019j -0.949-0.488j -0.02 -0.82j  -0.805-0.194j -0.021-0.287j -0.708-0.231j -0.152-0.159j]
    >>> random_floats_complex(10,-1,0)
    ans = [-0.433-0.792j -0.71 -0.633j -0.395-0.383j -0.782-0.336j -0.176-0.176j -0.78 -0.16j  -0.505-0.978j -0.199-0.963j -0.98 -0.456j -0.231-0.775j]
    >>> random_floats_complex(10,-1,1)
    ans = [-0.139-0.101j  0.84 -0.259j  0.347+0.632j -0.362+0.036j  0.002-0.942j -0.685+0.176j  0.852-0.988j  0.188-0.134j  0.011-0.434j -0.578-0.883j]
    >>> random_floats_complex(10,0,100)
    ans = [40.909+10.029j 51.376+61.357j 15.713+25.714j 99.301+76.956j  5.253+21.822j  8.723+75.36j  15.964+85.891j 20.968+12.191j 37.997+92.09j  87.132+89.107j]
    """

    return random_floats(*args,**kwargs)+random_floats(*args,**kwargs)*1j

def random_chance(probability: float = .5) -> bool:
    return random_float() < probability

def random_batch(full_list,batch_size: int = None,*,retain_order: bool = False):
    """
    Given an input list, torch tensor, numpy array, dict, etc - get a random subset with a given batch_size.
    If retain_order is True, will keep the original order. 
    Will not choose the same index twice.
    Batch size must be >=0 but <= the length of the input array
    If a dict is given, a dict will be outputted.
    If a numpy array is given, a numpy array will be outputted (it's fast).
    Same for torch tensors.
    If batch_size is not specified, it will use the full length of the input (basically acting like shuffle).

    EXAMPLES:
        >>> random_batch({1:2,3:4,5:6,7:8},2)                   --> {5: 6, 3: 4}
        >>> random_batch({1:2,3:4,5:6,7:8},2)                   --> {3: 4, 7: 8}
        >>> random_batch({1:2,3:4,5:6,7:8},2)                   --> {3: 4, 1: 2}
        >>> random_batch({1:2,3:4,5:6,7:8},2)                   --> {5: 6, 7: 8}
        >>> random_batch({1:2,3:4,5:6,7:8},3,retain_order=True) --> {3: 4, 5: 6, 7: 8}
        >>> random_batch({1:2,3:4,5:6,7:8},3,retain_order=True) --> {1: 2, 5: 6, 7: 8}
        >>> random_batch([1,2,3,4,5],3,retain_order=True)       --> [3, 4, 5]
        >>> random_batch([1,2,3,4,5],3,retain_order=True)       --> [1, 2, 4]
        >>> random_batch([1,2,3,4,5],3,retain_order=False)      --> [5, 2, 1]
        >>> random_batch([1,2,3,4,5],3,retain_order=False)      --> [2, 4, 1]
        >>> random_batch((1,2,3,4,5),3,retain_order=False)      --> [3, 2, 5]
        >>> random_batch({1,2,3,4,5},3,retain_order=False)      --> [4, 3, 2]
        >>> random_batch([1,2,3,4,5],retain_order=False)        --> [4, 2, 5, 3, 1]
        >>> random_batch([1,2,3,4,5],retain_order=True)         --> [1, 2, 3, 4, 5]
        >>> random_batch([1,2,3,4,5],retain_order=True)         --> [1, 2, 3, 4, 5]
        >>> random_batch([1,2,3,4,5],0)                         --> []
        >>> random_batch(np.arange(5),3)                        --> [2 3 4]
        >>> random_batch(np.arange(5),3)                        --> [3 2 1]
        >>> random_batch(torch.arange(5),3)                     --> tensor([2, 1, 3])
        >>> random_batch(torch.arange(5),3)                     --> tensor([4, 3, 2])


    Input conditions, assertions and rCode algebra:
    rCode: Let ⨀ ≣ random_batch ∴
          ⨀ a None b ≣ ⨀ a len a b
          list a ≣ ⨀ a None True
          b ≣ len ⨀ a b
    """

    # Check if the input is a pandas DataFrame by class name and presence of .iloc
    if _is_pandas_dataframe(full_list) or _is_pandas_iloc_iterable(full_list):
        if batch_size is None:
            batch_size = len(full_list)
        assert 0 <= batch_size <= len(full_list), "batch_size must be between 0 and the number of rows in the DataFrame"
        
        # Use random_batch recursively to select random row indices
        random_indices = random_batch(
            range(len(full_list)),
            batch_size,
            retain_order=retain_order,
        )

        return full_list.iloc[random_indices]

    if is_torch_tensor(full_list) or _is_numpy_array(full_list):
        random_indices = random_batch(
            range(len(full_list)),
            batch_size,
            retain_order=retain_order,
        )
        return full_list[random_indices]

    from collections.abc import Mapping, Set, Iterable
    if isinstance(full_list, Mapping):
        #Make it work with dicts too
        x = full_list # It's a dict not a list
        keys = list(x.keys())
        chosen_keys = random_batch(keys,batch_size=batch_size,retain_order=retain_order)
        return {key:x[key] for key in chosen_keys}

    if isinstance(full_list,set):
        full_list=list(full_list)

    # Optimized path for range objects - avoid materializing the entire range
    if isinstance(full_list, range):
        if batch_size is None:
            batch_size = len(full_list)
            if retain_order:
                return list(full_list)  # Return the range as a list
        else:
            assert 0 <= batch_size <= len(full_list), "batch_size == " + str(batch_size) + " ⋀ len(full_list) == " + str(len(full_list)) + "，∴  ¬ (0 <= batch_size <= len﹙full_list﹚)   Explanation: We do not allow duplicates, ∴ we cannot generate a larger batch than we have elements to choose from full_list"
        
        # Generate random indices directly from range parameters
        random_indices = random.sample(range(len(full_list)), batch_size)
        if retain_order:
            random_indices.sort()
        
        # Map indices to actual range values efficiently
        start, stop, step = full_list.start, full_list.stop, full_list.step
        return [start + i * step for i in random_indices]

    if batch_size is None:  # The default if not specified
        # If we don't specify the batch size, assume that we simply want a shuffled version of the full_list
        if retain_order:
            return list(full_list)  # A result of the rCode algebra. This simply speeds up the process.
        batch_size=len(full_list)
    else:
        assert 0 <= batch_size <= len(full_list),"batch_size == " + str(batch_size) + " ⋀ len(full_list) == " + str(len(full_list)) + "，∴  ¬ (0 <= batch_size <= len﹙full_list﹚)   Explanation: We do not allow duplicates, ∴ we cannot generate a larger batch than we have elements to choose from full_list"


    ⵁ=list(range(len(full_list)))  # All possible indices of full_list
    random.shuffle(ⵁ)  # This shuffles the ⵁ array but doesn't return anything
    ⵁ=ⵁ[0:batch_size]
    if retain_order:
        ⵁ.sort()
    return list(full_list[i] for i in ⵁ)



def random_batch_up_to(full_list, max_batch_size=None, retain_order=False):
    """
    Like random batch, but when batch_size is larger than the full_list, the output will only have the length of the input full list
    This behaviour is as opposed to returning an output with the length of batch_size when len(full_list)<batch_size
    """

    if max_batch_size is not None:
        assert isinstance(max_batch_size, int)
        batch_size = min(len(full_list), max_batch_size)
    else:
        batch_size = max_batch_size

    return random_batch(full_list, batch_size, retain_order = retain_order)

def random_batch_with_replacement(full_list, batch_size: int = None, method: str = "balanced"):
    """
    Like random_batch, but it handles batch_size larger than len(full_list) by nicely repeating elements.
    It supports different sampling methods specified by the 'method' argument.

    Args:
        full_list: The list to sample from.
        batch_size: The desired size of the output batch.
        method: The sampling method to use. Defaults to "balanced".
            - "balanced": Perform balanced sampling. The function tries to distribute the elements evenly across the
                          output list by repeating full_list and shuffling the elements. If duplicates are present,
                          the distance between them is constrained by the size of full_list.
            - "independent": Perform independent sampling with replacement. Each element in full_list has an equal probability
                             of being selected in each draw, similar to drawing balls from a hat with replacement.
                             There is no guarantee of duplicates, and if present, the distance between them can be arbitrary.
                             This is the most basic possible type of sampling with replacement.

    Returns:
        A list of size batch_size containing elements sampled from full_list.

    Examples:
        >>> random_batch_with_replacement([1, 2, 3], 10, method="balanced")
        [1, 2, 3, 3, 2, 1, 2, 3, 1, 3]

        >>> random_batch_with_replacement([1, 2, 3, 4, 5], 20, method="balanced")
        [1, 4, 5, 3, 2, 5, 1, 2, 3, 4, 4, 3, 1, 5, 2, 1, 5, 3, 4, 2]

        >>> random_batch_with_replacement([1, 2, 3], 10, method="independent")
        [1, 1, 1, 1, 1, 1, 1, 1, 1, 1]  # Astronomically unlikely, but possible with independent sampling
        [3, 3, 1, 3, 3, 2, 3, 1, 2, 2]  # A more typical result of this code

    Raises:
        ValueError: If an unsupported sampling method is specified.
        AssertionError: If the code reaches an unreachable state.
    """
    full_list = list(full_list)
    assert batch_size >= 0
    assert len(full_list) > 0

    valid_methods = ["balanced", "independent"]

    if method not in valid_methods:
        raise ValueError("Unsupported sampling method: %s. Valid methods are: %s" % (method, ', '.join(valid_methods)))

    if method == "balanced":
        # Perform balanced sampling
        repeats = batch_size // len(full_list)
        remainder = batch_size % len(full_list)

        output = list_flatten(shuffled(full_list) for _ in range(repeats))
        output += random_batch(full_list, remainder)

    elif method == "independent":
        # Perform independent sampling with replacement
        output = [random.choice(full_list) for _ in range(batch_size)]

    else:
        assert False, "This code is unreachable"

    return output

def random_substring(string:str,length:int=None):
    """ Gets a random substring with a given length """
    if length is None:
        #If length isn't given, choose a random length
        length=random_int(0,len(string))

    assert len(string)>=length
    assert length>=0

    index=random_index(len(string)-length+1)
    return string[index:index+length]

def shuffled(l):
    """ Randomly shuffle a copy of a list and return it """
    if isinstance(l,str):  # random_permutation("ABCDE") ⟶ 'EDBCA' special case: if its a string we want a string output, so we can jumble letters in words etc.
        return ''.join(shuffled(list(l)))
    return random_batch(l)  # Due to an r-code identity in random_batch

def random_parallel_batch(*full_lists,batch_size: int = None,retain_order: bool = False):
    # Created for machine learning input/output training-pairs generation.
    # rCode:
    # ⁠⁠⁠⁠       ⎧                                     ⎫
    # ⁠⁠⁠⁠       ⎪   ⎧                                ⎫⎪
    # ⁠⁠⁠⁠       ⎪   ⎪             ⎧                 ⎫⎪⎪
    # ⁠⁠⁠⁠       ⎪   ⎪             ⎪    ⎧       ⎫    ⎪⎪⎪
    #    list(zip(*random_batch(list(zip(*a)),b,c))) ≣ random_parallel_batch(*a,b,c)
    # ⁠⁠⁠⁠       ⎪   ⎪             ⎪    ⎩       ⎭    ⎪⎪⎪
    # ⁠⁠⁠⁠       ⎪   ⎪             ⎩                 ⎭⎪⎪
    # ⁠⁠⁠⁠       ⎪   ⎩                                ⎭⎪
    # ⁠⁠⁠⁠       ⎩                                     ⎭
    # print(parallel_batch(['a','b','c','d'],[1,2,3,4],batch_size=3)) --> [['c', 'b', 'd'], [3, 2, 4]]
    # assert_equality(*full_lists,equality_check=lambda a,b:len(a)==len(b))# All lists ∈ full_lists must have the same length
    # ⁠⁠⁠⁠                        ⎧                                                                               ⎫
    # ⁠⁠⁠⁠                        ⎪    ⎧                         ⎫                                                ⎪
    # ⁠⁠⁠⁠                        ⎪    ⎪     ⎧                  ⎫⎪                                                ⎪
    # ⁠⁠⁠⁠                        ⎪    ⎪     ⎪   ⎧             ⎫⎪⎪                                                ⎪
    batch_indexes=random_batch(list(range(len(full_lists[0]))),batch_size=batch_size,retain_order=retain_order)  # Select random possible indices that will be synchronized across all lists of the output
    # ⁠⁠⁠⁠                        ⎪    ⎪     ⎪   ⎩             ⎭⎪⎪                                                ⎪
    # ⁠⁠⁠⁠                        ⎪    ⎪     ⎩                  ⎭⎪                                                ⎪
    # ⁠⁠⁠⁠                        ⎪    ⎩                         ⎭                                                ⎪
    # ⁠⁠⁠⁠                        ⎩                                                                               ⎭
    # ⁠⁠⁠⁠         ⎧                                                                ⎫
    # ⁠⁠⁠⁠         ⎪   ⎧                                                           ⎫⎪
    # ⁠⁠⁠⁠         ⎪   ⎪              ⎧                                ⎫           ⎪⎪
    # ⁠⁠⁠⁠         ⎪   ⎪              ⎪   ⎧                           ⎫⎪           ⎪⎪
    return list(map(lambda x:tuple(map(lambda i:x[i],batch_indexes)),full_lists))  # Note that batch_indexes is referenced inside a lambda statement that is called multiple times. This is why it is declared as a separate variable above.
    # ⁠⁠⁠⁠         ⎪   ⎪              ⎪   ⎩                           ⎭⎪           ⎪⎪
    # ⁠⁠⁠⁠         ⎪   ⎪              ⎩                                ⎭           ⎪⎪
    # ⁠⁠⁠⁠         ⎪   ⎩                                                           ⎭⎪
    # ⁠⁠⁠⁠         ⎩                                                                ⎭
    # The single-lined return statement shown directly above this line is ≣ to the next 5 lines of code:
    # out=deepcopy_multiply([[]],len(full_lists))
    # for i in batch_indexes:
    #     for j in range(len(out)):
    #         out[j].append(full_lists[j][i])
    # return out



@contextlib.contextmanager
def temporary_random_seed(seed=None):
    """
    A context manager that sets the random seed for the duration of the context block
    using the standard library's random module. If no seed is provided, it does not change
    the random state.

    Parameters:
        seed (int, optional): The seed value to use for generating random numbers.
                              If None, the random state is not altered.

    Example:
        >>> import random
        >>> random.seed(42)
        >>> print("First random number:", random.random())
        >>> with temporary_random_seed(seed=99):
        ...     print("Random number in context:", random.random())
        >>> print("Second random number:", random.random())
        
        # Note how the above acts the same as if there was no context...
        >>> random.seed(42)
        >>> print("First random number:", random.random())
        >>> print("Second random number:", random.random())
        
        OUTPUT:
            First random number: 0.6394267984578837
            Random number in context: 0.40397807494366633
            Second random number: 0.025010755222666936
            First random number: 0.6394267984578837
            Second random number: 0.025010755222666936
    """
    import random
    old_state = random.getstate()
    
    try:
        if seed is not None:
            random.seed(seed)
        yield
    finally:
        random.setstate(old_state)

@contextlib.contextmanager
def temporary_numpy_random_seed(seed=None):
    """
    A context manager that sets the random seed for the duration of the context block
    using NumPy's random module. If no seed is provided, it does not change the random state.

    Parameters:
        seed (int, optional): The seed value to use for generating random numbers.
                              If None, the random state is not altered.

    Example:
        >>> import numpy as np
        >>> np.random.seed(42)
        >>> print("First random number:", np.random.rand())
        >>> with temporary_numpy_random_seed(seed=99):
        ...     print("Random number in context:", np.random.rand())
        >>> print("Second random number:", np.random.rand())
        
        # Note how the above acts the same as if there was no context...
        >>> np.random.seed(42)
        >>> print("First random number:", np.random.rand())
        >>> print("Second random number:", np.random.rand())
        
        OUTPUT:
            First random number: 0.3745401188473625
            Random number in context: 0.6722785586307918
            Second random number: 0.9507143064099162
            First random number: 0.3745401188473625
            Second random number: 0.9507143064099162
    """
    pip_import('numpy')
    
    import numpy as np
    old_state = np.random.get_state()
    
    try:
        if seed is not None:
            np.random.seed(seed)
        yield
    finally:
        np.random.set_state(old_state)


@contextlib.contextmanager
def temporary_torch_random_seed(seed=None):
    """
    A context manager that sets PyTorch's random seed for the duration of the context block.
    If no seed is provided, it does not change the random state.
    
    Parameters:
        seed (int, optional): The seed value to use for generating random numbers.
                              If None, the random state is not altered.
    
    Example:
        >>> import torch
        >>> torch.manual_seed(42)
        >>> print("First random tensor:", torch.rand(2, 2))
        >>> with temporary_torch_random_seed(seed=99):
        ...     print("Random tensor in context:", torch.rand(2, 2))
        >>> print("Second random tensor:", torch.rand(2, 2))
        
        >>> # Note how the above acts the same as if there was no context...
        >>> torch.manual_seed(42)
        >>> print("First random tensor:", torch.rand(2, 2))
        >>> print("Second random tensor:", torch.rand(2, 2))
        
        OUTPUT:
            First random tensor     : tensor([[0.8823, 0.9150], [0.3829, 0.9593]])
            Random tensor in context: tensor([[0.1033, 0.9702], [0.9481, 0.9787]])
            Second random tensor    : tensor([[0.3904, 0.6009], [0.2566, 0.7936]])
            First random tensor     : tensor([[0.8823, 0.9150], [0.3829, 0.9593]])
            Second random tensor    : tensor([[0.3904, 0.6009], [0.2566, 0.7936]])
    
    #WRITTEN BY CLAUDE - MAY 18 - DID NOT TEST EVERYTHING YET (i.e. multi-gpu cuda stress testing)
    """
    import torch
    old_rng_state = torch.get_rng_state()
    old_cuda_rng_state = torch.cuda.get_rng_state() if torch.cuda.is_available() else None
    old_deterministic = torch.backends.cudnn.deterministic
    
    try:
        if seed is not None:
            torch.manual_seed(seed)
            if torch.cuda.is_available():
                torch.cuda.manual_seed_all(seed)
                torch.backends.cudnn.deterministic = True
        yield
    finally:
        torch.set_rng_state(old_rng_state)
        if old_cuda_rng_state is not None and torch.cuda.is_available():
            torch.cuda.set_rng_state(old_cuda_rng_state)
        torch.backends.cudnn.deterministic = old_deterministic
        
def seed_all(seed=None):
    """
    Set random seeds for Python's random, NumPy, and PyTorch.
    
    Parameters:
        seed (int, optional): The seed value to use for all random number generators.
                              If None, does not set any seeds.
    
    Example:
        >>> seed_all(42)  # Sets all random seeds to 42
        >>> import random, numpy as np, torch
        >>> print(random.random(), np.random.rand(), torch.rand(1))
    """
    if seed is not None:

        # Standard library random
        import random
        random.seed(seed)
        
        # NumPy
        try:
            import numpy as np
            np.random.seed(seed)
        except ImportError:
            pass
        
        # PyTorch
        try:
            import torch
            torch.manual_seed(seed)
            if torch.cuda.is_available():
                torch.cuda.manual_seed_all(seed)
            torch.backends.cudnn.deterministic = True
        except ImportError:
            pass
        

@contextlib.contextmanager
def temporary_seed_all(seed=None):
    """
    A context manager that sets all random seeds (Python random, NumPy, and PyTorch)
    for the duration of the context block by combining all other temporary seed context managers.
    If no seed is provided, it does not change any random states.
    
    Parameters:
        seed (int, optional): The seed value to use for all random number generators.
                              If None, the random states are not altered.
    """

    contexts = [temporary_random_seed(seed=seed)]
    
    try:
        import numpy
        contexts+=[temporary_numpy_random_seed(seed=seed)]
    except ImportError:
        pass
    
    try:
        import torch
        contexts+=[temporary_torch_random_seed(seed=seed)]
    except ImportError:
        pass

    with contexts:
        yield

# endregion
# region rant/ranp: ［run_as_new_thread，run_as_new_process］
def run_as_new_thread(func,*args,**kwargs):
    """
    Used when we simply don't need/want all the complexities of the threading module.
    An anonymous thread that only ceases once the def is finished.

    Example:
        >>> @run_as_new_thread
        ... def _():
        ...     for _ in range(5) :
        ...         sleep(1)
        ...         print(_)

        >>> run_as_new_thread(save_image, image, 'image.jpg')
    """
    new_thread=threading.Thread
    new_thread=new_thread(target=func,args=args,kwargs=kwargs)
    new_thread.start()
    return new_thread

def run_as_new_process(func,*args,**kwargs):
    """
    Used when we simply don't need/want all the complexities of the multiprocessing module
    An anonymous process that only ceases once the def is finished.

    Example:
        >>> @run_as_new_process
        ... def _():
        ...     for _ in range(5) :
        ...         sleep(1)
        ...         print(_)

        >>> run_as_new_process(save_image, image, 'image.jpg')
    """
    import multiprocessing as mp
    new_process=mp.Process(target=func,args=args,kwargs=kwargs)
    new_process.start()  # can't tell the difference between start and run
    return new_process

# endregion
def is_valid_url(url:str)->bool:
    """
    Return true iff the url string is syntactically valid

    Args:
        url (str): String to validate as a URL. Must be a string type.
        
    Returns:
        bool: True if URL is syntactically valid, False otherwise.
              Returns False for non-string inputs.
    
    Examples:
        >>> rp.is_valid_url("https://www.google.com")
        True
        >>> rp.is_valid_url("ftp://files.example.com/file.txt")
        True
        >>> rp.is_valid_url("not-a-url")
        False
        >>> rp.is_valid_url("www.google.com")  # Missing scheme
        False
        >>> rp.is_valid_url(None)  # Non-string input
        False
    
    Note:
        - Requires both scheme (i.e. http, https, ftp, etc.) and netloc (domain)
        - Does not validate that URL actually exists or is reachable
        - Based on RFC 3986 URL specification via urllib.parse
    
    """
    from urllib.parse import urlparse
    if not isinstance(url,str):
        return False
    try:
        result=urlparse(url)
        return all([result.scheme, result.netloc])
    except Exception:
        return False

def is_valid_data_uri(data_uri:str)->bool:
    """
    Return true iff the string is a syntactically valid data URI

    Args:
        data_uri (str): String to validate as a data URI. Must be a string type.

    Returns:
        bool: True if data URI is syntactically valid, False otherwise.
              Returns False for non-string inputs.

    Examples:
        >>> rp.is_valid_data_uri("data:image/png;base64,iVBORw0KGgo...")
        True
        >>> rp.is_valid_data_uri("data:video/mp4;base64,AAAAIGZ...")
        True
        >>> rp.is_valid_data_uri("data:audio/wav;base64,UklGRiQA...")
        True
        >>> rp.is_valid_data_uri("https://example.com/image.png")
        False
        >>> rp.is_valid_data_uri("not-a-data-uri")
        False
        >>> rp.is_valid_data_uri(None)  # Non-string input
        False

    Note:
        - Validates the data: scheme and basic structure
        - Does not validate base64 encoding or data integrity
        - Supports any mediatype (image, video, audio, text, application, etc.)

    """
    try:
        _get_uri_mediatype(data_uri)
        return True
    except ValueError:
        return False

def _get_uri_mediatype(data_uri:str)->str:
    """
    Parse and return the mediatype prefix from a data URI.

    Args:
        data_uri (str): Data URI string to parse

    Returns:
        str: Mediatype prefix ('image', 'video', 'audio', 'text', 'application', etc.)

    Raises:
        ValueError if not a valid data URI or on parsing error

    Common mediatype prefixes to check against:
        - 'image'       : image/png, image/jpeg, image/gif, image/webp, image/svg+xml
        - 'video'       : video/mp4, video/webm, video/ogg, video/quicktime
        - 'audio'       : audio/mpeg, audio/wav, audio/ogg, audio/aac, audio/webm
        - 'text'        : text/plain, text/html, text/css, text/javascript, text/csv
        - 'application' : application/json, application/pdf, application/xml, application/zip
        - 'font'        : font/woff, font/woff2, font/ttf, font/otf

    Data URI format: data:[<mediatype>][;encoding],<data>
    Examples:
        - data:image/png;base64,iVBORw0KG... → 'image'
        - data:video/mp4;base64,AAAAIGZ... → 'video'
        - data:audio/wav;base64,UklGRiQA... → 'audio'
        - data:text/plain;charset=utf-8,Hello → 'text'
        - data:application/json;base64,eyJ... → 'application'

    Usage:
        >>> mediatype = _get_uri_mediatype(uri)
        >>> if mediatype == 'image':
        >>>     return load_image(uri)
        >>> elif mediatype == 'video':
        >>>     return load_video(uri)
        >>> elif mediatype == 'audio':
        >>>     return load_sound_file(uri)

    See Also:
        - _guess_mimetype(): Guess mediatype from file path extension
        - is_valid_data_uri(): Check if string is a valid data URI
    """
    import re
    if not isinstance(data_uri, str):
        return None
    try:
        # Data URI format: data:[<mediatype>][;encoding],<data>
        # Match: data:type/subtype[;encoding],data
        match = re.match(r'^data:([a-zA-Z0-9]+)/[a-zA-Z0-9+.-]+[;,]', data_uri)
        if match:
            return match.group(1)  # Return the prefix (e.g., 'image', 'video', 'audio')
        raise ValueError("Not a data URI")
    except Exception:
        raise ValueError("Not a data URI")


def _erase_terminal_line():
    """ erase and go to beginning of line https://stackoverflow.com/questions/5290994/remove-and-replace-printed-items """
    sys.stdout.write('\033[2K\033[1G')

def load_files(
    load_file,
    file_paths,
    *,
    num_threads:int=None,
    show_progress=False,
    strict=True,
    lazy=False,
    buffer_limit=None
):
    """
    Load a list of files with optional multithreading.

    - load_file (function): A function to load a single file. Expected signature: load_file(str) -> any.
    - file_paths (iterable): Paths to the files to be loaded. It's ok if this iterator is slow - paths are loaded concurrently with files.
    - num_threads (int, optional): Number of threads for concurrent loading. Defaults to 32 if set to None. If set to 0, runs on the main thread.
    - show_progress (True, False, 'eta' or 'tqdm'): Whether to show a progress bar. If set to 'tqdm', uses tqdm library. If set to 'eta', uses rp.eta. Defaults to False.
    - strict (True, False, or None): Behavior if a file fails to load. True throws an error, False skips the file, None yields None.

    Yields:
    - any or None: The content of each file, or None if the file fails to load and strict is set to None.

    This function is a generator that yields the loaded files one by one.


    TODO: This function is more powerful than the arg names and function name imply. This could be used for general-purpose parallelism with loading bars and error handling. Make a more generalized version of this function and then re-implement a shorter version of this function that uses it.
    TODO: Make a save_files version of this - big question: can a save_files function be implemented with the load_files function (and more elegantly with the above generalized version?)
    TODO: The eta can't display a specific message like "loading images" etc - it's locked to load_files right now. How can I *elegantly* allow this naming but also allow it to use tqdm? 
    TODO: Make convert_image_files take advantage of this function
    """

    files = _load_files(
        load_file,
        file_paths,
        num_threads,
        show_progress,
        strict,
        buffer_limit,
    )

    if lazy:
        return files
    else:
        return list(files)

def _load_files(
    load_file,
    file_paths,
    num_threads:int=None,
    show_progress=False,
    strict=True,
    buffer_limit=None,
):
    "Helper function for load_files"

    assert strict is True or strict is False or strict is None, "The 'strict' parameter must be set to either True, False, or None."
    assert show_progress in {True, False, "tqdm", "eta"} or isinstance(show_progress, str) and starts_with_any(show_progress, 'eta:'), "The 'show_progress' parameter must be either True, False, or 'tqdm'."
    assert num_threads is None or isinstance(num_threads, int) and num_threads >= 0, "Must have at least 1 thread, or set num_threads=0 to run in the main thread only"
    assert is_iterable(file_paths), 'rp.load_files: file_paths must be iterable, but type(file_paths) is '+str(type(file_paths))
    assert callable(load_file), 'rp.load_files: load_file must be a function that takes a file path and returns a value, but type(load_file) is '+str(type(load_file))

    if num_threads is None:
        # Choose a nice default value
        num_threads = 32 

    SKIP = object()  # Special object indicating a skipped file
    cancelled = None  # Will be set if any thread, including the main thread, throws an error

    # Define progress_func here...
    if show_progress:
        file_paths = list(file_paths)
        assert hasattr(file_paths,"__len__"), "Cannot show progress because file_paths doesnt have a length"
        num_paths = len(file_paths)

    if show_progress == "tqdm":
        pip_import('tqdm') # Ensures tqdm is installed
        from tqdm import tqdm
        pbar = tqdm(total=num_paths)
        def progress_func(action):
            if action == "update":
                pbar.update(1)
            elif action == "done":
                pbar.close()

    elif show_progress == True or isinstance(show_progress, str) and (show_progress=='eta' or show_progress.startswith('eta:')):
        eta_title = 'Loading Files'
        if isinstance(show_progress, str) and show_progress.startswith('eta:'):
            # This is currently-undocumented functionality, used internally in rp. Maybe I'll document it in the future
            eta_title = show_progress[len('eta:'):]
        show_eta = eta(num_paths, title=eta_title)
        num_yielded = 0
        start_time = gtoc()
        def progress_func(action):
            nonlocal num_yielded
            if action == "update":
                num_yielded += 1
                show_eta(num_yielded)
            elif action == "done":
                elapsed_time = gtoc() - start_time
                # _print_status("%s: Done! Did %i items in %.3f seconds"%(eta_title, num_yielded, elapsed_time))#This is here because of the specifics of the eta function we're using to display progress

    else:
        def progress_func(action):
            pass


    def _load_file(path):

        nonlocal cancelled
        if cancelled:
            raise cancelled

        try:
            content = load_file(path)
        except BaseException as e:
            if strict is True:
                cancelled = e
                raise
            elif strict is False:
                content = SKIP
            else:
                assert strict is None
                content = None
        
        progress_func("update")
        
        return content
    
    def skip_filter(iterable):
        return filter(lambda x: x is not SKIP, iterable)

    try:
        if not num_threads:
            # Load all the files in the main thread
            yield from skip_filter(map(_load_file, file_paths))
        else:
            # Load files with multiple threads
            yield from skip_filter(lazy_par_map(
                _load_file,
                file_paths,
                num_threads=num_threads, 
                buffer_limit=buffer_limit,
            ))

        progress_func("done")

    except BaseException as e:
        cancelled = e
        raise



# region  Saving/Loading Images: ［load_image，_load_image_from_url，save_image，save_image_jpg］

def copy_paths_to_clipboard(*paths):
    """ 
    Copies files to the system clipboard, as opposed to just the file paths
    It also copies the path as a string though:
        Basically if you try to paste in a place that only accepts strings it only pastes that string
        But if you paste in let's say finder, it will paste the actual file
    Currently only works on MacOS. TODO: Windows, Linux
    """
    
    paths=detuple(paths)
    if isinstance(paths,str):
        paths=[paths]
    if not all(isinstance(x, str) for x in paths):
        raise ValueError('Not all given paths were strings: '+repr(paths))

    if currently_running_mac():
        
        from subprocess import run
        import sys

        pip_import('AppKit')
        pip_import('Foundation')
        pip_import('objc')

        code = r"""
        #!/bin/python
        from pathlib import Path
        from AppKit import NSPasteboard, NSPasteboardItem, NSPasteboardTypeString
        from Foundation import NSURL
        import objc

        # Expect: 'paths' is a tuple of original strings, and 'resolved_paths' is the same length,
        # each a resolved absolute POSIX path string (validated to exist).
        with objc.autorelease_pool():
            pb = NSPasteboard.generalPasteboard()
            pb.clearContents()

            items = []
            for original_str, resolved_str in zip(paths, resolved_paths):
                # File URL from the resolved absolute path
                url = NSURL.fileURLWithPath_(resolved_str)

                item = NSPasteboardItem.alloc().init()
                # 1) Plain-text string representation (what you typed in)
                item.setString_forType_(original_str, NSPasteboardTypeString)
                # 2) File URL representation for apps that accept files
                #    'public.file-url' expects a URL string (e.g., 'file:///...')
                item.setString_forType_(url.absoluteString(), "public.file-url")

                items.append(item)

            ok = pb.writeObjects_(items)
            if not ok:
                raise RuntimeError("Failed to write items to the pasteboard.")
        """

        # Pre-validate & prepare the resolved absolute paths on the Python side
        # so the exec'd snippet can trust 'resolved_paths'.
        from pathlib import Path
        resolved = []
        for p in paths:
            ap = Path(p).expanduser().resolve()
            if not ap.exists():
                raise FileNotFoundError(ap)
            resolved.append(str(ap))

        command = [
            sys.executable, "-m", "rp", "exec", code,
            "--paths", repr(tuple(paths)),
            "--resolved_paths", repr(tuple(resolved)),
        ]
        
        run(command, check=True)


    elif currently_running_linux():
        #TODO
        raise NotImplementedError

    elif currently_running_windows():
        #TODO
        raise NotImplementedError

def paste_paths_from_clipboard():
    """
    Pastes file paths from the system clipboard that were copied as files (e.g., from Finder).
    Returns a list of file path strings.
    If only a single file was copied, still returns a list with one element.
    If no file paths are in the clipboard, returns an empty list.
    Currently only works on MacOS. TODO: Windows, Linux

    Example:
        >>> # Copy some files in Finder, then:
        >>> paths = paste_paths_from_clipboard()
        >>> paths
        ['/Users/username/Documents/file1.txt', '/Users/username/Documents/file2.txt']
    """

    if currently_running_mac():
        from subprocess import run, PIPE
        import sys

        pip_import('AppKit')
        pip_import('Foundation')
        pip_import('objc')

        code = r"""
        #!/bin/python
        from AppKit import NSPasteboard
        from Foundation import NSURL
        import objc
        import json

        with objc.autorelease_pool():
            pb = NSPasteboard.generalPasteboard()

            # Try to read file URLs from the pasteboard
            # The type we're looking for is "public.file-url"
            urls = pb.readObjectsForClasses_options_([NSURL], None)

            paths = []
            if urls:
                for url in urls:
                    if url.isFileURL():
                        # Convert file URL to path string
                        path = url.path()
                        if path:
                            paths.append(path)

            # Print as JSON so we can easily parse it
            print(json.dumps(paths))
        """

        command = [
            sys.executable, "-m", "rp", "exec", code,
        ]

        result = run(command, check=True, capture_output=True, text=True)

        # Parse the JSON output
        import json
        paths = json.loads(result.stdout.strip())

        return paths

    elif currently_running_linux():
        #TODO
        raise NotImplementedError

    elif currently_running_windows():
        #TODO
        raise NotImplementedError

def _load_animated_image_via_pil(location, *, use_cache=True):
    """
    Load animated images (GIF, PNG/APNG) using PIL/Pillow. Does not require ffmpeg.
    This is the fallback implementation for when load_video fails.
    Uses the same cache as load_video for consistency.
    
    Args:
        location (str): Path to animated image file or URL pointing to animated image.
        use_cache (bool, optional): If True, caches loaded frames for faster
                                   repeated access. Default: True.
    
    Returns:
        numpy.ndarray: Array of RGB image frames with shape (num_frames, height, width, 3) or RGBA (num_frames, height, width, 4) frames if transparency present.
    """
    if path_exists(location):
        location = get_absolute_path(location) #Important for caching
    
    # Use the same cache as load_video
    cache_id = location
    if use_cache and cache_id in _load_video_cache:
        return _load_video_cache[cache_id]

    pip_import('PIL')
    from PIL import Image, ImageSequence

    # Load image from URL or file
    if is_valid_url(location):
        from urllib.request import urlopen
        image = Image.open(urlopen(location))
    else:
        if not file_exists(location):
            raise FileNotFoundError('No such file exists: ' + repr(location))
        image = Image.open(location)

    # Check if any frame has transparency
    has_transparency = False
    for frame in ImageSequence.Iterator(image):
        if (hasattr(frame, 'info') and 'transparency' in frame.info) or \
           (hasattr(frame, 'mode') and 'A' in frame.mode):
            has_transparency = True
            break
    
    # Convert frames, preserving alpha if transparency present
    convert_mode = 'RGBA' if has_transparency else 'RGB'
    frames = [as_numpy_array(frame.convert(convert_mode)) for frame in ImageSequence.Iterator(image)]
    
    frames = as_numpy_array(frames)

    if use_cache:
        _load_video_cache[cache_id] = frames

    return frames

def load_image_from_clipboard():
    """ #Grab an image copied from your clipboard """
    #TODO: Use the "copykitten" library to paste images
    pip_import('PIL')
    from PIL import ImageGrab
    if not (currently_running_windows() or currently_running_mac()):
        raise OSError('load_image_from_clipboard() only works on Mac and Windows right now; sorry. This is because of PIL.')

    if not currently_running_desktop():
        #Shortcut: If we're not in a desktop environment we can't retrieve image from clipboard
        #ImageGrab.grabclipboard() can take up to .1 seconds, so this check saves time
        raise RuntimeError("r.load_image_from_clipboard: Not running in a desktop - not possible to have copied an image")
        
    ans=ImageGrab.grabclipboard()
    if ans is None:
        #No image in clipboard
        raise RuntimeError("r.load_image_from_clipboard: No image detected in clipboard")

    path=temporary_file_path('.png')
    ans.save(path)
    ans=load_image(path)
    delete_file(path)
    return ans

def _paste_from_clipboard():
    """
    Auto-detect clipboard contents and return appropriate type.
    Returns string for text content, image object for image content.
    Prioritizes speed by checking for text first since image loading is slow.
    """
    text_content = string_from_clipboard()
    if text_content:
        #If an image is copied, string will be ''
        return text_content
    
    try:
        image = load_image_from_clipboard()
        if image is not None:
            return image
    except RuntimeError:
        #No image pasted
        pass
    
    return ''

def _copy_image_to_clipboard_via_pyjpgclipboard(image):
    """
    (This function works fine! But it didnt support RGBA images so it's obsolete now)
    
    Takes an image or a path/url to an image and copies that image to your system clipboard. If you're using Ubuntu, you must install xclip. 

    Note that it only operates on RGB Jpg images right now - so alpha channels will be discarded. In the future, this will be fixed and ideally we will save png images by default.

    EXAMPLE:
        >>> ans = get_youtube_video_thumbnail('https://www.youtube.com/watch?v=iu54gTucsiE')
        ... copy_image_to_clipboard(ans)
        ... #Try pasting into photoshop or something

    """
    pip_import('pyjpgclipboard')
    
    if isinstance(image,str):
        image=load_image(image)
    
    temp_image_path=temporary_file_path('jpg')
    try:
        save_image_jpg(image,temp_image_path)
        import pyjpgclipboard
        pyjpgclipboard.clipboard_load_jpg(temp_image_path)
    finally:
        squelch_call(delete_file,temp_image_path)

def _copy_image_to_clipboard_via_copykitten(image):
    """ 
    Copies an image to the system clipboard
    Can handle RGBA images
    https://github.com/Klavionik/copykitten 
    """
    pip_import('copykitten')
    #image=as_byte_image(image)
    image=as_rgba_image(image)
    image=as_pil_image(image)
    
    import copykitten
    copykitten.clear()
    copykitten.copy_image(image.tobytes(), image.width, image.height)

def copy_image_to_clipboard(image,*,backend=None):
    """
    Copies an image to the system clipboard

    EXAMPLE:

        >>> ans = get_youtube_video_thumbnail('https://www.youtube.com/watch?v=iu54gTucsiE')
        ... copy_image_to_clipboard(ans)
        ... #Try pasting into photoshop or something

    """

    if backend is None: backend = 'copykitten'

    if   backend=='copykitten':     _copy_image_to_clipboard_via_copykitten    (image) #Supports RGBA
    elif backend=='pyjpgclipboard': _copy_image_to_clipboard_via_pyjpgclipboard(image) #Only supports RGB
    else: raise ValueError('Invalid backend '+backend)

def _as_rgba_image_if_2_channels(image):
    """
    It is possible to load a PNG that has just two channels (Value + Alpha)
    In this case, we turn it into and RGBA image to make it compatible with everything else
    """

    H, W, C = image.shape

    if C in {3, 4}:
        return image

    if C==2:
        V, A = np.transpose(image,(2,0,1))
        return np.stack([V, V, V, A], axis=2)
    
    raise ValueError("_as_rgba_image_if_2_channels: Expected 2, 3, or 4 channels but got "+str(C))

_load_image_cache={} #Used by all helper funcs: Dict of location -> image
def load_image(location,*,use_cache=False):
    """ 
    Loads an image file or URL, returns a numpy array
    Handles both local file paths and web URLs
    
    Parameters:
        - location (str): File path or URL to image
        - use_cache (bool, optional): Enable caching for faster repeated loads. Default False.
                                      If enabled and loading an image from a URL, downloads to a file
    
    Returns:
        - numpy.ndarray: Image array in HWC format, dtype uint8, values 0-255
                         For RGB images: shape (height, width, 3)
                         For RGBA images: shape (height, width, 4)  
                         For grayscale (rare): shape (height, width) or (height, width, 1)
                            There are some PNG's that do this...
      
    See also:
        - load_images(...): Plural version for batch loading
        - _load_image_from_file(...): Direct file loading (no URL support)
        - _load_image_from_url(...): Direct URL loading
        - load_rgb_image(...): Force RGB conversion (syntactic sugar vs as_rgb_image(load_image(...))
    
    Supported formats (non-exhaustive):
        - Common: .jpg, .jpeg, .png, .gif, .bmp, .tiff, .tif
        - Advanced: .webp, .jxl, .exr, .psd, .tga, .jp2
    
    Examples:
        >>> image = load_image("https://github.com/RyannDaGreat/Diffusion-Illusions/blob/gh-pages/images/emma.png?raw=true", use_cache=True)
        ... display_image(image)
    
    """
    assert isinstance(location,str),'load_image error: location should be a string representing a URL or file path. However, location is not a string. type(location)=='+repr(type(location))+' and location=='+repr(location)

    # Clean delegation to helpers (each handles its own caching)
    if is_valid_data_uri(location):
        # Handle data URIs (e.g., data:image/png;base64,iVBORw0KG...)
        output = _load_image_from_data_uri(location)
    elif is_valid_url(location):
        output = _load_image_from_url(location, use_cache=use_cache)
    else:
        output = _load_image_from_file(location, use_cache=use_cache)

    output = _as_rgba_image_if_2_channels(output)

    return output

def load_rgb_image(location,*,use_cache=False):
    """
    Like load_image, but makes sure there's no alpha channel
    This function is really only here to save you from having to write it out every time
    """
    return as_rgb_image(load_image(location,use_cache=use_cache), copy=False)

class LazyLoadedImages:
    def __init__(self,image_paths:list,*args,**kwargs):
        self.image_paths=image_paths
        self.args=args
        self.kwargs=kwargs
    def __getitem__(self,i):
        image_path=self.image_paths[i]
        return load_image(image_path,*self.args,**self.kwargs)
    def __len__(self):
        return len(self.image_paths)

def load_images(*locations,use_cache=False,show_progress=False,num_threads=None,strict=True):
    """
    Simply the plural form of load_image
    This is much faster than using load_image sequentially because it's multithreaded. I've had performance boosts of up to 8x speed
    This function will throw an error if any one of the images fails to load
    If given a folder as the input path, will load all image files from that folder
    The locations parameter:
        Can be a list    of images: load_images(['img1.png','img2.jpg','img3.bmp'])
        Can be a varargs of images: load_images( 'img1.png','img2.jpg','img3.bmp' )
        Can be a folder  of images: load_images( 'path/to/image/folder' )
    The strict parameter controls what this function should do when an image fails to load. This is useful when loading a folder full of images, some of which might be corrupted.
        If strict==True, this function will throw an error if any one of the images fails to load
        If strict==False, this function will skip any images that fail to load (so you might not have as many images in the output as you did paths in the input)
        If strict==None, this function will replace any images that failed to load with 'None' instead of a numpy array. So the output might look like [image0. image1, image2, None, image4] where image1, image0 etc are numpy arrays
    """

    assert strict in {True, False, None}, 'load_images: The \'strict\' parameter must be set to either True, False, or None. See the documentation for this function to see what that means.'

    if len(locations)==1:
        locations=locations[0]
    if isinstance(locations,str) and is_a_folder(locations):
        locations=get_all_image_files(locations,sort_by='number')
        return load_images(locations,use_cache=use_cache,show_progress=show_progress,strict=strict)
    if isinstance(locations,str):
        locations=[locations]

    if show_progress in ['eta',True]: show_progress='eta:Loading Images'
    return load_files(lambda path:load_image(path,use_cache=use_cache), locations, show_progress=show_progress, strict=strict, num_threads=num_threads)

    """
    #The below code works perfectly fine! But since load_files (implemented much later, and actually based on the below code) has a variable number of threads, it's just a teeny bit faster, and now makes this function more concise

    if show_progress:
        number_of_images_loaded=0
        show_time_remaining=eta(len(locations), title='Loading Images')
        start_time=gtoc()

    cancelled=False

    def _load_image(path):
        # assert isinstance(path,str)
        
        nonlocal cancelled
        if cancelled:
            if isinstance(cancelled,Exception):
                raise cancelled
            else:
                return None

        try:
            image=load_image(path,use_cache=use_cache)
        except Exception as e:
            if strict==True:
                cancelled=e
                raise
            else:
                image=None

        if cancelled:
            return image

        if show_progress:
            nonlocal number_of_images_loaded
            number_of_images_loaded+=1
            show_time_remaining(number_of_images_loaded)
        
        return image

    try:
        assert all(isinstance(x,str) for x in locations)
        images = par_map(_load_image,locations)#This is fast because it's multithreaded

        if strict is False:
            #When strict is False (as opposed to None), we skip any images that failed to load; meaning we exclude them from the output
            images = [image for image in images if image is not None]

    except KeyboardInterrupt:
        cancelled=True
        raise

    if show_progress:
        end_time=gtoc()
        elapsed_time=end_time-start_time
        sys.stdout.write('\033[2K\033[1G')#erase and go to beginning of line https://stackoverflow.com/questions/5290994/remove-and-replace-printed-items
        print('rp.load_images: Done! Loaded %i images in %.3f seconds'%(len(images),elapsed_time))#This is here because of the specifics of the eta function we're using to display progress

    return images
    """


_pdf_to_images_via_pdf2image_cache={}
def _load_images_via_pdf2image(path, *, dpi=200, use_cache=False):
    pip_import('pdf2image')
    import pdf2image
    
    # Handle Cache
    cache_key = handy_hash([path, dpi])
    cache = _pdf_to_images_via_pdf2image_cache
    if use_cache:
        if cache_key not in cache:
            cache[cache_key] = gather_args_recursive_call(use_cache=False)
        return cache[cache_key]
    elif cache_key in cache:
        del cache[cache_key]

    #It has many other options. Not sure when I'll need to expose them though.
    output = pdf2image.convert_from_path(path,dpi=dpi)
    output=as_byte_images(output)
    return output

def load_pdf_as_images(path):
    """ Given a PDF, returns a list of images - one for each page """
    return _load_images_via_pdf2image(path)

def get_pdf_num_pages(path:str)->int:
    """ Given a path to a PDF file, returns the number of pages in it """
    pip_import("pdf2image")
    import pdf2image

    return pdf2image.pdfinfo_from_path(path)["Pages"]

def load_pdf_as_text(path):
    """ Given a PDF, returns the text content as a string """
    return _load_pdf_as_text_via_pdfminer(path)

def _load_pdf_as_text_via_pdfminer(path):
    """ Extract text from PDF using pdfminer.six """
    pip_import('pdfminer')
    from pdfminer.high_level import extract_text
    
    return extract_text(path)

extract_pdf_text = load_pdf_as_text
# pdf_to_text = load_pdf_as_text

def load_docx_as_text(path):
    """ Given a DOCX file, returns the text content as a string """
    pip_import('docx')
    from docx import Document

    doc = Document(path)
    paragraphs = [paragraph.text for paragraph in doc.paragraphs]
    return '\n'.join(paragraphs)

extract_docx_text = load_docx_as_text
# docx_to_text = load_docx_as_text

def _load_image_from_file(file_name, *, use_cache=False):
    """ Can try opencv as a fallback if this ever breaks """
    assert file_exists(file_name),'No such image file exists: '+repr(file_name)
    
    # Normalize path for consistent caching
    file_name = get_absolute_path(file_name, physical=False)
    
    # RP cache pattern
    if use_cache:
        if file_name not in _load_image_cache:
            _load_image_cache[file_name] = _load_image_from_file(file_name, use_cache=False)
        return _load_image_cache[file_name].copy()
    elif file_name in _load_image_cache:
        del _load_image_cache[file_name]

    if get_file_extension(file_name).upper()=='EXR':
        #Imageio doesn't load exr files well consistently across my computers. Sometimes it gives incorrect results because of some glitch in the freeimage library. I don't know why this is...
        #That being said, the load_openexr_image is more versatile anyway...and loads exr files properly
        try             :return load_openexr_image(file_name)
        except Exception:pass

    if get_file_extension(file_name).upper()=='HEIC':
        #Apple photo format - use dedicated HEIC loader
        return _load_image_heic(file_name)

    if get_file_extension(file_name).upper()=='PDF':
        #Assume it's 1 page. Load the image.
        return load_pdf_as_images(file_name)[0]

    if is_video_file(file_name):
        return load_video(file_name, length=1, show_progress=False)[0]

    try               :return _load_image_from_file_via_imageio(file_name)#Imageio will not forget the alpha channel when loading png files
    except Exception  :pass #Don't cry over spilled milk...if imageio didn't work we'll try the other libraries.
    try               :return _load_image_from_file_via_scipy  (file_name)#Expecting that scipy.misc.imread doesn't exist on the interpereter for whatever reason
    except ImportError:pass
    try               :return _load_image_from_file_via_opencv (file_name)#OpenCV is our last choice here, because when loading png files it forgets the alpha channel...
    except Exception  :pass
    try               :return _load_image_from_file_via_PIL    (file_name)#Last resort - use PIL. Its slow.
    except Exception  :raise

def _load_image_heic(path):
    """Clean HEIC loader following RP patterns"""
    pip_import('pillow_heif')
    import pillow_heif
    return as_numpy_array(pillow_heif.open_heif(path))

def _load_image_from_file_via_PIL(file_name):
    pip_import('PIL')
    from PIL import Image
    out = as_numpy_array(Image.open(file_name))
    assert is_image(out),'Sometimes when PIL fails to load an image it doesnt throw an exception, and returns a useless object. This might be one of those times.'
    return out

def _load_image_from_file_via_imageio(file_name):
    """
    NOTE if this method fails try the following function:
    imageio.plugins.freeimage.download() #https://github.com/imageio/imageio/issues/334 #This helps when it fails. I don't know why it sometimes fails and sometimes doesn't...
    NOTE that even though this made it not crash, it's still sometimes reading exr files wrong...depending on the computer....
    """
    pip_import('imageio')
    from imageio import imread
    return imread(file_name)

def _load_image_from_file_via_scipy(file_name):
    from scipy.misc import imread
    return imread(file_name)

def _load_image_from_file_via_opencv(file_name):
    cv2=pip_import('cv2')
    image=cv2.imread(file_name, cv2.IMREAD_ANYCOLOR | cv2.IMREAD_ANYDEPTH)
    # image=cv2.imread(file_name) 
    if image is None:
        assert False,("OpenCV failed to load image file at the path: "+file_name)#By default, opencv doesn't raise an error when the file isn't found, and just returns None....which is dumb. It should act like scipy.misc.imread, which throws a FileNotFoundError when given an invalid path.
    return cv_bgr_rgb_swap(image)#OpenCV is really weird and doesn't use RGB: It uses BGR for some strange legacy reason. We have to swap the channels to make it useful.


@contextlib.contextmanager
def _disable_insecure_request_warning():
    # Catch warnings related to insecure requests
    pip_import('requests')
    from requests.packages.urllib3.exceptions import InsecureRequestWarning
    with warnings.catch_warnings():
        # Temporarily suppress InsecureRequestWarning
        warnings.simplefilter("ignore", InsecureRequestWarning)
        yield

def _load_image_from_data_uri(data_uri: str):
    """
    Load an image from a data URI (e.g., data:image/png;base64,iVBORw0KG...)
    Returns a numpy array

    Example:
        >>> # Load a tiny 16x16 Mario sprite
        >>> mario_uri = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAA7ElEQVQ4EaXBsW2DQBiG4feTUrDGrcAK16RgAAYI7lx6CpfufBmAASjSsAIr3BoUkf6AhK1fpxNNnkf8k6gwMCoEoiAKBsYJgXBEwcA4IRCOKAwxGJvn/YF3uV3ZpTkLRzhDDMbmeX9Qc7ld2aU5i4NwbJmMjdoOWyY8tR22TOzUduIgCrZMxgm1nXBExRCDUZHmLAqiwsCoEIiCKBgYJwTCEaV+NQ6/Xz/sPr4/eRsb4QivX42XsRFevxovYyMO4qVfDW9shNevhjc2YiMOQwyGk+YsnCEGw0lzFhuxGWIwCmnOwhliMAppzvoD6bpMEei4FxAAAAAASUVORK5CYII='
        >>> mario = _load_image_from_data_uri(mario_uri)
        >>> mario.shape
        (16, 16, 4)
    """
    import re
    match = re.match(r'data:image/[^;]+;base64,(.+)', data_uri)
    if not match:
        raise ValueError("Invalid data URI format: {0}...".format(data_uri[:100]))
    return decode_image_from_base64(match.group(1))

def _load_image_from_url(url: str, *, use_cache=False):
    """
    Url should either be like http://website.com/image.png or like data:image/png;base64,iVBORw0KGgoAAAANSUhEUg...
    Returns a numpy image
    
    With use_cache=True, downloads to persistent file cache and saves images to memory
    """
    assert url.startswith('data:image') or is_valid_url(url), '_load_image_from_url error: invalid url: ' + repr(url)
    
    # Memory cache check
    if use_cache and url in _load_image_cache:
        return _load_image_cache[url].copy()
    elif not use_cache and url in _load_image_cache:
        del _load_image_cache[url]  # RP cache deletion pattern
    
    # Calculate cache path for file caching
    cache_path = get_cache_file_path(url)

    if use_cache:
        #Use the cache if possible - don't even check if the cache file is there
        if cache_path in _load_image_cache:
            return _load_image_cache[cache_path]
    
        # Download the file then load it offline
        with _disable_insecure_request_warning():
            download_url(url, cache_path, skip_existing=True)
        return _load_image_from_file(cache_path, use_cache=True)  # Will add to _load_image_cache if not already there

    else:
        # Directly load the image from the web without writing to a file. Requires PIL.

        # RP cache deletion pattern for file cache
        if file_exists(cache_path):
            delete_file(cache_path)
        if cache_path in _load_image_cache:
            del _load_image_cache[cache_path]
    
        # Direct PIL loading (no caching - current behavior for backward compatibility)
        pip_import('requests')
        pip_import('PIL')

        import requests
        from PIL import Image
        from io import BytesIO

        if not connected_to_internet():
            raise RuntimeError("_load_image_from_url: Not connected to internet, cannot load "+url)
        
        with _disable_insecure_request_warning():
            response = requests.get(url, verify=False)
        
        return np.add(Image.open(BytesIO(response.content)), 0)  # Converts it to a numpy array by adding 0 to it.

def load_image_from_matplotlib(*,dpi:int=None,fig=None):
    """
    Return matplotlib's current display as an image
    You can increase the DPI to get a higher resolution. Set it to something like 360 or higher.
    Example:
        line_graph(random_ints(10))
        cv_imshow(load_image_from_matplotlib())
    """
    import io
    import cv2
    import numpy as np
    import matplotlib.pyplot as plt
    if fig is None: fig=plt.gcf()
    if dpi is None: dpi=fig.dpi
    buf = io.BytesIO()
    fig.savefig(buf, format="png", dpi=dpi)
    buf.seek(0)
    img_arr = np.frombuffer(buf.getvalue(), dtype=np.uint8)
    buf.close()
    img = cv2.imdecode(img_arr, 1)
    img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
    return img


#def load_openexr_image(file_path:str):
#    #NOTE: This function is unnesecary for loading EXR files with full quality...assuming they're RGB. load_image works fine; I haven't tested it with more than 4 channels yet though
#
#    #Takes .exr image file with a depth map, and returns an RGBAZ image (where Z is depth, as opposed to an RGBA image.)
#    #Because of the way .exr files work, the output of this function is not an image as defined by rp.is_image, because it has 5 channels (all floating point)
#    #This function exists because load_image ignores the depth-map channel, which is important informatoin but is ignored by OpenCV's importer as well as Snowy's and all other libraries I've tried so far
#    #This function requires a python package called 'openexr'. It can be annoying to install.
#    pip_import('OpenEXR') # This package can be a bit of a pain-in-the-ass to get working; it requires apt-installs on Ubuntu and brew-installs on Mac. On ubuntu, try 'sudo apt install openexr ; sudo apt install libopenexr-dev' and if that fails try 'sudo apt remove libopenexr22' and try installing openexr and libopenexr-dev again
#    
#    assert file_exists(file_path),'File not found: '+file_path
#
#    import OpenEXR, Imath, numpy
#    
#    #Below code adapted from: https://www.blender.org/forum/viewtopic.php?t=24549
#
#    exrimage = OpenEXR.InputFile(file_path)
#
#    dw = exrimage.header()['dataWindow']
#    (width, height) = (dw.max.x - dw.min.x + 1, dw.max.y - dw.min.y + 1)
#    
#    def fromstr(s):
#      mat = numpy.fromstring(s, dtype=numpy.float16)
#      mat = mat.reshape (height,width)
#      return mat
#    
#    pt = Imath.PixelType(Imath.PixelType.HALF)
#    (r, g, b, a, z) = [fromstr(s) for s in exrimage.channels('RGBAZ', pt)]
#    return np.dstack((r,g,b,a,z)).astype(float)

def _get_openexr_image_dimensions(file_path)->set:
    #Returns the height and width of an .exr image file
    
    pip_import('OpenEXR')
    import OpenEXR
    if isinstance(file_path,OpenEXR.InputFile):
        #This is to save a bit of time when calling this function from other rp functions:
        #Preferably don't re-read a file more than once
        input_file = file_path
    else:
        input_file = OpenEXR.InputFile(file_path)
        
    dw = input_file.header()['dataWindow']    
    width, height= (dw.max.x - dw.min.x + 1, dw.max.y - dw.min.y + 1)    
    
    return height, width
    
def is_valid_openexr_file(file_path):    
    """
    Returns True iff the file path points to an exr file 
    """
    pip_import('OpenEXR')
    import OpenEXR
    return OpenEXR.isOpenExrFile(file_path)

def get_openexr_channels(file_path)->set:
    """
    Gets a set of strings indicating what channels are in a given .exr file
    Note that .exr files are floating point images that can contain arbitrary numbers of named channels
    EXAMPLE:
         >>> get_openexr_channels('Image0032.exr')
        ans = {'A', 'G', 'R', 'B'}
    """
    
    pip_import('OpenEXR')
    import OpenEXR
    
    if isinstance(file_path,OpenEXR.InputFile):
        #This is to save a bit of time when calling this function from other rp functions:
        #Preferably don't re-read a file more than once
        input_file = file_path
    else:
        input_file = OpenEXR.InputFile(file_path)
        
    return set(input_file.header()['channels'])

def load_openexr_image(file_path,*,channels=None):
    """
    Will load a floating point openexr image as a numpy array.
    The 'channels' argument is used to specify the channel names that are loaded
    By default this works only with RGB or RGBA floating point .exr images, but note that .exr files are interesting: they can have arbitrarily many *named* channels, and blender can exploit this.
        For example, if you look at the readme on https://github.com/cheind/py-minexr (a library that loads .exr files), they have a demo showing blender outputting both normals, depth, and color in a single exr file
        To access these channels, or if you have alpha etc, simply list those channels' names in the 'channels' argument
        If you're not sure what channels an openexr image has (maybe it's RGB maybe it's RGBA? Maybe it has depth? Etc) then call get_openexr_channels(file_path)
    
    Args:
        file_path: Path to OpenEXR file
        channels: List of channel names to load (defaults to RGB or RGBA)
        
    Returns:
        numpy.ndarray: Loaded HDR image as float array
        
    EXAMPLES:
         >>> load_openexr_image('Image0032.exr',channels=None).shape #It happens to be the case that Image0032.exr is RGBA. This was detected automatically because channels=None
        ans = (716, 862, 4)
         >>> load_openexr_image('Image0032.exr',channels=('R','G','B','A')).shape  #You can specify the channels in any order you want manually
        ans = (716, 862, 4)
         >>> load_openexr_image('Image0032.exr',channels=('R','G','B')).shape      #If you want, you can even exclude certain channels
        ans = (716, 862, 3)
         >>> load_openexr_image('Image0032.exr',channels=('R','G','B','B','B','B')).shape  #...or even use some channels more than once, just to prove my point...
        ans = (716, 862, 6)
         >>> load_openexr_image('Image0032.exr',channels=('R','G','B','asdf')).shape   #It will throw errors if the channels you give aren't in the exr file
        ERROR: AssertionError: load_openexr_image: OpenEXR file is missing the following channels: {'asdf'}
         >>> get_openexr_channels('Image0032.exr')  #If you're not sure what channels are contained in an OpenEXR image, use this function
        ans = {'A', 'G', 'R', 'B'}
    
    This code was originally from https://www.programcreek.com/python/example/124985/OpenEXR.InputFile
    Imageio used to do this well...but for some reason, as of April 27 2022, it suddenly stopped working correctly and gave wrong values along the blue channel
    This function has been checked to make sure the resulting floating point numbers are correct
    """

    pip_import('OpenEXR')
    pip_import('Imath')
    pip_import('numpy')
    import OpenEXR
    import Imath
    import numpy as np

    # Read OpenEXR file
    if not is_valid_openexr_file(file_path):
        assert False, 'rp.load_openexr_image: Image %s is not a valid OpenEXR file'%file_path
    input_file = OpenEXR.InputFile(file_path)
    pixel_type = Imath.PixelType(Imath.PixelType.FLOAT)
    height, width = _get_openexr_image_dimensions(input_file)
    
    #Handle the 'channels' argument if it's None
    input_channels = get_openexr_channels(input_file)
    if channels is None:
        #If channels is None, assume the image is either RGB or RGBA
        if   input_channels=={'R','G','B'    }: channels=('R','G','B'    )
        elif input_channels=={'R','G','B','A'}: channels=('R','G','B','A')
        else:
            assert False, 'rp.load_openexr_image: This image (aka %s) is neither RGB nor RGBA. Please specify the channels manually, such as channels==%s'%(file_path,str(get_openexr_channels(input_file)))
    assert set(channels)<=input_channels, 'rp.load_openexr_image: OpenEXR file is missing the following channels: '+repr(set(channels) - input_channels)

    # Read into tensor
    image = np.zeros((height, width, len(channels)))
    for i, channel in enumerate(channels):
        rgb32f = np.fromstring(input_file.channel(channel, pixel_type), dtype=np.float32)
        image[:, :, i] = rgb32f.reshape(height, width)

    return image

_opencv_supported_image_formats='bmp dib exr hdr jp2 jpe jpeg jpg pbm pfm pgm pic png pnm ppm pxm ras sr tif tiff webp'.split()


def _encode_image_to_bytes(image,filetype:str,quality):
    "Helper function for encode_image_to_bytes"

    assert is_image(image)
    assert isinstance(filetype,str)

    pip_import('cv2')
    import cv2

    filetype=filetype.lower() #Make filetype not case sensitive
    if not filetype.startswith('.'):
        #Allow filetype to be 'png' which gets turned into '.png'
        filetype='.'+filetype
    assert filetype.startswith('.')
    
    assert filetype[1:] in _opencv_supported_image_formats, 'Unsupported image format: '+repr(filetype)+', please choose from [.'+', .'.join(_opencv_supported_image_formats)+']'

    image=as_byte_image(image)
    if filetype in '.png .tif .tiff .webp'.split(): #All filetypes that support transparency should go here
        if not is_rgb_image(image): #But don't add transparency unless we have to
            image=as_rgba_image(image)
    else:
        image=as_rgb_image(image)
    image=cv_rgb_bgr_swap(image)
    
    if filetype in '.jpg .jpeg .webp .jp2 .jpe'.split():
        encode_param = [int(cv2.IMWRITE_JPEG_QUALITY), quality]
        success, encoded_image = cv2.imencode(filetype, image, encode_param)
    else:
        success, encoded_image = cv2.imencode(filetype, image)
    
    if not success:
        raise IOError('Failed to encode image to '+filetype+' bytes')

    return encoded_image.tobytes()


def encode_image_to_bytes(image,filetype=None,quality=100):
    """
    Encodes an image into a bytestring, without actually saving it to your harddrive
    The bytes are the same as if you saved an image to a file with this filetype and quality

    Args:
        image (str or object): Image location, or an image as defined by rp.is_image
        filetype (str, optional): The type of image file we encode it to. If not specified, automatically determined.
            * Supported options: bmp, dib, exr, hdr, jp2, jpe, jpeg, jpg, pbm, pfm, pgm, pic, png, pnm, ppm, pxm, ras, sr, tif, tiff, webp
        quality (int): If applicable, defines the image quality (useful when filetype=='jpg' for instance). Specified as a percentage.
            * Supported filetypes: webp, jpeg, jpg, jpe, jp2
            * NOTE: Even at quality=100, it is not currently lossless. TODO: Fix this
        
    Returns:
        str: a byttestring that contains the encoded image file
    
    See also: decode_bytes_to_image to decode back to image

    EXAMPLE:
        ans='https://upload.wikimedia.org/wikipedia/commons/6/6e/Golde33443.jpg'
        ans=load_image(ans)
        ans=encode_image_to_bytes(ans,'png')
        ans=decode_image_from_bytes(ans)
        display_image(ans)

    TODO: Add PIL support too, in case cv2 fails. PIL can also do this.
    TODO: Add image quality parameters for jpg 
    Reference: https://jdhao.github.io/2019/07/06/python_opencv_pil_image_to_bytes/
    """
    assert is_image(image) or isinstance(image, str)

    if filetype is None:
        if isinstance(image,str):
            filetype=get_file_extension(image)
        else:
            if is_rgba_image(image):
                #If we have alpha, use a png
                filetype='png'
            else:
                #Otherwise, might as well save a bit of bandwidth
                #Assumes we will encode losslessly when quality=100
                #filetype='jpg'
                #But actually, right now that assumption is false, so...
                filetype='png'
    
    if isinstance(image,str) and get_file_extension(image)==filetype and quality==100:
        #Save some time and just return the bytestring directly
        return curl_bytes(image)

    if isinstance(image, str):
        image = load_image(image)
    
    byte_data = gather_args_call(_encode_image_to_bytes)

    return byte_data


def encode_images_to_bytes(images, filetype=None, quality=100):
    """Batch encode multiple images to bytes. See encode_image_to_bytes for single images.
    To decode, use decode_images_from_bytes."""
    object = [encode_image_to_bytes(x, filetype=filetype, quality=quality) for x in images]
    return object_to_bytes(object)

def decode_images_from_bytes(encoded_images):
    """
    Decode multiple images from byte-encoded format.
    
    Args:
        encoded_images: Byte-encoded image data. Can be:
                       - bytes object: single serialized collection created with rp.encode_images_to_bytes(...)
                       - List/iterable of individual byte-encoded images
        
    Returns:
        np.ndarray or list: Array of decoded images if all have same shape, otherwise list
        
    Examples:
        >>> # Encode some images first (for demonstration)
        >>> images = [np.random.randint(0, 255, (50, 50, 3), dtype=np.uint8) for _ in range(3)]
        >>> encoded = encode_images_to_bytes(images)  # Hypothetical encoding
        >>> 
        >>> # Decode them back
        >>> decoded = decode_images_from_bytes(encoded)
        >>> len(decoded)  # Should be 3 images
        3
        
    See Also: decode_image_from_bytes, encode_images_to_bytes, bytes_to_object
    
    Tags: decoding, serialization, bytes, images, deserialization
    """
    if isinstance(encoded_images, bytes):
        encoded_images = bytes_to_object(encoded_images)

    images = [decode_image_from_bytes(x) for x in encoded_images]

    if len(set(x.shape for x in images))==1:
        #If possible turn it into a numpy array
        images = as_numpy_array(images)

    return images
      

def encode_image_to_base64(image,filetype=None,quality=100):
    """
    Encodes an image into a base64 string. Useful for HTTP requests, or displaying HTML images in jupyter.

    Args:
        image (str or object): Image location, or an image as defined by rp.is_image
        filetype (str, optional): The type of image file we encode it to
        quality (int): If applicable, defines the image quality (useful when filetype=='jpg' for instance)
    Returns:
        str: a base-64 string containing the image
    
    See also: decode_image_from_base64 to decode back
    """
    import base64
    
    byte_data = encode_image_to_bytes(image, filetype)

    return base64.b64encode(byte_data).decode("utf-8")

def encode_images_to_base64(images,filetype=None,quality=100):
    """Batch encode multiple images to base64 strings. See encode_image_to_base64 for single images.
    To decode: decode_images_from_base64"""
    return [encode_image_to_base64(image, filetype, quality) for image in images]

def decode_image_from_base64(base64_string):
    """Decode a base64 string back to an image.
    Convenience function combining base64_to_bytes and decode_bytes_to_image.
    See also: encode_image_to_base64"""
    return decode_bytes_to_image(base64_to_bytes(base64_string))

def decode_images_from_base64(base64_strings):
    """Decode multiple base64 strings back to images.
    Convenience function for batch decoding base64 encoded images.
    See also: encode_images_to_base64"""
    return decode_images_from_bytes([base64_to_bytes(s) for s in base64_strings])

def decode_bytes_to_image(encoded_image:bytes):
    """
    Supports any filetype in r._opencv_supported_image_formats, including jpg, bmp, png, exr and tiff
    TODO: Fix support for opencv, I suspect it will be faster.
    
    EXAMPLE:
        ans='https://upload.wikimedia.org/wikipedia/commons/6/6e/Golde33443.jpg'
        ans=load_image(ans)
        ans=encode_image_to_bytes(ans)
        ans=decode_image_from_bytes(ans)
        display_image(ans)
    """

    pip_import('PIL')

    from io import BytesIO
    from PIL import Image
    
    return np.array(Image.open(BytesIO(encoded_image)))

    # #TODO: Fix this if PIL is too slow
    #pip_import('cv2')
    #import cv2    
    #success, decoded_image = cv2.imdecode(encoded_image,cv2.IMREAD_ANYCOLOR)
    #if not success:
    #    raise IOError('Failed to decode image')
    #return decoded_image

decode_image_from_bytes = decode_bytes_to_image

def save_image(image, file_name=None, add_png_extension: bool = True):
    """
    An easy function to save images. 

    Args:
        image: Image to save. Accepts numpy arrays, PIL images, torch tensors, and any other rp-supported image type
        file_name (str, optional): Output file path. If None, generates unique filename
        add_png_extension (bool): Legacy parameter for PNG extension handling (default True)

    Returns:
        str: Path to the saved image file

    Provides several fallbacks to saving an image file

    Saves an image to many different formats, depending on the given file extension.
    If no file extension is given (i.e. if file_name=='image' instead of 'image.jpg'), by default it will save it as a png

    Supported filetypes (non-exhaustive - it can save many more types):
        png bmp jpg tiff tga
        jxl avif webp exr 
        
    Note: If you want to specify details about how it's saved, such as quality - please use a specific function

    See also:
        - save_image_jpg
        - save_image_webp
        - save_image_jxl
        - save_image_exr (aka save_openexr_image)
        
    Examples:
        >>> import numpy as np
        >>> # Save random image with automatic filename
        >>> img = np.random.randint(0, 255, (100, 100, 3), dtype=np.uint8)
        >>> path = save_image(img)  # Creates unique filename like 'image.png'
        
        >>> # Save to specific path (format detected from extension)
        >>> save_image(img, 'my_image.jpg')  # Uses JPEG format
        >>> save_image(img, 'my_image.webp') # Uses WebP format
        >>> save_image(img, 'my_image.avif') # Uses AVIF format
        
        >>> # Works with float images (auto-converted to bytes)
        >>> float_img = np.random.random((50, 50, 3))
        >>> save_image(float_img, 'float_image.png')
        
    Todo: Add support for imageio, which can also write images
    Note: The add_png_extension is annoying legacy stuff...sorry...it would break some of my other scripts to change that right now.
    """

    image = as_numpy_image(image, copy=False)

    if file_name==None:
        # file_name=temporary_file_path('png')
        file_name=get_unique_copy_path('image.png')

    if file_name.startswith('~'):
        file_name=get_absolute_path(file_name)

    #If the specified path's folders don't exist, make them. Don't whine and throw errors.
    make_parent_directory(file_name)
    
    if get_file_extension(file_name)=='exr':
        #Note that exr filetypes must have a float32 dtype
        # image=as_float_image(image).astype(np.float32)
        save_openexr_image(image, file_name)
        return file_name

    elif get_file_extension(file_name)=='jpg':
        #Try to save using this jpg-specific method - it guarentees 100% quality
        try: return save_image_jpg(image, file_name, quality=100)
        except Exception: pass
    elif get_file_extension(file_name)=='jxl':
        try: return save_image_jxl(image, file_name, quality=100)
        except Exception: pass
    elif get_file_extension(file_name)=='avif':
        try: return save_image_avif(image, file_name, quality=100)
        except Exception: pass
    elif get_file_extension(file_name)=='webp':
        try: return save_image_webp(image, file_name, quality=100)
        except Exception: pass
    else:
        #Suppress any warnings about losing data when coming from a float_image...that's a given, considering that png's only have one byte per color channel...
        image=as_byte_image(image)
    
    try:
        try:
            pip_import('imageio') #This is the best library for this task: it handles the most image types, and it does it just as fast as opencv
            if get_file_extension(file_name)=='exr':
                from imageio import imwrite as imsave #Imageio is the best at saving .exr files
            else:
                from imageio import imsave as imsave
        except Exception:
            from scipy.misc import imsave
    except Exception:
        try:
            from skimage.io import imsave
        except Exception:
            try:
                pip_import('cv2')
                from cv2 import imwrite
                imsave = lambda filename, data: imwrite(
                    filename,
                    cv_bgr_rgb_swap(
                        as_rgba_image(
                            as_byte_image(
                                data,
                                copy=False,
                            ),
                            copy=False,
                        ),
                    ),
                )
            except Exception:
                pass

    if add_png_extension and not has_file_extension(file_name):#Save a png file by default
        file_name+=".png"

    if get_file_extension(file_name).lower() in 'jpg jpeg'.split():
        image=as_rgb_image(image)

    imsave(file_name,image)

    return file_name

def save_images(images,paths:list=None,skip_overwrites=False,show_progress=False):
    """
    Save images to specified paths concurrently.

    Parameters:
        - images (list): List of image objects to save, or a video file
        - paths (list, str, or None, optional): Determines the file paths for saving images.
          * If None, each image is saved with a random name followed by an index (such as 'Aos8Bs32_00001.png', 'Aos8Bs32_00002.png' ...)
          * If a string:
            - If its a folder, uses same names as setting paths==None, except images are saved in that subfolder
            - Treated as a format string (like 'image_%03i.png') to generate file paths.
          * If a list, each element should be a path corresponding to each image.
        - skip_overwrites (bool, optional): If True, does not overwrite existing files. Default is False.
        - show_progress (bool, optional): If True, shows progress and estimated time remaining. Default is False.

    Returns:
        - list: Paths where images were saved.

    Examples:
        >>>  save_images(my_images, 'image_%03i.png', skip_overwrites=True, show_progress=True)

    """

    if isinstance(images, str) and is_video_file(images):
        images = load_video(images)
        
    if paths is None or isinstance(paths,str) and folder_exists(paths):
        new_paths=random_namespace_hash()+'_%05i.png'
        if folder_exists(paths):
            new_paths=path_join(paths,new_paths)
        paths=new_paths
        
        # paths=[None]*len(images)

        #if show_progress:
            #print("rp.save_images: No paths were specified for your %i images, so their names will be their hash values...calculating the image hash values...",end='',flush=True)
          #
        #paths=[str(handy_hash(image)) for image in images] #By defualt, give each image it's own unique name
#
        #if show_progress:
            #sys.stdout.write('\033[2K\033[1G')#erase and go to beginning of line https://stackoverflow.com/questions/5290994/remove-and-replace-printed-items

    if isinstance(paths,str):
        #Assume paths is a formattable string that takes an int index
        paths=[paths % i for i in range(len(images))]

    if show_progress:
        number_of_images_saved=0
        show_time_remaining=eta(len(paths),title='Saving Images')
        start_time=gtoc()
    
    assert len(paths)==len(images),'Must have exactly one path to go with every image'
    assert all(map(is_image,images)),'All images must be images as defined by rp.is_image'
    assert all(isinstance(path,str) or path is None for path in paths),'All paths must be strings. They are where the images are saved to.'
    
    cancelled=False #This variable is used to make sure that all the other image-saving threads halt if the user of this function throws an exception, such as a KeyboardInterrupt (maybe it was taking too long and they got impatient...)

    def _save_image(image,path):

        if cancelled:
            return
        
        if path is None:
            path=str(handy_hash(image))+'.png'

        if skip_overwrites and path_exists(path):
            pass #We do nothing, we're skipping this image!
        else:    
            maybe_path = save_image(image,path)
            if maybe_path is not None:
                #Sometimes save_image will change the filename, such as adding .png at the end
                path=maybe_path
        
        if cancelled:
            return

        if show_progress:
            nonlocal number_of_images_saved
            number_of_images_saved+=1
            show_time_remaining(number_of_images_saved)

        return path
        
    try:
        saved_paths = par_map(_save_image,images,paths)#This is fast because it's multithreaded
    except:
        cancelled=True
        raise

    if show_progress:
        end_time=gtoc()
        elapsed_time=end_time-start_time
        sys.stdout.write('\033[2K\033[1G')#erase and go to beginning of line https://stackoverflow.com/questions/5290994/remove-and-replace-printed-items
        print('rp.save_images: Done! Saved %i images in %.3f seconds'%(len(images),elapsed_time))#This is here because of the specifics of the eta function we're using to display progress

    return saved_paths

def temp_saved_image(image):
    """
    Return the path of an image, and return the path we saved it to
    Originally used for google colab to display images nicely:
       from IPython.display import Image
       Image(temp_saved_image(‹some numpy image›,retina=True)) #<-- Displays image at FULL resolution, optimized for a retina monitor. 'retina=True' is totally optional, it  just looks really nice on my macbook.
    """
    image_name="rp_temp_saved_image_"+random_namespace_hash(10)
    save_image(as_byte_image(as_rgba_image(as_float_image(image))),image_name)
    return image_name+'.png'

def save_image_to_imgur(image):
    """
    Takes an image, or an image path
    Returns the url of the saved image
    Note: This function can sometimes take up to 10 seconds, depending on the size of the input image
    """
    assert is_image_file(image) or is_image(image),'The input image must either be a path to an image, or a numpy array representing an image'
    if isinstance(image,str):
        assert file_exists(image),'Cannot find a file at path '+repr(image)
        assert is_image_file(image),'There is a file, but its not an image: '+repr(image)
        image_path=image
        
        pip_import('imgurpython')
        from imgurpython import ImgurClient
        client=ImgurClient(client_id='e5b018ddc6db007',client_secret='2adb606c63637a04a55dfcbe7e929fb64f48b83d')#Please don't abuse this. There are limited uploads per month.
        response = client.upload_from_path(image_path, anon=True)
        return response['link']
    elif is_image(image):
        temp_image_path=temporary_file_path('.png')
        try:
            save_image(image,temp_image_path)
            return save_image_to_imgur(temp_image_path)
        finally:
            if file_exists(temp_image_path):
                delete_file(temp_image_path)
                
def save_image_jpg(image,path=None,*,quality=100,add_extension=True):
    """
    Saves an image in JPEG format with specified quality settings.
    Handles various input formats (PIL, numpy arrays, torch tensors, taichi fields, etc)
    Automatically converts images to RGB (instead of RGBA, which jpeg doesn't support)
    
    If add_extension is True, will add a '.jpg' or '.jpeg' extension to path IFF it 
        doesn't allready end with such an extension 
        (AKA 'a/b/c.jpg' -> 'a/b/c.jpg' BUT 'a/b/c.png' -> 'a/b/c.png.jpg')
    
    Parameters:
        image: Image data in any RP-supported format (PIL, numpy array, torch tensor, taichi field, etc)
        path (str, optional): Output file path. If None, uses "image.jpg", "image_copy.jpg", "image_copy1.jpg", etc
        quality (int): JPEG quality 1-100 (100 = best quality, larger file)
        add_extension (bool): Whether to add .jpg extension if not present
    
    Returns:
        str: Path where the image was saved
    
    Usage Examples:
        >>> # Save with default quality
        >>> img = load_image("photo.png")
        >>> save_image_jpg(img, "output.jpg")
        
        >>> # Save with lower quality for smaller file
        >>> save_image_jpg(img, "compressed.jpg", quality=80)
        
        >>> # Auto-add extension
        >>> path = save_image_jpg(img, "photo")
        >>> print(path)
        photo.jpg
    
    Related Functions:
        - save_image(): General image saving (auto-detects format)
        - save_image_png(): Save as PNG format
        - save_image_webp(): Save as WebP format
        - load_image(): Load images for processing
    
    Notes:
        - Converts RGBA to RGB (JPEG doesn't support transparency)
    
    Tags: image, jpeg, jpg, save, quality, compression
    """

    if path is None:
        path=get_unique_copy_path('image.jpg')

    make_parent_directory(path) #Make sure the directory exists
    image=as_numpy_image(image)
    image=as_rgb_image(image)
    image=as_byte_image(image)
    assert 0<=quality<=100,'Jpg quality is measured in percent' 
    if is_image(image):image=as_rgb_image(image)
    if not get_file_extension(path).lower() in {'jpeg','jpg'}:
        path+='.jpg'
        
    extra_kwargs={}
    if quality==100:
        #Chroma Subsampling != 0 --> Essentially gives Hue a lower resolution than brightness
        #https://stackoverflow.com/questions/19303621/why-is-the-quality-of-jpeg-images-produced-by-pil-so-poor
        #https://pillow.readthedocs.io/en/stable/reference/JpegPresets.html
        extra_kwargs['subsampling']=0
        
    as_pil_image(image, copy=False).save(path, "JPEG", quality=quality, optimize=False, progressive=True,**extra_kwargs)
    return path

def save_image_webp(image, path=None, *, quality=100, add_extension=True):
    """
    Save image in WebP format. Set lossless=True for lossless compression, False for lossy.
    If add_extension is True, adds '.webp' extension if not already present.
    
    Args:
        image: Image data in any RP-supported format
        path: Output file path (auto-generated if None)
        quality: WebP quality 1-100 (100 = lossless)
        add_extension: Whether to add .webp extension
        metadata: String of text to embed in EXIF metadata
        
    Examples:
        >>> save_image_webp(img, "photo.webp", metadata="My WebP image")
    """

    image = as_numpy_image(image)
    if is_grayscale_image(image):
        image = as_rgb_image(image)
    image = as_byte_image(image)

    if path is None:
        path = get_unique_copy_path('image.webp')

    make_parent_directory(path)

    assert 0 <= quality <= 100, 'WebP quality is measured in percent'

    if add_extension and not get_file_extension(path).lower() == 'webp':
        path += '.webp'

    kwargs = dict(lossless=quality==100, quality=quality)
    as_pil_image(image, copy=False).save(path, "WEBP", **kwargs)

    return path


def save_image_avif(image, path=None, *, quality=100, add_extension=True):
    """
    Save image in AVIF format. Set lossless=True for lossless compression, False for lossy.
    If add_extension is True, adds '.avif' extension if not already present.
    Returns the path of the saved image.
    
    Args:
        image: Image data in any RP-supported format
        path: Output file path (auto-generated if None)
        quality: AVIF quality 1-100 (100 = lossless)
        add_extension: Whether to add .avif extension if not given
        
    Examples:
        >>> path = save_image_avif(img, "photo.avif", )
    """
    pip_import("pillow_avif", "pillow-avif-plugin")

    image = as_numpy_image(image)
    if is_grayscale_image(image):
        image = as_rgb_image(image)
    image = as_byte_image(image)

    if path is None:
        path = get_unique_copy_path('image.avif')

    make_parent_directory(path)

    assert 0 <= quality <= 100, 'AVIF quality is measured in percent'

    if add_extension and not get_file_extension(path).lower() == 'avif':
        path += '.avif'

    kwargs = dict(lossless=quality==100, quality=quality)
    as_pil_image(image, copy=False).save(path, "AVIF", **kwargs)

    return path

def save_image_jxl(image, path=None, *, quality=100, add_extension=True):
    """
    Save image in JPEG XL format. Set quality=100 for lossless compression.
    If add_extension is True, adds '.jxl' extension if not already present.
    Returns the path of the saved image.
    
    Args:
        image: Image data in any RP-supported format
        path: Output file path (auto-generated if None)  
        quality: JXL quality 1-100 (100 = lossless)
        add_extension: Whether to add .jxl extension
        
    EXAMPLE:
        >>> saved_path = save_image_jxl(img, "photo.jxl")
    
    EXAMPLE (Comparison video between JPG and JXL quality):
        
        >>> emma = load_image('https://github.com/RyannDaGreat/Diffusion-Illusions/blob/gh-pages/images/emma.png?raw=true', use_cache=True)
        ... text_image = load_image('https://lh3.googleusercontent.com/EE9uifZsj9rVE4PDHKRx4jaTYUymIDItRbgxCNzKc7o14NJijwvj2uhSC7oKByRfxEF1SRqMUVispOb3W6r340P4KA=w640-h400-e365-rj-sc0x00ffffff',use_cache=True)
        ... text_image=resize_image_to_fit(text_image,width=get_image_width(emma))
        ... emma=vertically_concatenated_images(emma,text_image)
        ... emma = full_range(emma)
        ... 
        ... dir = make_directory('jxl_jpg_webp_avif_comparisons')
        ... r._pterm_cd(dir)
        ... 
        ... jpg_sizes = []
        ... jxl_sizes = []
        ... wep_sizes = []
        ... avf_sizes = []
        ... 
        ... jpg_paths = []
        ... jxl_paths = []
        ... wep_paths = []
        ... avf_paths = []
        ... 
        ... qualities = [1,2,3,4,5,6,7,8,9,10,20,30,40,50,60,70,80,90,91,92,93,94,95,96,97,98,99,100]
        ... 
        ... for quality in eta(qualities, title='Saving Images'):
        ...     jpg_path = save_image_jpg (emma, path=f'{quality}', quality=quality)
        ...     jxl_path = save_image_jxl (emma, path=f'{quality}', quality=quality)
        ...     wep_path = save_image_webp(emma, path=f'{quality}', quality=quality)
        ...     avf_path = save_image_avif(emma, path=f'{quality}', quality=quality)
        ...     
        ...     jpg_paths += [jpg_path]
        ...     jxl_paths += [jxl_path]
        ...     wep_paths += [wep_path]
        ...     avf_paths += [avf_path]
        ... 
        ...     jpg_sizes.append(get_file_size(jpg_path))
        ...     jxl_sizes.append(get_file_size(jxl_path))
        ...     wep_sizes.append(get_file_size(wep_path))
        ...     avf_sizes.append(get_file_size(avf_path))
        ... 
        ... #########
        ... 
        ... comparisons = []
        ... 
        ... jpg_index=len(qualities)-1
        ... jxl_index=len(qualities)-1
        ... wep_index=len(qualities)-1
        ... avf_index=len(qualities)-1
        ... 
        ... while jpg_index>=0 and jxl_index>=0 and wep_index>=0 and avf_index>=0:
        ... 
        ...     print(jpg_index,jxl_index,wep_index,avf_index)
        ...     #Making sure we're always comparing near-equal filesizes
        ...     #We keep popping the largest file of the 4 types
        ... 
        ...     jpg_size = jpg_sizes[jpg_index]
        ...     jxl_size = jxl_sizes[jxl_index]
        ...     wep_size = wep_sizes[wep_index]
        ...     avf_size = avf_sizes[avf_index]
        ... 
        ...     jpg_path = jpg_paths[jpg_index]
        ...     jxl_path = jxl_paths[jxl_index]
        ...     wep_path = wep_paths[wep_index]
        ...     avf_path = avf_paths[avf_index]
        ... 
        ...     jpg_img = load_image(jpg_path, use_cache=True)
        ...     jxl_img = load_image(jxl_path, use_cache=True)
        ...     wep_img = load_image(wep_path, use_cache=True)
        ...     avf_img = load_image(avf_path, use_cache=True)
        ... 
        ...     jpg_img = labeled_image(jpg_img,  f'JPG\n{jpg_path}\n{human_readable_file_size(jpg_size)}', font='G:Hind', size=20, size_by_lines=True)
        ...     jxl_img = labeled_image(jxl_img,  f'JXL\n{jxl_path}\n{human_readable_file_size(jxl_size)}', font='G:Hind', size=20, size_by_lines=True) 
        ...     wep_img = labeled_image(wep_img, f'WEBP\n{wep_path}\n{human_readable_file_size(wep_size)}', font='G:Hind', size=20, size_by_lines=True)
        ...     avf_img = labeled_image(avf_img, f'AVIF\n{avf_path}\n{human_readable_file_size(avf_size)}', font='G:Hind', size=20, size_by_lines=True)
        ...     
        ...     comparison = tiled_images([jpg_img, jxl_img, wep_img, avf_img], border_color='red')
        ...     comparisons.append(comparison)
        ... 
        ...     display_image(comparison)
        ... 
        ...     size_max = max(jpg_size, jxl_size, wep_size, avf_size)
        ...     
        ...     if jpg_size == size_max: jpg_index-=1
        ...     if jxl_size == size_max: jxl_index-=1
        ...     if wep_size == size_max: wep_index-=1
        ...     if avf_size == size_max: avf_index-=1
        ...         
        ... mp4 = save_video_mp4(comparisons, framerate=60, video_bitrate='max') 
        ... open_file_with_default_application(mp4)
        ... 
        ... #final_grid = tiled_images(comparisons)
        ... #png = save_image(final_grid, 'comparison_grid.png')
        
    """
    pip_import("pillow_jxl", "pillow-jxl-plugin")

    image = as_numpy_image(image)
    if is_grayscale_image(image):
        image = as_rgb_image(image)
    image = as_byte_image(image)

    if path is None:
        path = get_unique_copy_path('image.jxl')
    
    make_parent_directory(path)
    
    assert 0 <= quality <= 100, 'JpgXL quality is measured in percent'
    
    if add_extension and not get_file_extension(path).lower() == 'jxl':
        path += '.jxl'

    kwargs = dict(lossless=True) if quality == 100 else dict(quality=quality)
    as_pil_image(image).save(path, "JXL", **kwargs)

    return path

def save_animated_webp(video, path=None, *, framerate=60, quality=100, loop=True, add_extension=True):
    """
    Save an animated video in WebP format.
    If add_extension is True, adds '.webp' extension if not already present.
    """
    if path is None:
        path = get_unique_copy_path('video.webp')

    make_parent_directory(path)

    assert 0 <= quality <= 100, 'WebP quality is measured in percent'

    if add_extension and not get_file_extension(path).lower() == 'webp':
        path += '.webp'
   
    video = as_rgba_images(video) 
    video = as_byte_images(video)
    video = as_pil_images(video)
    
    kwargs = dict(
        save_all=True,
        append_images=video[1:],
        duration=1000 // framerate,
        loop=0 if loop else 1,
        quality=quality,
    )
    video[0].save(path, "WEBP", **kwargs)
    
    return path
save_video_webp=save_animated_webp

def save_openexr_image(image, file_path):
    """
    Counterpart to load_openexr_image with optional text metadata support.
    
    Args:
        image: Image to save (numpy array)
        file_path: Output EXR file path
        
    Examples:
        >>> save_openexr_image(img, "photo.exr", metadata="My HDR image")
        
    Alias: save_image_exr

    TODO: Add support for custom channels
    This code is based on https://stackoverflow.com/questions/65605761/write-pil-image-to-exr-using-openexr-in-python
    """

    pip_import('OpenEXR')
    pip_import('Imath')
    pip_import('numpy')
    import OpenEXR
    import Imath
    import numpy as np
    
    #Input assertions
    assert is_image(image)
    assert isinstance(file_path,str)
        
    #Prepare the image: It should be either an RGB or RGBA floating point image
    if is_grayscale_image(image):
        image=as_rgb_image(image)
    image=as_float_image(image,copy=False)
    assert len(image.shape)==3 #(height, width, num_channels)
    height, width, num_channels = image.shape
    
    # Read OpenEXR file
    pixel_type = Imath.PixelType(Imath.PixelType.FLOAT)
        
    if   num_channels==3: channels=('R','G','B'    )
    elif num_channels==4: channels=('R','G','B','A')
    else:assert False #Impossible: num_channels comes from an image as defined by rp.is_image, and is not grayscale
    
    header=OpenEXR.Header(width,height)
    header['channels']={c:Imath.Channel(pixel_type) for c in channels}

    #print(header) #Shows interesting info. Useful for debugging the OpenEXR library - it doesnt have much documentation
    
    output_file = OpenEXR.OutputFile(file_path,header)
    output_file.writePixels({channels[i] : image[:,:,i].astype(np.float32).tobytes() for i in range(num_channels)})
    output_file.close()

save_image_exr = save_openexr_image

def _get_files_from_paths(paths, get_files=None):
    """
    Takes a folder, a list of files, or a list of files and folders as input - all of which can be globbed. 
    It applies all globbings, then for each folder it fetches releveant files and includes them in the output.
    This function is used in other functions to preprocess some argument that aims to specify a bunch of files.

    It will always return a list of paths, none of which should be folders
    """

    if get_files is None:
        get_files = rp.get_all_files

    from glob import glob
    if isinstance(paths,str):
        paths = [paths]
    paths = list_flatten(glob(x) for x in paths)
    paths = list_flatten(get_files(x) if is_a_folder(x) else [x] for x in paths)
    return paths

def convert_image_file(
    input_file,
    new_extension=None,
    output_folder=None,
    *,
    skip_overwrite=False,
    image_transform=lambda image, image_file: image,
    name_transform=lambda file_name: file_name,
    load_image=lambda path       : rp.load_image(path       ),
    save_image=lambda image, path: rp.save_image(image, path),
    delete_original=False
):
    """
    Converts an image file to a specified format and saves it to the provided output folder.
    It can also be used to resize image files or recolor them or whatever - beyond just filetype conversions.

    Args:
        input_file (str): Path to the image file to be converted.
        new_extension (str, optional): Desired extension for the output files. If None, the extension is not changed.
        output_folder (str, optional): Path to the folder where the converted image will be saved. 
                                       If not provided, the image is saved in the same folder as the input file.
        skip_overwrite (bool, optional): If True, won't overwrite existing files. Defaults to False.
        image_transform (func, optional): A function that modifies the image before it's saved again. 
                                          It should take in an image and a path string and return an image.
                                          It also works if you pass it a 1-argument function like lambda image:image.
        name_transform (func, optional): Modifies the image name (without extension). Takes in the old name and returns a new one.
                                         Defaults to no change. But you might want to have something like lambda x:"new_"+x etc.
                                         Note that it will receive a file name like "image" and not "image.png" or "/path/to/image.png"
                                         It should return an image name in the same way (no /'s or file extensions)
        load_image (func, optional): A function to load the image file. Defaults to rp.load_image(path).
        save_image (func, optional): A function to save the image file. Defaults to rp.save_image(image, path).
        delete_original (bool, optional): USE WITH CAUTION! If true, will delete the original input image after converting it.

    Returns:
        str: Path to the output file.

    Raises:
        TypeError: If the input file, output folder, or new extension is not a string.
    """

    if output_folder is None:
        output_folder = get_parent_folder(input_file)
    if new_extension is None:
        new_extension = get_file_extension(input_file)
    
    if not isinstance(input_file   , str): raise TypeError("Input file must be a string, but got %s."   %type(input_file   ))
    if not isinstance(new_extension, str): raise TypeError("New extension must be a string, but got %s."%type(new_extension))
    if not isinstance(output_folder, str): raise TypeError("Output folder must be a string, but got %s."%type(output_folder))

    input_file_name = get_file_name(input_file, include_file_extension=False)
    output_file_name = input_file_name
    output_file_name = name_transform(output_file_name)
    output_file_name = with_file_extension(output_file_name, new_extension)
    output_file = path_join(output_folder, output_file_name)

    assert isinstance(output_file, str)

    if file_exists(output_file) and skip_overwrite:
        return output_file

    image = load_image(input_file)

    try:
        image = image_transform(image, input_file)
    except TypeError:
        image = image_transform(image)

    output = save_image(image, output_file)

    if delete_original and file_exists(output) and file_exists(input_file):
        delete_file(input_file)

    return output

    

def _convert_files(
    input_files,
    get_all_files_func,
    convert_single_file_func,
    progress_title,
    *,
    strict=True,
    parallel=True,
    show_progress=False
):
    """
    Generic function for batch file conversion with progress tracking and error handling.
    Used by convert_image_files and convert_audio_files.
    
    Args:
        input_files: Input files or directory
        get_all_files_func: Function to get all files of specific type (e.g., get_all_image_files)
        convert_single_file_func: Function that converts a single file
        progress_title: Title for progress bar (e.g., "Converting Images")
        strict: Error handling mode
        parallel: Whether to use parallel processing
        show_progress: Whether to show progress bar
    
    Returns:
        list: Converted file paths
    """
    assert is_iterable(input_files), "Input files should be a list of files or a string (a folder path), but got {}.".format(type(input_files))
    assert strict in {True, False, None}, 'The \'strict\' parameter must be set to either True, False, or None.'

    import functools
    
    input_files = _get_files_from_paths(input_files, get_files=get_all_files_func)

    cancelled = False
    output_files = []

    def _convert_single(input_file):
        output = None

        nonlocal cancelled
        if cancelled:
            if isinstance(cancelled, Exception):
                raise cancelled
            else:
                return None

        try:
            output = convert_single_file_func(input_file)

        except Exception as e:
            if strict is True:
                cancelled = e
                raise
            elif strict is None:
                output = None

        if show_progress and not cancelled:
            nonlocal number_of_files_converted
            number_of_files_converted += 1
            show_time_remaining(number_of_files_converted)

        return output

    try:
        if show_progress:
            number_of_files_converted = 0
            show_time_remaining = eta(len(input_files), title=progress_title)
            start_time = gtoc()

        mapper = functools.partial(par_map, buffer_limit=None) if parallel else seq_map
        output_files = mapper(_convert_single, input_files)

        if strict is False:
            output_files = [output_file for output_file in output_files if output_file is not None]

        if show_progress:
            end_time = gtoc()
            elapsed_time = end_time - start_time
            sys.stdout.write('\033[2K\033[1G')  # erase and go to beginning of line
            print('Converted {0} files in {1:.3f} seconds'.format(len(output_files), elapsed_time))

    except KeyboardInterrupt:
        cancelled = True
        raise

    return output_files


def convert_image_files(
    input_files=".",
    new_extension=None, 
    output_folder=None,
    *, 
    strict=True,
    parallel=True,
    show_progress=False, 
    skip_overwrite=False, 
    image_transform=lambda image, image_file: image,
    name_transform=lambda file_name: file_name,
    load_image=lambda path       : rp.load_image(path       ),
    save_image=lambda image, path: rp.save_image(image, path),
    delete_original=False
):

    """
    Converts multiple image files to a specified format and saves them to the provided output folder. The function
    leverages concurrent processing for enhanced performance. The function also includes a strict mode that controls
    how the function behaves when an image fails to convert.

    It can also be used to resize image files or recolor them or whatever - beyond just filetype conversions.

    Args:
        input_files (list or str): Paths to the image files to be converted, or a folder containing image files.
                                   Defaults to '.', aka all images in the current working directory.
        new_extension (str, optional): Desired extension for the output files. If None, the extension is not changed.
        output_folder (str, optional): Path to the folder where the converted images will be saved.
                                       If not provided, the images are saved in the same folder as the input files.
        strict (bool, optional): Controls what happens when an image fails to convert. 
                                 If True (default), an error is raised.
                                 If False, images that fail to convert are skipped.
                                 If None, positions of images that fail to convert are filled with None in the output.
        parallel (bool, optional): If True, runs multithreaded. Otherwise computes in main thread.
        show_progress (bool, optional): If True, shows a progress bar. Defaults to False.
        skip_overwrite (bool, optional): If True, won't overwrite existing files. Defaults to False.
        image_transform (func, optional): A function that modifies the images before they're saved again. 
                                          It should take in an image and a path string and return an image.
                                          It also works if you pass it a 1-argument function like lambda image:image.
        name_transform (func, optional): Modifies the image name (without extension). Takes in the old name and returns a new one.
                                         Defaults to no change. But you might want to have something like lambda x:"new_"+x etc.
                                         Note that it will receive a file name like "image" and not "image.png" or "/path/to/image.png"
                                         It should return an image name in the same way (no /'s or file extensions)
        load_image (func, optional): A function to load the image file. Defaults to rp.load_image(path).
        save_image (func, optional): A function to save the image file. Defaults to rp.save_image(image, path).
        delete_original (bool, optional): USE WITH CAUTION! If true, will delete the original input image after converting it.

    Returns:
        list: Paths to the output files.

    Raises:
        TypeError: If the input files, output folder, or new extension is not of type string.
    
    Example:
        convert_image_files(
            "photoshop_files/*.psd",
            new_extension="png",
            output_folder="output_files",
            image_transform=lambda image, path: labeled_image(
                resize_image(image, 0.1), text=get_file_name(path)),
            name_transform=lambda name: "smaller_labeled_" + name,
            show_progress=True,
            skip_overwrite=True,
        )

    """
    
    assert output_folder is None or isinstance(output_folder, str), "Output folder must be a string, but got {}.".format(type(output_folder))

    def convert_single_image_file(input_file):
        return convert_image_file(
            input_file, new_extension, output_folder,
            skip_overwrite=skip_overwrite,
            image_transform=image_transform,
            name_transform=name_transform,
            load_image=load_image,
            save_image=save_image,
            delete_original=delete_original
        )

    return _convert_files(
        input_files=input_files,
        get_all_files_func=get_all_image_files,
        convert_single_file_func=convert_single_image_file,
        progress_title='Converting Images',
        strict=strict,
        parallel=parallel,
        show_progress=show_progress
    )




# endregion
# region Text-To-Speech: ［text_to_speech，text_to_speech_via_apple，text_to_speech_via_google，text_to_speech_voices_comparison，text_to_speech_voices_for_apple，text_to_speech_voices_for_google，text_to_speech_voices_all，text_to_speech_voices_favorites］
# region ［text_to_speech_via_apple］
text_to_speech_voices_for_apple=['Alex','Alice','Alva','Amelie','Anna','Carmit','Damayanti','Daniel','Diego','Ellen','Fiona','Fred','Ioana','Joana','Jorge','Juan','Kanya','Karen','Kyoko','Laura','Lekha','Luca','Luciana','Maged','Mariska','Mei-Jia','Melina','Milena','Moira','Monica','Nora','Paulina','Samantha','Sara','Satu','Sin-ji','Tessa','Thomas','Ting-Ting','Veena','Victoria','Xander','Yelda','Yuna','Yuri','Zosia','Zuzana']  # The old voices (that don't work on sierra. They used to work on el-capitan though): ["Samantha",'Bad News','Bahh','Bells','Boing','Bubbles','Cellos','Deranged','Good News','Hysterical','Pipe Organ','Trinoids','Whisper','Zarvox','Agnes','Kathy','Princess','Vicki','Victoria','Alex','Bruce','Fred','Junior','Ralph','Albert']
# Favorites (in this order): Samantha, Alex, Moira, Tessa, Fiona, Fred
def text_to_speech_via_apple(
    text: str,
    voice="Samantha",
    run_as_thread=True,
    rate_in_words_per_minute=None
    # filter_characters=True
):
    """
    Apple macOS text-to-speech backend using the system 'say' command.
    
    Enhanced Documentation:
    This is RP's Apple-specific TTS backend that leverages macOS's built-in speech 
    synthesis. It's part of RP's multiplexing pattern - the base `text_to_speech` 
    function dispatches to this variant when running on macOS.
    
    macOS-Only: This function only works on macOS systems with the 'say' command.
    
    Parameters:
        text (str): The text to be spoken
        voice (str): Voice name from Apple's TTS voices (default: "Samantha")
            Choose from rp.text_to_speech_voices_for_apple
        run_as_thread (bool): If True, runs TTS asynchronously (default: True)
        rate_in_words_per_minute (int, optional): Speech rate (90-720 WPM effective range)
    
    Returns:
        None: Function initiates speech synthesis but returns nothing
    
    Examples:
        >>> from rp import text_to_speech_via_apple
        >>> 
        >>> # Basic usage with default Samantha voice
        >>> text_to_speech_via_apple("Hello World")
        >>> 
        >>> # Use different voice
        >>> text_to_speech_via_apple("Bonjour", voice="Amelie")  # French Canadian
        >>> text_to_speech_via_apple("Guten Tag", voice="Anna")  # German
        >>> 
        >>> # Control speech rate
        >>> text_to_speech_via_apple("Fast speech", rate_in_words_per_minute=300)
        >>> text_to_speech_via_apple("Slow speech", rate_in_words_per_minute=120)
        >>> 
        >>> # Synchronous execution
        >>> text_to_speech_via_apple("Wait for this", run_as_thread=False)
        >>> 
        >>> # View available voices
        >>> from rp import text_to_speech_voices_for_apple
        >>> print(text_to_speech_voices_for_apple[:5])  # First 5 voices
        ['Alex', 'Alice', 'Alva', 'Amelie', 'Anna']
        
        >>> # Popular voices by accent
        >>> text_to_speech_via_apple("American English", voice="Samantha")  # US English
        >>> text_to_speech_via_apple("British English", voice="Daniel")     # UK English
        >>> text_to_speech_via_apple("Irish accent", voice="Moira")         # Irish English
        >>> text_to_speech_via_apple("Scottish accent", voice="Fiona")      # Scottish English
    
    Available Voices (47 total):
        Popular English voices:
        - Samantha (en_US): Default American female voice
        - Alex (en_US): Well-known American male voice  
        - Daniel (en_GB): British male voice
        - Fiona (en-scotland): Scottish female voice
        - Moira (en_IE): Irish female voice
        - Karen (en_AU): Australian female voice
        - Tessa (en_ZA): South African female voice
        - Veena (en_IN): Indian English female voice
        
        International voices support 25+ languages including:
        French, German, Italian, Spanish, Portuguese, Russian, Chinese, 
        Japanese, Korean, Arabic, Hebrew, Hindi, and many more.

        - Will raise AssertionError for invalid voice names
    
    Implementation Details:
        - Uses macOS 'say' command
        - Employs fog() for deferred execution with threading
        - Character filtering prevents shell injection and command confusion
        - Rate validation ensures effective speech speed (90-720 WPM)
    
    Related Functions:
        - text_to_speech: Base multiplexing function that chooses backend
        - text_to_speech_via_google: Alternative Google TTS backend
        - text_to_speech_voices_for_apple: Complete voice list
        - fog: Used internally for threading the shell command
        
    Platform Requirements:
        - macOS only (uses system 'say' command)
    
    # region  All text_to_speech_via_apple voices along with their descriptions (type 'say -v ?' into terminal to get this):
    Alex                en_US    # Most people recognize me by my voice.
    Alice               it_IT    # Salve, mi chiamo Alice e sono una voce italiana.
    Alva                sv_SE    # Hej, jag heter Alva. Jag är en svensk röst.
    Amelie              fr_CA    # Bonjour, je m'appelle Amelie. Je suis une voix canadienne.
    Anna                de_DE    # Hallo, ich heiße Anna und ich bin eine deutsche Stimme.
    Carmit              he_IL    # שלום. קוראים לי כרמית, ואני קול בשפה העברית.
    Damayanti           id_ID    # Halo, nama saya Damayanti. Saya berbahasa Indonesia.
    Daniel              en_GB    # Hello, my name is Daniel. I am a British-English voice.
    Diego               es_AR    # Hola, me llamo Diego y soy una voz española.
    Ellen               nl_BE    # Hallo, mijn naam is Ellen. Ik ben een Belgische stem.
    Fiona               en-scotland # Hello, my name is Fiona. I am a Scottish-English voice.
    Fred                en_US    # I sure like being inside this fancy computer
    Ioana               ro_RO    # Bună, mă cheamă Ioana . Sunt o voce românească.
    Joana               pt_PT    # Olá, chamo-me Joana e dou voz ao português falado em Portugal.
    Jorge               es_ES    # Hola, me llamo Jorge y soy una voz española.
    Juan                es_MX    # Hola, me llamo Juan y soy una voz mexicana.
    Kanya               th_TH    # สวัสดีค่ะ ดิฉันชื่อKanya
    Karen               en_AU    # Hello, my name is Karen. I am an Australian-English voice.
    Kyoko               ja_JP    # こんにちは、私の名前はKyokoです。日本語の音声をお届けします。
    Laura               sk_SK    # Ahoj. Volám sa Laura . Som hlas v slovenskom jazyku.
    Lekha               hi_IN    # नमस्कार, मेरा नाम लेखा है.Lekha मै हिंदी मे बोलने वाली आवाज़ हूँ.
    Luca                it_IT    # Salve, mi chiamo Luca e sono una voce italiana.
    Luciana             pt_BR    # Olá, o meu nome é Luciana e a minha voz corresponde ao português que é falado no Brasil
    Maged               ar_SA    # مرحبًا اسمي Maged. أنا عربي من السعودية.
    Mariska             hu_HU    # Üdvözlöm! Mariska vagyok. Én vagyok a magyar hang.
    Mei-Jia             zh_TW    # 您好，我叫美佳。我說國語。
    Melina              el_GR    # Γεια σας, ονομάζομαι Melina. Είμαι μια ελληνική φωνή.
    Milena              ru_RU    # Здравствуйте, меня зовут Milena. Я – русский голос системы.
    Moira               en_IE    # Hello, my name is Moira. I am an Irish-English voice.
    Monica              es_ES    # Hola, me llamo Monica y soy una voz española.
    Nora                nb_NO    # Hei, jeg heter Nora. Jeg er en norsk stemme.
    Paulina             es_MX    # Hola, me llamo Paulina y soy una voz mexicana.
    Samantha            en_US    # Hello, my name is Samantha. I am an American-English voice.
    Sara                da_DK    # Hej, jeg hedder Sara. Jeg er en dansk stemme.
    Satu                fi_FI    # Hei, minun nimeni on Satu. Olen suomalainen ääni.
    Sin-ji              zh_HK    # 您好，我叫 Sin-ji。我講廣東話。
    Tessa               en_ZA    # Hello, my name is Tessa. I am a South African-English voice.
    Thomas              fr_FR    # Bonjour, je m'appelle Thomas. Je suis une voix française.
    Ting-Ting           zh_CN    # 您好，我叫Ting-Ting。我讲中文普通话。
    Veena               en_IN    # Hello, my name is Veena. I am an Indian-English voice.
    Victoria            en_US    # Isn't it nice to have a computer that will talk to you?
    Xander              nl_NL    # Hallo, mijn naam is Xander. Ik ben een Nederlandse stem.
    Yelda               tr_TR    # Merhaba, benim adım Yelda. Ben Türkçe bir sesim.
    Yuna                ko_KR    # 안녕하세요. 제 이름은 Yuna입니다. 저는 한국어 음성입니다.
    Yuri                ru_RU    # Здравствуйте, меня зовут Yuri. Я – русский голос системы.
    Zosia               pl_PL    # Witaj. Mam na imię Zosia, jestem głosem kobiecym dla języka polskiego.
    Zuzana              cs_CZ    # Dobrý den, jmenuji se Zuzana. Jsem český hlas.
    # endregion
    
    Tags: tts, text-to-speech, apple, macos, speech-synthesis, voices, multiplexing, via-variant
    """
    # endregion
    # Only works on macs
    assert voice in text_to_speech_voices_for_apple
    text=str(text)
    # if filter_characters:  # So you don't have to worry about confusing the terminal with command characters like '|', which would stop the terminal from reading anything beyond that.
    #     text=''.join(list(c if c.isalnum() or c in ".," else " " for c in text))  # remove_characters_that_confuse_the_terminal
    text = shlex.quote(text)
    if rate_in_words_per_minute is not None and not 90 <= rate_in_words_per_minute <= 720:
        fansi_print("r.text_to_speech_via_apple: The rate you chose is ineffective. Empirically, I found that only rates between 90 and 720 have any effect in terminal, \n and you gave me a rate of " + str(rate_in_words_per_minute) + " words per minute. This is the same thing as not specifying a rate at all, as it won't cap off at the max or min.")
#⁠⁠⁠⁠                                                ⎧                                                                                                                                   ⎫
#⁠⁠⁠⁠                                                ⎪   ⎧                                                                                                                              ⎫⎪
#⁠⁠⁠⁠                                                ⎪   ⎪              ⎧                                                                                                              ⎫⎪⎪
#⁠⁠⁠⁠                                                ⎪   ⎪              ⎪                    ⎧                                                                           ⎫             ⎪⎪⎪
#⁠⁠⁠⁠                                                ⎪   ⎪              ⎪                    ⎪⎧                                      ⎫                                   ⎪             ⎪⎪⎪
#⁠⁠⁠⁠   ⎧                                           ⎫⎪   ⎪              ⎪                    ⎪⎪            ⎧                        ⎫⎪                                   ⎪             ⎪⎪⎪
    (run_as_new_thread if run_as_thread else run_func)(partial(shell_command,("say -v " + voice + ((" -r " + str(rate_in_words_per_minute)) if rate_in_words_per_minute else"") + " " + text)))
#⁠⁠⁠⁠   ⎩                                           ⎭⎪   ⎪              ⎪                    ⎪⎪            ⎩                        ⎭⎪                                   ⎪             ⎪⎪⎪
#⁠⁠⁠⁠                                                ⎪   ⎪              ⎪                    ⎪⎩                                      ⎭                                   ⎪             ⎪⎪⎪
#⁠⁠⁠⁠                                                ⎪   ⎪              ⎪                    ⎩                                                                           ⎭             ⎪⎪⎪
#⁠⁠⁠⁠                                                ⎪   ⎪              ⎩                                                                                                              ⎭⎪⎪
#⁠⁠⁠⁠                                                ⎪   ⎩                                                                                                                              ⎭⎪
#⁠⁠⁠⁠                                                ⎩                                                                                                                                   ⎭

# OLD, DIRTIER CODE: (for example, it references shell_command twice!! The new one of course doesn't do that.)
# def text_to_speech_via_apple(msg:str,voice="Samantha",run_as_thread=True,filter_characters=True):
#     if filter_characters:
#         msg=''.join(list(c if c.isalnum() or c in ".," else " " for c in msg))# remove_characters_that_confuse_the_terminal
#     # Only works on macs
#     assert voice in text_to_speech_voices_for_apple
#     if run_as_thread:
#         run_as_new_thread(lambda :shell_command("say -v "+voice+" "+msg))
#     else:
#         shell_command("say -v " + voice + " " + msg)
# endregion
# region ［text_to_speech_via_google］
text_to_speech_voices_for_google=['fr','es-us','el','sr','sv','la','af','lv','zh-tw','sq','da','en-au','ko','cy','mk','id','hy','es','ro','is','zh-yue','hi','zh-cn','th','ta','it','de','ca','sw','ar','nl','pt','cs','sk','ja','tr','zh','hr','es-es','eo','pt-br','pl','fi','hu','en','ru','en-uk','bn','no','en-us','vi']

_text_to_speech_via_google_sound_cache={}

#def text_to_sound(text):
#    #Takes a string, turns it into audio (a numpy vector with range [-1,1]) via google's text-to-speech api

def text_to_speech_via_google(text: str,voice='en',*,play_sound: bool = True,run_as_thread: bool = True):
    """
    Convert text to speech using Google Translate's Text-to-Speech API.
    
    A _via_ variant function implementing the Google TTS backend. This implementation uses 
    Google's translate service to generate high-quality speech synthesis with support for 
    50+ languages and accents. Note that this requires an internet connection and has 
    higher latency compared to native system TTS functions.
    
    Enhanced Documentation:
    - Usage patterns: Multi-language speech synthesis, accessibility features, voice notifications
    - Related functions: text_to_speech() for platform-agnostic TTS, text_to_speech_via_apple() for macOS native
    - Performance: Requires internet connection, caches results, ~1-2s latency for first request per text/voice combo
    - Backend: Custom gTTS implementation with tokenization for long text, audio file caching
    
    Parameters:
        text (str): Text to synthesize into speech. Automatically handles long text by chunking into 100-char segments.
        voice (str): Language/voice code. Defaults to 'en' (English). See supported voices list below.
        play_sound (bool): Whether to immediately play the generated audio. Defaults to True.
        run_as_thread (bool): Whether to run synthesis in background thread. Defaults to True for non-blocking operation.
    
    Returns:
        None: Function performs audio synthesis and playback as side effect.
    
    Supported Voices:
        Basic: 'en' (English), 'es' (Spanish), 'fr' (French), 'de' (German), 'it' (Italian), 'pt' (Portuguese), 'ru' (Russian), 'ja' (Japanese), 'ko' (Korean), 'zh' (Chinese)
        Accents: 'en-us', 'en-uk', 'en-au', 'es-es', 'es-us', 'zh-cn', 'zh-tw', 'zh-yue' (Cantonese), 'pt-br'
        Regional: 'da' (Danish), 'sv' (Swedish), 'no' (Norwegian), 'fi' (Finnish), 'is' (Icelandic), 'nl' (Dutch)
        Others: 50+ languages including Arabic, Hindi, Thai, Vietnamese, Welsh, etc.
        
    Examples:
        >>> text_to_speech_via_google("Hello world")  # Basic English
        >>> text_to_speech_via_google("Bonjour le monde", voice="fr")  # French
        >>> text_to_speech_via_google("こんにちは", voice="ja", play_sound=False)  # Japanese, no playback
        >>> text_to_speech_via_google("This is a long sentence that will be automatically chunked into smaller segments for the Google API.", voice="en-au")  # Australian English
    
    Tags: tts, speech-synthesis, google, audio, via-variant, multilingual
    """
    # This only works when online, and has a larger latency than the native OSX text-to-speech function
    # Favorite voices: da
    # region gTTS: My own version of https://github.com/pndurette/gTTS (I modified it so that it can actually play voices from other languages, which it couldn't do before. I put that functionality in a comment because I don't know how to use Github yet (Feb 2017))
    pip_import('requests')
    import re,requests
    pip_import('gtts_token')
    from gtts_token.gtts_token import Token
    mp3_file_path=temporary_file_path('mp3')
    class gTTS:
        """ gTTS (Google Text to Speech): an interface to Google'_s Text to Speech API """

        GOOGLE_TTS_URL='https://translate.google.com/translate_tts'
        MAX_CHARS=100  # Max characters the Google TTS API takes at a time
        LANGUAGES={
            'af':'Afrikaans',
            'sq':'Albanian',
            'ar':'Arabic',
            'hy':'Armenian',
            'bn':'Bengali',
            'ca':'Catalan',
            'zh':'Chinese',
            'zh-cn':'Chinese (Mandarin/China)',
            'zh-tw':'Chinese (Mandarin/Taiwan)',
            'zh-yue':'Chinese (Cantonese)',
            'hr':'Croatian',
            'cs':'Czech',
            'da':'Danish',
            'nl':'Dutch',
            'en':'English',
            'en-au':'English (Australia)',
            'en-uk':'English (United Kingdom)',
            'en-us':'English (United States)',
            'eo':'Esperanto',
            'fi':'Finnish',
            'fr':'French',
            'de':'German',
            'el':'Greek',
            'hi':'Hindi',
            'hu':'Hungarian',
            'is':'Icelandic',
            'id':'Indonesian',
            'it':'Italian',
            'ja':'Japanese',
            'ko':'Korean',
            'la':'Latin',
            'lv':'Latvian',
            'mk':'Macedonian',
            'no':'Norwegian',
            'pl':'Polish',
            'pt':'Portuguese',
            'pt-br':'Portuguese (Brazil)',
            'ro':'Romanian',
            'ru':'Russian',
            'sr':'Serbian',
            'sk':'Slovak',
            'es':'Spanish',
            'es-es':'Spanish (Spain)',
            'es-us':'Spanish (United States)',
            'sw':'Swahili',
            'sv':'Swedish',
            'ta':'Tamil',
            'th':'Thai',
            'tr':'Turkish',
            'vi':'Vietnamese',
            'cy':'Welsh'
        }

        def __init__(self,text,lang='en',debug=False):
            self.debug=debug
            if lang.lower() not in self.LANGUAGES:
                raise Exception('Language not supported: %s' % lang)
            else:
                self.lang=lang.lower()

            if not text:
                raise Exception('No text to speak')
            else:
                self.text=text

            # Split text in parts
            if len(text) <= self.MAX_CHARS:
                text_parts=[text]
            else:
                text_parts=self._tokenize(text,self.MAX_CHARS)

                # Clean
            def strip(x):
                return x.replace('\n','').strip()
            text_parts=[strip(x) for x in text_parts]
            text_parts=[x for x in text_parts if len(x) > 0]
            self.text_parts=text_parts

            # Google Translate token
            self.token=Token()

        def save(self,savefile):
            """ Do the Web request and save to `savefile` """
            with open(savefile,'wb') as f:
                self.write_to_fp(f)
                f.close()

        def write_to_fp(self,fp):
            LANGUAGES={'af':'Afrikaans','sq':'Albanian','ar':'Arabic','hy':'Armenian','bn':'Bengali','ca':'Catalan','zh':'Chinese','zh-cn':'Chinese (Mandarin/China)','zh-tw':'Chinese (Mandarin/Taiwan)','zh-yue':'Chinese (Cantonese)','hr':'Croatian','cs':'Czech','da':'Danish','nl':'Dutch','en':'English','en-au':'English (Australia)','en-uk':'English (United Kingdom)','en-us':'English (United States)','eo':'Esperanto','fi':'Finnish','fr':'French','de':'German','el':'Greek','hi':'Hindi','hu':'Hungarian','is':'Icelandic','id':'Indonesian','it':'Italian','ja':'Japanese','ko':'Korean','la':'Latin','lv':'Latvian','mk':'Macedonian','no':'Norwegian','pl':'Polish','pt':'Portuguese','pt-br':'Portuguese (Brazil)','ro':'Romanian','ru':'Russian','sr':'Serbian','sk':'Slovak','es':'Spanish','es-es':'Spanish (Spain)','es-us':'Spanish (United States)','sw':'Swahili','sv':'Swedish','ta':'Tamil','th':'Thai','tr':'Turkish','vi':'Vietnamese','cy':'Welsh'}
            """ Do the Web request and save to a file-like object """
            for idx,part in enumerate(self.text_parts):
                payload={'ie':'UTF-8',
                         'q':part,
                         'tl':self.lang,
                         'total':len(self.text_parts),
                         'idx':idx,
                         'client':'tw-ob',
                         'textlen':len(part),
                         'tk':self.token.calculate_token(part)}
                headers={
                    "Referer":"http://translate.google.com/",
                    "User-Agent":"Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/47.0.2526.106 Safari/537.36"
                }
                if self.debug: print(payload)
                try:
                    r=requests.get(self.GOOGLE_TTS_URL,params=payload,headers=headers)
                    if self.debug:
                        print("Headers: {}".format(r.request.headers))
                        print("Reponse: {}, Redirects: {}".format(r.status_code,r.history))
                    r.raise_for_status()
                    for chunk in r.iter_content(chunk_size=1024):
                        fp.write(chunk)
                except Exception:
                    raise

        def _tokenize(self,text,max_size):
            """ Tokenizer on basic roman punctuation """

            punc="¡!()[]¿?.,;:—«»\n"
            punc_list=[re.escape(c) for c in punc]
            pattern='|'.join(punc_list)
            parts=re.split(pattern,text)

            min_parts=[]
            for p in parts:
                min_parts+=self._minimize(p," ",max_size)
            return min_parts

        def _minimize(self,thestring,delim,max_size):
            """ Recursive function that splits `thestring` in chunks
            of maximum `max_size` chars delimited by `delim`. Returns list. """

            if len(thestring) > max_size:
                idx=thestring.rfind(delim,0,max_size)
                return [thestring[:idx]] + self._minimize(thestring[idx:],delim,max_size)
            else:
                return [thestring]
                # endregion
    # endregion
    if run_as_thread:
        return run_as_new_thread(
            text_to_speech_via_google(
                text=text,
                voice=voice,
                mp3_file_path=mp3_file_path,
                play_sound=play_sound,
                run_as_thread=False,
            )
        )
    # Note that this method has to save a sound file in order for it to work. I put a default sound_file_path so that it will overwrite itself each time, so that I can avoid putting a ,delete_sound_file_afterwards:bool=True parameter in there (in case you do infact want to save a file)
    # NOTE: sound_file_path is only compatible with .mp3 files, so don't try putting a wav extension on it (it will break it)!
    lang=voice
    assert lang in text_to_speech_voices_for_google,'r.text_to_speech_via_google: The language you input, "' + lang + '", is not a valid option! Please choose one of the following values for lang instead: ' + ', '.join(text_to_speech_voices_for_google)  # These are the available languages we can choose from.

    if not (text,lang) in _text_to_speech_via_google_sound_cache:
        gTTS(text=text,lang=lang).save(mp3_file_path)  # gTTS is a class, and .save is a function of an instance of that class.
        _text_to_speech_via_google_sound_cache[text,lang]=load_sound_file(mp3_file_path,samplerate=True)
        
    samples,samplerate=_text_to_speech_via_google_sound_cache[text,lang]
    if play_sound:
        play_sound_from_samples(samples,samplerate)
    if file_exists(mp3_file_path):
        delete_file(mp3_file_path)

# endregion
text_to_speech_voices_all=text_to_speech_voices_for_apple + text_to_speech_voices_for_google
text_to_speech_voices_favorites=['da','en-au','zh-yue','hi','sk','zh','en','it','Samantha','Alex','Moira','Tessa','Fiona','Fred']
def text_to_speech_voices_comparison(text="Hello world",time_per_voice=2,voices=None):
    """
    Will cycle through different voices so you can choose which one you like best. I selected my favorite voices to be the beginning, and it will cycle through all available voices by the end.
    """
    voices = voices or text_to_speech_voices_favorites + shuffled(text_to_speech_voices_all)
    for voice in voices:
        print("Voice: " + voice)
        text_to_speech(text=text,voice=voice,run_as_thread=True)
        sleep(time_per_voice)
def text_to_speech(text: str,voice: str = None,run_as_thread=True):
    """
    An abstract combination of the other two text-to-speech methods that automatically selects the right one depending on platform compatiability/whether you specified a compatiable voice etc.
    Feel free to add more methods into this one: This is what makes the r module so generalizable.

    Enhanced Documentation:
    
    Multi-platform text-to-speech system that automatically selects the best
    available TTS engine based on platform and voice compatibility.
    
    Parameters:
        text (str): Text content to convert to speech
        voice (str, optional): Specific voice name to use. Auto-selects if None.
        run_as_thread (bool): Whether to run TTS in separate thread (non-blocking)
    
    Usage Examples:
        >>> # Basic text-to-speech
        >>> text_to_speech("Hello, world!")
        
        >>> # Blocking speech (wait for completion)
        >>> text_to_speech("Important message", run_as_thread=False)
        
        >>> # Use specific voice
        >>> text_to_speech("Custom voice", voice="Alex")
    
    Related Functions:
        - text_to_speech_via_apple(): macOS system TTS
        - text_to_speech_via_google(): Google TTS API
        - text_to_speech_voices_comparison(): Test different voices
    
    Notes:
        - Uses multiplexing pattern to select best TTS backend
        - Automatically detects platform capabilities
        - Thread-safe for concurrent speech synthesis
    
    Tags: speech, tts, audio, voice, multiplexing, cross-platform
    """
    if run_as_thread:
        run_as_new_thread(text_to_speech,text=text,voice=voice,run_as_thread=False)
    else:
        kwargs=dict(text=text,run_as_thread=False)
        if voice is not None:
            if voice.lower() == 'random':  # A little tidbit i decided to throw in
                voice=random_element(text_to_speech_voices_favorites)
            kwargs['voice']=voice
        if currently_running_mac():
            text_to_speech_via_apple(**kwargs)
        else:
            text_to_speech_via_google(**kwargs)
# endregion
# region Audio/Sound Functions: ［load_sound_file，play_sound_from_samples，play_sound_file，play_sound_file_via_afplay，play_sound_file_via_pygame，stop_sound，mp3_to_wav］

# np=None
# def _module_loader():
#     try:
#         import numpy#importing numpy takes bootup time
#         global np
#         np=numpy
#         np.set_printoptions(precision=3)#My personal default print option preference: I don't want to see all those digits.
#     except:
#         pass
# _module_loader()# run_as_new_thread(_module_loader) <--- This caused problems when I tried to show images, so the bootup speed increase (like .1 seconds) is definately not worth it



fig=None
def _fig():
    #initialize the fig singleton
    global fig
    if fig is None:
        global plt
        plt=get_plt()
        fig=plt.gcf()#to Get Current Figure
        fig=plt.figure()#Commented this line out because this created a second figure. This has to be done in the main thread or else Mac OS Mojave will crash
    return fig

def set_numpy_print_options(**kwargs):
    """
    np.set_printoptions is used to format the printed output of arrays. It makes the terminal output much easier to read depending on your context.
    However, it has a flaw: you can't set a single option without resetting all the other options to the default values.
    In other words, when you use np.set_printoptions, such as...
           np.set_printoptions(precision=3,suppress=True,edgeitems=123,linewidth=get_terminal_width()),
    ...only every parameter you didn't specify will be reset to the default value. This isn't as useful as it could be.
    Introducing set_numpy_print_options: This function takes the same arguments that np.set_printoptions does, except it sets only the arguments you give it.
    See np.set_printoptions?/ for more documentation on what these arguments do.
    EXAMPLE: set_numpy_print_options(precision=8) #Prints floating points with up to 8 decimals of precision
    """
    import numpy as np
    for kwarg in kwargs:
        #Make sure we feed only valid parameters to np.set_printoptions
        assert kwarg in np.get_printoptions(),'set_numpy_print_options: '+repr(kwarg)+' is not a valid argument name. Available print options: '+repr(np.get_printoptions())#Prints something like this: "AssertionError: set_numpy_print_options: 'sodf' is not a valid argument name. Available print options: {'nanstr': 'nan', 'precision': 3, 'floatmode': 'maxprec', 'linewidth': 152, 'formatter': None, 'suppress': False, 'edgeitems': 3, 'infstr': 'inf', 'sign': '-', 'legacy': False, 'threshold': 1000}"
    np.set_printoptions(**{**np.get_printoptions(),**kwargs})


def load_mp3_file(path):
    """
    Takes an mp3 file path, and returns a bunch of samples as a numpy array
    Returns floating-point samples in the range [-1.0 , 1.0]
    """
    pip_import('pydub')
    import pydub
    
    #A function I got from stackoverflow, minimally changed
    #https://stackoverflow.com/questions/53633177/how-to-read-a-mp3-audio-file-into-a-numpy-array-save-a-numpy-array-to-mp3
    #TODO: Use this same answer to create a save_mp3_file function
    def read(f, normalized=False):
        """MP3 to numpy array"""
        a = pydub.AudioSegment.from_mp3(f)
        y = np.array(a.get_array_of_samples())
        if a.channels == 2:
            y = y.reshape((-1, a.channels))
        if normalized:
            return a.frame_rate, np.float32(y) / 2**15
        else:
            return a.frame_rate, y
    samplerate,samples= read(path,True)
    return samples,samplerate

def load_wav_file(path):
    """
    Takes a wav file path, and returns a bunch of samples as a numpy array
    Returns floating-point samples in the range [-1.0 , 1.0]
    """
    pip_import('scipy')
    import scipy.io.wavfile as wav
    samplerate,samples=wav.read(path)
    try:
        samples=np.ndarray.astype(samples,float) / np.iinfo(samples.dtype).max  # ⟶ All samples ∈ [-1,1]
    except Exception:
        pass
    return samples,samplerate

def adjust_samplerate(samples,original_samplerate:int,new_samplerate:int):
    """
    Used to change the samplerate of an audio clip (for example, from 9600hz to 44100hz)
    """
    pip_install('scipy') 

    from scipy.signal import resample
    length_in_seconds=len(samples) / original_samplerate
    new_number_of_samples=int(length_in_seconds * new_samplerate)
    return resample(samples,num=new_number_of_samples)

def load_sound_file(file_path:str, samplerate:int=None):
    """
    Returns the contents of a sound file at file_path as a numpy array of floats in the range [-1, 1]
    samplerate: either True, None or an int. If True, returns (samples, samplerate). If None, returns (samples at original samplerate). If int, returns (samples converted to samplerate).
    TODO: Add conversion functions between stereo and mono, and add parameters to this function that use them
    """

    #Make sure we support the requested file type
    assert isinstance(file_path,str),'r.load_sound_file: file_path must be a string, but you gave it a %s'%str(type(file_path))
    assert has_file_extension(file_path), 'r.load_sound_file: Your file doesnt have an extension, so I\'m not sure what to do with it. Your file path: %s. Supported filetypes include: %s'%(repr(file_path),', '.join(supported_filetypes))
    supported_filetypes=['mp3','wav']
    filetype=get_file_extension(file_path)
    assert filetype.lower() in supported_filetypes, 'r.load_sound_file: Sorry, but this function doesnt support %s files. It only supports the following filetypes: %s'%(filetype,', '.join(supported_filetypes))

    #Load the specific filetype
    if   filetype=='wav': samples, original_samplerate = load_wav_file(file_path)
    elif filetype=='mp3': samples, original_samplerate = load_mp3_file(file_path)

    #Handle the samplerate parameter
    if samplerate is True:
        return samples, original_samplerate
    elif samplerate is None:
        return samples
    elif is_number(samplerate):
        if samplerate!=original_samplerate:
            samples=adjust_samplerate(samples, original_samplerate, samplerate)
        return samples
    else:
        assert False,'r.load_sound_file: samplerate must either be True (which will return both the samples and the samplerate), None (which will return the audio at its original samplerate)elif , or an integer representing the desired samplerate.'

#def load_sound_file(file_path: str,samplerate_adjustment=False,override_extension: str = None) :
#    #TODO: Integrate this function with load_mp3_file
#    #TODO: Use the 'audioread' library to decode more than just .wav files, using more than just ffmpeg. This will make this function more robust. https://github.com/beetbox/audioread
#    # Opens sound files and turns them into numpy arrays! Unfortunately right now it only supports mp3 and wav files.
#    # Supports only .mp3 and .wav files.
#    # samplerate_adjustment:
#    # If true, your sound will be re-sampled to match the default_samplerate.
#    # If false, it will leave it as-is.
#    # If it'_s None, this function will output a tuple containing (the original sound, the original samplerate)
#    # Otherwise, it should be a number representing the desired samplerate it will re-sample your sound to match the given samplerate.
#    # Set override_extension to either 'mp3' or 'wav' to ignore the extension of the file name you gave it. For example, using override_extension='mp3' on 'music.wav' will force it to read music as an mp3 file instead.
#    if file_path.endswith(".mp3") or override_extension is not None and 'mp3' in override_extension:
#        return load_mp3_file(file_path)
#        file_path=mp3_to_wav(file_path)
#    else:
#        assert file_path.endswith(".wav") or 'wav' in override_extension,'sound_file_to_samples: ' + file_path + " appears to be neither an mp3 nor wav file." + " Try overriding the extension?" * (override_extension is None)
#    pip_import('scipy')
#    import scipy.io.wavfile as wav
#    samplerate,samples=wav.read(file_path)
#    try:
#        samples=np.ndarray.astype(samples,float) / np.iinfo(samples.dtype).max  # ⟶ All samples ∈ [-1,1]
#    except Exception:
#        pass

#    if samplerate_adjustment is False:
#        return samples
#    if samplerate_adjustment is None:
#        return samples,samplerate
#    new_samplerate=default_samplerate if samplerate_adjustment is True else samplerate_adjustment
#    if new_samplerate == samplerate:  # Don't waste time by performing unnecessary calculations.
#        return samples
#    from scipy.signal import resample
#    length_in_seconds=len(samples) / samplerate
#    new_number_of_samples=int(length_in_seconds * new_samplerate)
#    return resample(samples,num=new_number_of_samples)

def save_wav(samples,path,samplerate=None) -> None:  # Usually samples should be between -1 and 1
    pip_import('scipy')
    from scipy.io import wavfile
    if samples.dtype == np.float64:
        samples=samples.astype(np.float32)
    wavfile.write(path,samplerate or default_samplerate,samples)

default_samplerate=44100  # In (Hz ⨯ Sample). Used for all audio methods in the 'r' class.
def play_sound_from_samples(samples,samplerate=None,blocking=False,loop=False,**kwargs):
    """
    For stereo, use a np matrix
    Example: psfs((x%100)/100 for x in range(100000))
    Each sample should ∈ [-1,1] or else it will be clipped (if it wasn't clipped it would use modular arithmeti
    c on the int16, which would be total garbage for sound)
    Just like matlab'_s 'sound' method, except this one doesn't let you play sounds on top of one-another.
    """
    try:
        pip_import('sounddevice')
    except OSError as error:
        if OSError.args==('PortAudio library not found',) and currently_running_linux:
            fansi_print("Error importing sounddevice; try running\n\tsudo apt-get install libportaudio2","red")
        raise
    if not running_in_ipython():
        import sounddevice
        wav_wave=np.array(np.minimum(2 ** 15 - 1,2 ** 15 * np.maximum(-1,np.minimum(1,np.matrix(list(samples)))).transpose()),dtype=np.int16)  # ⟵ Converts the samples into wav format. I tried int32 and above: None of them worked. 16-bit seems to be the highest resolution available.
        sounddevice.play(wav_wave,samplerate=samplerate or default_samplerate,blocking=blocking,loop=loop,**kwargs)
    else:
        #This works in google colab!
        from IPython.display import Audio
        assert not loop,'This function cannot currently play looped audio when running in Jupyter'
        assert not blocking,'This function cannot currently block while playing audio when running in Jupyter'#This might change in future versions of rp
        Audio(samples,rate=samplerate,autoplay=True)

def play_sound_file(path):
    """
    THIS Function is an abstraction of playing sound files. Just plug in whatever method works on your computer into this one to make it work
    NOTE: These functions should all run on separate threads from the main thread by default!
    """
    try:
        if currently_running_linux():
            samples,samplerate=load_sound_file(path,samplerate=True)
            # ic(samples,samplerate)
            play_sound_from_samples(samples,samplerate)

        elif currently_running_mac():
            play_sound_file_via_afplay(path)
            
        elif currently_running_windows():
            pip_import('playsound')
            from playsound import playsound
            playsound(path)# Worked on windows, but didn't work on my mac

    except Exception:
        play_sound_file_via_pygame(path)

def play_sound_file_via_afplay(absolute_file_path_and_name: str,volume: float = None,rate: float = None,rate_quality: float = None,parallel: bool = True,debug: bool = True):
    """
    Use stop_sound to stop it.
    If parallel==False, the code will pause until the song is finished playing.
    If parallel==True the sound is run in a new process, and returns this process so you can .terminate() it later. It lets things continue as usual (no delay before the next line of code)
    This seems to be a higher quality playback. On the other hand, I can't figure out any way to stop it.
    This version doesn't require any dependencies BUT doesn't work on windows and doesn't let us play .mp3 files. The new version uses pygame and DOES allow us to.
    Only tested on my MacBook. Uses a terminal command called 'afplay' to play a sound file.
    Might not work with windows or linux.
    """
    command="afplay '" + absolute_file_path_and_name + "'"
    if rate is not None:
        assert rate > 0,"r.play_sound_file_via_afplay: Playback rate cannot rate=" + str(rate)
        command+=' -r ' + str(rate)
    if rate_quality is not None:
        if rate is None and debug:
            print("r.play_sound_file_via_afplay: There'_s no reason for rate_quality not to be none: rate==None, so rate_quality doesn't matter. Just sayin'. To make me shut up, turn the debug parameter in my method to True.")
        command+=' -q ' + str(rate_quality)
    if volume is not None:
        command+=' -v ' + str(volume)
    return (run_as_new_thread if parallel else run_func)(shell_command,command)  # If parallel==True, returns the process so we can terminate it later.

def play_sound_file_via_pygame(file_name: str,return_simple_stopping_function=True):
    """
    Old because it uses the pygame.mixer.sound instead of pygame.mixer.music, which accepts more file types and has more controls than this one does.
    Though, audio and file things are weird. I'm keeping this in case the other two fail for some reason. Other than being a backup like that, this method serves no purpose.
    noinspection PyUnresolvedReferences
    """
    pip_import('pygame')
    import pygame
    pygame.init()
    pygame.mixer.init()
    sound=pygame.mixer.Sound(file_name)
    assert isinstance(sound,pygame.mixer.Sound)
    sound.play()
    if return_simple_stopping_function:
        return sound.stop  # The 'Sound' class has only two methods: play and stop. Because we've already used the play method, the only other possible method we would want is the stop() method.
    return sound  # This version gives us a little more control; it gives us the 'play' method too. That'_s the only difference. but python doesn't tell us the method names! This gives us options to, perhaps, stop the sound later on via sound.stop()

def stop_sound():
    """
    Stop sounds from all sources I know of that the 'r' module can make.
    So far I have been unsuccessful in stopping
    """
    try:
        shell_command("killall afplay")  # Used with 'play_sound_file_via_afplay' on macs.
    except ImportError:
        pass
    # try:run_as_new_thread(shell_command,"killall com.apple.speech.speechsynthesisd")# ⟵ Works when I enter the command in terminal, but doesn't work when called from python! It'_s not very important atm though, so I'm not gonna waste time over it.
    # except Exception:pass
    try:
        import sounddevice
        sounddevice.stop()
    except ImportError:
        pass
    try:
        import pygame
        pygame.mixer.stop()
    except ImportError:
        pass

def mp3_to_wav(mp3_file_path: str,wav_output_path: str = None,samplerate=None) -> str:
    """
    This is a audio file converter that converts mp3 files to wav files.
    You must install 'lame' to use this function.
    Saves a new wav file derived from the mp3 file you gave it.
    shell_command('lame --decode '+mp3_file_path+" "+wav_output_path)# From https://gist.github.com/kscottz/5898352
    """

    if wav_output_path is None:
        wav_output_path = os.path.splitext(mp3_file_path)[0] + ".wav"

    _run_sys_command(
        "lame "
        + str(samplerate or default_samplerate)
        + " -V 0 -h --decode "
        + shlex.quote(mp3_file_path)
        + " "
        + shlex.quote(wav_output_path)
    )  # From https://gist.github.com/kscottz/5898352

    return wav_output_path

def wav_to_mp3(wav_file_path: str, mp3_output_path: str = None, samplerate: int = 44100) -> str:
    """
    This is an audio file converter that converts wav files to mp3 files.
    You must install 'ffmpeg' to use this function.
    Saves a new mp3 file derived from the wav file you gave it.
    """
    
    _ensure_ffmpeg_installed()


    import os
    import subprocess

    if mp3_output_path is None:
        mp3_output_path = os.path.splitext(wav_file_path)[0] + ".mp3"
    
    cmd = ['ffmpeg', '-i', wav_file_path]
    if samplerate:
        cmd.extend(['-ar', str(samplerate)])
    cmd.extend(['-y', mp3_output_path])
    cmd+='-hide_banner -loglevel error'.split()
    
    subprocess.run(cmd, check=True)
    return mp3_output_path

def convert_audio_file(input_file, output_file, *, skip_existing=False, show_progress=False):
    """
    Convert an audio file to a different format using FFmpeg.

    Args:
        input_file (str): Path to the input audio file.
        output_file (str): Desired output format or path. If only extension is provided
                          (e.g., 'mp3', 'wav'), output will use input filename with new extension.
                          Supported formats: wav, mp3, ogg, mp4.
        skip_existing (bool, optional): If True, won't overwrite existing files. Defaults to False.
        show_progress (bool, optional): If True, shows FFmpeg progress output. Defaults to False.

    Returns:
        str: Path to the converted audio file.

    Raises:
        RuntimeError: If FFmpeg encounters an error or is not installed.
        FileNotFoundError: If the input file does not exist or output file could not be created.

    Notes:
        - Requires FFmpeg to be installed and available in PATH.
        - Automatically creates a unique filename if output path already exists.

    EXAMPLE:
        >>> convert_audio_file('/Users/ryan/Downloads/Diffusion Illusions: SIGGRAPH 2024 Talk.mp4','wav')
        ans = /Users/ryan/Downloads/Diffusion Illusions: SIGGRAPH 2024 Talk_copy.wav
    """
    import subprocess
    import os

    if not os.path.exists(input_file):
        raise FileNotFoundError("Input file not found: "+input_file)

    _ensure_ffmpeg_installed()

    supported_output_filetypes = "wav ogg mp3 mp4".split()

    if output_file in supported_output_filetypes or "." + output_file in supported_output_filetypes:
        output_file = rp.with_file_extension(input_file, output_file, replace=True)

        if output_file==input_file:
            #Converting a file to its own type...take a shortcut and just return the original file!
            return input_file

        output_file = rp.get_unique_copy_path(output_file)

    if os.path.exists(output_file) and skip_existing:
        return output_file

    make_parent_directory(output_file)

    maybe_silent = ["-loglevel", "quiet"] if not show_progress else []

    try:
        subprocess.run(
            ["ffmpeg"] + maybe_silent + ["-i", input_file, "-y", output_file],
            check=True,
            stdout=subprocess.PIPE if not show_progress else None,
            stderr=subprocess.PIPE if not show_progress else None,
        )
    except subprocess.CalledProcessError as e:
        error_msg = e.stderr.decode() if e.stderr else "Unknown error"
        raise RuntimeError("Error converting audio file: " + error_msg) from e
    except FileNotFoundError:
        raise RuntimeError("FFmpeg not found. Please install FFmpeg to convert audio files.")

    if not os.path.exists(output_file):
        raise FileNotFoundError("Failed to create output file: " + output_file)

    return output_file


def convert_audio_files(
    input_files=".",
    output_format=None,
    output_folder=None,
    *,
    strict=True,
    parallel=True,
    show_progress=False,
    skip_existing=False
):
    """
    Convert multiple audio files to a specified format using FFmpeg.
    
    Args:
        input_files (list or str): Paths to the audio files to be converted, or a folder containing audio files.
                                   Defaults to '.', aka all audio files in the current working directory.
        output_format (str, optional): Desired output format for the files (e.g., 'mp3', 'wav', 'ogg', 'mp4').
                                      If None, the format is not changed.
        output_folder (str, optional): Path to the folder where the converted audio files will be saved.
                                      If not provided, files are saved in the same folder as the input files.
        strict (bool, optional): Controls what happens when an audio file fails to convert.
                                If True (default), an error is raised.
                                If False, files that fail to convert are skipped.
                                If None, positions of files that fail to convert are filled with None in the output.
        parallel (bool, optional): If True, runs multithreaded. Otherwise computes in main thread.
        show_progress (bool, optional): If True, shows a progress bar. Defaults to False.
        skip_existing (bool, optional): If True, won't overwrite existing files. Defaults to False.
    
    Returns:
        list: Paths to the converted audio files.
    
    Raises:
        RuntimeError: If FFmpeg encounters an error or is not installed (in strict mode).
        FileNotFoundError: If input files do not exist or output files could not be created (in strict mode).
    
    Notes:
        - Requires FFmpeg to be installed and available in PATH.
        - Automatically creates unique filenames if output paths already exist.
        - Supports wav, mp3, ogg, mp4 formats.
    
    EXAMPLE:
        >>> convert_audio_files(['audio1.mp4', 'audio2.mp4'], 'mp3')
        ans = ['audio1.mp3', 'audio2.mp3']
    """
    assert output_folder is None or isinstance(output_folder, str), "Output folder must be a string, but got {}.".format(type(output_folder))

    def convert_single_audio_file(input_file):
        output = convert_audio_file(input_file, output_format or input_file, skip_existing=skip_existing)
        
        if output_folder:
            import os
            basename = os.path.basename(output)
            final_output = os.path.join(output_folder, basename)
            if final_output != output:
                make_parent_directory(final_output)
                os.rename(output, final_output)
                output = final_output
        
        return output

    return _convert_files(
        input_files=input_files,
        get_all_files_func=get_all_audio_files,
        convert_single_file_func=convert_single_audio_file,
        progress_title='Converting Audio Files',
        strict=strict,
        parallel=parallel,
        show_progress=show_progress
    )

class _TranscribedSegment:
    """Text segment with timing information from speech recognition.

    >>> seg = _TranscribedSegment("Hello world", 1.5, 3.2)
    >>> seg.text
    'Hello world'
    >>> seg.time_start
    1.5
    >>> seg.time_end
    3.2
    >>> seg.duration
    1.7
    """
    def __init__(self, text, time_start, time_end):
        self.text = text
        self.time_start = time_start
        self.time_end = time_end

    @property
    def duration(self):
        return self.time_end - self.time_start

    def __repr__(self):
        return "<{0} @ {1:.2f}s>".format(repr(self.text), self.time_start, self.duration)
        # return "_TranscribedSegment('{0}', {1:.2f}s, {2:.2f}s)".format(self.text, self.time_start, self.duration)


class _Transcription(list):
    """
    List of transcribed segments with convenience text concatenation.

    Acts as a list of _TranscribedSegment objects, but provides a .text property
    for easy access to the full concatenated text.

    >>> seg1 = _TranscribedSegment("Hello", 0.0, 1.0)
    >>> seg2 = _TranscribedSegment("world", 1.5, 2.5)
    >>> trans = _Transcription([seg1, seg2])
    >>> trans.text
    'Hello world'
    >>> len(trans)
    2
    >>> trans[0].text
    'Hello'
    """
    @property
    def text(self):
        return ' '.join(seg.text for seg in self)


def transcribe_audio_file_via_whisper(path, *, model="base",show_progress=True):
    """
    Transcribe audio file, returning text segments with timestamps

    Audio transcription using whisper.cpp (with Apple Silicon Metal GPU support).
    Automatically uses Metal GPU acceleration on M1/M2/M3 Macs.
    Streams segments with accurate timestamps as transcription progresses.

    Args:
        path: Path to audio file (WAV, MP3, etc.)
        model: Whisper model size:
                    - "tiny": Fastest, lower accuracy
                    - "base": Good balance (default)
                    - "small": Better accuracy
                    - "medium": Very good accuracy, slower
                    - "large-v3": Best accuracy, slowest
        show_progress: Show loading/processing progress (default: True)

    Returns:
        _Transcription: List-like object containing _TranscribedSegment objects.
                        Has a .text property for full concatenated text.

    EXAMPLE:
        >>> result = transcribe_audio_file("audio.mp3")
        >>> result.text  # Full transcription
        'Hello world this is a test'
        >>> for segment in result:  # Iterate over segments
        ...     print(f"[{segment.time_start:.1f}s] {segment.text}")
    """

    pip_import('pywhispercpp')
    from pywhispercpp.model import Model
    import sys
    
    output = _Transcription()

    if show_progress:
        print("Loading {0} model...".format(model), flush=True)

    # Create model (automatically uses Metal GPU on Apple Silicon)
    # Suppress whisper.cpp debug logs
    model = Model(model, n_threads=4, redirect_whispercpp_logs_to=None)

    if show_progress:
        print("Processing audio...", flush=True)

    # Transcribe - use print_progress to show progress, print_realtime=False to suppress intermediate text
    segments = model.transcribe(path, print_progress=show_progress, print_realtime=show_progress)

    for segment in segments:
        text = segment.text.strip()
        if text:
            # Timestamps are in centiseconds, convert to seconds
            start_time = segment.t0 / 100.0
            end_time = segment.t1 / 100.0
            segment = _TranscribedSegment(text, start_time, end_time)
            output.append(segment)

    return output


# endregionx
# region  Matplotlib: ［display_image，brutish_display_image，display_color_255，display_grayscale_image，line_graph，block，clf］

def _display_image_in_notebook_via_ipyplot(image):
    """
    Private function to display images in Jupyter notebooks using ipyplot.
    Converts to RGB
    Used as a backend by display_image_in_notebook().
    
    Args:
        image: Image to display. Must pass is_image() validation
    """
    assert is_image(image)
    image=as_rgb_image(as_byte_image(image))
    image_width=get_image_width(image)
    pip_import('ipyplot').plot_images(images=[image],img_width=image_width,labels=[''])
    #pip_import('ipyplot').plot_images(images=[image],img_width=image_width,labels=[''],force_b64=True)#force_b64 is set to true so that ipyplot doesn't complain when we're in google colab: ' WARNING! Google Colab Environment detected!   If images are not displaying properly please try setting `base_64` param to `True`.' This has never been an issue, but the warning is annoying

def _display_image_in_notebook_via_ipython(image):
    import IPython
    return IPython.display.display_png(encode_image_to_bytes(image,'png'),raw=True)

def add_ipython_kernel(kernel_name: str = None, display_name: str = None):
    """
    Add the current Python interpreter as a Jupyter IPython kernel.

    Parameters:
    - kernel_name: The name for the kernel, as it would appear in the command to start it. For example, "python3.9".
    - display_name: The name as it appears in the Jupyter UI. Defaults to kernel_name. Example: "Python 3.9.5".

    Usage:
    add_current_python_as_kernel("python39", "Python 3.9.5")
    """
    pip_import('ipykernel')    

    if kernel_name is None:
        print("Please enter the title of the new iPython kernel:")
        default = _get_session_title()

        # Kernel names can't have whitespace
        default = default.strip() 
        default = '-'.join(default.split())

        kernel_name = input_default(' > ', default)

    import sys
    import subprocess

    assert isinstance(kernel_name, str)
    assert display_name is None or isinstance(display_name, str)

    if display_name is None:
        display_name = kernel_name

    command = [
        sys.executable, "-m", "ipykernel", "install", "--user",
        "--name", kernel_name,
        "--display-name", "Python " + display_name
    ]

    try:
        result = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
        stderr = result.stderr.decode()
    except Exception as e:
        raise RuntimeError("Error running subprocess: " + str(e))

    if result.returncode != 0:
        raise RuntimeError("Error adding kernel: " + stderr)

    print("Successfully added Python " + display_name + " as a Jupyter kernel.")


def display_video(video, framerate=30, *, loop=False):
    """
    Video can either be a string, or a video (aka a 4d tensor or iterable of images)
    Example: display_video('https://www.youtube.com/watch?v=jvipPYFebWc')

    Works on desktop, Jupyter, and Marimo

    For Marimo it returns a mo.video object - so it must be put at the end of a cell

    TODO: Implement loop arg properly for jupyter, right now it always loops
    """

    if isinstance(video,str):
        if not is_valid_url(video):
            if not file_exists(video):
                raise FileNotFoundError(video)
            assert is_video_file(video),repr(video)+' is not a video file'

    def loop_wrapper(video):
        if hasattr(video, '__getitem__') and hasattr(video, '__len__'):
            length = len(video)
            index = 0
            while True:
                index +=1
                yield video[index % length]
        else:
            seen_frames = []
            for frame in video:
                yield frame
                seen_frames.append(frame)
            while True:
                yield from seen_frames

    if running_in_jupyter_notebook():
        display_video_in_notebook(video,framerate=framerate)

    elif running_in_marimo_notebook():
        return _display_video_in_marimo(video, loop=loop, framerate=framerate)

    else:
        #Todo: Add keyboard controls to play, pause, rewind, restart, next frame, prev frame, go to frame, adjust framerate
        #It would be much like display_image_slideshow (maybe even add functionality to display_image_slideshow and use that?)
        if isinstance(video,str):
            video=load_video_stream(video)

        if loop:
            video = loop_wrapper(video)

        time_start=gtoc()
        time_per_frame=1/framerate

        for i, frame in enumerate(video):
            try:
                time_before_display = gtoc()
                display_image(frame)
                time_after_display = gtoc()
                sleep(max(0, time_per_frame - (time_after_display - time_before_display)))
            except KeyboardInterrupt:
                fansi_print("rp.display_video: Received KeyboardInterrupt - stopping playback", 'cyan', 'bold')
                break

def _display_video_in_marimo(video, *, framerate, loop):
    """
    Returns an object! You must put this at the end of the cell
    """
    import marimo as mo

    if isinstance(video, str):
        #If it's a path just display that path...
        content = video
    else:
        #Else we need to turn that video into bytes to display it...
        content = encode_video_to_bytes(video, framerate=framerate)

    return mo.video(content, loop=loop, autoplay=True)

# def display_video_in_notebook(video,framerate=30):
#     """
#     Video can be either a string pointing to the path of a video, or the video itself. If it is the video itself, it will be embedded as a gif and displayed that way. 
#     This function can also display gif's and other video URL's we find on the web
#     """
#     if isinstance(video,str):
#         if file_exists(video) or is_valid_url(video):
#             filetype=get_file_extension(video)
#
#             video_filetypes='webm mp4 ogg'.split() #These are the only video filetypes officially supported by the HTML standard (see https://www.w3schools.com/html/html_media.asp)
#             image_filetypes='gif'.split()
#
#             assert filetype in video_filetypes+image_filetypes,'Invalid filetype: '+repr(video)+', video must be one of these types: '+str(video_filetypes+image_filetypes).replace("'",'')
#
#             if filetype in video_filetypes:
#                 from IPython.display import Video,display_html
#                 if is_valid_url(video):
#                     display_html(Video(url=video))
#                 else:
#                     display_html(Video(data=video))
#             else:
#                 assert filetype in image_filetypes
#                 from IPython.display import Image,display_html
#                 if is_valid_url(video):
#                     display_html(Image(url=video))
#                 else:
#                     display_html(Image(data=video))
#         else:
#             raise FileNotFoundError(video)
#     else:
#         display_embedded_video_in_notebook(video)

def _make_video_dimensions_even(video):
    """ 
    Make the video have an even height and width. Used for saving MP4's. 
    Without this, if a video with odd height or odd width is displayed with mediapy, it renders as a black rectangle.
    If you download that video, it can be viewed with more niche video viewers like Videoloupe, but it cannot be viewed in Vivaldi
    """
    video = rp.crop_images_to_max_size(video)
    height, width = rp.get_video_dimensions(video)
    if height%2 or width%2:
        #Can't display an MP4 video with odd height or width!
        new_height = math.ceil(height/2)*2
        new_width  = math.ceil(width /2)*2
        video = rp.crop_images(video, new_height, new_width)
    return video


def _display_video_via_mediapy(video, framerate):
    """ Use mediapy to display a video in a Jupyter notebook """
    rp.pip_import('mediapy')
    import mediapy

    #Prepare the video
    video = rp.as_numpy_images(video)
    video = rp.as_rgb_images(video)
    video = rp.as_byte_images(video)
    video = _make_video_dimensions_even(video)

    return mediapy.show_video(video, fps=framerate)

def display_video_in_notebook(video, filetype='mp4', *, embed=True, framerate=60):
    """
    Display a video or image in a Jupyter notebook.

    Args:
        video: The video object to display.
            - Can be a video: i.e. a list of images as defined by rp.is_image (such as a list of PIL images), or a TCHW torch tensor with values between 0 and 1, or a THWC numpy array
            - Can be an existing file path: Such as /path/to/video.mp4
            - Can be a URL: Such as https://file-examples.com/storage/feaef0a3ad67b78fd9cc1df/2017/04/file_example_MP4_480_1_5MG.mp4
        filetype (str, optional): The filetype of the video or image. Supported filetypes are 'gif', 'png', 'mp4', 'webp' and 'avi'. Defaults to 'gif'.
        framerate (int, optional): The framerate of the video. Defaults to 60.
        embed (bool, optional): If true, encodes the video as a base-64 string into the notebook itelf - so that the video is saved with the notebook.
            If false, it will display it as a reference to some file on the host's computer. Good for decreasing filesize of the notebooks.

    Raises:
        ValueError: If an unsupported filetype is provided.

    Examples:
        >>> video = create_video(...)
        >>> display_video_in_jupyter_notebook(video, filetype='mp4', embed=True)
    """
    return gather_args_call(_display_video_in_notebook)

def _display_video_in_notebook(video, filetype, *, embed, framerate, save_video=None):

    if save_video is None:
        save_video=save_video

    from IPython.display import Image, display, HTML, Video

    filetype = filetype.strip('.').lower()
    image_filetypes = 'gif png webp'.split()
    video_filetypes = 'mp4 avi'.split()

    if embed and not isinstance(video, str) and filetype=='mp4':
        try:
            return _display_video_via_mediapy(video, framerate)
        except ImportError:
            #If we don't import mediapy, it's ok! We have a fallback, seen below.
            pass

    try:
        if isinstance(video, str):
            assert is_valid_url(video) or file_exists(video), 'rp.display_video_in_notebook: Video file {0} does not exist'.format(video)
            temp_path = video
            filetype = get_file_extension(temp_path)
        else:
            temp_path = temporary_file_path(filetype)
            save_video(video, temp_path, framerate=framerate)

        if not embed and filetype in video_filetypes and not is_valid_url(temp_path):
            #We need a url like http://0.0.0.0:5678/files/TEMP/video.png
            #For some reason this is needed on videos and NOT images??
            temp_path = get_relative_path(
                temp_path,
                _original_pwd,
            )

        if filetype in image_filetypes:
            if embed:
                image_hex = file_to_base64(temp_path)
                display_object = HTML('<img src="data:image/{0};base64,{1}">'.format(filetype, image_hex))
                # display_object = Image(filename=temp_path, embed=embed) #Equivalent - but the former offers more control if we need it later
            else:
                assert is_valid_url(temp_path), "I wasn't able to get embed=False to work with image paths yet: temp_path=%s"%temp_path
                display_object = Image(filename=temp_path, embed=embed)
        elif filetype in video_filetypes:
            if embed:
                video_hex = file_to_base64(temp_path)
                display_object = HTML('<video loop autoplay controls><source src="data:video/{0};base64,{1}" type="video/{0}"></video>'.format(filetype, video_hex))
            else:
                display_object = Video(filename=temp_path, html_attributes='autoplay loop controls')
        else:
            raise ValueError("rp.display_video_in_notebook: Unsupported filetype: {0}. Supported filetypes are {1}".format(filetype, ', '.join(image_filetypes + video_filetypes)))

        return display(display_object)

    finally:
        if not isinstance(video, str) and embed and file_exists(temp_path):
            delete_file(temp_path)

def display_video_in_notebook_webp(video, quality=100, framerate=60):
    """
    Displays an animated webp in a Jupyter notebook with a specified quality and framerate
    See rp.display_video_in_notebook's docstring for explanations of what the args do

    EXAMPLE:
        >>> import rp
        ... video_url = "https://file-examples.com/storage/feaef0a3ad67b78fd9cc1df/2017/04/file_example_MP4_480_1_5MG.mp4"
        ... video = rp.load_video(video_url, use_cache=True)
        ... 
        ... for quality in [1, 10, 25, 50, 90, 95, 100]:
        ...     #Ranges from ~500KB to 14MB
        ...     print(quality)
        ...     rp.display_video_in_notebook_webp(video, quality)
    """
    def save_video(video, path, framerate):
        return save_video_webp(video, path, quality=quality, framerate=framerate)
    return gather_args_call(_display_video_in_notebook, filetype='webp', embed=True)

# def display_embedded_video_in_notebook(video,framerate:int=30,filetype:str='gif'):
#     """
#     This will embed a video into the jupyter notebook you're using
#     Warning: This function is still experimental, and sometimes the videos are messed up a bit
#     Warning: This can make your notebooks very large, so please be careful to only use small videos with this function
#     """
#     assert running_in_jupyter_notebook(),'display_embedded_video_in_notebook: This function only works in a jupyter notebook, such as Google Colab or Jupyter Lab'
#     
#     video_filetypes='webm mp4 ogg'.split() #These are the only video filetypes officially supported by the HTML standard (see https://www.w3schools.com/html/html_media.asp)
#     image_filetypes='gif'.split()
#     assert filetype in video_filetypes+image_filetypes,'Invalid filetype: '+repr(filetype)+', please choose from '+str(video_filetypes+image_filetypes).replace("'",'')
#     
#     from IPython.display import HTML, display_html
#     from base64 import b64encode
#
#     
#     video_encoded = b64encode(encode_video_to_bytes(video,filetype,framerate=framerate)).decode()
#     
#     if filetype in video_filetypes:
#         html = '<video controls alt="test" src="data:video/{0};base64,{1}">'.format(filetype, video_encoded)
#     else:
#         assert filetype in image_filetypes
#         html = video_tag = '<img src="data:image/{0};base64,{1}" />'.format(filetype, video_encoded)
#     
#     display_html(html,raw=True)


def _display_downloadable_image_in_notebook_via_ipython(image, file_name:str):
    #When clicked, the image will be downloaded with the given file name
    pip_import("IPython")
    file_name=with_file_extension(file_name,'png')
    import base64
    from IPython.display import HTML,display
    img_str = base64.b64encode(encode_image_to_bytes(image,'png')).decode('utf-8')
    html = '<a href="data:image/png;base64,{img_str}" download="displayed_image.png">' \
           '<img src="data:image/png;base64,{img_str}" /></a>'.replace('{img_str}',img_str)
    display(HTML(html))

def display_image_in_notebook(image):#, file_name:str=None):
    """ Display an image at full resolution in a jupyter notebook. Returns an updatable channel. """

    channel = JupyterDisplayChannel()
    channel.update(image)
    channel.display()
    return channel
    

    # if file_name is not None:
    #     #TODO: This doesn't actually work right now :(
    #     assert isinstance(file_name,str), 'The given file name must be a string, but got type %s'%type(file_name)
    #     if not has_file_extension(file_name):
    #         file_name=with_file_extension(file_name,'png')
    #     _display_downloadable_image_in_notebook_via_ipython(image, file_name)

    #First method: Try to use iPython.display to do it directly. It's faster than ipyplot, and gives crisper images on my macbook.
    try: _display_image_in_notebook_via_ipython(image);return
    except Exception: pass

    #Second method: If that fails, try ipyplot. It gives good image displays as well.
    try: _display_image_in_notebook_via_ipyplot(image);return
    except Exception: raise

#def display_image_in_notebook(image):
#        #Display an image at full resolution in a jupyter notebook
#    assert is_image(image)
#    image=as_rgb_image(as_byte_image(image))
#    pip_import('ipyplot').plot_images([image],img_width=width(image))


def _image_to_html(image):
    # Also good: See below
    base64_image = rp.encode_image_to_base64(image)
    return '<img src="data:content/png;base64,%s"/>' % base64_image

class JupyterDisplayChannel:
    def __init__(self):
        """
        Used for displaying and updating content in Jupyter notebooks.
        It's analagous to a bunch of televisions, all subscribed to this channel.
        
        The JupyterDisplayChannel allows you to create multiple viewports and efficiently
        update them with various types of content, including text, numbers, images, and grids of these.

        First, create a channel. Then, display it whever you want (can be multiple places in your notebook).
        Then, push updates to it to show content with update() or grid_update()
        See the self-contained examples below for how to do this.

        TODO: Support more update methods, such as side-by-side images and slideshows etc
        TODO: Fully support more content types, such as video and audio
        
        EXAMPLE:
            >>> from rp import *
            >>> channel = JupyterDisplayChannel()

            >>> #You can have multiple viewports for a given channel
            >>> print("First viewport:")
            >>> channel.display()
            >>> print("Second viewport:")
            >>> channel.display()

            >>> #You can efficiently animate images this way
            >>> image = rp.cv_text_to_image("Hello\nWorld!")
            >>> for angle in range(360 * 3):
            >>>     channel.update(rp.rotate_image(image, angle))

            >>> #You can update anything that Jupyter can display
            >>> for num in range(45):
            >>>     channel.update(list(range(num%15)))
            >>>     rp.sleep(.1)

            >>> #Here's a demo showing how the grid works...
            >>> rows = [range(i) for i in range(10)]
            >>> for _ in range(30):
            >>>     rows = rows[1:] + [rows[0]]
            >>>     channel.grid_update(rows)
            >>>     rp.sleep(.1)
            
            >>> #And here's a demo showing how the grid can have images in it too...
            >>> colors = "red green blue cyan magenta yellow black".split()
            >>> rows = [
            >>>     [
            >>>         rp.rotate_image(
            >>>             rp.cv_text_to_image(color, background_color=rp.color_name_to_byte_color(color)),
            >>>             rp.random_int(-90, 90)
            >>>         ) for color in colors
            >>>     ],
            >>>     [
            >>>         rp.rotate_image(
            >>>             rp.cv_text_to_image(color, color=rp.color_name_to_byte_color(color)),
            >>>             rp.random_int(-90, 90)
            >>>         ) for color in colors
            >>>     ],
            >>>     colors
            >>> ]
            >>> for _ in range(30):
            >>>     rows = [row[1:] + [row[0]] for row in rows]
            >>>     channel.grid_update(rows)
            >>>     rp.sleep(0.1)
        """
        rp.pip_import("IPython")

        self._display_id = rp.random_namespace_hash()
        self._update(None)

    @staticmethod
    def _convert_content(content):
        from IPython.display import Image, HTML

        if content is None:
            #Return nothing
            return HTML("")
        elif rp.is_image(content):
            #Return an image
            return rp.as_pil_image(content)

            # Also good: See below
            return HTML(_image_to_html(content))
        else:
            #Return whatever you gave it
            return content

    
    @staticmethod
    def _convert_content_grid(content_grid):
        rp.pip_import('pandas')
        from pandas import DataFrame
        from IPython.display import HTML

        #If this errors you gave it an invalid grid
        grid = [list(row) for row in content_grid]

        #Make the grid rectangular by padding each row to max length
        width = max(map(len,grid))
        grid = [row + [None] * (width - len(row)) for row in grid]
        
        def convert_grid_item(item):
            if rp.is_image(item):
                item = _image_to_html(item)
            elif item is None:
                item = ""
            return item

        grid = [list(map(convert_grid_item, row)) for row in grid]

        df = DataFrame(grid)
        html = df.to_html(escape=False)
        return HTML(html)

    def _update(self, converted_content):
        from IPython.display import update_display
        self._converted_content = converted_content
        update_display(self._converted_content, display_id=self._display_id)

    def display(self):
        """Adds a new viewport"""
        from IPython.display import display, HTML
        display(self._converted_content, display_id=self._display_id)

    def clear(self):
        """Clears the viewports"""
        self.update(None)
    
    def update(self, content):
        """Updates all viewports spawned from this channel"""
        self._update(self._convert_content(content))

    def grid_update(self, content_grid):
        """
        Updates all viewports spawned from this channel with a grid of content
        Pass it like [[x0y0, x1y0, x2y0], [x0y1, x1y1, x2y1] ... ]
        Supports text, numbers and images as elements
        """
        self._update(self._convert_content_grid(content_grid))

    def row_update(self, content_row):
        """A row of content gets displayed"""
        self.grid_update([content_row])



_disable_display_image=False #Set rp.r._disable_display_image=True to disable the display_image function. Right now this is undocumented functionality, might make it documented later on via helper functions like enable_display_image() and disable_display_image()

def display_image(image,block=False):
    """
    Very simple to understand: this function displays an image.
    At first, it tries to use matplotlib and if that errors it falls back to opencv's imshow function.
    By default this function will not halt your code, but if you set block=True, it will.
    This function works in Jupyter Notebooks such as google colab, and will automatically scale the DPI of the output such that the full-resolution image is shown (don't take this for granted)
    You can pass this function binary, rgb, rgba, grayscale matrices -- most types of images (see rp.is_image() for more information)

    - Smart environment detection: Jupyter → HTML, SSH → terminal display, Desktop → OpenCV
    - block=True pauses execution until window closed (useful for debugging)

    Common usage:
        >>> display_image(my_image)  # Quick visualization
        >>> display_image(result, block=True)  # Debug with pause

    Tags: display, visualization, matplotlib, opencv
    """

    if _disable_display_image:
        return fansi_print("rp.display_image: Currently disabled; no image displayed",'yellow')
    
    if currently_in_a_tty() and running_in_ssh() and not running_in_ipython() :
    # if currently_in_a_tty() and not currently_running_desktop() and not running_in_ipython() :   #THIS MIGHT BE BETTER - this one is more recent, but I decided to make minimal changes today. Maybe uncomment this in the future if you want.
        #Let display_image work in terminals too, when in ssh and we have no GUI or ipynb options
        return display_image_in_terminal_color(image)

    if not running_in_ipython() and not currently_running_desktop():
        fansi_print("rp.display_image: Warning, no image was displayed - not in desktop environment.",'yellow') #Please note that cv2.imshow will usually segfault if there's no desktop environment!
        return

    if isinstance(image,str):
        fansi_print("display_image usually meant for use with numpy arrays, but you passed it a string, so we'll try to load the image load_image("+repr(image)+") and display that.")
        image=load_image(image)
    if is_pil_image(image) or is_torch_tensor(image):
        image=as_numpy_image(image,copy=False)
    if not isinstance(image,np.ndarray) and not isinstance(image,list):
        try:
            import torch
            if isinstance(image,torch.autograd.Variable):
                image=image.data
            elif isinstance(image,torch.Tensor):
                image=image.cpu().numpy()
        except Exception:pass
    elif running_in_ipython():
        #Use the ipyplot library to display images at full resultion while in a jupyter notebook
        return display_image_in_notebook(image)
    elif module_exists('cv2'):
        try:
            #Personally, I think cv_imshow is better because it's faster.
            #If we have opencv installed, try to use that.
            #If not, then oh well - we'll just continue on and try matplotlib instead
            return cv_imshow(image,wait=10 if not block else 10000000,label='rp.display_image()')
        except Exception:#Only excepting exceptions because KeyboardInterrupt is a BaseException, and we want to be able to interrupt while True:display_image(load_image_from_webcam()) without tryiggering matplotlib
            pass #Oh well, we tried!
    global plt
    plt=get_plt()
    if is_image(image):
        image=as_rgb_image(as_float_image(image))
    try:
        plt.clf()
        if running_in_ipython():
            fig=plt.figure()#Make a new figure. When jupyter, this makes sense; but normally we don't want this (it will make a bazillion windows)
            mpl=pip_import('matplotlib')
            # import matplotlib as mpl
            #region Set the jupyter resolution to the true image size (it usually squashes the image too small for comfort)
            old_dpi,old_figsize=mpl.rcParams['figure.dpi'],mpl.rcParams['figure.figsize']#
            arbitrary_number=100
            mpl.rcParams['figure.dpi'] = arbitrary_number
            mpl.rcParams['figure.figsize']=[image.shape[0]/arbitrary_number,image.shape[1]/arbitrary_number]
        else:
            fig = _fig()
        ax = plt.Axes(fig, [0., 0., 1., 1.])
        ax.set_axis_off()
        fig.add_axes(ax)
        ax.imshow(image, aspect='equal')
        plt.show(block=block)
        if not block:
            plt.pause(0.0001)
        if running_in_ipython():
            plt.close(fig)#I don't know if this is necessary. It's a hunch it might make it faster in the long term if we have 239239872 figures opened in jupyter. It doesn't hurt, though, so I'm keeping it here.
            mpl.rcParams['figure.dpi'],mpl.rcParams['figure.figsize']=old_dpi,old_figsize
    except Exception:
        if not running_in_google_colab():
            image=np.asarray(image)
            #The above seems not to work anymore, so the next thing to try is opencv's image display (in the event that it fails)...
            ndim=len(image.shape)
            assert ndim in {2,3},'Image tensor must have either two or three dimensions (either a grayscale image or RGB or RGBA image)'
            if ndim==2:
                image=grayscale_to_rgb(image)
            if image.dtype==bool:
                image=image.astype(_float_image_dtype())
            return cv_imshow(image,wait=10 if not block else 1000000)#Hit esc in the image to exit it

def with_alpha_checkerboard(image, *, tile_size=8, first_color=1.0, second_color=0.75):
    """ If the given image is RGBA, put a checkerboard pattern behind it and return a new opaque image """
    checkers = get_checkerboard_image(
        *get_image_dimensions(image),
        tile_size=tile_size,
        first_color=first_color,
        second_color=second_color
    )
    return blend_images(checkers, image)

def with_alpha_checkerboards(*images, tile_size=8, first_color=1.0, second_color=.75, lazy=False):
    """ Plural of rp.with_alpha_checkerboard """
    images = detuple(images)
    is_numpy = is_numpy_array(images)
    output = (
        with_alpha_checkerboard(
            image,
            tile_size=tile_size,
            first_color=first_color,
            second_color=second_color,
        )
        for image in images
    )
    if not lazy:
        output = list(output)
    if is_numpy and not lazy:
        #In future: Optimize
        output = as_numpy_array(output)
    return output


def display_alpha_image(image, block=False, tile_size=8, first_color=1.0, second_color=0.75):
    """Display image with checkerboard background to visualize transparency.
    
    Shows transparent areas as checkerboard pattern (like Photoshop).
    Useful for viewing PNG/RGBA images with alpha channels.
    """
    alpha_checkerboard_image = with_alpha_checkerboard(
        image, 
        tile_size=tile_size,
        first_color=first_color,
        second_color=second_color
    )
    display_image(alpha_checkerboard_image, block=block)

def _display_image_slideshow_animated(images):
    """
    This works best on Jupyter notebooks right now
    It technically works without a jupyter notebook...but at that rate you might as well use display_video...
        ...this is because jupyter notebooks display nice controls for the video, while default matplotlib doesn't
    """
    
    if not running_in_jupyter_notebook():
        display_video(images) #This is objectively better at the moment
    
    pip_import('matplotlib')

    import matplotlib
    import matplotlib.pyplot as plt
    import matplotlib.animation as animation
    
    assert len(images)>0, 'Must have at least one image to display, but len(images)==%i'%len(images)
    height,width=get_image_dimensions(images[0])
    
    try:
        
        #Adjust the size of matplotlib's display of the image to match it's resolution
        old_dpi,old_figsize=matplotlib.rcParams['figure.dpi'],matplotlib.rcParams['figure.figsize']
        arbitrary_number=100
        matplotlib.rcParams['figure.dpi'] = arbitrary_number
        figsize=[height/arbitrary_number,width/arbitrary_number]
        matplotlib.rcParams['figure.figsize']=figsize

        #Make sure all the images are standardized
        images=[as_rgb_image(as_byte_image(image)) for image in images]

        #Remove matplotlib's white border around the image
        fig = plt.figure(figsize=figsize[::-1])
        plt.axis("off")
        ax = plt.Axes(fig, [0., 0., 1., 1.])
        ax.set_axis_off()
        fig.add_axes(ax)
        
        #Display the animation
        ims = [[plt.imshow(image, animated=True)] for image in images]
        ani = animation.ArtistAnimation(fig, ims, interval=1000, repeat_delay=1000, blit=True)
        if running_in_jupyter_notebook():
            #Note: In jupyter notebook, this animation will be embedded.
            #This can make the .ipynb files quite large if you're not careful to keep the videos small
            from IPython.display import HTML
            matplotlib.rcParams['animation.embed_limit'] = 2**128
            html=HTML(ani.to_jshtml())
            from IPython.display import display_html
            display_html(html)
            plt.close() #We have to call plt.close, or else it will display an additional image under the animation
        else:
            plt.show()
            
    finally:
        matplotlib.rcParams['figure.dpi'],matplotlib.rcParams['figure.figsize']=old_dpi,old_figsize

def display_qr_code_in_terminal(text):
    """
    EXAMPLE:
        #Done in Alacritty or the default Mac Terminal
        display_qr_code_in_terminal('https://google.com')
    EXAMPLE:
        #This one is really annoying (funny prank): it will cover the entire camera of the iPhone that sees it for a brief moment
        display_qr_code_in_terminal('a'*2300)
    """
    pip_import('qrcode')
    import qrcode
    
    code=qrcode.QRCode()
    code.add_data(text)
    
    if currently_in_a_tty():
        code.print_tty()
    else:
        code.print_ascii()

def display_website_in_terminal(url):
    """
    Enhanced Documentation:
    
    Fetches and displays a website's content as formatted text in the terminal.
    
    Args:
        url (str): Valid URL of the website to display. Must pass is_valid_url() check.
    
    Returns:
        None: Prints the converted website content directly to the terminal.
    
    Examples:
        >>> # Display a simple webpage (if accessible)
        >>> rp.display_website_in_terminal("https://httpbin.org/html")
        # Outputs formatted text representation of the HTML page
        
        >>> # Display news website
        >>> rp.display_website_in_terminal("https://news.ycombinator.com")
        # Outputs readable text version of Hacker News
    """
    assert is_valid_url(url),'Invalid url: %s'%url
    html=curl(url)
    pip_import('html2text')
    pip_import('rich')
    import html2text
    output=html2text.html2text(html)
    import rich.markdown
    rich.print(rich.markdown.Markdown(output))
    # rp.r._rich_print(output)

def display_image_slideshow(images='.',display=None,use_cache=True):
    """
    Enters an interactive image slideshow
    Useful for exploring large folders/lists of images
    images:
        images can be a path to a folder containing images
        images can be a list of images as defined by r.is_image()
        images can be a list of image file paths
    display:
        if you set display=display_image_in_terminal, you can view the slideshow entirely over SSH
    
    EXAMPLE:
        display_image_slideshow(list(map(cv_text_to_image,'abcdefghijklmnopqrstuvwxyz')),display=display_image_in_terminal)
    
    EXAMPLE:
        images=line_split('''https://upload.wikimedia.org/wikipedia/commons/4/41/Left_side_of_Flying_Pigeon.jpg
        https://d17fnq9dkz9hgj.cloudfront.net/uploads/2020/04/shelter-dog-cropped-1.jpg
        https://i.pinimg.com/736x/4d/8e/cc/4d8ecc6967b4a3d475be5c4d881c4d9c.jpg
        https://www.dictionary.com/e/wp-content/uploads/2018/03/doge-300x300.jpg
        https://i.pinimg.com/originals/cb/e9/b4/cbe9b4280f390636e4d9432a02159528.jpg
        https://i.insider.com/5989fc4eefe3df1f008b48b9?width=1100&format=jpeg&auto=webp
        https://pyxis.nymag.com/v1/imgs/cd8/804/e0f612fa12d17e68e3d68ccf55f93cac4f-06-rick-morty.rsquare.w700.jpg
        https://assets.bwbx.io/images/users/iqjWHBFdfxIU/iXusLDq1QUac/v1/1000x-1.jpg
        https://i0.wp.com/huskerchalktalk.com/wp-content/uploads/2016/09/chessboard.jpg?fit=698%2C400&ssl=1https://www.colorado.edu/mcdb/sites/default/files/styles/medium/public/article-image/logo-blm.png?itok=sbQ6vxqb''')
        display_image_slideshow(images,display_image_in_terminal)
    """

    if display is None:
        if running_in_jupyter_notebook():
            #If we're in a jupyter notebook, by default display a gui.
            #However, if we want that default functionality, we can set display=display_image to override this
            _display_image_slideshow_animated(images)
            return
        elif running_in_ssh() and currently_in_a_tty():
            print('Currently running in SSH, so we will print the images into the terminal')
            display=display_image_in_terminal
        else:
            display=display_image

    if display in [display_image_in_terminal, display_image_in_terminal_color]:
        old_display = display
        def display(image):
            # _terminal_move_cursor_to_top_left()
            old_display(image)

    if isinstance(images,str) and is_a_folder(images):
        images=get_all_paths(images,sort_by='number',include_files=True,include_folders=False)
        images=[path for path in images if is_image_file(path)]
    if len(images) and isinstance(images[0],str):
        assert all(isinstance(path,str) for path in images)
        images=[path for path in images if is_image_file(path) or is_valid_url(path)]
        #Todo: Make the images load lazily, but also somehow in parallel
        # images=load_images(images,use_cache=use_cache,strict=False)
        
    assert all(is_image(image) or is_image_file(image) for image in images)
    assert len(images)>0,'Must have at least one image to create a slideshow'

    index=0
    
    def display_help():
        print('r.image_slideshow: Displaying a slideshow of %i images'%len(images))
        print('    Use the following keymap:')
        print('        n: Go to the next image')
        print('        p: Go to the prev image')
        print('        r: Go to a random image')
        print('        #: Go to a selected image')
        print('        a: Play Animation - press any key to stop')
        print('        +: Zoom In')
        print('        -: Zoom Out')
        print('        l: Pan Right')
        print('        k: Pan Up')
        print('        j: Pan Down')
        print('        h: Pan Left')
        print('        q: Quit the slideshow')
        print('        ?: Display this help text')
    
    display_help()

    skip_load=False
    origin_x=0
    origin_y=0
    scale=1
    scales={}

    def zoom_crop_origin(image):
        #TODO: Don't waste time when scale=1
        #TODO: Fix the issue where it resets if scale is too large (play aronud with zoom pan to see what I mean)
        if scale not in scales:
            #Do a bit of memoization to speed things up 
            if scale==1:
                scaled_image=image
            else:
                scaled_image=cv_resize_image(image,scale,interp='nearest')#Todo: Memoize this
            scales[scale]=scaled_image
        new_image=scales[scale]
        new_image=new_image[origin_y*scale:origin_y*scale+get_image_height(image), origin_x*scale:origin_x*scale+get_image_width(image)]
        return new_image
    
    autoplay=0
    def stop_autoplay_on_keypress():
        nonlocal autoplay
        input_keypress(handle_keyboard_interrupt=True)
        autoplay=0

    while True:
        if not skip_load:
            index%=len(images)
            image=images[index]
            origin_x=0
            origin_y=0
            scale=1
            scales={}
        skip_load=False

        try:
            image_path=None
            if isinstance(image,str):
                image_path=image
                try:
                    image=load_image(image,use_cache=use_cache)
                except Exception:
                    print("Failed to load image: "+repr(image))
                    raise
            display(zoom_crop_origin(image))
            if scale!=1 or origin_x!=0 or origin_y!=0:
                print('Zoom Factor: %i   X: %i   Y:%i'%(scale,origin_x,origin_y))
            if image_path is not None:
                print("Image Location:",image_path)
            print('Displaying image #%i/%i, %ix%i'%(index+1,len(images),get_image_width(image),get_image_height(image)))
        except Exception as e:
            print('Failed to display image #%i/%i'%(index+1,len(images)))
            # print_stack_trace(e)
        
        if autoplay:
            index += autoplay
            continue

        if currently_in_a_tty():
            key=input_keypress()
        else:
            key=input('Enter a key: ')
            
        #Image Navigation
        if key=='n':
            index+=1
        elif key=='p':
            index-=1
        elif key=='r':
            index=random_index(images)
        elif key=='#':
            print("Which image would you like to view?")
            index=input_integer(0,len(images)-1)

        elif key=='a':
            autoplay=1
            run_as_new_thread(stop_autoplay_on_keypress)

        #Panning and zooming
        elif key=='+':
            scale+=1
            scale=max(scale,1)
            skip_load=True
        elif key=='-':
            scale-=1
            scale=max(scale,1)
            skip_load=True
        elif key=='j':
            origin_y+=1
            origin_y+=int(max(1,get_image_height(image)/scale/10))
            origin_y=min(get_image_height(image)-1,max(origin_y,0))
            skip_load=True
        elif key=='k':
            origin_y-=1
            origin_y-=int(max(1,get_image_height(image)/scale/10))
            origin_y=min(get_image_height(image)-1,max(origin_y,0))
            skip_load=True
        elif key=='h':
            origin_x-=1
            origin_x-=int(max(1,get_image_width(image)/scale/10))
            origin_x=min(get_image_width(image)-1,max(origin_x,0))
            skip_load=True
        elif key=='l':
            origin_x+=1
            origin_x+=int(max(1,get_image_width(image)/scale/10))
            origin_x=min(get_image_width(image)-1,max(origin_x,0))
            skip_load=True

        #Exiting
        elif key=='q':
            break

        #Help
        elif key=='?':
            display_help()
    



def display_color_255(*color: list):
    """ Example: display_color_255(255,0,0)# ⟵ Displays Red """
    # noinspection PyUnresolvedReferences
    display_image([(np.matrix(detuple(color)) / 256).tolist()])

def display_float_color(*color):
    color=detuple(color)
    image=uniform_float_color_image(height=128, width=128, color=color)
    display_alpha_image(image,first_color=1,second_color=0)

def bar_graph(values,*,width=.9,align='center',block=False,xlabel=None,ylabel=None,title=None,label_bars=False,**kwargs):
    """
    Create a bar graph with the given y-values
    The 'values'     parameter is a list of bar heights. They should all be real numbers.
    The 'width'      parameter sets the width of each bar
    The 'align'      parameter sets whether the bars are to the center, right or left of each index
    The 'label_bars' parameter, if true, will display numbers above each bar displaying their quantity. NOTE: This works best with integers, as opposed to floats!
    EXAMPLE: bar_graph(randints(10))
    """
    pip_import('matplotlib')
    plt=get_plt()

    assert align in {'center','left','right'}
    if align=='right':
        #The right of the bars touch the index numbers, like in a right-riemann-sum
        #According to matplotlib, to do this we set align to 'edge' and multiply width by -1
        width*=-1
        align='edge'
    if align=='left':
        #Vice versa, see 'right' above
        align='edge'

    x=list(range(len(values)))

    plt.clf()
    plt.bar(x,values,width=width,align=align,**kwargs)

    if xlabel is not None: plt.xlabel(xlabel)
    if ylabel is not None: plt.ylabel(ylabel)
    if title  is not None: plt.title (title )
    
    if label_bars:
        for i in range(len(values)):
            plt.text(x=i,y=values[i]+1,s=str(values[i]),size=10,ha='center')

def histogram_in_terminal(values,sideways=False):
    """
    Right now this function is very simple (it doesnt let you specify the number of bins, for example)
    In the future I might add more functionality like that, or use unicode_loading_bar to make better sideways plots
    This is really meant to be used interactively...please don't use this in serious code...
    The 'sideways' argument might be renamed to 'dirction='horizontal'' etc...
    """
    pip_import('plotille')
    import plotille

    values=as_numpy_array(values).flatten()
    if sideways==True:
        out=plotille.hist(values,width=get_terminal_width()-33,bins=get_terminal_height()-0)
    else:
        out=plotille.histogram(values,width=get_terminal_width()-20,height=get_terminal_height()-15)

    print(out)

def line_graph_via_plotille(
    y_values,
    x_values=None,
    width=None,
    height=None,
    y_min=None,
    y_max=None,
    x_min=None,
    x_max=None,
    background_color=None,
    line_color=None,
    xlabel="X",
    ylabel="Y",
    silent=False,
):
    """
    Draws a line graph in the terminal using Plotille with the given values and colors.

    Args:
        y_values (list):           The y-values of the data points.
        x_values (list, optional): The x-values of the data points.     Defaults to the indices of y_values will be used.
        width     (int, optional): The width of the output graph.       Defaults to the terminal width.
        height    (int, optional): The height of the output graph.      Defaults to the terminal height.
        y_min   (float, optional): The minimum value of the y-axis.     Defaults to the minimum y-value.
        y_max   (float, optional): The maximum value of the y-axis.     Defaults to the maximum y-value.
        x_min   (float, optional): The minimum value of the x-axis.     Defaults to the minimum x-value.
        x_max   (float, optional): The maximum value of the x-axis.     Defaults to the maximum x-value.
        background_color (str, optional): The color of the background.  Defaults to None (transparent background).
        line_color (str, optional): The color of the line.              Defaults to None (default terminal text color).
        xlabel (str, optional): The label for the x-axis.               Defaults to "X".
        ylabel (str, optional): The label for the y-axis.               Defaults to "Y".
        silent (bool, optional): If True, the graph will not be printed to the terminal.  Default is False.

    Returns:
        str: The string representation of the graph.

    Notes:
        The graph is printed directly to the terminal using Plotille unless `silent` is True.
        The width and height of the graph are automatically adjusted based on
        the terminal size if not provided.

    Example:
        >>> import math
        >>> x_values = [x / 10.0 for x in range(-50, 50, 1)]
        >>> y_values = [math.sin(x) for x in x_values]
        >>> graph = line_graph_in_terminal(y_values, x_values, line_color='blue', xlabel='Angle', ylabel='Sine', silent=True)
        >>> print(graph)
    """
    pip_import("plotille")
    import plotille

    if x_values is None:
        x_values = list(range(len(y_values)))

    graph = plotille.plot(
        x_values,
        y_values,
        bg=background_color,
        lc=line_color,
        width =width  or (get_terminal_width () - 20),
        height=height or (get_terminal_height() - 13),
        x_min=x_min,
        x_max=x_max,
        y_min=y_min,
        y_max=y_max,
        X_label=xlabel,
        Y_label=ylabel,
    )

    if not silent:
        print(graph)

    return graph

def line_graph_live(func, *, length=None, framerate=60, graph=None):
    """
    Continuously update and display a line graph based on values returned by a given function.

    This function repeatedly calls the provided `func` to obtain new values, appends them to a list
    of `values`, and updates the displayed line graph using the specified `graph` function.

    Args:
        func (callable): A function that returns the next value to be plotted on the line graph.
            Takes no arguments, and returns a number.

        length (int, optional): The maximum number of values to display on the graph. If specified,
            the list of values will be truncated to this length. Defaults to None, which keeps all values.
            Effectively creates a sliding window, like an oscilloscope.

        framerate (int, optional): The desired framerate of the graph updates in frames per second.
            Defaults to 60.

        graph (callable, optional): A function that takes the list of `values` and displays the line graph.

    Examples:
        >>> #Using matplotlib
        ... line_graph_live(
        ...     get_mouse_y,
        ...     graph=line_graph,
        ...     length=100,
        ... )

        >>> #Using cv_line_graph
        ... line_graph_live(
        ...     get_mouse_y,
        ...     graph=lambda values: display_image(cv_line_graph(values, height=200, width=500)),
        ...     length=100,
        ... )

        >>> #Use plotille to plot everything
        ... line_graph_live(
        ...     get_mouse_y,
        ...     graph=lambda values:line_graph_via_plotille(values,line_color='cyan',background_color='red'),
        ...     length=100,
        ...     framerate=30,
        ... )

        >>> #Faster than plotille
        ... height=get_terminal_height()
        ... width=get_terminal_width()
        ... line_graph_live(
        ...     get_mouse_y,
        ...     graph=lambda values: display_image_in_terminal(
        ...         inverted_image(
        ...             cv_line_graph(
        ...                 values,
        ...                 height=height,
        ...                 width=width,
        ...                 antialias=False,
        ...             )
        ...         )
        ...     ),
        ...     length=300,
        ... )

    """
    if graph is None:
        if currently_in_a_tty():
            graph = line_graph_via_plotille
        else:
            # TODO: Use JupyterDisplayChannel where applicable
            graph = line_graph

    interval = 1 / framerate
    values = []

    while True:
        start_time = time.time()
        new_value = func()
        values.append(new_value)

        if length is not None and len(values) > length:
            values = values[-length:]

        graph(values)

        end_time = time.time()
        elapsed_time = end_time - start_time

        sleep(max(0, interval - elapsed_time))


def line_graph_in_terminal(y):
    line_graph_via_plotille(y)
    # pip_import('plotille')
    # import plotille
    # print(plotille.plot(list(range(len(y))),y,bg=None,lc=None,width=get_terminal_width()-20,height=get_terminal_height()-13))

def line_graph(*y_values,
                show_dots: bool         = False,
                clf: bool               = True,
                ylabel: str             = None,
                xlabel: str             = None,
                use_dashed_lines: bool  = False,
                line_color: str         = None,
                title                   = None,
                block: bool             = False,
                background_image        = None,
                logx:float              = None,
                logy:float              = None) -> None:
    """
    This is mainly here as a simple reference for how to create a line-graph with matplotlib.pyplot.
    There are plenty of options you can configure for it, such as the color of the line, label of the
    axes etc. For more information on this, see http://matplotlib.org/users/pyplot_tutorial.html
    """
    pip_import('matplotlib')
    global plt
    plt=get_plt()
    if clf:
        plt.clf()

    def plot(values):
        kwargs={}
        if show_dots:
            # Put a dot on each point on the line-graph.
            kwargs['marker']='o'
        if use_dashed_lines:
            kwargs['linestyle']='--'
        if line_color:
            kwargs['color']=line_color  # could be 'red' 'green' 'cyan' 'blue' etc
        plt.plot(values,**kwargs)

    try:
        plot(*y_values)  # If this works, then y_values must have been a single-graph.
    except Exception:  # y_values must have been an iterable of iterables, so we will graph each one on top of each other.
        # old_hold_value=plt.ishold() #This uses deprecated matplotlib stuff: https://github.com/matplotlib/matplotlib/issues/12337/
        # plt.hold(True)  # This lets us plot graphs on top of each other.
        for y in y_values:
            plot(y)
        # plt.hold(old_hold_value)

    if ylabel:
        plt.ylabel(ylabel)
    if xlabel:
        plt.xlabel(xlabel)
    if title:
        plt.title(title)

    if logy:
        if logy is True:
            logy=2
        plt.yscale('log',base=logy)

    if logx:
        if logx is True:
            logx=2
        plt.xscale('log',base=logx)

    plt.draw()
    display_update(block=block)
    plt.pause(.001)


def display_polygon(path,*,
                    filled    =True,
                    fill_color=None,
                    line_width=1,
                    line_style='solid',
                    line_color=None,
                    clear     =False,
                    block     =False,
                    alpha     =1):
    """
    Uses matplotlib
    Parameters:
        line_width: The width of the border around the polygon (set to 0 for no border)
        line_style: Please see https://matplotlib.org/3.1.0/gallery/lines_bars_and_markers/linestyles.html
        line_color: The color of the outline aka border of the polygon (like (1,0,0) for red, etc)
        
        filled    : boolean whether we should fill the object or just use an outline
        fill_color: The color of the area of the polygon (like (1,0,0) for red, etc)
        
        alpha     : The transparency value (1 is opaque, 0 is completely transparent)
        
        clear     : Whether we should clear the plot before drawing this polygon
        block     : True for an interactive plot that blocks the current python code; False to display immediately and continue python code; None to just plot it and skip the displaying step (which is faster and useful if you want to plot a lot of polygons at once)
    EXAMPLE: display_polygon(random_floats_complex(5),alpha=.5)
    """
    pip_import('matplotlib')
    from matplotlib.patches import Polygon
    from matplotlib import pyplot as plt
    
    path=as_points_array(path)

    if fill_color is None: fill_color=random_rgb_float_color()
    
    if clear:    
        plt.clf()

    if len(path): #Prevent edge case errors
        #Setting up the polygon
        polygon=Polygon(path, True)
        
        polygon.set_fill     (filled    )
        polygon.set_alpha    (alpha     )
    if clear:    
        plt.clf()

    if len(path): #Prevent edge case errors
        #Setting up the polygon
        polygon=Polygon(path, True)
        
        polygon.set_fill     (filled    )
        polygon.set_alpha    (alpha     )
        #Setting up the polygon
        polygon=Polygon(path, True)
        
        polygon.set_fill     (filled    )
        polygon.set_alpha    (alpha     )
        polygon.set_facecolor(fill_color)
        polygon.set_linewidth(line_width)
        polygon.set_linestyle(line_style)
        polygon.set_edgecolor(line_color)
        
        plt.axes().add_patch(polygon)

        #Autoscaling
        bounding_points=np.row_stack((np.max(path,axis=0),np.min(path,axis=0)))#Get two points representing the bounding box of path
        plt.plot(*bounding_points,marker='o')[0].set_visible(False)#Plot two invisible points on this bounding box, so that matplotlib will automatically rescale to accomidate whatever path you gave it

    #Displaying
    if block is not None:
        plt.show(block=block)
        if not block:
            plt.pause(.01)

def block(on_click=None,on_unclick=None):
    _fig()#Initialize fig
    # You may specify methods you would like to overwrite here.
    # Makes the plot interactive, but also prevents python script from running until the user clicks closes the graph window.
    pip_import('matplotlib')
    import matplotlib.backend_bases
    def handler(function,event_data: matplotlib.backend_bases.MouseEvent):
        args=event_data.xdata,event_data.ydata,event_data.button,event_data.dblclick
        if None not in args:
            function(*args)
    handler_maker=lambda function:lambda event:handler(function,event)
    if on_click is not None:
        assert callable(on_click)
        # def on_click(x,y,button,dblclick)
        _fig.canvas.mpl_connect('button_press_event',handler_maker(on_click))
    if on_unclick is not None:
        assert callable(on_unclick)
        # def on_unclick(x,y,button,dblclick)
        _fig.canvas.mpl_connect('button_release_event',handler_maker(on_unclick))
    # PLEASE NOTE THAT MORE METHODS CAN BE ADDED!!!!! A LIST OF THEM IS IN THE BELOW COMMENT:
    # - 'button_press_event'
    # - 'button_release_event'
    # - 'draw_event'
    # - 'key_press_event'
    # - 'key_release_event'

    # - 'motion_notify_event'
    # - 'pick_event'
    # - 'resize_event'
    # - 'scroll_event'
    # - 'figure_enter_event',
    # - 'figure_leave_event',
    # - 'axes_enter_event',
    # - 'axes_leave_event'
    # - 'close_event'
    plt.show(True)

def display_update(block=False,time=.01):
    """
    This should be preferred over the older block() function shown above
    Note: If time is too low, you can try setting it to a higher value
    """
    pip_import('matplotlib')
    if block is None:
        return#A convention that if block is "None" for some display function, it means we don't actually want to display it right away (for speed purposes, mostly)
    import matplotlib.pyplot as plt
    if block:
        plt.show(block=block)
    else:
        plt.gcf().canvas.blit()
        plt.pause(time)
update_display=display_update#Synonyms

def display_clear():
    pip_import('matplotlib')
    import matplotlib.pyplot as plt
    plt.gcf().clf()
clear_display=display_clear#Synonyms

def clf():
    """
    Clear the current matplotlib figure.
    
    Same as matplotlib.pyplot.clf()
    Used to start fresh plots without interference from previous plot elements.
    
    EXAMPLE:

        >>> #Best when ran on desktop
        >>> for _ in range(5):
        ...     clf()
        ...     for X in range(5):
        ...         for Y in range(5):
        ...             scatter_plot(
        ...                 random_floats_complex(300) + X + 1j * Y,
        ...                 dot_size=20,
        ...                 color="blue altbw randomhue",
        ...                 clear=False,
        ...             )

    Returns: None
    """
    pip_import('matplotlib')
    import matplotlib.pyplot as plt
    plt.clf()


def display_cv_color_histogram(
    image,
    *,
    channels="rgb",
    linestyle="-",
    alpha=1,
    block=False,
    clf=True
):
    """
    Displays a color histogram of an image using OpenCV and Matplotlib.

    Args:
        image (str or numpy.ndarray):
            The input image. It can be either a file path or a numpy array.
        channels (str, optional):
            The color channels to plot. It can be a combination of "r", "g", "b" and "a".
            For example, "r", "g", "rgba", "bgr", etc. Defaults to "rgb".
        linestyle (str, optional):
            The linestyle of the histogram plot. Defaults to "-".
        alpha (float, optional):
            The transparency of the histogram plot. Defaults to 1.
        block (bool, optional):
            If True, blocks execution and allows interactive plot manipulation.
            If False, displays the plot without blocking the code.
            If None, doesn't draw the plot. Defaults to False.
        clf (bool, optional):
            If True, clears the display before drawing the plot. Defaults to True.
    """
    pip_import("cv2")
    pip_import("numpy")
    pip_import("matplotlib")

    if isinstance(image, str):
        image = load_image(image)

    if clf:
        display_clear()

    import cv2 as cv
    from matplotlib import pyplot as plt

    image = as_rgba_image(image)
    image = as_byte_image(image)

    colors = {"r": 0, "g": 1, "b": 2, "a": 3}
    for channel in channels:
        index = colors[channel]

        plot_color = channel
        if plot_color == "a":
            plot_color = "black"

        hist = cv.calcHist([image], [index], None, [256], [0, 256])

        plt.plot(hist, color=plot_color, linestyle=linestyle, alpha=alpha)
        plt.xlim([0, 256])

    plt.draw()
    display_update(block=block)
    plt.pause(0.001)


def display_cv_color_histograms(
    image1,
    image2,
    channels="rgb",
    block=False,
    clf=True,
):
    """
    Plots color histograms of two images side by side for comparison using OpenCV and Matplotlib.

    Args:
        image1 (str or numpy.ndarray):
            The first input image. It can be either a file path or a numpy array.
        image2 (str or numpy.ndarray):
            The second input image. It can be either a file path or a numpy array.
        channels (str, optional):
            The color channels to plot. It can be a combination of "r", "g", and "b".
            Defaults to "rgb".
        block (bool, optional):
            If True, blocks execution and allows interactive plot manipulation.
            If False, displays the plot without blocking the code. Defaults to False.
            If None, doesn't even draw the plot.
        clf (bool, optional):
            If True, clears the display before drawing the plot. Defaults to True.

    Example:
        >>> image1 = "/path/to/image1.jpg"
        >>> image2 = "/path/to/image2.png"
        >>> display_cv_color_histograms(image1, image2, channels="rg", block=True)
    """
    display_cv_color_histogram(
        image1,
        block=None,
        clf=clf,
        channels=channels,
    )

    display_cv_color_histogram(
        image2,
        block=block,
        clf=False,
        alpha=0.5,
        channels=channels,
    )

# endregion
# region Min/Max Indices/Elements:［min_valued_indices，max_valued_indices，min_valued_elements，max_valued_elements，max_valued_index，min_valued_index］
def _minmax_indices(l,f=None,key=None)->list:    
    if len(l) == 0:
        return [] # An empty list
    # A helper method for the min/max methods below. f is either 'min' or 'max'
    if isinstance(l,dict):
        return matching_keys(f(l.values(),key=key),l,key=key)
    else:
        return matching_indices(f(l,key=key),l,key=key)

def min_valued_indices(l,key=None)->list:
    """
     Returns the indices with the minimum-valued elements
    TODO: Make this work properly with dicts, like max_valued_index does
    """
    return _minmax_indices(l,min,key=key)
def max_valued_indices(l,key=None)->list:
    """
     Returns the indices with the maximum-valued elements
    TODO: Make this work properly with dicts, like min_valued_index does
    EXAMPLE:
         >>> max_valued_indices({'a':123,'b':23424})
        ans = ['b']
    """
    return _minmax_indices(l,max,key=key)

def min_valued_elements(l,key=None):
    """ Returns the elements with the smallest values """
    return gather(l,min_valued_indices(l,key=key))
def max_valued_elements(l,key=None):
    """ Returns the elements with the largest values """
    return gather(l,max_valued_indices(l,key=key))

def max_valued_index(l,key=None):
    """
    Returns the index of the maximum value in a list or key of maximum value in dict.
    EXAMPLE:
        >>> max_valued_index([1, 5, 3, 2]) == 1
    """
    if isinstance(l,dict):
        #Let this function work with dictionaries, such that max_valued_index({'a':1,'b':3,'c':2})=='b'
        inverted_dict=invert_dict(l)
        return inverted_dict[max(inverted_dict,key=key)]
    return list(l).index(max(l))  # Gets the index of the maximum value in list 'l'. This is a useful def by rCode standards because it references 'l' twice.

def min_valued_index(l,key=None):
    """
    Returns the index of the minimum value in a list or key of minimum value in dict.
    EXAMPLE:
        >>> min_valued_index([1, 5, 3, 2]) == 0
    """
    if isinstance(l,dict):
        #Let this function work with dictionaries, such that max_valued_index({'a':1,'b':3,'c':2})=='b'
        inverted_dict=invert_dict(l)
        return inverted_dict[min(inverted_dict,key=key)]
    return list(l).index(min(l))  # Gets the index of the minimum value in list 'l'. This is a useful def by rCode standards because it references 'l' twice.

# endregion
# region  Blend≣Lerp/sign: ［blend，iblend，lerp，interp，linterp］

def blend(𝓍,𝓎,α):  # Also known as 'lerp'
    """
    Linear interpolation between two values. Also known as 'lerp'.
    EXAMPLE:
        >>> blend(0, 10, 0.5) == 5.0
    """
    return (1 - α) * 𝓍 + α * 𝓎  # More α --> More 𝓎 ⋀ Less 𝓍

def iblend(z,𝓍,𝓎):  # iblend≣inverse blend. Solves for α， given 𝓏﹦blend(𝓍,𝓎,α)
    """
    Inverse blend. Solves for α given z = blend(x, y, α).
    EXAMPLE:
        >>> iblend(5, 0, 10) == 0.5
    """
    z=z-𝓍
    z=z/(𝓎-𝓍)
    return z

    #OLD: Has mutations
    # z-=𝓍
    # z/=𝓎-𝓍
    # return z

def interp(x,x0,x1,y0,y1):  # 2 point interpolation
    return (x - x0) / (x1 - x0) * (y1 - y0) + y0  # https://www.desmos.com/calculator/bqpv7tfvpy

def linterp(values: list, index: float, *, cyclic=False, blend_func=blend):
    """
    Linearly interpolates between different values with fractional indices.
    This is written in pure python, so any values that implement addition, subtraction and multiplication will work
    (This includes floats, vectors, and even images)
    Note that linterp(values,some_integer) == values[some_integer] for any valid integer some_integer
    # Where l is a list or vector etc

    Args:
        values (list): A list of values to interpolate between. These values should support addition, subtraction, 
                       and scalar multiplication.
        index (float): The fractional index at which to interpolate. If cyclic=False, index should be in the range 
                       [0, len(values)-1]. If cyclic=True, index can be any real number and will be wrapped around 
                       to fall within the valid range.
        cyclic (bool, optional): If True, the interpolation will treat the values list as cyclic, wrapping around 
                                 from the last element back to the first. Default is False.
        blend_func (function, optional): The function used to blend between two adjacent values. Default is the 
                                         blend function, which performs a linear interpolation.
                                         
    Returns:
        The interpolated value at the specified index. The type of the returned value will match the type of the 
        elements in the values list.
        
    Raises:
        AssertionError: If index is not a number or values is not an iterable.
        IndexError: If index is out of bounds and cyclic is False.
        
    Mathematically, the interpolation is performed as follows:
        - If index is an integer i, the function returns values[i].
        - If cyclic is False:
            - The function calculates the two integer indices x0 and x1 that surround index, such that 
              x0 <= index <= x1.
            - The interpolated value is then calculated as:
              blend_func(values[x0], values[x1], (index - x0) / (x1 - x0))
        - If cyclic is True:
            - The index is first wrapped around to the range [0, len(values)] using modular arithmetic.
            - The interpolation is then performed as in the non-cyclic case, but with the values list treated as 
              cyclic (i.e., values[-1] is followed by values[0]).

    EXAMPLE: INTERPOLATING VECTORS
         >>> as_numpy_array([ linterp( as_numpy_array([[0,1], [0,0], [1,0]]), index)   for   index   in   [0, .5, 1, 1.5, 2] ])
         ans = [[0.  1. ]
                [0.  0.5]
                [0.  0. ]
                [0.5 0. ]
                [1.  0. ]]

    EXAMPLE: INTERPOLATING IMAGES
        >>> mountain=load_image('https://cdn.britannica.com/67/19367-050-885866B4/Valley-Taurus-Mountains-Turkey.jpg')
        ... chicago=load_image('https://pbs.twimg.com/media/EeqFCjvWkAI-rv_.jpg')
        ... doggy=load_image('https://s3-prod.dogtopia.com/wp-content/uploads/sites/142/2016/05/small-dog-at-doggy-daycare-birmingham-570x380.jpg')
        ... images=[resize_image(image,(256,256)) for image in [mountain,chicago,doggy]]

        With cyclic=True, it will loop through the images
        >>> for index in np.linspace(0,10,num=100):
        ...     frame=linterp(images,index,cyclic=True)
        ...     display_image(frame)
        ...     sleep(1/30)

        With cyclic=False, it will play the animation only once
        >>> for index in np.linspace(0,2,num=100):
        ...    frame=linterp(images,index,cyclic=False)
        ...    display_image(frame)
        ...    sleep(1/30)
    """

    assert is_number(index),'The \'index\' parameter should be a single number (which can be a float, but doesnt have to be), but got type '+str(type(index))
    assert is_iterable(values),'The \'values\' parameter should be a list of values you\'d like to interpolate between, but type '+str(type(index))+' is not iterable and does not have numerical indices'
    l=values
    x=index
    try:
        if cyclic:
            x%=len(l)
            l=list(l)
            l=l+[l[0]]# Don't use append OR += (which acts the same way apparently); this will mutate l!
        assert x>=0

        x0=int(np.floor(x))
        x1=int(np.ceil(x))
        if x0==x1:
            return l[int(x)]
        return blend_func(l[x0],l[x1],iblend(x,x0,x1))
    except IndexError:
        if cyclic:
            fansi_print("ERROR: r.linterp: encountered an index error; did you mean to enable the 'cyclic' parameter?",'red')
        raise

# def sign(x):
#     return 1 if x>0 else (0 if x==0 else -1)
# endregion
# region  Gathering/Matching: ［matching_indices，gather，pop_gather］
def matching_keys(x,d:dict,check=lambda x,y:x==y,key=None)->list:
    """
     Retuns a list [x0,x1,...] such that for all xi, d[xi]=x
    EXAMPLE:
       matching_keys('a',{3:'c','q':'a',():'a'}) ==== ['q',()]
    """
    assert isinstance(d,dict)
    if key is None:key=identity
    out=[]
    for key,value in d.items():
        if key(value)==key(x):
            out.append(key)
    return out
    
def matching_indices(x,l,check=lambda x,y:x == y,key=None)->list:
    """
     Retuns a list [x0,x1,...] such that for all xi, l[xi]=x
    EXAMPLE:
       matching_indices('a',['a','b','c','a','t']) ==== [0,4]
       matching_indices('a','abcat') ==== [0,4]
       matching_indices('a',{3:'c','q':'a',():'a'}) ==== ['q',()]
     Returns the matching indices of element 'x' in list 'l'
    """

    if key is None:key=identity

    if isinstance(l,dict):
        #Let this function work for dicts too
        return matching_keys(x,l,check=check)

    out=[]
    for i,y in enumerate(l):
        if check(key(x),key(y)):
            out.append(i)
    return out

def gather(iterable,*indices, as_dict=False, strict=True):
    """
    TODO: Add skip_missing or strict option (idk which yet but probably skip_missing if following in lines with gather_vars)
    # indices ∈ list of integers
    """
    assert isinstance(strict, bool)
    indices=detuple(indices)
    if isinstance(indices,str):
        #Dont treat each char of a string a key thats weird
        indices=[indices]
    # indices=delist(indices)
    assert is_iterable(iterable),"The 'iterable' parameter you fed in is not an iterable!"
    assert is_iterable(indices),"You need to feed in a list of indices, not just a single index.  indices == " + str(indices)

    def valid_index(it, i):
        #Silly. TODO: Optimize
        try:
            it[i]
            return True
        except (KeyError, IndexError, TypeError):
            return False

    if not as_dict:
        return [iterable[i] for i in indices if (strict or valid_index(iterable, i))]
    else:
        return {i:iterable[i] for i in indices if (strict or valid_index(iterable, i))}

def pop_gather(x,*indices):
    """
    Uses CSE214 definition of 'pop', in the context of popping stacks.
    It is difficult to simultaneously delete multiple indices in a list.
    My algorithm goes through the indices chronologically, compensating for
    the change in indices by subtracting incrementally larger values from them
    Example:
     >>> ⵁ = ['0', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i']
     >>> pop_gather(ⵁ,1,3,5,7,9)
    ans = ['a', 'c', 'e', 'g', 'i']
     >>> g
    ans = ['0', 'b', 'd', 'f', 'h']
    """
    indices=detuple(indices)
    out=gather(x,indices)
    for a,b in enumerate(sorted(set(indices))):
        del x[b - a]
    return out

def gather_vars(*var_names, frames_back=1, skip_missing=False, as_dict=True):
    """
    TODO: Elaborate on frames_back = ... functionality for getting ALL frames back - we want a min_frames_back and max_frames_back
    Better yet use slice objects, and gather_args_wrap(func)[2:]() lets us specify the frames_back via slices
    Also perhaps we would have [2,...,callable] or even [2,...,func_name,...func_name] -- or something likke that....its complicated :( 


    Collect the given variable names from the specified scope into an EasyDict.

    This function takes any number of variable names in different formats as arguments and
    collects them into an EasyDict. The variable names can be provided as separate strings,
    space-separated strings, lists of strings, or any combination of these formats.

    Args:
        *var_names: Variable names to be gathered. These can be provided in various formats.
        frames_back: An integer specifying the number of frames to go back to find the correct scope. Default is 1.
        skip_missing: If True, the output will simply omit any variables it can't find. Otherwise, it will throw an error if any variables are missing.

    Returns:
        An EasyDict containing the variables specified by the given names.

    Examples:
        a = 1
        b = 2
        c = 3
        d = 4
        e = 5

        # Different input formats for gather_vars
        result1 = gather_vars('a', 'b', 'c', 'd', 'e')
        result2 = gather_vars('a b c d e')
        result3 = gather_vars(['a', 'b', 'c', 'd', 'e'])
        result4 = gather_vars('a', 'b', ['c', 'd', 'e'])
        result5 = gather_vars(['a', 'b', 'c'], 'd', 'e')
        result6 = gather_vars('a b', ['c', 'd'], {'e'})

        # All results are equivalent and have the same values
        assert result1 == result2 == result3 == result4 == result5 == result6

    Examples:
        >>> a=5
        >>> b=6
        >>> del c
        >>> gather_vars('a b c',skip_missing=True)
                ans = {'a': 5, 'b': 6}
        >>> gather_vars('a b c',skip_missing=False)
                ERROR: KeyError: "Can't find variable 'c'"

    Written partially with GPT4: https://shareg.pt/g9N1X3U
    """

    if not as_dict:
        assert not skip_missing, 'rp.gather_vars: Cannot have as_dict and skip_missing. If we return a list of vars instead of a dict, there cannot be any gaps'

    assert frames_back==... or frames_back>=1, 'gather_vars is useless if we don\'t look at least one frame back'
    min_frames_back = 1 if frames_back==... else frames_back

    pip_import('easydict')
    
    import itertools
    import inspect
    from easydict import EasyDict

    flattened_var_names = list(itertools.chain.from_iterable(
        (arg.split() if isinstance(arg, str) else arg) for arg in var_names))


    frame = inspect.currentframe()
    for _ in range(min_frames_back):
        frame = frame.f_back
    #local_vars = frame.f_locals  <-- Old version only looked for vars in one frame
    
    def is_comprehension(frame):
        #Check if a frame is in a list comprehension, or dict comprehension etc
        #https://chat.openai.com/share/5cd8b897-402c-4e64-97c9-e8bf32e6f930
        if frame is None: return False
        name=frame.f_code.co_name
        return name.startswith('<') and name.endswith('>') and 'comp' in name

    #Get variables from all frames equal to or behind frames_back
    #QUESTION: Is this equivalent to f_globals? ANSWER: No, after testing - not all vars are in globals
    frame_locals = []
    needed_vars = set(flattened_var_names)
    while frame is not None and needed_vars:
        was_comprehension = is_comprehension(frame)
        needed_vars -= set(frame.f_locals) # In deep stacks, avoid traversing if we already have what we need
        frame_locals.append(frame.f_locals)
        frame=frame.f_back
        if was_comprehension or frames_back==...:
            #I can't think of an instance where I want the search to stop in the list comprehension - this seems confusing to read and debug - asking 'why does the scope stop here??'
            continue
        else:
            break
    local_vars =merged_dicts(frame_locals,precedence='first')
    
    result_dict = {}
    for name in flattened_var_names:
        if name in local_vars:
           result_dict[name] = local_vars[name]
        elif not skip_missing:
            raise KeyError("Can't find variable '%s'"%name)

    if not as_dict:
        assert not skip_missing
        return gather(result_dict, flattened_var_names)

    return EasyDict(result_dict)


def bundle_vars(*args, **kwargs):
    """
    Collect the given variables from the calling scope into an EasyDict.

    This function takes any number of local variable names as arguments and
    collects them into an EasyDict. Optionally, you can also pass additional
    key-value pairs as keyword arguments to be included in the output.

    Note: This function will raise a ValueError if an expression is passed as an argument,
    as it only supports variable names (e.g., bad: bundle_vars(a + b), good: bundle_vars(a, b)).

    Examples:
        x = 1
        y = 2
        result = bundle_vars(x, y)
        print(result.x)  # Output: 1
        print(result.y)  # Output: 2

        a = 5
        b = 6
        c = 11
        result = bundle_vars(a, b, c, extra_var=123)
        print(result.a)  # Output: 5
        print(result.b)  # Output: 6
        print(result.c)  # Output: 11
        print(result.extra_var)  # Output: 123

    Raises:
        ValueError: If an expression is passed as an argument (e.g., bad: bundle_vars(a + b), good: bundle_vars(a, b))

    Written partially with GPT4: https://shareg.pt/g9N1X3U
    """
    #TODO: This currently only works when the arguments are put *ON THE SAME LINE*. It also can't handle when two bundle_vars are on the same line.
    #TODO: Look into the implementation of icecream to figure out how. Icecream has some nice classes like Source, that when you use pudb for an icecream.ic call, you'll see
    pip_import('easydict')
    pip_import('astor')

    import ast
    import inspect
    import astor
    from easydict import EasyDict

    frame = inspect.currentframe().f_back # Get the previous frame (the calling function's frame)
    line = inspect.getframeinfo(frame).code_context[0].strip() # Get the line of code that called bundle_vars
    parsed_code = ast.parse(line) # Parse the line of code into an AST (Abstract Syntax Tree)

    # Find the bundle_vars function call node in the AST
    call_node = None
    for node in ast.walk(parsed_code):
        if isinstance(node, ast.Call) and hasattr(node.func, "id") and node.func.id == "bundle_vars":
            call_node = node
            break

    if call_node is not None:
        # Check if any argument is an expression and raise an error if so
        for arg in call_node.args:
            if not isinstance(arg, ast.Name):
                raise ValueError("Only variable names are supported, expressions are not allowed (e.g., bad: bundle_vars(a + b), good: bundle_vars(a, b))")

        variable_names = [astor.to_source(arg).strip() for arg in call_node.args] # Get the variable names passed to bundle_vars
        result_dict = gather_vars(*variable_names, frames_back=2) # Use gather_vars to get the variables from the calling scope
        result_dict.update(kwargs) # Add any extra keyword arguments to the dictionary
        return EasyDict(result_dict) # Return the result as an EasyDict
    else:
        raise RuntimeError("Couldn't find the variable names")

def gather_attrs(x, *attrs, as_dict=False):
    """ li, si = gather_attrs(rp, 'load_image save_image') """
    attrs = ' '.join(attrs)
    attrs = attrs.split()

    if as_dict:
        return as_easydict({name:getattr(x, name) for name in attrs})
    else:
        return [getattr(x, name) for name in attrs]

def destructure(d: dict) -> tuple:
    """
    Extracts values from a dictionary based on the variable names in the
    assignment expression in the calling line of code. 
    Mimics Javascript's destructuring assignment feature.

    The main purpose of this function is to make your code just a little shorter and less redundant.
    It compliments rp.bundle_vars and rp.gather_vars quite nicely.

    Note: This function should be considered voodoo, as it's very strange!
    It's extremely convenient though and can make your life easier.
    It can make your code less redundant, but relies on being able to find
    you source code - an assumption which doesnt always hold (for example,
    in ptpython or the default python repl. Jupyter and rp work fine though.)


    Parameters
    ----------
    d : dict
        The dictionary from which to extract values.

    Returns
    -------
    tuple or value
        A tuple of extracted values, or a single value if only one is extracted.

    Examples
    --------
        d = {'x': 1, 'y': 2, 'z': 3}
        
        # Destructuring into multiple variables
        >>> x, y = destructure(d)
        >>> print(x, y)
        1 2

        # Destructuring into a single variable
        >>> z = destructure(d)
        >>> print(z)
        3

        # Useful for getting kwargs out
        def make_color(**kwargs):
            red,green,blue = destructure(kwargs)

    Pitfalls
    --------
        # Variables on the left-hand side must match keys in the dictionary.
        >>> a, b = destructure(d)
        KeyError: 'Key not found in the provided dictionary.'

        # The function must be used within an assignment operation.
        >>> destructure(d)
        ValueError: 'Destructuring must be used within an assignment operation.'

        # The function doesn't support nested destructuring.
        >>> d = {'p': {'q': 4}}
        >>> p.q = destructure(d)
        AttributeError: 'tuple' object has no attribute 'q'

        # Multi-line assignments are not supported
        >>> a, \
        ... b = destructure(d)
        TypeError: 'Cannot unpack non-iterable int object.'
    """

    import inspect
    import ast

    # Get the source code of the line that called this function
    frame = inspect.currentframe().f_back
    info = inspect.getframeinfo(frame)
    code = info.code_context[0].strip()

    # Use the ast module to parse the source code into a syntax tree
    tree = ast.parse(code)

    try:
        # Find the Assign node (i.e., the assignment operation)
        assign_node = next(node for node in ast.walk(tree) if isinstance(node, ast.Assign))

        # Check if there are multiple assignment targets
        if isinstance(assign_node.targets[0], ast.Tuple):
            # Extract the variable names from the left-hand side of the assignment
            var_names = [target.id for target in assign_node.targets[0].elts]
        else:  # Single target
            var_names = [assign_node.targets[0].id]
    except StopIteration:
        raise Error("Destructuring must be used within an assignment operation.")

    # Use the variable names as keys to get the corresponding values from the dictionary
    values = tuple(d[name] for name in var_names)

    # Return single value instead of a tuple if there is only one value
    if len(values) == 1:
        return values[0]
    
    return values

def gather_args(func, *args, frames_back=1, **kwargs):
    """
    Gathers the necessary positional arguments and keyword arguments to call the given function.

    This function collects the required arguments and keyword arguments for the specified function
    based on a priority ordering. It retrieves the values from the caller's scope, default values,
    and overridden values provided through *args and **kwargs.

    Priority ordering (highest to lowest):
        1. Overridden keyword arguments (**kwargs)
        2. Overridden positional arguments (*args)
        3. Varargs and varkw:
            Overridden keyword arguments' varkw variable's keyword arguments
            Overridden keyword arguments' varargs variable's positional arguments
        4. Gathered arguments and keyword arguments from the caller's scope
        5. Varargs and varkw:
            Gathered varkw variable's keyword arguments
            Gathered varargs variable's positional arguments
        6. Default arguments and keyword arguments from the function signature

    Args:
        func (callable): The function for which to gather arguments and keyword arguments.
        *args: Positional arguments to override the gathered values.
        frames_back (int, optional): The number of frames to go back in the caller's scope to gather variables. Defaults to 1.
            NOTE: frames_back will not be inferred from the environment, it's the only special keyword argument here!
        **kwargs: Keyword arguments to override the gathered values.

    Returns:
        tuple: A tuple containing the gathered positional arguments and keyword arguments.
            - out_args (list): The gathered positional arguments.
            - out_kwargs (dict): The gathered keyword arguments.

    Raises:
        TypeError: If the number of positional arguments provided exceeds the number of positional arguments
                   in the function signature and the function does not have a *varargs parameter.
        TypeError: If a required argument is missing from the gathered variables and is not provided
                   through *args, **kwargs, or the function's default values.

    TODO: Use inspect.signature instead of inspect.getfullargspec, because getfullargspec is old - and functools.wrap doesn't change those signatures
        If we want to use this with memoized or stack this on other decorators, it has to read these arguments more robustly

    EXAMPLE USE CASE:
        # gather_args can be used to greatly simplify code where functions need a lot of each other's variables
        
        def f(a,b,c,d,e,f,g):
            return a+b+c+d+e+f+g

        #Without gather_args
        def g(a,b,c,d,e,f,g):
            print(f(a,b,c,d,e,f,g))

        #With gather_args (equivalent)
        def g(a,b,c,d,e,f,g):
            args, kwargs = gather_args(f)
            print(f(*args,**kwargs))

        #With our sister function, gather_args_wrap:
        def g(a,b,c,d,e,f,g):
            print(gather_args_wrap(f)())

        #With our other sister function, gather_args_call:
        def g(a,b,c,d,e,f,g):
            print(gather_args_call(f))

    EXAMPLES:
        def example_func(a, b=2, /, c=3, *d, e, f, g=1, h=2, i=3, **kw):
            pass

        # Gather arguments and keyword arguments
        a = 10
        e = 50
        f = 60
        kw = dict(z=123)
        args, kwargs = gather_args(example_func, frames_back=1)
        # args: [10, 2, 3]
        # kwargs: {'e': 50, 'f': 60, 'g': 1, 'h': 2, 'i': 3, 'z': 123}

        # Override arguments and keyword arguments
        args, kwargs = gather_args(example_func, 100, e=500, f=600, j=1000, kw=dict(q=321), frames_back=1)
        # args: [100, 2, 3]
        # kwargs: {'e': 500, 'f': 600, 'g': 1, 'h': 2, 'i': 3, 'j': 1000, 'q': 321}

    EXAMPLES:
        # These examples are meant to cover many edge cases
        >>> def no_kwargs_func(a,b,c,d):
            pass
        a=1
        b=2
        >>> gather_args(no_kwargs_func)
        ERROR: AssertionError: Missing variables for function call: c, d
        >>> gather_args(no_kwargs_func,c=3,d=4)
        ans = ([1, 2, 3, 4], {})
        >>> gather_args(no_kwargs_func,3,4)
        ERROR: AssertionError: Missing variables for function call: c, d
        >>> gather_args(no_kwargs_func,None,None,3,4)
        ans = ([None, None, 3, 4], {})
        >>> gather_args(no_kwargs_func,None,None,3,4,a=a,b=b) #Overriding the None's with kwargs, which have higher priority
        ans = ([1, 2, 3, 4], {})
        >>> gather_args(no_kwargs_func,c=3,d=4,e=5)
        ERROR: AssertionError: Too many keyword arguments given to a function without **kwargs: e
        >>> gather_args(no_kwargs_func,5,6,7,8)
        ans = ([5, 6, 7, 8], {})
        >>> gather_args(no_kwargs_func,5,6,7,8,9)
        ERROR: AssertionError: Too many args specified for a function without varargs!

    EXAMPLES:
        # Showing how priority ordering affects *args for a func
        >>> def varargs_func(a,b,*varargs):
                pass
            a=1
            b=2
        >>> gather_args(varargs_func)
        ans = ([1, 2], {})
        >>> gather_args(varargs_func,3,4,5)
        ans = ([3, 4, 5], {})
        >>> gather_args(varargs_func,varargs=[3,4,5])
        ans = ([1, 2, 3, 4, 5], {})
        >>> varargs=[6,7]
        >>> gather_args(varargs_func)
        ans = ([1, 2, 6, 7], {})
        >>> gather_args(varargs_func,8,9)     #Varargs can be inferred from the scope
        ans = ([8, 9, 6, 7], {})
        >>> gather_args(varargs_func,8,9,10)  #If varargs are directly specified, they take precedent
        ans = ([8, 9, 10], {})
        >>> gather_args(varargs_func,8,9,varargs=[1,2])
        ans = ([8, 9, 1, 2], {})
        >>> gather_args(varargs_func,8,9,varargs=[1,2],b=None)
        ans = ([8, None, 1, 2], {})

        ### Continued: What if specified varargs isn't iterable? Answer: It will be ignored

        >>> varargs=None #The variable exists but it no longer iterable, so it won't be used for varargs. Something else can override it.
        >>> gather_args(varargs_func,8,9)
        ans = ([8, 9], {})
        >>> gather_args(varargs_func,8,9,varargs='ABC')
        ans = ([8, 9, 'A', 'B', 'C'], {})
        >>> args=[1,2,3]
        >>> gather_args(varargs_func,8,9,args=None) #Similarily, if the manually specified args isn't iterable - that will also be ignored
        ans = ([8, 9, 1, 2, 3], {})

    FOR REFERENCE: EXAMPLE OF HOW WE GET ARGS AND KWARGS FROM FUNC:
         >>> def f(a,b=2,/,c=3,*d,e,f,g=1,h=2,i=3,**kwargs):pass
         >>> inspect.getfullargspec(f)
         ans = FullArgSpec(
                 args=['a', 'b', 'c'], 
                 varargs='d',           #Or None
                 varkw='kwargs',        #Or None
                 defaults=(2, 3), 
                 kwonlyargs=['e', 'f', 'g', 'h', 'i'], 
                 kwonlydefaults={'g': 1, 'h': 2, 'i': 3}, 
                 annotations={}
             )
         >>> inspect.getfullargspec(f)
         >>> get_positional_only_arg_names(f)
         ans = ['a', 'b']
    """
    assert frames_back>=1, 'gather_args is useless if we don\'t look at least one frame back'

    import inspect

    # Get the full argument specification of the function
    fullargspec = inspect.getfullargspec(func)

    func_arg_names = fullargspec.args
    func_kwarg_names = fullargspec.kwonlyargs

    #These are optional and might be none, but cannot be empty string
    varkw = fullargspec.varkw
    varargs = fullargspec.varargs

    #Priority #6: Get default variables: the default values given in the function signature
    pos_arg_defaults = fullargspec.defaults or []
    num_pos_arg_defaults = len(pos_arg_defaults)
    default_arg_vars = {name:value for name,value in zip(func_arg_names[-num_pos_arg_defaults:],pos_arg_defaults)} 
    default_kwarg_vars = dict(fullargspec.kwonlydefaults or {})
    assert not (set(default_arg_vars) & set(default_kwarg_vars)), 'This should be impossible - args and kwargs should not have a name conflict in a function signature'
    default_vars = {**default_arg_vars, **default_kwarg_vars}
    varargs_value = [] #There cant be default value for varargs

    #Used by gather_args_bind to allow us to construct partial outputs
    do_replace_missing=hasattr(func, 'gather_args_placeholder')
    if do_replace_missing:
        placeholder = func.gather_args_placeholder

    def maybe_add_varkw(variables:dict):
        if varkw in variables:
            varkw_variables = variables[varkw]
            del variables[varkw] #Don't keep it - it cant be passed as a kwarg to the func directly, it has to be expanded
            try:
                #varkw gets lower priority than originals
                varkw_variables = dict(varkw_variables) #If this line errors, skip it
                return {**varkw_variables, **variables}
            except Exception:
                #The gathered varkw simply wasn't a valid set of kwargs
                #Dont throw an error for this though - right?
                pass
        return variables


    def maybe_replace_varargs(variables:dict):
        nonlocal varargs_value
        if varargs in variables:
            try:
                varargs_value = tuple(variables[varargs])
            except Exception:
                #The varargs_value wasn't iterable, its ok - ignore it
                #Let something else take priority
                pass
            del variables[varargs]
        return varargs_value

    #Priority #4: Get gathered variables: the variables in the scope of the caller
    gathered_vars = gather_vars(
        func_arg_names,
        func_kwarg_names,
        [varargs] * bool(varargs),
        [varkw] * bool(varkw),
        skip_missing=True,
        frames_back=frames_back+1,
    )
    gathered_vars = maybe_add_varkw(gathered_vars) #Priority #5
    varargs_value = maybe_replace_varargs(gathered_vars)

    ##Priority #1: Get variable overrides: the *args and **kwargs passed to this function
    override_args = {name:value for name,value in zip(func_arg_names,args)} #Priority #1
    override_kwargs = dict(kwargs) #Priority #2
    override_vars = {**override_args, **override_kwargs}
    override_vars = maybe_add_varkw(override_vars) #Priority #3
    varargs_value = maybe_replace_varargs(override_vars)
    if len(args)>len(func_arg_names):
        assert varargs, 'Too many args specified for a function without varargs!' #TODO: This shouldn't be an assertion, should be an error
        varargs_value = args[len(func_arg_names):]

    #Compose the priorities together into available_vars - varags will be handles at the end after validation
    available_vars = {}
    available_vars.update(default_vars)
    available_vars.update(gathered_vars)
    available_vars.update(override_vars)

    #Get all variables we need but don't have yet
    get_missing_names = lambda: (set(func_arg_names) | set(func_kwarg_names)) -  set(available_vars)

    #Used by gather_args_bind
    if do_replace_missing:
        #Replace any missing values with the default value if applicable
        for name in get_missing_names():
            available_vars[name]=placeholder

    #Validation: Make sure we have enough args - TODO: this should be a custom exception not an assertion though
    assert not get_missing_names(), 'Missing variables for function call: '+', '.join(sorted(get_missing_names()))

    out_args = []
    out_kwargs = {}

    for arg_name in func_arg_names:
        out_args.append(available_vars[arg_name])
        del available_vars[arg_name]
    out_args += varargs_value

    out_kwargs.update(available_vars)
    if not varkw:
        assert not len(out_kwargs) < len(func_kwarg_names), 'This should be impossible at this point'
        assert len(out_kwargs) == len(func_kwarg_names), 'Too many keyword arguments given to a function without **kwargs: '+', '.join(sorted(set(out_kwargs)-set(func_kwarg_names)))

    return out_args, out_kwargs



def gather_args_call(func, *args, frames_back=1, **kwargs):
    """
    Calls the given function with arguments gathered from the current scope, using rp.gather_args
    Please see the docstring of `rp.gather_args` for more info.

    Args:
        func (callable): The function to call with the gathered arguments.
        *args: Positional arguments to override the gathered values.
        frames_back (int, optional): The number of frames to go back in the caller's scope to gather variables. Defaults to 1.
        **kwargs: Keyword arguments to override the gathered values.

    Returns:
        Any: The return value of the called function.

    Note: gather_args_call will not grab from globals - if you want to do that, you should call gather_args_call on the module-level function that calls gather_args_call
          This design choice is to encourage you to use this function cleanly

    Example:
        >>> def example_func(a, b, c):
        ...     print(f"a={a}, b={b}, c={c}")
        ...
        >>> # In the caller's scope
        >>> a = 1
        >>> b = 2
        >>> c = 3
        >>>
        >>> gather_args_call(example_func)
        a=1, b=2, c=3

    Example:
        def connect_to_database(db_host, db_port, db_name, db_user, db_password):
            # Establish database connection
            return db_connection

        def load_data_from_api(api_url, api_key, timeout):
            # Load data from API
            return api_data

        def process_data(db_connection, api_data, processing_params):
            # Process the data
            return processed_data

        # Configuration
        db_host = 'localhost'
        db_port = 5432
        db_name = 'my_database'
        db_user = 'admin'
        db_password = 'secret'
        api_url = 'https://api.example.com'
        api_key = 'abcdefgh12345'
        timeout = 10
        processing_params = {...}

        db_connection = gather_args_call(connect_to_database)
        api_data = gather_args_call(load_data_from_api)
        processed_data = gather_args_call(process_data)
    """
    out_args, out_kwargs = gather_args(func, *args, frames_back=frames_back+1, **kwargs)
    return func(*out_args, **out_kwargs)

def gather_args_wrap(func, *, frames_back=1):
    """
    Decorates the given function to use arguments gathered from the current scope, using rp.gather_args
    The arguments will be gathered from the scope where the function is called, not where it is wrapped.
    Please see the docstring of `rp.gather_args` for more info.

    Args:
        func (callable): The function to wrap and call with the gathered arguments.
        frames_back (int, optional): The number of frames to go back in the caller's scope to gather variables. Defaults to 1.

    Returns:
        callable: The wrapped function that, when invoked, calls the original function with the gathered arguments.

    TODO: Make this play nice with rp.memoized, right now they don't like each other
    TODO: Make gather_args_call implemented with gather_args_wrap, not the other way around - that way we can still use the frames_back argument in gather_args_wrap

    Example:
        >>> @gather_args_wrap
        ... def example_func(a, b, c):
        ...     print(f"a={a}, b={b}, c={c}")
        ...
        >>> # In the caller's scope
        >>> a = 1
        >>> b = 2
        >>> c = 3
        >>>
        >>> example_func()
        a=1, b=2, c=3
        >>> c = 999
        >>> example_func()
        a=1, b=2, c=999

    """
    import functools
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        return gather_args_call(func, *args, frames_back=frames_back+1, **kwargs)
    return wrapper


def gather_args_bind(func, *args, frames_back=1, **kwargs):
    """
    Like gather_args_wrap, but binds the values in the namespace upon creation.
    Here's an example to show the difference:

    TODO: Use inspect.signature instead of inspect.getfullargspec, because getfullargspec is old - and functools.wrap doesn't change those signatures
        If we want to use this with memoized or stack this on other decorators, it has to read these arguments more robustly

    EXAMPLE:
        >>> def f(x,y):
                print(x,y)
            x=1
            y=2
            b=gather_args_bind(f)
            w=gather_args_wrap(f)
        >>> b()
        1 2
        >>> w()
        1 2
        >>> x=3
        >>> b()
        1 2
        >>> w()
        3 2

    EXAMPLE:
        >>> def f(x,y,z):
                print(x,y,z)
            y=123
            g=gather_args_bind(f)
        >>> g(1,z=99)
        1 123 99
        >>> g(1)
        ERROR: TypeError: rp.gather_args_bind(f): 1 missing arguments:
            Missing Positional Arguments:
                #3: z
        >>> g(z=99)
        ERROR: TypeError: rp.gather_args_bind(f): 1 missing arguments:
            Missing Positional Arguments:
                #1: x
        >>> y=999
        >>> g(7,z=99)
        7 123 99

    EXAMPLE:
        >>> def f(x,y,z,*,u,v):
                print(x,y,z,u,v)
           
            y=123
            u=444
           
            b=gather_args_bind(f)
            w=gather_args_wrap(f)
        >>> w()
        ERROR: AssertionError: Missing variables for function call: v, x, z
        >>> b()
        ERROR: TypeError: rp.gather_args_bind(f): 3 missing arguments:
            Missing Positional Arguments:
                #1: x
                #3: z
            Missing Keyword Arguments:
                v
        >>> x=y=z=u=v=9000
        >>> w()
        9000 9000 9000 9000 9000
        >>> b()
        ERROR: TypeError: rp.gather_args_bind(f): 3 missing arguments:
            Missing Positional Arguments:
                #1: x
                #3: z
            Missing Keyword Arguments:
                v
        >>> b(1)
        ERROR: TypeError: rp.gather_args_bind(f): 2 missing arguments:
            Missing Positional Arguments:
                #3: z
            Missing Keyword Arguments:
                v
        >>> b(1,z=2,v=3)
        1 123 2 444 3
    """
    import functools
    import inspect

    fullargspec = inspect.getfullargspec(func)
    func_arg_names = fullargspec.args
    func_name = func.__name__

    placeholder = object() #Will only be equal to itself
    with TemporarilySetAttr(func, gather_args_placeholder = placeholder):
        saved_args, saved_kwargs = gather_args(func, *args, **kwargs, frames_back=frames_back+1)

    import functools
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        new_kwargs = dict(saved_kwargs)
        new_kwargs.update(kwargs)

        new_args = list(saved_args)
        new_args[:len(args)] = args

        for name in set(new_kwargs) & set(func_arg_names):
            #Turn any kwargs in to args
            new_args[func_arg_names.index(name)] = new_kwargs.pop(name)

        #Check for placeholder values that haven't been replaced - indicating we didn't fill enough function arguments
        missing_arg_indices  = [i for i,a in enumerate(new_args) if a is placeholder]
        missing_kwarg_names  = [n for n,k in new_kwargs.items()  if k is placeholder]
        missing_arg_names    = gather(func_arg_names, missing_arg_indices)
        missing_names = missing_arg_names + missing_kwarg_names
        if missing_names:
            indent = "    "
            error_message_lines = []
            error_message_lines.append("rp.gather_args_bind(%s): %i missing arguments:"%(func_name,len(missing_names)))
            if missing_arg_names:
                error_message_lines.append(indent + "Missing Positional Arguments:")
                for index,name in zip(missing_arg_indices, missing_arg_names):
                    error_message_lines.append(2*indent + "#%i: %s"%(index+1,name))
            if missing_kwarg_names:
                error_message_lines.append(indent + "Missing Keyword Arguments:")
                for name in missing_kwarg_names:
                    error_message_lines.append(2*indent + name)
            error_message = line_join(error_message_lines)
            raise TypeError(error_message)

        return func(*new_args, **new_kwargs)

    return wrapper



def get_current_function(frames_back=0):
    """
    Retrieves the function object from the specified number of frames back in the call stack.

    Args:
        frames_back (int, optional): The number of frames to go back in the call stack. Defaults to 2.

    Returns:
        function: The function object from the specified frame.

    Raises:
        TypeError: If frames_back is not an integer.
        ValueError: If frames_back is less than or equal to 1.
        RuntimeError: If the specified frame does not correspond to a function.

    TODO: Handle being inside list comprehensions etc - like gather_vars does. Also perhaps any other edge cases?
    
    EXAMPLE:
        >>> def z(): return get_current_function()
            print(z)
            print(z())
        <function z at 0x127709900>
        <function z at 0x127709900>
    
    EXAMPLE:
        >>> def f(i): return get_current_function(i)
            def g(i): return f(i)
            def h(i): return g(i)
            print(f)
            print(g)
            print(h)
            print(h(1))
            print(h(2))
            print(h(3))
            print(h(4))
        <function f at 0x130c811b0>
        <function g at 0x130c82050>
        <function h at 0x130c80160>
        <function f at 0x130c811b0>
        <function g at 0x130c82050>
        <function h at 0x130c80160>
        ERROR: RuntimeError: The frame 4 levels back does not correspond to a function.
    
    """
    import inspect

    frames_back+=1

    if not isinstance(frames_back, int):
        raise TypeError("frames_back must be an integer.")
    if frames_back < 1:
        raise ValueError("frames_back must be greater than or equal to 1.")
    
    # Access the call stack
    stack = inspect.stack()

    # Ensure there are enough frames in the stack
    if frames_back >= len(stack):
        raise RuntimeError("No sufficient frames in the call stack.")

    # The target frame is `frames_back` levels up the stack
    target_frame = stack[frames_back].frame

    # Get function name from the target frame
    func_name = target_frame.f_code.co_name

    # Iterate through the stack to find the function object
    for frame_info in stack:
        # Check function name and code object to ensure the correct function is found
        if frame_info.function == func_name and frame_info.frame.f_code == target_frame.f_code:
            output = frame_info.frame.f_globals.get(func_name)
            if output is not None:
                return output

    raise RuntimeError("The frame %i levels back does not correspond to a function."%frames_back)

def get_current_function_name(frames_back=0):
    """
    Gets the name of the currently executing function as a string.
    Useful for logging, debugging, and creating cache keys based on function names.
    
    Parameters:
        frames_back (int): How many call frames back to look (default: 0 = current function)
    
    Returns:
        str: Name of the function at the specified frame level
    
    EXAMPLE:
        >>> def emmy():
        ...     print('I am',get_current_function_name(),'called by '+get_current_function_name(frames_back=1))
        ... def clara():
        ...     print('I am '+get_current_function_name())
        ...     emmy()
        ... clara()
        I am clara
        I am emmy called by clara
    
    Related Functions:
        - get_current_function(): Get function object instead of name
    
    Tags: introspection, function, name, debugging, caching
    """
    return get_current_function(frames_back+1).__name__

def gather_args_recursive_call(*args, frames_back=0, **kwargs):
    """
    Recursively calls the current function with gathered arguments from local scope.
    EXAMPLE:
        >>> def factorial(n): 
        ...     return 1 if n <= 1 else n * gather_args_recursive_call(n-1)
    """
    frames_back+=1
    function=get_current_function(frames_back)
    return gather_args_call(function,*args,frames_back=frames_back+1,**kwargs)

def replace_if_none(value):
    """
    Used to replace default values in a concise way
    Please read the examples - this function uses introspection
    and the context where this function is called matters!

    TODO: Make this work with older versions of python, using destructure's strategy

    Parameters
    ----------
    value: any
        A value that will be returned if the left-hand of assignment is None

    Returns
    -------
    any
        Returns either value, or the value of the left-hand of the assignment 
        operation where this function is called

    Examples
    --------
        a = None
        b = 123
        default = 'Hello'

        # Plebian, redundant way to write code:
        a = default if a is None else a
        b = default if b is None else b

        # Equivalent way to write it with this func
        a = replace_if_none(default)
        b = replace_if_none(default)

        # Array assignment
        arr = [None, 2, 3]
        arr[0] = replace_if_none(42)
        
        # Use-case
        >>> def f(x=None):
                x=replace_if_none('default')
                return x
        >>> f()
        ans = default
        >>> f(123)
        ans = 123

    Pitfalls
    --------
        # The function must be used within an assignment operation.
        >>> replace_if_none(default)
        ValueError: 'replace_if_none must be used within an assignment operation.'

        # Chained assignments are not supported
        >>> a = b = replace_if_none(42)
        ValueError: 'replace_if_none only supports single assignment targets.'

        # Augmented assignments are not supported
        >>> a += replace_if_none(42)
        ValueError: 'replace_if_none only supports simple assignments.'

        # Tuple unpacking assignments are not supported
        >>> a, b = replace_if_none((1, 2))
        ValueError: 'replace_if_none only supports single assignment targets.'
       
        # Assignment must be on one line, or this function will get confused
        >>> a = \
        replace_if_none(123)
        ValueError: 'replace_if_none must be used within an assignment operation.'
    """

    import inspect
    import ast

    # Get the source code of the line that called this function
    frame = inspect.currentframe().f_back
    info = inspect.getframeinfo(frame)
    code = info.code_context[0].strip()

    # Use the ast module to parse the source code into a syntax tree
    tree = ast.parse(code)

    try:
        # Find the Assign node (i.e., the assignment operation)
        assign_node = next(node for node in ast.walk(tree) if isinstance(node, ast.Assign))
        
        # Check for chained assignments
        if len(assign_node.targets) > 1:
            raise ValueError("rp.replace_if_none only supports single assignment targets.")

        # Check for augmented assignments
        if isinstance(assign_node.targets[0], (ast.AugAssign, ast.AnnAssign)):
            raise ValueError("rp.replace_if_none only supports simple assignments.")

        # Extract the assignment target
        target = ast.unparse(assign_node.targets[0])

        # Evaluate the assignment target in the caller's frame
        target_value = eval(target, frame.f_locals)

        # Return value if the target value is None, otherwise return the target value
        return value if target_value is None else target_value

    except StopIteration:
        raise ValueError("rp.replace_if_none must be used within an assignment operation.")

# Should probably use current_module = __import__(__name__) instead where this is used
# def get_current_module():
#     """
#     Traverse up the call stack and return the first module found.
#     """
#     import inspect
#    
#     frame = inspect.currentframe()
#     while frame is not None:
#         frame = frame.f_back
#         output = inspect.getmodule(frame)
#         if output is not None:
#             return output
#     raise Exception('get_current_module(): failed to get the current module') 

def squelch_call(func, *args, exception_types=(Exception,), on_exception=identity, **kwargs):
    """
    Calls the given function with the provided arguments and keyword arguments, suppressing specified exceptions.

    Args:
        func (callable): The function to be called.
        *args: Positional arguments to be passed to the function.
        exception_types (type or iterable of types, optional): The exception type(s) to be caught and suppressed.
            Default is (Exception,), which catches all exceptions.
        on_exception (callable, optional): A function to be called with the caught exception as an argument.
            Default is identity, which returns the exception unchanged.
        **kwargs: Keyword arguments to be passed to the function.

    Returns:
        The return value of the function if no exception is raised.
        If an exception of the specified type(s) is raised, returns the result of calling on_exception with the caught exception.

    Raises:
        AssertionError: If exception_types is not a single exception type or an iterable of exception types.

    Example:
        >>> def divide(a, b):
        ...     return a / b
        ...
        >>> squelch_call(divide, 10, 2)
        5.0
        >>> squelch_call(divide, 10, 0, exception_types=ZeroDivisionError, on_exception=lambda e: "Cannot divide by zero")
        'Cannot divide by zero'
    """
    
    #exception_types can be an exception type, or an iterable of exception types
    if not (isinstance(exception_types,type) and issubclass(exception_types,BaseException)):
        assert is_iterable(exception_types)
        exception_types=tuple(exception_types)
        assert all(isinstance(x,type) and issubclass(x,BaseException) for x in exception_types)
        
    try:
        return func(*args, **kwargs)
    except exception_types as exception:
        return on_exception(exception)

def squelch_wrap(func, exception_types=(Exception,), on_exception=identity):
    """ 
    Wraps a function using squelch_call (can be a decorator)
    squelch_wrap is to squelch_call as gather_args_wrap is to gather_args_call
    TODO: Make squelch_call implemented with squelch_wrap, not the other way around - that way we can still use the on_exception and exception_types arguments in squelch_wrap

    EXAMPLE:
        >>> def f():
        ...     1/0
        >>> f()
        ERROR: ZeroDivisionError: division by zero
        >>> print(repr(squelch_call(f)))
        ZeroDivisionError('division by zero')
        >>> g=squelch_wrap(f)
        >>> print(repr(g()))
        ZeroDivisionError('division by zero')
    """
    import functools
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        return squelch_call(
            func,
            *args,
            exception_types=exception_types,
            on_exception=on_exception,
            **kwargs,
        )
    return wrapper
    

def rebind_globals_to_module(module, *, monkey_patch=False):
    """
    Decorator to change the global environment of functions and classes to another module's namespace.
    If monkey_patch is True, the function or class is also added to the module.

    The result: the decorated function is as good as if it were created in that module's source code,
    allowing it to both read and write from that module's globals. As a consequence, it can no longer 
    read or write from the module where this decorator is called.
    

    Args:
        module: The target module to bind the globals to.
        monkey_patch: If True, adds the object to the module.

    Returns:
        An object with its globals rebound to the target module's namespace.

    EXAMPLE:
        import rp.r as r
        @rebind_globals_to_module(r)
        def f():
            #returns r._BundledPath
            return _BundledPath

        some_var=123
        @rebind_globals_to_module(r)
        def g():
            #This crashes as it can no longer see names from the current module.
            return some_var
    """

    assert is_a_module(module), 'rebind_globals_to_module is a decorator'

    import types
    import functools

    def decorator(obj):
        original_module = obj.__module__

        if isinstance(obj, types.FunctionType):
            if hasattr(obj, "__module__") and obj.__module__ == original_module:
                bound_func = types.FunctionType(
                    obj.__code__,
                    module.__dict__,
                    obj.__name__,
                    obj.__defaults__,
                    obj.__closure__,
                )
                return bound_func
            return obj

        elif isinstance(obj, type):  # It's a class
            raise NotImplementedError("rebind_globals_to_module has not been tested/verified on classes yet")

            # Create a new class with all attributes copied over
            new_class_dict = {}
            for attr_name, attr_value in obj.__dict__.items():
                try:
                    new_attr_value = decorator(attr_value)
                except TypeError:
                    new_attr_value = attr_value

                new_class_dict[attr_name] = attr_value

            # Construct the new class type in the module's namespace
            new_class = type(obj.__name__, obj.__bases__, new_class_dict)
            return new_class

        else:
            raise TypeError("rebind_globals_to_module can only be applied to functions or classes.")


    return decorator


def globalize_locals(func):
    """
    Decorator that makes a function's local variables available globally,
    allowing a function to effectively act as a macro.

    Useful for making reusable cells in a Jupyter notebook.
    
    When a function decorated with @globalize_locals is called, all local variables created
    within the function become available in the global namespace during execution
    This includes both function parameters and local variables.
    
    The global namespace modification is the primary feature of this decorator,
    intentionally creating side effects by design. Variables defined inside the
    function will persist after execution.
    
    Example (Normal Execution):
        >>> # Variables don't exist before using the decorator
        >>> 'x' in globals(), 'y' in globals(), 'z' in globals()
        (False, False, False)
        
        >>> @globalize_locals
        ... def f(x, y):
        ...     z = x + y
        >>> f(1, 2)
        
        >>> # Variables now exist in the global scope
        >>> x, y, z
        (1, 2, 3)
    
    Example (With Error):
        >>> # Variables are still preserved even when an error occurs
        >>> @globalize_locals
        ... def f(x, y):
        ...     1/0  # Division by zero error
        ...     z = x + y  # This line never executes
        >>> try:
        ...     f(100, 200)
        ... except ZeroDivisionError:
        ...     print("Error caught")
        Error caught
        
        >>> # Parameters are still preserved in the global scope
        >>> x, y
        (100, 200)
        >>> # But z isn't defined since that line never executed
        >>> 'z' in globals()
        False

    Example:
        >>> @globalize_locals
        ... def part_0a():
        ...     url = "https://www.pupsgonewild.com/s/cc_images/cache_7853498.jpg"
        ... 
        ... 
        ... @globalize_locals
        ... def part_0b():
        ...     url = "https://mortenhannemose.github.io/assets/img/Lena_1024.png"
        ... 
        ... 
        ... @globalize_locals
        ... def part_0c():
        ...     url = "https://res.cloudinary.com/lancaster-puppies-laravel/image/upload/$date_!12%252F26%252F2024!/t_lpr-full-transform/t_lpr-date-transform/r_16/v1/default/eoxl4syekwivgrdgze5u?_a=AJAJZWI0"
        ... 
        ... 
        ... @globalize_locals
        ... def part_1():
        ...     image = load_image(url)
        ... 
        ... 
        ... @globalize_locals
        ... def part_2():
        ...     images = []
        ...     for part_0 in [part_0a, part_0b, part_0c]:
        ...         part_0()
        ...         part_1()
        ...         images.append(image)
        ... 
        ... 
        ... @globalize_locals
        ... def part_3():
        ...     part_2()
        ...     display_image(horizontally_concatenated_images(images))
        ... 
        ... 
        ... part_3()
    """
    import inspect
    import functools
    import sys

    #DISABLED because this can be accomplished just by reordering the decorators appropriately
    # if hasattr(func, '__wrapped__'):
    #     #If we're globalizing another func made with functools.wrap,
    #     #We need to globalize the locals of the innermost function
    #     func.__wrapped__ = globalize_locals(func)
    #     return func

    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        # Get the caller's globals
        caller_frame = sys._getframe(1)
        caller_globals = caller_frame.f_globals
        
        # First, bind the arguments to parameter names to ensure parameters are captured
        sig = inspect.signature(func)
        bound_args = sig.bind(*args, **kwargs)
        bound_args.apply_defaults()
        
        def trace_func(frame, event, arg):            # Set up trace function to capture variables on each line
            if frame.f_code == func.__code__:         # We're only interested in the function we're decorating
                caller_globals.update(frame.f_locals) # Push all local variables to globals
                return trace_func                     # Keep tracing this function
            return None                               # Don't trace other functions
            
        # Set tracing and execute the function
        original_trace = sys.gettrace()
        sys.settrace(trace_func)
        
        try:     return func(*args, **kwargs) # Call the function and get its result
        finally: sys.settrace(original_trace) # Restore the original trace function
            
    return wrapper

def _filter_dict_via_fzf(input_dict,*,preview=None):
    """Uses fzf to select a subset of a dict and returns that dict."""
    #Refactored using GPT4 from a mess: https://chat.openai.com/share/66251028-75eb-4c55-960c-1e7477e34060

    pip_import('iterfzf')
    import iterfzf

    # Extract dictionary items
    items_list = list(input_dict.items())
    keys, values = list_transpose(items_list)
    sorted_keys, sorted_values = sync_sorted(keys, values)

    # Define a utility function inside the main function to reduce the number of repeated tasks
    def format_string(item):
        item=str(item)
        if '\n' in item:
            return repr(item)
        return item

    formatted_values = map(format_string, sorted_values)
    formatted_keys = map(format_string, sorted_keys)
    
    # Join lines and format the display
    joined_values = line_join(formatted_values)
    joined_keys = line_join(formatted_keys)
    
    separator = ' | '
    for char in separator:
        joined_keys = make_string_rectangular(joined_keys + char, fillchar=char)

    display_str = horizontally_concatenated_strings(joined_keys, joined_values)
    display_lines = display_str.splitlines()

    # Use fzf to select lines
    selected_lines = _iterfzf(display_lines, multi=True, exact=True, preview=preview)
    if selected_lines is None:
        fansi_print("r._ISM: Canceled!",'cyan','bold')
        selected_lines=[]
    selected_indices = [display_lines.index(line) for line in selected_lines]

    if selected_indices is None:
        # The user cancelled. I'm not sure what the best thing is to return here...so I'll return None for now.
        return None

    # Extract the selected keys
    selected_keys = [sorted_keys[index] for index in selected_indices]

    # Return the dictionary subset
    return gather(input_dict, selected_keys, as_dict=True)

# endregion
# region  List/Dict Functions/Displays: ［list_to_index_dict，invert_dict，invert_dict，invert_list_to_dict，dict_to_list，list_set，display_dict，display_list］

def list_to_index_dict(l: list) -> dict:
    """ ['a','b','c'] ⟶ {0: 'a', 1: 'b', 2: 'c'} """
    return {i:v for i,v in enumerate(l)}

def invert_dict(d: dict, bijection=True, precedence='last') -> dict:
    """
    Inverts a dictionary, reversing the mapping of keys to values.

    Args:
        d (dict): The dictionary to invert.
        bijection (bool, optional): If True, assumes the dictionary is a bijection (one-to-one mapping)
            and inverts it directly. If False, handles non-bijective dictionaries by grouping keys with
            the same value into tuples. Defaults to True.
        precedence (str): Either 'first' or 'last' (default). If 'first', we prioritize values that appeared earlier in the dict.

    Returns:
        dict: The inverted dictionary.

    Examples:
        >>> invert_dict({0: 'a', 1: 'b', 2: 'c'})
        {'a': 0, 'b': 1, 'c': 2}
        
        >>> invert_dict({0: 'a', 1: 'a', 2: 'b'}, bijection=False)
        {'a': (0, 1), 'b': (2,)}
    """
    assert precedence in ['first', 'last']

    if bijection:
        # {0: 'a', 1: 'b', 2: 'c'} ⟶ {'c': 2, 'b': 1, 'a': 0}
        items = list(d.items())
        if precedence=='first':
            items = items[::-1]
        return {v:k for v,k in items}
    else:
        # {0: 'a', 1: 'a', 2: 'b'} ⟶ {'a': (0,1), 'b': (2,)}
        out={}
        for k,v in d.items():
            if v in out:
                out[v]+=k,
            else:
                out[v]=k,
        return out

def invert_list_to_dict(l: list) -> dict:
    """ ['a','b','c'] ⟶ {'c': 2, 'a': 0, 'b': 1} """
    assert len(set(l)) == len(l),'r.dict_of_values_to_indices: l contains duplicate values, so we cannot return a 1-to-1 function; and thus ∄ a unique dict that converts values to indices for this list!'
    return invert_dict(list_to_index_dict(l))

def dict_to_list(d: dict) -> list:
    """ Assumes keys should be in ascending order """
    return gather(d,sorted(d.keys()))

# ――――――――――――――――――――――
# Three fansi colors (see the fansi function for all possible color names):
default_display_key_color=lambda x123:fansi(x123,'cyan')
default_display_arrow_color=lambda x123:fansi(x123,'green')
default_display_value_color=lambda x123:fansi(x123,'blue')
def display_dict(d: dict,
                 key_color      = default_display_key_color,
                 arrow_color    = default_display_arrow_color,
                 value_color    = default_display_value_color,
                 clip_width     = False,
                 post_processor = identity,
                 key_sorter     = sorted,
                 print_it       = True,
                 arrow          = " --> "
                 # arrow          = " ⟶  "
                 ) -> None:
    """
    Made by Ryan Burgert for the purpose of visualizing large dictionaries.
    EXAMPLE DISPLAY:
        >>> display_dict({'name': 'Zed', 'age': 39, 'height': 6 * 12 + 2})
        age ⟶  39
        height ⟶  74
        name ⟶  Zed
    """
    # Of course, in the console you will see the appropriate colors for each section.
    # Theres a lot of code here because we're trying to make large amounts of text user-friendly in a terminal environment. Thats why this is so complicated and possibly perceived as messy
    #                                       ┌                                                                                                                                                                                                                               ┐
    #                                       │                                                                                                       ┌                                                                                                                      ┐│
    #                                       │                                                                                                       │              ┌                                                                                                      ┐││
    #                                       │                                                                                                       │              │         ┌                                                                                           ┐│││
    #                                       │┌                                                                                                     ┐│              │         │┌                                                         ┐                                ││││
    #                                       ││┌                                                                       ┐                            ││              │         ││                                                 ┌      ┐│                      ┌        ┐││││
    #      ┌                               ┐│││                          ┌                                           ┐│                            ││              │         ││         ┌   ┐              ┌     ┐              │ ┌   ┐││                      │      ┌┐│││││
    return (print if print_it else identity)(((lambda x:clip_string_width(x,max_wraps_per_line=2,clipped_suffix='………')) if clip_width else identity)(post_processor('\n'.join((key_color(key) + arrow_color(arrow) + value_color(d[key])) for key in key_sorter(d.keys())))))
    #      └                               ┘│││                          └                                           ┘│                            ││              │         ││         └   ┘              └     ┘              │ └   ┘││                      │      └┘│││││
    #                                       ││└                                                                       ┘                            ││              │         ││                                                 └      ┘│                      └        ┘││││
    #                                       │└                                                                                                     ┘│              │         │└                                                         ┘                                ││││
    #                                       │                                                                                                       │              │         └                                                                                           ┘│││
    #                                       │                                                                                                       │              └                                                                                                      ┘││
    #                                       │                                                                                                       └                                                                                                                      ┘│
    #                                       └                                                                                                                                                                                                                               ┘

def display_list(l: list,
                 key_color   = default_display_key_color,
                 arrow_color = default_display_arrow_color,
                 value_color = default_display_value_color,
                 print_it    = True) -> None:
    """
    Displays a list.

    EXAMPLE:
        >>> display_list('ABCD')
        0 --> A
        1 --> B
        2 --> C
        3 --> D
    """
    return display_dict(d=list_to_index_dict(l),key_color=key_color,arrow_color=arrow_color,value_color=value_color,print_it=print_it)

def display_markdown(markdown:str):
    """
    Display markdown text in both Jupyter notebook and terminal environments.
    
    markdown : str
        Markdown text to display or path to a .md file
        
    EXAMPLES:
        >>> # Basic markdown elements
        >>> display_markdown('''
        ... # Main Heading
        ... ## Secondary Heading
        ... 
        ... **Bold text** and *italic text*
        ... 
        ... - Bullet point 1
        ... - Bullet point 2
        ...   - Nested bullet
        ... 
        ... 1. Numbered item
        ... 2. Another numbered item
        ... 
        ... > This is a blockquote
        ... 
        ... [Link text](https://example.com)
        ... 
        ... ---
        ... 
        ... ```python
        ... def example_function():
        ...     return "Code blocks work too!"
        ... ```
        ... ''')
        
        >>> # Advanced markdown elements
        >>> display_markdown('''
        ... ## Tables
        ... 
        ... | Header 1 | Header 2 | Header 3 |
        ... |----------|----------|----------|
        ... | Value 1  | Value 2  | Value 3  |
        ... | Value 4  | Value 5  | Value 6  |
        ... 
        ... ## Task Lists
        ... 
        ... - [x] Completed task
        ... - [ ] Incomplete task
        ... 
        ... ## Math Formulas (in Jupyter)
        ... 
        ... Inline equation: $E = mc^2$
        ... 
        ... Block equation:
        ... 
        ... $$
        ... \\frac{d}{dx}e^x = e^x
        ... $$
        ... 
        ... ## Diagrams (in some environments)
        ... 
        ... ```mermaid
        ... graph TD;
        ...     A-->B;
        ...     A-->C;
        ...     B-->D;
        ...     C-->D;
        ... ```
        ... ''')
    """
    if markdown.endswith(".md") and file_exists(markdown):
        markdown = load_text_file(markdown)

    if running_in_jupyter_notebook():
        from IPython.display import display, Markdown

        display(Markdown(markdown))

    else:
        pip_import("rich")
        from rich import print
        from rich.markdown import Markdown

        print(Markdown(markdown))

def _get_carbon_url(code):
    """
    Generate a Carbon URL to visualize code snippets with syntax highlighting.

    code : str
        The code to display
    Returns str:
        URL that can be opened in a browser to display the code
    """
    import urllib.parse

    params = {
        "code": code,
        "l": "python",
        "t": "monokai",
    }
    encoded_params = urllib.parse.urlencode(params)
    return "https://carbon.now.sh/?"+str(encoded_params)

def display_code_cell(code, *, title="Code Cell", language=None):
    """
    Print code cell with formatting, line numbers, and syntax highlighting.
    In a terminal, it displays a clickable link to bring you to the source code copyable online via carbon.sh!
    In Jupyter, it displays a custom HTML cell with a copy button and macOS-style window controls!

    Parameters:
    -----------
    code : str
        The code to display
    title : str
        The cell number to display in the title
    language: str
        If specified, can be like 'py' or 'md' or 'python3' or 'markdown' or 'JSX' or 'javascript' etc
        If not specified, defaults to 'python3'
        Right now, only python syntax highlighting is supported in the Jupyer version...though this could change.

    EXAMPLE:
        >>> display_code_cell(get_source_code(load_image))

    """
    if not isinstance(code, str):
        raise TypeError('rp.display_code_cell: Need str, but input was of type '+str(type(code)))

    # IMPORTANT: Do not use f-strings in this function to maintain compatibility
    code = code.rstrip() #We have to for the printer...
    language = language or 'python3'

    if not running_in_jupyter_notebook():
        num_prefix = "%s│"
        mln = number_of_lines(code)
        mln = len(num_prefix % mln)
        print(
            "\n"
            + indentify(
                " " * (mln - 1)
                + fansi(
                    "┌"
                    + (title).center(
                        string_width(code) + 1,
                        "─",
                    ),
                    "bold  dark white white dark white",
                    link=_get_carbon_url(code),
                )
                + "\n"
                + with_line_numbers(
                    fansi_pygments(code, language),
                    align=True,
                    prefix=fansi(num_prefix, "dark white white dark white"),
                    start_from=1,
                ),
                "  ",
            )
            + "\n"
        )

    else:
        from IPython.display import display, HTML, Javascript
        import re
        import uuid
        import html

        # First, make sure highlight.js is loaded (if it's not already)
        display(
            HTML(
                """
        <script>
        if (typeof hljs === 'undefined') {
            var link = document.createElement('link');
            link.rel = 'stylesheet';
            link.href = 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.7.0/styles/monokai.min.css';
            document.head.appendChild(link);
            
            var script = document.createElement('script');
            script.src = 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.7.0/highlight.min.js';
            document.head.appendChild(script);
        }
        </script>
        """
            )
        )

        # Split the code into lines for line numbers
        code_lines = code.splitlines()
        num_lines = len(code_lines)
        line_num_width = len(str(num_lines))

        # Escape HTML characters in the code
        escaped_code = (
            code.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
        )

        # Create HTML with line numbers and code content
        line_numbered_code = []
        for i, line in enumerate(code_lines, 1):
            escaped_line = (
                line.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
            )
            if not escaped_line:  # Handle empty lines
                escaped_line = " "

            # Avoid f-strings
            line_numbered_code.append(
                '<div class="code-line">'
                '<span class="line-number">' + str(i) + '</span>'
                '<span class="code-content"></span>'  # Empty content to be filled by JS
                "</div>"
            )

        # Combine all lines
        code_with_line_numbers = "\n".join(line_numbered_code)

        # Store the original code in a data attribute (safely escaped)
        code_for_attr = html.escape(code)

        # Generate a unique ID for this code cell (avoid hyphens for JS compatibility)
        cell_id = "codecell" + uuid.uuid4().hex[:8]

        # Create style and HTML content using string formatting instead of f-strings
        style_template = """
        <style>
            #{0} .code-cell-container {{
                border: 1px solid #2d2d2d;
                border-radius: 4px;
                margin: 10px 0;
                overflow: hidden;
                font-size: 8pt;
                font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', 'source-code-pro', monospace;
            }}
            #{0} .code-cell-header {{
                background-color: #272822;
                color: #f8f8f2;
                padding: 5px;
                text-align: center;
                font-weight: bold;
                display: flex;
                align-items: center;
                position: relative;
            }}
            #{0} .window-controls {{
                display: flex;
                position: absolute;
                left: 10px;
                gap: 6px;
            }}
            #{0} .control-button {{
                width: 12px;
                height: 12px;
                border-radius: 50%;
                border: none;
                cursor: pointer;
            }}
            #{0} .close-button {{
                background-color: #ff5f56;
            }}
            #{0} .minimize-button {{
                background-color: #ffbd2e;
            }}
            #{0} .control-button:hover {{
                filter: brightness(90%);
            }}
            #{0} .title-text {{
                flex: 1;
                text-align: center;
            }}
            #{0} .copy-button, #{0} .hamburger-menu {{
                background-color: #49483e;
                border: none;
                color: #f8f8f2;
                padding: 3px 8px;
                border-radius: 3px;
                cursor: pointer;
                font-size: 12px;
                display: flex;
                align-items: center;
                gap: 5px;
                transition: background-color 0.2s;
            }}
            #{0} .copy-button {{
                position: absolute;
                right: 50px;
            }}
            #{0} .hamburger-menu {{
                position: absolute;
                right: 10px;
                padding: 3px 5px;
            }}
            #{0} .copy-button:hover, #{0} .hamburger-menu:hover {{
                background-color: #75715e;
            }}
            #{0} .copy-icon, #{0} .hamburger-icon {{
                width: 14px;
                height: 14px;
                fill: currentColor;
                transition: transform 0.3s ease;
            }}
            #{0} .hamburger-icon.active {{
                transform: rotate(90deg);
            }}
            #{0} .hamburger-icon rect {{
                fill: currentColor;
                transition: y 0.3s ease, transform 0.3s ease, opacity 0.3s ease;
            }}
            #{0} .secondary-menu {{
                max-height: 0;
                overflow: hidden;
                background-color: #272822;
                padding: 0 10px;
                border-top: 1px solid #49483e;
                display: flex;
                justify-content: flex-end;
                gap: 10px;
                transition: max-height 0.3s ease, padding 0.3s ease, opacity 0.3s ease;
                opacity: 0;
            }}
            #{0} .secondary-menu.visible {{
                max-height: 40px;
                padding: 5px 10px;
                opacity: 1;
            }}
            #{0} .secondary-menu.closing {{
                max-height: 0;
                padding: 0 10px;
                opacity: 0;
            }}
            #{0} .secondary-menu button {{
                background-color: #49483e;
                border: none;
                color: #f8f8f2;
                padding: 3px 8px;
                border-radius: 3px;
                cursor: pointer;
                font-size: 12px;
                transition: background-color 0.2s;
            }}
            #{0} #minimize-all-{0}:hover, #{0} #maximize-all-{0}:hover {{
                background-color: #ffbd2e;
                color: #272822;
            }}
            #{0} #close-all-{0}:hover {{
                background-color: #ff5f56;
                color: #272822;
            }}
            #{0} #minimize-all-{0}.active, #{0} #maximize-all-{0}.active {{
                background-color: #ffbd2e;
                color: #272822;
            }}
            #{0} #close-all-{0}.active {{
                background-color: #ff5f56;
                color: #272822;
            }}
            #{0} .code-cell-content {{
                padding: 10px;
                background-color: #272822;
                overflow-x: auto;
                line-height: 1.5;
                color: #f8f8f2;
                max-height: 2000px;
                transition: all 0.4s cubic-bezier(0.19, 1, 0.22, 1);
                transform-origin: top;
                opacity: 1;
            }}
            #{0}.minimized .code-cell-content {{
                max-height: 0;
                padding-top: 0;
                padding-bottom: 0;
                opacity: 0.7;
                transform: scaleY(0.01);
                overflow: hidden;
            }}
            #{0} .code-line {{
                display: flex;
                white-space: pre;
                min-height: 1.5em;
            }}
            #{0} .code-content {{
                flex: 1;
                padding-left: 0.5em;
                white-space: pre;
            }}
            #{0} .line-number {{
                color: #75715e !important;
                border-right: 1px solid #49483e;
                padding-right: 0.5em;
                text-align: right;
                user-select: none;
                min-width: {1}ch;
                display: inline-block;
            }}
        </style>
        """
        
        content_template = """
        <div id="{0}" data-original-code="{1}">
            <div class="code-cell-container">
                <div class="code-cell-header">
                    <div class="window-controls">
                        <button class="control-button close-button" id="close-button-{0}" title="Close"></button>
                        <button class="control-button minimize-button" id="minimize-button-{0}" title="Minimize"></button>
                    </div>
                    <div class="title-text">{2}</div>
                    <button class="copy-button" id="copy-button-{0}">
                        <svg class="copy-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
                            <path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/>
                        </svg>
                        Copy
                    </button>
                    <button class="hamburger-menu" id="hamburger-menu-{0}" title="More options">
                        <svg class="hamburger-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 14 14">
                            <rect x="2" y="3" width="10" height="2" rx="1"/>
                            <rect x="2" y="6" width="10" height="2" rx="1"/>
                            <rect x="2" y="9" width="10" height="2" rx="1"/>
                        </svg>
                    </button>
                </div>
                <div class="secondary-menu" id="secondary-menu-{0}">
                    <button id="minimize-all-{0}">Minimize All</button>
                    <button id="maximize-all-{0}">Maximize All</button>
                    <button id="close-all-{0}">Close All</button>
                </div>
                <div class="code-cell-content">
                    <div style="display:none">
                        <pre><code class="python">{3}</code></pre>
                    </div>
                    <div class="line-numbers-code">
                        {4}
                    </div>
                </div>
            </div>
        </div>
        """
        
        script_template = """
        <script>
        (function() {{
            // Setup copy button functionality
            function setupCopyButton() {{
                const copyButton = document.getElementById('copy-button-{0}');
                if (copyButton) {{
                    copyButton.addEventListener('click', function() {{
                        try {{
                            // Get the original code directly from our data attribute
                            const codeContainer = document.getElementById('{0}');
                            const originalCode = codeContainer.getAttribute('data-original-code');
                            
                            // Create a temporary textarea element to copy the text
                            const textarea = document.createElement('textarea');
                            textarea.value = originalCode;
                            textarea.setAttribute('readonly', '');
                            textarea.style.position = 'absolute';
                            textarea.style.left = '-9999px';
                            document.body.appendChild(textarea);
                            
                            // Select the text and copy it
                            textarea.select();
                            document.execCommand('copy');
                            
                            // Remove the textarea
                            document.body.removeChild(textarea);
                            
                            // Update the button to show feedback
                            const originalText = copyButton.innerHTML;
                            copyButton.innerHTML = `
                                <svg class="copy-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
                                    <path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z"/>
                                </svg>
                                Copied!
                            `;
                            copyButton.style.backgroundColor = '#a6e22e';
                            
                            setTimeout(() => {{
                                copyButton.innerHTML = originalText;
                                copyButton.style.backgroundColor = '';
                            }}, 2000);
                        }} catch (err) {{
                            console.error('Failed to copy code: ', err);
                            alert('Failed to copy code to clipboard: ' + err.message);
                        }}
                    }});
                }}
            }}
            
            // Setup the window control buttons
            function setupWindowControls() {{
                const closeButton = document.getElementById('close-button-{0}');
                const minimizeButton = document.getElementById('minimize-button-{0}');
                const codeContainer = document.getElementById('{0}');
                
                if (closeButton) {{
                    closeButton.addEventListener('click', function() {{
                        // Hide the entire code cell
                        codeContainer.style.display = 'none';
                    }});
                }}
                
                if (minimizeButton) {{
                    minimizeButton.addEventListener('click', function() {{
                        // Toggle minimized class to show/hide content
                        const isMinimized = codeContainer.classList.toggle('minimized');
                        
                        // Add visual indicator to the minimize button
                        if (isMinimized) {{
                            minimizeButton.style.boxShadow = 'inset 0 0 0 1px rgba(0, 0, 0, 0.3)';
                            minimizeButton.setAttribute('title', 'Maximize');
                        }} else {{
                            minimizeButton.style.boxShadow = '';
                            minimizeButton.setAttribute('title', 'Minimize');
                            
                            // If maximizeing, scroll into view after animation completes
                            setTimeout(() => {{
                                const content = document.querySelector('#{0} .code-cell-content');
                                content.scrollIntoView({{ behavior: 'smooth', block: 'nearest' }});
                            }}, 400);
                        }}
                    }});
                }}
            }}
            
            // Setup hamburger menu and secondary controls
            function setupHamburgerMenu() {{
                const hamburgerButton = document.getElementById('hamburger-menu-{0}');
                const hamburgerIcon = hamburgerButton.querySelector('.hamburger-icon');
                const secondaryMenu = document.getElementById('secondary-menu-{0}');
                const minimizeAllButton = document.getElementById('minimize-all-{0}');
                const maximizeAllButton = document.getElementById('maximize-all-{0}');
                const closeAllButton = document.getElementById('close-all-{0}');
                
                if (hamburgerButton && secondaryMenu) {{
                    // Toggle secondary menu visibility when hamburger is clicked
                    hamburgerButton.addEventListener('click', function() {{
                        // If menu is visible, start closing animation
                        if (secondaryMenu.classList.contains('visible')) {{
                            // Add closing class for animation
                            secondaryMenu.classList.add('closing');
                            secondaryMenu.classList.remove('visible');
                            
                            // Reset the hamburger icon
                            hamburgerIcon.classList.remove('active');
                            hamburgerButton.style.backgroundColor = '';
                            
                            // When animation completes, remove the closing class
                            setTimeout(() => {{
                                secondaryMenu.classList.remove('closing');
                            }}, 300); // Match the transition duration
                        }} else {{
                            // Show the menu
                            secondaryMenu.classList.add('visible');
                            secondaryMenu.classList.remove('closing');
                            
                            // Animate the hamburger icon
                            hamburgerIcon.classList.add('active');
                            hamburgerButton.style.backgroundColor = '#75715e';
                        }}
                    }});
                }}
                
                // Secondary menu controls - Minimize All button
                if (minimizeAllButton) {{
                    minimizeAllButton.addEventListener('click', function() {{
                        // Get all code cells on the page
                        const allCodeCells = document.querySelectorAll('[id^="codecell"]');
                        
                        // Mark the button as active temporarily
                        minimizeAllButton.classList.add('active');
                        maximizeAllButton.classList.remove('active');
                        
                        // Remove active state after a short delay
                        setTimeout(() => {{
                            minimizeAllButton.classList.remove('active');
                        }}, 1000);
                        
                        // Minimize all cells
                        allCodeCells.forEach(cell => {{
                            // Minimize if not already minimized
                            if (!cell.classList.contains('minimized')) {{
                                cell.classList.add('minimized');
                                
                                // Also update the minimize button's visual state
                                const minButton = document.querySelector('#' + cell.id + ' .minimize-button');
                                if (minButton) {{
                                    minButton.style.boxShadow = 'inset 0 0 0 1px rgba(0, 0, 0, 0.3)';
                                    minButton.setAttribute('title', 'Maximize');
                                }}
                            }}
                        }});
                    }});
                }}
                
                // Maximize All button
                if (maximizeAllButton) {{
                    maximizeAllButton.addEventListener('click', function() {{
                        // Get all code cells on the page
                        const allCodeCells = document.querySelectorAll('[id^="codecell"]');
                        
                        // Mark the button as active temporarily
                        maximizeAllButton.classList.add('active');
                        minimizeAllButton.classList.remove('active');
                        
                        // Remove active state after a short delay
                        setTimeout(() => {{
                            maximizeAllButton.classList.remove('active');
                        }}, 1000);
                        
                        // Maximize all cells
                        allCodeCells.forEach(cell => {{
                            // Maximize if minimized
                            if (cell.classList.contains('minimized')) {{
                                cell.classList.remove('minimized');
                                
                                // Update the minimize button's visual state
                                const minButton = document.querySelector('#' + cell.id + ' .minimize-button');
                                if (minButton) {{
                                    minButton.style.boxShadow = '';
                                    minButton.setAttribute('title', 'Minimize');
                                }}
                            }}
                        }});
                    }});
                }}
                
                if (closeAllButton) {{
                    // Close all code cells
                    closeAllButton.addEventListener('click', function() {{
                        // Get all code cells
                        const allCodeCells = document.querySelectorAll('[id^="codecell"]');
                        
                        // Hide all cells
                        allCodeCells.forEach(cell => {{
                            cell.style.display = 'none';
                        }});
                    }});
                }}
            }}
            
            // Setup all interactive elements
            setTimeout(() => {{
                setupCopyButton();
                setupWindowControls();
                setupHamburgerMenu(); // Setup the new hamburger menu and secondary controls
            }}, 300);
            
            function applyHighlighting() {{
                if (typeof hljs !== 'undefined') {{
                    try {{
                        // Get the code element and highlight it
                        const codeElement = document.querySelector('#{0} code');
                        hljs.highlightElement(codeElement);
                        
                        // Get the highlighted code
                        const highlightedCode = codeElement.innerHTML;
                        
                        // Process the highlighted code line by line
                        const processedLines = [];
                        let currentLine = '';
                        let inTag = false;
                        let tagContent = '';
                        
                        // Parse the highlighted HTML to extract properly tagged lines
                        for (let i = 0; i < highlightedCode.length; i++) {{
                            const char = highlightedCode[i];
                            
                            if (char === '<') {{
                                inTag = true;
                                tagContent = char;
                            }} else if (inTag && char === '>') {{
                                inTag = false;
                                tagContent += char;
                                currentLine += tagContent;
                            }} else if (inTag) {{
                                tagContent += char;
                            }} else if (char === '\\n') {{
                                processedLines.push(currentLine || ' ');
                                currentLine = '';
                            }} else {{
                                currentLine += char;
                            }}
                        }}
                        
                        if (currentLine) {{
                            processedLines.push(currentLine);
                        }}
                        
                        // Apply the highlighted lines to our line-numbered code
                        const codeContentElements = document.querySelectorAll('#{0} .code-content');
                        for (let i = 0; i < codeContentElements.length; i++) {{
                            if (i < processedLines.length) {{
                                codeContentElements[i].innerHTML = processedLines[i];
                            }} else {{
                                codeContentElements[i].innerHTML = ' ';
                            }}
                        }}
                        
                        // Hide the original code block
                        document.querySelector('#{0} [style="display:none"]').style.display = 'none';
                    }} catch (e) {{
                        console.error('Error applying highlighting:', e);
                    }}
                }} else {{
                    // If highlight.js is not loaded yet, wait and try again
                    setTimeout(applyHighlighting, 100);
                }}
            }}
            
            // Start the highlighting process
            setTimeout(applyHighlighting, 300);
        }})();
        </script>
        """

        # Apply the style and content templates
        style = style_template.format(cell_id, line_num_width + 1)
        content = content_template.format(
            cell_id,
            code_for_attr,
            title,
            escaped_code,
            code_with_line_numbers
        )
        script = script_template.format(cell_id)
        
        # Combine all HTML parts
        html_output = style + content + script

        # Display the complete HTML
        display(HTML(html_output))

# endregion
# region Suppress/Restore all console output/warnings: ［suppress_console_output，restore_console_output，force_suppress_console_output，force_restore_console_output，force_suppress_warnings，force_restore_warnings］
# b=sys.stdout.write;sys.stdout.write=None;sys.stdout.write=b
_original_stdout_write=sys.stdout.write  # ⟵ DO NOT ALTER THIS! It will cause your code to crash.
def _muted_stdout_write(x: str):
    assert isinstance(x,str)  # ⟵ The original method only accepts strings.
    return len(x)  # ⟵ The original method returns the length of the string; I don't know why. '
_console_output_level=1
def suppress_console_output():  # Will turn off ALL console output until restore_console_output() is called.
    global _console_output_level
    _console_output_level-=1
    if _console_output_level < 1:
        sys.stdout.write=_muted_stdout_write
def restore_console_output():  # The antidote for suppress_console_output
    global _console_output_level
    _console_output_level+=1
    if _console_output_level >= 1:
        sys.stdout.write=_original_stdout_write
def force_suppress_console_output():  # Will turn off ALL console output until restore_console_output() is called.
    global _console_output_level
    _console_output_level=0
    sys.stdout.write=_muted_stdout_write
def force_restore_console_output():
    global _console_output_level
    _console_output_level=1
    sys.stdout.write=_original_stdout_write
def force_suppress_warnings():
    warnings.filterwarnings("ignore")
def force_restore_warnings():
    warnings.filterwarnings("default")
def TemporarilySuppressConsoleOutput():
    return TemporarilySetAttr(sys.stdout, write=_muted_stdout_write)

class TeeStdout:
    def __init__(self):
        """
        Context manager that captures stdout while still printing it to the console.

        It correctly captures ANSI colorings by treating the context as a tty

        This class duplicates all output written to `sys.stdout`, so text is both:
        - Displayed in the terminal immediately.
        - Stored in an internal buffer that can be accessed later.

        Attributes:
            text (str): The captured output as a single string.

        Example:
            >>> with TeeStdout() as tee:
            ...     print("Hello, world!")
            ... 
            Hello, world!
            >>> print("Captured:\n" + tee.text)
            Captured:
            Hello, world!

        Nested Example:
            >>> with TeeStdout() as tee1:
            ...     print("Captured only by tee1")
            ...     with TeeStdout() as tee2:
            ...         print("Captured by both tee1 and tee2")
            ...
            Captured only by tee1
            Captured by both tee1 and tee2
            >>> print("tee1 buffer:\n" + tee1.text)
            tee1 buffer:
            Captured only by tee1
            Captured by both tee1 and tee2
            >>> print("tee2 buffer:\n" + tee2.text)
            tee2 buffer:
            Captured by both tee1 and tee2
        """
        self._buf = io.StringIO()
        self._orig = None

    def __enter__(self):
        self._orig = sys.stdout
        sys.stdout = self
        return self

    def __exit__(self, exc_type, exc, tb):
        sys.stdout = self._orig

    def write(self, s):
        self._orig.write(s); self._orig.flush()  # show immediately
        self._buf.write(s)
        return len(s)

    def flush(self):
        self._orig.flush(); self._buf.flush()

    def isatty(self):
        return getattr(self._orig, "isatty", lambda: False)()
    
    @property
    def text(self):
        return self._buf.getvalue()



# def toggle_console_output ⟵ I was going to implement this, but then decided against it: it could get really annoying/confusing if used often.
# endregion
# region Ryan's Inspector: ［rinsp］

#def get_bytecode(obj):
#    #Commented this function out because it's broken, even though it's a good idea
#    import dis
#    return dis.Bytecode(lambda x:x + 1).dis()

#def format_date(date)->str:
#    """
#    This function formats datetimes the way I personally like to read them.
#
#    EXAMPLE:
#        >>> get_current_date()
#        ans = 2023-08-22 14:06:01.764838
#        >>> format_date(ans)
#        ans = Tue Aug 22, 2023 at 2:06:01PM
#
#    TODO: In the future, only if I want to, I'll add another argument to let you customize the date string. But I really like this format lol
#    """
#    import datetime
#    assert isinstance(date,datetime.datetime)
#
#    assert isinstance(date, datetime.datetime), "Input must be a datetime object"
#
#    # Format the date string and append the timezone abbreviation
#    formatted_date = date.strftime('%a %b %d, %Y at %-I:%M:%S%p')
#
#    if date.tzinfo is not None:
#        #If the date has a timezone, add it to the output
#         formatted_date += ' ' + date.tzname() #PST, EST, Etc
#
#    return formatted_date


_timezone_translations = {
    # Translates from  the IANA times
    # North America
    "PST": "America/Los_Angeles",  # Pacific Standard Time
    "PDT": "America/Los_Angeles",  # Pacific Daylight Time
    "PT" : "America/Los_Angeles",  # Pacific Time
    "MST": "America/Denver",       # Mountain Standard Time
    "MDT": "America/Denver",       # Mountain Daylight Time
    "MT" : "America/Denver",       # Mountain Time
    "CST": "America/Chicago",      # Central Standard Time
    "CDT": "America/Chicago",      # Central Daylight Time
    "CT" : "America/Chicago",      # Central Time
    "EST": "America/New_York",     # Eastern Standard Time
    "EDT": "America/New_York",     # Eastern Daylight Time
    "ET" : "America/New_York",     # Eastern Time
    "HST": "Pacific/Honolulu",     # Hawaii Standard Time
    "AKST": "America/Anchorage",   # Alaska Standard Time
    "AKDT": "America/Anchorage",   # Alaska Daylight Time
    "AST": "America/Puerto_Rico",  # Atlantic Standard Time
    "ADT": "America/Halifax",      # Atlantic Daylight Time
    "UTC": "Etc/UTC",              # Coordinated Universal Time
    "AOE": "Etc/UTC",              # Anywhere on Earth

    # Asia
    # "IST": "Asia/Kolkata",         # Indian Standard Time
    # "CST": "Asia/Shanghai",        # China Standard Time
    "JST": "Asia/Tokyo",           # Japan Standard Time
    "KST": "Asia/Seoul",           # Korea Standard Time
    "IDT": "Asia/Jerusalem",       # Israel Daylight Time
    "IST": "Asia/Jerusalem",       # Israel Standard Time

    # Europe
    "BST": "Europe/London",        # British Summer Time
    "GMT": "Europe/London",        # Greenwich Mean Time
    "CET": "Europe/Berlin",        # Central European Time
    "CEST": "Europe/Berlin",       # Central European Summer Time
    "EET": "Europe/Athens",        # Eastern European Time
    "EEST": "Europe/Athens",       # Eastern European Summer Time
    "MSK": "Europe/Moscow",        # Moscow Standard Time

    # Australia
    "AEST": "Australia/Sydney",    # Australian Eastern Standard Time
    "AEDT": "Australia/Sydney",    # Australian Eastern Daylight Time
    "ACST": "Australia/Adelaide",  # Australian Central Standard Time
    "ACDT": "Australia/Adelaide",  # Australian Central Daylight Time
    "AWST": "Australia/Perth",     # Australian Western Standard Time

    # More of North America
    "NST": "America/St_Johns",     # Newfoundland Standard Time
    "NDT": "America/St_Johns",     # Newfoundland Daylight Time

    # Central and South America
    "ART": "America/Buenos_Aires", # Argentina Time
    "BRT": "America/Sao_Paulo",    # Brasilia Time
    "BRST": "America/Sao_Paulo",   # Brasilia Summer Time
    "CLT": "America/Santiago",     # Chile Standard Time
    "CLST": "America/Santiago",    # Chile Summer Time
    "COT": "America/Bogota",       # Colombia Time

    # More of Europe
    "WET": "Europe/Lisbon",        # Western European Time
    "WEST": "Europe/Lisbon",       # Western European Summer Time
    # "IST": "Europe/Dublin",        # Irish Standard Time

    # Africa
    "EAT": "Africa/Nairobi",       # East Africa Time
    "CAT": "Africa/Harare",        # Central Africa Time
    "WAT": "Africa/Lagos",         # West Africa Time
    "WAST": "Africa/Windhoek",     # West Africa Summer Time
    "SAST": "Africa/Johannesburg", # South Africa Standard Time

    # More of Asia
    "SGT": "Asia/Singapore",       # Singapore Time
    "HKT": "Asia/Hong_Kong",       # Hong Kong Time
    "MYT": "Asia/Kuala_Lumpur",    # Malaysia Time
    "WIT": "Asia/Jakarta",         # Western Indonesia Time
    "PHT": "Asia/Manila",          # Philippine Time
    "THA": "Asia/Bangkok",         # Thailand Standard Time

    # Middle East
    # "AST": "Asia/Riyadh",          # Arabian Standard Time
    "GST": "Asia/Dubai",           # Gulf Standard Time

    # Pacific
    "NZST": "Pacific/Auckland",    # New Zealand Standard Time
    "NZDT": "Pacific/Auckland",    # New Zealand Daylight Time
    "FJT": "Pacific/Fiji",         # Fiji Time
    "FJST": "Pacific/Fiji",        # Fiji Summer Time
    "TOT": "Pacific/Tongatapu",    # Tonga Time
    "CHAST": "Pacific/Chatham",    # Chatham Standard Time
    "CHADT": "Pacific/Chatham",    # Chatham Daylight Time
    "LINT": "Pacific/Kiritimati",  # Line Islands Time
    
    # GMT Offsets
    "GMT-11": "Etc/GMT+11",
    "GMT-10": "Etc/GMT+10",
    "GMT-9" : "Etc/GMT+9",
    "GMT-8" : "Etc/GMT+8",
    "GMT-7" : "Etc/GMT+7",
    "GMT-6" : "Etc/GMT+6", 
    "GMT-5" : "Etc/GMT+5",
    "GMT-4" : "Etc/GMT+4",
    "GMT-3" : "Etc/GMT+3", 
    "GMT-2" : "Etc/GMT+2",
    "GMT-1" : "Etc/GMT+1",  
    "GMT+1" : "Etc/GMT-1",
    "GMT+2" : "Etc/GMT-2", 
    "GMT+3" : "Etc/GMT-3",
    "GMT+4" : "Etc/GMT-4",
    "GMT+5" : "Etc/GMT-5", 
    "GMT+6" : "Etc/GMT-6",
    "GMT+7" : "Etc/GMT-7",
    "GMT+8" : "Etc/GMT-8",
    "GMT+9" : "Etc/GMT-9",
    "GMT+10": "Etc/GMT-10",
    "GMT+11": "Etc/GMT-11",
    "GMT+12": "Etc/GMT-12",
}

def _translate_timezone(x):
    assert isinstance(x, str)
    x = x.upper()
    if not x in _timezone_translations:
        raise KeyError(
            "Invalid timezone string: "
            + repr(x)
            + ". Please choose from: "
            + ", ".join(_timezone_translations)
        )
    return _timezone_translations[x]


_default_timezone=None
def format_date(date, timezone=None, *, align=False):
    """
    EXAMPLES:
        >>> get_current_date()
        ans = 2024-07-11 01:12:59.200330
        >>> print(type(ans))                ––>   <class 'datetime.datetime'>
        >>> print(format_date(ans))         ––>   Thu Jul 11, 2024 at 1:12:59AM
        >>> print(format_date(ans,'pdt'))   ––>   Thu Jul 11, 2024 at 1:12:59AM
        >>> print(format_date(ans,'cdt'))   ––>   Thu Jul 11, 2024 at 3:12:59PM CDT
        >>> print(format_date(ans,'edt'))   ––>   Thu Jul 11, 2024 at 4:12:59AM EDT
        >>> print(format_date(ans,'aoe'))   ––>   Thu Jul 11, 2024 at 8:12:59AM UTC
        >>> print(format_date(ans,''))      ––>   Thu Jul 11, 2024 at 1:12:59AM
        >>> print(format_date(ans,None))    ––>   Thu Jul 11, 2024 at 1:12:59AM
        >>> r._default_timezone='UTC'
        >>> print(format_date(ans,None))    ––>   Thu Jul 11, 2024 at 8:12:59AM UTC
        >>> print(format_date(ans,''))      ––>   Thu Jul 11, 2024 at 8:12:59AM

    ALIGN IS SO YOU CAN DO THINGS LIKE THIS:
        35: b06bb58256 [[Fri Feb 28, 2025 at 11:55:05PM]] Ficx eta
        34: 04cf83f8d2 [[Sat Mar 01, 2025 at 12:37:04AM]] Fixes and get_nested_attr
        33: aedd490a14 [[Sat Mar 01, 2025 at  1:11:26AM]] ETA better!
        32: 710eca8bf6 [[Sat Mar 01, 2025 at  2:15:10AM]] Updoot


    """
    import datetime

    if timezone is None: timezone = _default_timezone

    if timezone:
        pip_import('pytz')
        import pytz

    assert isinstance(date, datetime.datetime), "Input must be a datetime object"

    # Convert to the desired timezone if specified
    if timezone:
        target_timezone = pytz.timezone(_translate_timezone(timezone))
        date = date.astimezone(target_timezone)

    # Manually format the hour to remove leading zeros for cross-platform compatibility
    hour = date.hour % 12
    hour = hour if hour else 12  # Convert 0 hour to 12 for 12-hour clock
    minute = date.minute
    second = date.second
    am_pm = "AM" if date.hour < 12 else "PM"

    # Use platform-independent formatting for the rest of the date
    date_part = date.strftime("%a %b %d, %Y")
    time_part = "{hour}:{minute:02d}:{second:02d}{am_pm}".format(
        hour=hour, minute=minute, second=second, am_pm=am_pm
    )

    if align:
        time_part = time_part.rjust(len(" 2:32:45AM"))

    formatted_date = "{date_part} at {time_part}".format(
        date_part=date_part, time_part=time_part
    )

    if date.tzinfo is not None:
        # If the date has a timezone, add it to the output
        timezone_str = date.strftime("%Z")
        formatted_date += " " + timezone_str

    return formatted_date


def format_current_date(timezone=None):
    """
    EXAMPLES:
        >>> format_current_date()#I'm in California
        ans = "Thu Jun 27, 2024 at 8:15:24PM"
        >>> format_current_date('PST')
        ans = "Thu Jun 27, 2024 at 8:15:29PM PDT"
        >>> format_current_date('EST')
        ans = "Thu Jun 27, 2024 at 11:15:31PM EDT"
    """
    # TODO: See format_date todo
    return format_date(get_current_date(), timezone=timezone)


    


_format_datetime = format_date #For compatiability - older code used rp.r._format_datetime


def _method_decorator_metaclass(decorator,docstring=None):
    """
    Factory that creates a metaclass applying decorator to all methods
    Used to make more compact and readable source code
    """
    class Meta(type):
        def __new__(cls, name, bases, class_dict):
            from types import FunctionType
            new_class_dict = {}
            for attr_name, attr_value in class_dict.items():
                if isinstance(attr_value, FunctionType) and not attr_name.startswith('__'):
                    new_class_dict[attr_name] = decorator(attr_value)
                else:
                    new_class_dict[attr_name] = attr_value
            output=super().__new__(cls, name, bases, new_class_dict)
            if docstring is not None:
                output.__doc__=docstring
            return output
    return Meta

# Now your original metaclasses become one-liners:
_ClassMethodMetaclass  = _method_decorator_metaclass(classmethod ,'Automatically apply @classmethod to all non-wrapped subclass funcs')
_StaticMethodMetaclass = _method_decorator_metaclass(staticmethod,'Automatically apply @staticmethod to all non-wrapped subclass funcs')

class _FormTranslator(metaclass=_ClassMethodMetaclass):
    """
    Originally inspired by translation funcs such as 
        rp.is_image, rp.as_rgba_image, rp.as_rgb_image, as_grayscale_image, rp.as_pil_image etc etc
    It took O(N^2) code to write, and thought there's gotta be a better way...here it is!
    Please make a docstring for each child class explaining the methods created, they shouldn't have to look here.
    """
    def __init_subclass__(cls):
        super().__init_subclass__()
        
        #The forms this class handles are determined by the collection of is_* methods defined
        cls.forms = [name[3:] for name in dir(cls) if name.startswith('is_') and not name=='is_form']
        
        # Add as_* methods
        for form in cls.forms:
            setattr(cls, 'as_'+form, classmethod(lambda c, v, f=form: c.as_form(v, f)))
            
        #CLASS VALIDATION: Make sure we have total connectivity - translators from every form to every other form
        for from_form, to_form in itertools.product(cls.forms, repeat=2):
            if from_form != to_form:
                #For all pairs of different forms, we expect to find some method to translate between them
                translator_name = from_form + "_to_" + to_form
                if not hasattr(cls, translator_name):
                    raise RuntimeError(cls.__name__ + ": Can't find a " + translator_name + " method. We have " + str(len(cls.forms)) + " forms: " + str(cls.forms))
            
    
    def get_form(cls, value):
        for form in cls.forms:
            if cls.is_form(value,form):
                return form
        raise ValueError(cls.__name__ + ": Unknown form: " + str(value))
    
    def as_form(cls, value, form):
        from_form = cls.get_form(value)
        
        if from_form==form:
            #We don't need to translate anything - they're already the right form
            return value
        
        return getattr(cls, from_form + "_to_" + form)(value)
    
    def is_form(cls,value,form):
        return getattr(cls, "is_" + form)(value)


class _TimezoneFormTranslator(_FormTranslator):
    """
    Contains functions to help translate beteen timezone data formats
    
    human = 'EST', iana = 'America/New_York', and tzinfo = <DstTzInfo 'America/New_York' LMT-1 day, 19:04:00 STD> all represent the same data

    EXAMPLES:
        
        >>> print(TimezoneTranslator.as_tzinfo('EST').__repr__())             # --> <DstTzInfo 'America/New_York' LMT-1 day, 19:04:00 STD>
        ... print(TimezoneTranslator.as_iana('EST').__repr__())               # --> 'America/New_York'
        ... print(TimezoneTranslator.as_human('America/New_York').__repr__()) # --> 'ET'
        ... print(TimezoneTranslator.as_iana('ET').__repr__())                # --> 'America/New_York'
        ... 
        ... tz='EST'
        ... 
        ... print(TimezoneTranslator.as_form(tz,'human').__repr__())  # --> 'EST'
        ... print(TimezoneTranslator.as_form(tz,'tzinfo').__repr__()) # --> <DstTzInfo 'America/New_York' LMT-1 day, 19:04:00 STD>
        ... print(TimezoneTranslator.as_form(tz,'iana').__repr__())   # --> 'America/New_York'
        ... 
        ... print(TimezoneTranslator.is_human(tz))  # --> True
        ... print(TimezoneTranslator.is_iana(tz))   # --> False
        ... print(TimezoneTranslator.is_tzinfo(tz)) # --> False
        ... 
        ... print(TimezoneTranslator.get_form(tz))  # -->Human
        
    """
    # Converters
    def human_to_iana  (cls,tz): return _timezone_translations[tz]
    def iana_to_tzinfo (cls,tz): return rp.pip_import('pytz').timezone(tz)
    def iana_to_human  (cls,tz): return rp.invert_dict(_timezone_translations, precedence='first')[tz]
    def tzinfo_to_iana (cls,tz): return tz.zone
    def human_to_tzinfo(cls,tz): return cls.iana_to_tzinfo(cls.human_to_iana(tz))
    def tzinfo_to_human(cls,tz): return cls.iana_to_human(cls.tzinfo_to_iana(tz))
    
    # Type checkers - should all be mutually exclusive
    def is_iana  (cls,tz): return isinstance(tz, str) and '/' in tz #Area/Location - Google "IANA timezone form"
    def is_human (cls,tz): return isinstance(tz, str) and tz in _timezone_translations
    def is_tzinfo(cls,tz): return isinstance(tz, datetime.tzinfo)

class _FilesizeFormTranslator(_FormTranslator):
    """
    EXAMPLES:
        >>> _FilesizeFormTranslator.as_num_bytes('10mb')
        ans = 10485760
        >>> _FilesizeFormTranslator.as_num_bytes('1024')
        ans = 1024
        >>> _FilesizeFormTranslator.as_human(1024)
        ans = 1KB
        >>> _FilesizeFormTranslator.as_human('1024')
        ans = 1024
        >>> _FilesizeFormTranslator.as_human('1024kb')
        ans = 1024kb
    """
    # Converters
    def human_to_num_bytes(cls,size): return string_to_file_size     (size)
    def num_bytes_to_human(cls,size): return human_readable_file_size(size)
    
    # Type checkers - should all be mutually exclusive
    def is_num_bytes(cls,size): return isinstance(size, int)
    def is_human    (cls,size): return isinstance(size, str)




def get_current_timezone(*,form='human'):
    """
    EXAMPLE:
        >>> get_current_timezone()
        ans = PST
    """
    if _default_timezone is not None:
        # If we manually set an override in rprc...
        return _TimezoneFormTranslator.as_form(_default_timezone, form)

    date = get_current_date()
    target = format_date(date, "")
    for timezone in _timezone_translations:
        result = format_date(date, timezone)
        if result.startswith(target):
            return _TimezoneFormTranslator.as_form(timezone, form)


_rinsp_temp_object=None
_builtin_print=print
def rinsp(
    object,
    search_or_show_documentation: bool = False,
    show_source_code: bool = False,
    show_summary: bool = False,
    *,
    max_str_lines: int = 5
) -> None:  # r.inspect
    """
    Ryan's comprehensive object inspection tool - shows everything about any Python object.
    It's what rp.pterm's inspection commands such as ?, ?? and ??? use behind the scenes

    Parameters:
        object: Any Python object to inspect (modules, functions, classes, instances, etc.)
        search_or_show_documentation (bool/str): If bool, shows docs. If str, filters attributes containing the string.
        show_source_code (bool): Whether to display the source code if available. Defaults to False.
        show_summary (bool): Whether to show a brief summary instead of full details. Defaults to False.
        max_str_lines (int): Maximum lines to show when displaying string representations. Defaults to 5.

    Returns:
        None: Prints comprehensive inspection output directly to terminal

    Examples:
        >>> rinsp(list)  # Inspect the list class
        # Shows: type, methods, attributes, documentation
        >>> rinsp("hello", show_source_code=True)  # String with source
        # Shows: string details, available methods
        >>> rinsp(numpy, "array")  # Search for 'array' in numpy
        # Shows: only numpy attributes/methods containing 'array'
        >>> rinsp(my_function, show_summary=True)  # Brief overview
        # Shows: concise function information


    Example:
        >>> ans = [1,2,3,4,5]

        >>> rinsp(ans)
        rinsp report (aka Ryan's Inspection):
           ENTRIES: 47 things: [append, clear, copy, count, extend, index, ... , __setitem__, __sizeof__, __str__, __subclasshook__]
           STR: [1, 2, 3, 4, 5]
           LEN: 5
           ANCESTRY: object
           TYPE: class 'list'

        >>> rinsp(ans,True)
        rinsp report (aka Ryan's Inspection):
           ... (same as first one) ... 
           DOCUMENTATION:
        Built-in mutable sequence.
        If no argument is given, the constructor creates a new empty list.
        The argument must be an iterable if specified.

        >>> rinsp(ans,False,True)
        rinsp report (aka Ryan's Inspection):
           ... (same as first one) ... 
           SUMMARY:
              append --> [builtin_function_or_method : doc()] Append object to the end of the list.
              clear --> [builtin_function_or_method : doc()] Remove all items from list.
              copy --> [builtin_function_or_method : doc()] Return a shallow copy of the list.

                ... (it keeps going) ...

              __sizeof__ --> [builtin_function_or_method : doc()] Return the size of the list in memory, in bytes.
              __str__ --> [method-wrapper : doc()] Return str(self).
                 ………continues for 2 more lines and 74 more characters………


            Tags: inspection, debugging, introspection, analysis, development, explore

    """
    # This method is really uglily written (by Cthulu Himself, would ya believe!) because I made no attempt to refactor it. But it works and its really useful.
    # search_or_show_documentation: If this is a string, it won't show documentation UNLESS show_source_code ⋁ show_summary. BUT it will limit dir⋃dict to entries that contain search_or_show_documentation. Used for looking up that function name you forgot.


    printed_lines=[]
    def print(*x,end='\n',flush=False):
        out=' '.join(map(str,x))+end
        printed_lines.append(out)
        _builtin_print(end=out,flush=flush)

    """
    rinsp report (aka Ryan's Inspection):
        OBJECT: rinsp(object, show_source_code=False, max_str_lines:int=5)
        TYPE: class 'function'
        FILE: module '__main__' from '/Users/Ryan/PycharmProjects/RyanBStandards_Python3.5/r.py'
        STR: <function rinsp at 0x109eb10d0>"""
    search_filter=isinstance(search_or_show_documentation,str) and search_or_show_documentation or ''
    if search_filter:
        search_or_show_documentation=False or show_source_code or show_summary
    import inspect as i
    def linerino(x,prefix_length=0):
        max_string_length=max(0,max_str_lines*get_terminal_width()-prefix_length)
        number_of_lines=x.count("\n") + 1
        if len(x)<=max_string_length:
            new_x='\n'.join(x.split('\n')[:max_str_lines])
            continuation=fansi("\n" + tab + "\t………continues for " +str(number_of_lines -       max_str_lines) + " more lines and "+str(len(x)-       len(new_x)) + " more characters………",colour)
            return new_x + (continuation if (number_of_lines > max_str_lines + 1) else "")  # max_str_lines+1 instead of just max_str_lines so we dont get '………continues for 1 more lines………'
        else:
            new_x=x[:max_string_length]
            continuation=fansi("\n" + tab + "\t………continues for " +str(number_of_lines - new_x.count('\n')-1) + " more lines and "+str(len(x)-max_string_length) + " more characters………",colour)
            return new_x + continuation
    tab='   '
    colour='cyan'
    col=lambda x:fansi(x,colour,'bold')
    # _=col('rinsp report (aka Ryan\'s Inspection):')
    _=col('rinsp report (aka Ryan\'s Inspection):')
    print(_)
    if True:
        #Display ENTRIES
        temp=object
        try:  # noinspection PyStatementEffect
            object.__dict__
            print(col(tab + "ENTRIES: "),end="",flush=False)
            # print(col(tab + "DIR⋃DICT: "),end="",flush=False) # <---- ORIGINAL CODE
        except:
            temp=type(object)
            print(col(tab + "ENTRIES: "),end="",flush=False)  # If we can't get the dict of (let's say) a numpy array, we get the dict of it's type which gives all its parameters' names, albeit just their defgault values.
            # print(col(tab + "DIR⋃TYPE.DICT: "),end="",flush=False)  # If we can't get the dict of (let's say) a numpy array, we get the dict of it's type which gives all its parameters' names, albeit just their defgault values.  # <---- ORIGINAL CODE
        dict_used=set(temp.__dict__)
        dict_used=dict_used.union(set(dir(object)))
        d=dict_used
        if search_filter:
            print(fansi(tab + "FILTERED: ",'yellow','bold'),end="",flush=False)
            d={B for B in d if search_filter in B}
        def sorty(d):
            A=sorted([x for x in d if x.startswith("__") and x.endswith("__")])  # Moving all built-ins and private variables to the end of the list
            B=sorted([x for x in d if x.startswith("_") and not x.startswith("__") and not x.endswith("__")])
            C=sorted(list(set(d) - set(A) - set(B)))
            return C + B + A
        dict_used=sorty(d)
        if len(dict_used) != 0:
            global _rinsp_temp_object
            _rinsp_temp_object=object
            attrs={}
            for attrname in dict_used:
                try:
                    attrs[attrname]=(eval('_rinsp_temp_object.' + attrname))
                except:
                    attrs[attrname]=(fansi("ERROR: Cannot evaluate",'red'))
            def color(attr):
                try:
                    attr=eval('_rinsp_temp_object.' + attr)  # callable(object.__dir__.__get__(attr))
                except:
                    return ('red',None)
                if callable(attr):
                    return ('green', ) # Green if callable
                def is_module(x):
                    import types
                    return isinstance(x,types.ModuleType)
                if is_module(attr):
                    return ('blue',)
                return [None]  # Plain and boring if else
            dict_used_with_callables_highlighted_green=[fansi(x,*color(x)) for x in dict_used]
            print_string=(str(len(dict_used)) + ' things: [' + ', '.join(dict_used_with_callables_highlighted_green) + "]")  # Removes all quotes in the list so you can rad ) +" ⨀ ⨀ ⨀ "+str(dict_used).replace("\n","\\n"))
            print_string=print_string.replace('\x1b[0m','')#Make rendering large amounts of commas etc faster (switching between formats seems to make terminal rendering slow and even crashes windows)
            if currently_running_windows():
                print_string=strip_ansi_escapes(print_string)#This is to prevent really slowwww crashes on windows cause windows sucks lol
            print(end=print_string)
            print()
        else:
            print(end="\r")  # Erase the previous line (aka "DICT: " or "TYPE.DICT: ")

    str_on_top=True
    if str_on_top: #ENABLE THIS REGION TO PRINT 'STR:' on the TOP instead of the bottom
        try:
            # GETTING CHARACTER FOR TEMP
            def is_module(x):
                import types
                return isinstance(x,types.ModuleType)
            if not is_module(object):
                prefix=tab + "STR: "
                print((col(prefix) + linerino(str(object),len(prefix))))
        except:
            pass
        # try:
        #     # GETTING CHARACTER FOR TEMP
        #     def is_module(x):
        #         import types
        #         return isinstance(x,types.ModuleType)
        #     if not is_module(object):
        #         print(end=col(tab + "STR: ") + linerino(str(object)))
        #         print()
        # except Exception as e:
        #     # print_verbose_stack_trace(e)
        #     pass


    if False:
        pass
        # _=col(tab + 'OBJECT: ')
        # ⵁ_errored=False
        # try:
        #     _+=object.__name__
        # except Exception as e:
        #     _+='[cannot obtain object.__name__ without error: ' + str(e) + ']'
        #     ⵁ_errored=True
        # try:
        #     _+=str(i.signature(object))
        # except:
        #     pass
        # if not ⵁ_errored and _.strip():
        #     # print()
        #     print(end=_)
        #     print()
    try:
        temp=object
        from types import ModuleType
        neednewline=False
        try:
            print(col(tab+"LEN: ")+str(len(object)),end=' ')
            if isinstance(object,bytes) or isinstance(object,str):
                print(col('aka '+human_readable_file_size(len(object))),end=' ')
                
            neednewline=True
        except Exception:pass
        if isinstance(object,str):
            print(col(tab + "LINES: ")+repr(number_of_lines(object)),flush=False,end='')
            
        if hasattr(object,'shape'):
            print(col(tab + "SHAPE: ")+repr(object.shape),flush=False,end='')
            neednewline=True
            if hasattr(object,'dtype'):
                print(col(tab + "DTYPE: ")+repr(object.dtype),flush=False,end='')
            if is_numpy_array(object):
                print(col(tab + "MEMORY: ")+human_readable_file_size(object.size*object.dtype.itemsize),flush=False,end='')
            if is_torch_tensor(object):
                print(col(tab + "MEMORY: ")+human_readable_file_size(object.numel()*object.itemsize),flush=False,end='')
            if hasattr(object,'device'):
                try: print(col(tab + "DEVICE: ")+str(object.device),flush=False,end='')
                except Exception: pass
            if hasattr(object,'min') and callable(object.min):
                try: print(col(tab + "min:")+" %.6f"%(object.min()),flush=False,end='')
                except Exception: pass
            if hasattr(object,'max') and callable(object.max):
                try: print(col(tab + "max:")+" %.6f"%(object.max()),flush=False,end='')
                except Exception: pass
            if hasattr(object,'mean') and callable(object.mean):
                try: print(col(tab + "mean:")+" %.6f"%(object.mean()),flush=False,end='')
                except Exception: pass

        if neednewline:
            print(flush=False)
        if isinstance(object,ModuleType):
            submodulenames=[x.split('.')[-1] for x in get_all_submodule_names(object)]
            if submodulenames:
                print(col(tab + "SUBMODULES: ")+(', '.join(submodulenames)),end="\n",flush=False)  # If we can't get the dict of (let's say) a numpy array, we get the dict of it's type which gives all its parameters' names, albeit just their defgault values.
            if hasattr(object,'__version__'):
                print(col(tab + "VERSION: ")+str(object.__version__),end="\n",flush=False)  # If we can't get the dict of (let's say) a numpy array, we get the dict of it's type which gives all its parameters' names, albeit just their defgault values.
        # try:  # noinspection PyStatementEffect
        #     object.__dict__
        #     print(col(tab + "ENTRIES: "),end="",flush=False)
        #     # print(col(tab + "DIR⋃DICT: "),end="",flush=False) # <---- ORIGINAL CODE
        # except:
        #     temp=type(object)
        #     print(col(tab + "ENTRIES: "),end="",flush=False)  # If we can't get the dict of (let's say) a numpy array, we get the dict of it's type which gives all its parameters' names, albeit just their defgault values.
        #     # print(col(tab + "DIR⋃TYPE.DICT: "),end="",flush=False)  # If we can't get the dict of (let's say) a numpy array, we get the dict of it's type which gives all its parameters' names, albeit just their defgault values.  # <---- ORIGINAL CODE
        # dict_used=set(temp.__dict__)
        # dict_used=dict_used.union(set(dir(object)))
        # d=dict_used
        # if search_filter:
        #     print(fansi(tab + "FILTERED: ",'yellow','bold'),end="",flush=False)
        #     d={B for B in d if search_filter in B}
        # def sorty(d):
        #     A=sorted([x for x in d if x.startswith("__") and x.endswith("__")])  # Moving all built-ins and private variables to the end of the list
        #     B=sorted([x for x in d if x.startswith("_") and not x.startswith("__") and not x.endswith("__")])
        #     C=sorted(list(set(d) - set(A) - set(B)))
        #     return C + B + A
        # dict_used=sorty(d)
        # if len(dict_used) != 0:
        #     global _rinsp_temp_object
        #     _rinsp_temp_object=object
        #     attrs={}
        #     for attrname in dict_used:
        #         try:
        #             attrs[attrname]=(eval('_rinsp_temp_object.' + attrname))
        #         except:
        #             attrs[attrname]=(fansi("ERROR: Cannot evaluate",'red'))
        #     def color(attr):
        #         try:
        #             attr=eval('_rinsp_temp_object.' + attr)  # callable(object.__dir__.__get__(attr))
        #         except:
        #             return 'red',None
        #         if callable(attr):
        #             return 'green',  # Green if callable
        #         return [None]  # Plain and boring if else
        #     dict_used_with_callables_highlighted_green=[fansi(x,*color(x)) for x in dict_used]
        #     print_string=(str(len(dict_used)) + ' things: [' + ', '.join(dict_used_with_callables_highlighted_green) + "]")  # Removes all quotes in the list so you can rad ) +" ⨀ ⨀ ⨀ "+str(dict_used).replace("\n","\\n"))
        #     print_string=print_string.replace('\x1b[0m','')#Make rendering large amounts of commas etc faster (switching between formats seems to make terminal rendering slow and even crashes windows)
        #     if currently_running_windows():
        #         print_string=strip_ansi_escapes(print_string)#This is to prevent really slowwww crashes on windows cause windows sucks lol
        #     print(print_string)
        # else:
        #     print(end="\r")  # Erase the previous line (aka "DICT: " or "TYPE.DICT: ")
    except:
        pass
    def parent_class_names(x,exclude={'object'}):
        #returns a set of strings containing the names of x's parent classes, exclu
        if not isinstance(x,type):
            x=type(x)
        return {y.__name__ for y in x.__bases__}-exclude
    parents=parent_class_names(object)
    parent_string=''
    if parents:
        prefix='PARENT'
        if len(parents)>1:
            prefix+='S'
        parent_string=col(', '+prefix+': ')+', '.join(sorted(parents))
    def get_full_class_name(class_object):
        out=repr(class_object)
        if out.startswith('<class \'') and out.endswith("'>"):
            return out[len('<class \''):-len("'>")]
        return class_object.__name__
    def get_parent_hierarchy(object):
        from collections import OrderedDict
        # out=OrderedDict()
        out={}
        if not isinstance(object,type):
            object=object.__class__
        for parent in object.__bases__:
            # out[parent.__name__]=get_parent_hierarchy(parent)
            out[get_full_class_name(parent)]=get_parent_hierarchy(parent)
        return out

    def format_parent_hierarchy(hierarchy:dict,spaces=len('   ANCESTRY: ')):
        import pprint
        ans=pprint.pformat(hierarchy)
        ans=ans.replace("{'object': {}}",'object')
        ans=ans.replace("'",' ')
        ans=ans.splitlines()
        if len(ans)>1:
            ans[1:]=[' '*spaces+line for line in ans[1:]]
        ans=line_join(ans)
        return ans

    print(col(tab + 'ANCESTRY: ') + format_parent_hierarchy(get_parent_hierarchy(object)))#This is presenred in an ugly format right now and should eventually replace 'parent'. But this can be done later.

    print(col(tab + 'TYPE: ') + str(type(object))[1:-1]+parent_string)
    if i.getmodule(object) is not None:
        # print(col(tab + 'FILE: ') + str(i.getmodule(object))[1:-1])
        try:
            print(col(tab + 'FILE: ') + str(get_source_file(object)))
        except TypeError as e:
            print(col(tab + 'FILE: ') + str(e))

    if isinstance(object, int) and process_exists(object):
        print_process_info(object)


    if isinstance(object, int) and get_port_is_taken(object):
        try:
            process=get_process_using_port(object)
            by_string = 'BY PROCESS %i'%(process)
            print(col(tab + 'PORT %i IS TAKEN '%object + by_string))
            print_process_info(process)
        except Exception as e:
            by_string = '(unknown process: %s)'%repr(e)
            print(col(tab + 'PORT %i IS TAKEN '%object + by_string))
                
    if isinstance(object, int):
        filesize_string = human_readable_file_size(object)
        print(col(tab + 'Filesize:'),filesize_string)
                
 
    if is_symlink(object):
        print(col(tab + 'SYMLINK --> '+read_symlink(object)))
        if symlink_is_broken(object):
            print(tab+tab+col("(symlink is broken)"))

    def is_dictlike(x):
        return issubclass(type(x),dict)
    if is_dictlike(object) and all((isinstance(x, str) and not " " in x) for x in object):
        print(col(tab + 'KEYS (%i): '%len(object)) + ' '.join(object))
        

    if isinstance(object,str) and path_exists(object):
        stats=[]
        def append_stat(title,stat=''):
            stats.append(col(title+':')+str(stat))
        try:
            path=object
            if file_exists(path):
                append_stat('FILE STATS')
                append_stat('size',get_file_size(path,human_readable=True))
                if is_image_file(path):
                    append_stat('resolution',str(get_image_file_dimensions(path)))
                if is_utf8_file(path):
                    append_stat('#lines',number_of_lines_in_file(path))    
                if is_video_file(path):
                    append_stat('duration',str(get_video_file_duration(path))+'s')
                    append_stat('shape',str(get_video_file_shape(path)))
            else:
                append_stat('FOLDER STATS')
                append_stat('#files',len(get_all_files(path)))
                append_stat('#subfolders',len(get_all_folders(path)))
            append_stat('date_modified',str(_format_datetime(date_modified(path))))
        except Exception as e:
            print_stack_trace(e)
            pass
        print(col(tab + '     '.join(stats)))



    def errortext(x):
        return fansi(x,'red','underlined')

    # if not str_on_top:
    #     try:
    #         # GETTING CHARACTER FOR TEMP
    #         def is_module(x):
    #             import types
    #             return isinstance(x,types.ModuleType)
    #         if not is_module(object):
    #             prefix=tab + "STR: "
    #             print((col(prefix) + linerino(str(object),len(prefix))))
    #     except:
    #         pass
    # else:
    #     pass

    if True:
        _=col(tab + 'OBJECT: ')
        ⵁ_errored=False
        try:
            _+=object.__name__
        except Exception as e:
            _+='[cannot obtain object.__name__ without error: ' + str(e) + ']'
            ⵁ_errored=True
        try:
            def format_signature(item):
                assert callable(item)
                import inspect
                def autoformat_python_via_black(code:str):

                    if sys.version_info>(3,6):
                        pip_import('black')
                        import black
                        return black.format_str(code,mode=black.Mode())
                    #Python versions older than 3.6 don't support black
                    return code

                sig=inspect.signature(item)
                sig=item.__name__+str(sig)
                sig='def '+sig+':pass'
                sig=autoformat_python_via_black(sig)
                sig=sig[len('def '):]
                sig=sig.strip()
                sig=sig[:-len('pass')]
                sig=sig.strip()
                sig=sig[:-len(':')]
                return sig
            def indentify_all_but_first_line(string,indent):
                lines=line_split(string)
                if len(lines)<1:
                    return string
                lines[1:]=[indent+line for line in lines[1:]]
                return line_join(lines)
            try:
                signature=format_signature(object)
                # signature=signature[len(object.__name__):]
                signature=indentify_all_but_first_line(signature,' '*len('   SIGNATURE: '))
            except Exception:
                signature=object.__name__+str(i.signature(object))
            _=col(tab+'SIGNATURE: ')+fansi_syntax_highlighting(str(signature))
        except:
            pass
        if not ⵁ_errored and _.strip():
            # print()
            print(end=_)
            print()


    if show_summary:
        def to_str(x):
            if x is None:
                return str(x)

            outtype='str()'
            out=str(x)
            if out and out[0] == '<' and out[-1] == '>':
                out=x.__doc__
                if out is None:
                    try:
                        out=i.getcomments(object)
                        outtype='doc()'
                    except:
                        out=str(out)
                        outtype='str()'
                else:
                    outtype='doc()'

            typestr=str(type(x))
            if typestr.count("'") >= 2:
                typestr=typestr[typestr.find("'") + 1:]
                typestr=typestr[:typestr.find("'")]
            elif typestr.count('"') >= 2:
                typestr=typestr[typestr.find('"') + 1:]
                typestr=typestr[:typestr.find('"')]

            out=fansi('[' + typestr + " : " + outtype + "]",'green') + " " + fansi(out,'blue')
            if '\n' in out:
                indent_prefix=''  # '···'
                out='\n'.join((indent_prefix + x) for x in out.split('\n'))
                while '\n\n' in out:
                    out=out.replace('\n\n','\n')
                out=linerino(out)
                out=out.lstrip()
                out=out.rstrip()
            return out
        print(col(tab + "SUMMARY:"))
        print_string=display_dict(attrs,print_it=False,key_sorter=sorty,value_color=to_str,arrow_color=lambda x:fansi(x,'green'),key_color=lambda x:fansi(x,'green','bold'),clip_width=True,post_processor=lambda x:'\n'.join(2 * tab + y for y in x.split('\n')))
        if currently_running_windows():
            print_string=strip_ansi_escapes(print_string)#To avoid crashing windows terminals, cut down on the terminal colorings...
        print(print_string)
    if show_source_code:
        sourcecodeheader=tab + "SOURCE CODE:"
        print(col(sourcecodeheader) + fansi("―"*max(0,get_terminal_width()-len(sourcecodeheader)),'cyan','blinking'))
        _=code_string_with_comments=''
        _+=i.getcomments(object) or ''  # ≣i.getc omments(object) if i.getcomments(object) is not None else ''
        _=fansi_syntax_highlighting(_)
        try:
            try:
                _+=fansi_syntax_highlighting(str(i.getsource(object)))
            except:
                _+=fansi_syntax_highlighting(str(i.getsource(object.__class__)))
        except Exception as e:
            _+=2 * tab + errortext('[Cannot retrieve source code! Error: ' + linerino(str(e)) + "]")
        print(_)
    if search_or_show_documentation:
        print(col(tab + "DOCUMENTATION: "))
        try:
            if object.__doc__ and not object.__doc__ in _:
                print(fansi(str(object.__doc__),'gray'))
            else:
                if not object.__doc__:
                    print(2 * tab + errortext("[__doc__ is empty]"))
                else:  # ∴ object.__doc__ in _
                    print(2 * tab + errortext("[__doc__ can be found in source code, which has already been printed]"))
        except Exception as e:
            print(2 * tab + errortext("[Cannot retrieve __doc__! Error: " + str(e) + "]"))
    _maybe_display_string_in_pager(''.join(printed_lines),with_line_numbers=False)
# endregion
# region Webcam: ［load_image_from_webcam, load_image_from_webcam_in_jupyter_notebook］

#Under construction...
#class CVCamera:
#    #This class is a wrapper for OpenCV's camera class
#    #
#    def __init__(self, index=0):
#        pip_import('cv2')
#        import cv2
#
#        self.index=index
#        self.cap=cv2.VideoCapture(index)
#
#    def read(self):
#        success, img = self.cap.read()
#        if not success:
#            assert img==None,'This assertion is not important - its just that every time it fails, img seems to be None'
#            raise IOError('r.CVCamera: Failed to take a picture using camera %s'%str(self))
#
#        return img
#
#    def __repr__(self):
#        return 'CVCamera(index=%i)'%self.index
#
#    def __call__(self):
        
        

_cameras=[]
def _cv_initialize_cameras():
    if _cameras:
        return  # Allready initialized
    fansi_print("r._cv_initialize_cameras: Initializing camera feeds; this will take a few seconds...",'green',new_line=False)
    # noinspection PyUnresolvedReferences
    pip_import('cv2')
    from cv2 import VideoCapture
    i=0
    while True:
        cam=VideoCapture(i)
        if not cam.read()[0]:
            break
        _cameras.append(cam)
        fansi_print("\rr._cv_initialize_cameras: Added camera #" + str(i),'green',new_line=False)
        i+=1
    fansi_print("\rr._cv_initialize_cameras: Initialization complete!",'green')

def _cv_print_cam_props(index=0):
    """
    Prints available opencv camera properties for a given camera index
    EXAMPLE:
        >>> print_cam_info(1)
       CAP_PROP_BACKEND        1200.0
       CAP_PROP_FORMAT 16.0
       CAP_PROP_FPS    5.0
       CAP_PROP_FRAME_HEIGHT   1080.0
       CAP_PROP_FRAME_WIDTH    1920.0
    """
    import cv2
    cap = cv2.VideoCapture(index)
    props=[x for x in dir(cv2) if  x.startswith('CAP_PROP_')]
    def list_cap_props(cap):
        for prop in props:
            val=cap.get(getattr(cv2,prop))
            if val:
                print(prop+'\t'+str(val))
    list_cap_props(cap)
    cap.release()

def load_image_from_webcam(webcam_index: int = 0,
                           *,
                           width:int=None,
                           height:int=None,
                           shutup=False
                           ):
    """
    If your camera supports multiple resolutions, input the dimensions in the height and width parameters
    For example, Xiang's raspberry pi webcam was taking pictures at 720p even though the camera could take 1080p pictures.
      When I set width=1920 and height=1080, it fixed the problem, letting it take 1080p pictures

    Note: this can be finicky! You have to set both the height and width correctly for this to work.
    Note that when you set width and height using this method, they will stay like that until changed again.
    For example, on my 2021 Macboox Max, using the default webcam, here are some results:

    These properties can be discovered using the r._cv_print_cam_props function
    To discover which resolutions are supported by your webcam, see this tutorial: 
       https://www.learnpythonwithrune.org/find-all-possible-webcam-resolutions-with-opencv-in-python/

    Change webcam_index if you have multiple cameras

    EXAMPLE: while True: display_image(med_filter(load_image_from_webcam(1),σ=0));sleep(0);clf()#⟵ Constant webcam display
    """

    if running_in_google_colab():return _load_image_from_webcam_in_jupyter_notebook()

    pip_import('cv2')
    import cv2

    _cv_initialize_cameras()

    # _,img=_cameras[webcam_index].read()
    # if webcam_index>=_cameras.__len__():
    #     if not shutup:
    #         print("r.load_image_from_webcam: Warning: Index is out of range: webcam_index="+str(webcam_index)+" BUT len(_cameras)=="+str(len(_cameras))+", setting webcam_index to 0")
    #     webcam_index=0
    
    cap=_cameras[webcam_index]

    if width  is not None: cap.set(cv2.CAP_PROP_FRAME_WIDTH , width )
    if height is not None: cap.set(cv2.CAP_PROP_FRAME_HEIGHT, height)

    for _ in range(2): 
        #This many tries to initialize the camera
        #I found the camera returns black
        success,img=cap.read()
        if img.any():
            break

    assert success        , 'Failed to take a photo with webcam #%i'%webcam_index 
    assert img is not None, 'Failed to take a photo with webcam #%i'%webcam_index

    img=np.add(img,0)  # Turns it into numpy array
    img=cv_bgr_rgb_swap(img)

    return img

def load_webcam_stream():
    """ Instead of loading a single image from webcam, returns a generator that returns such images infinitely - essentially an infinitely long video """
    while True:
        yield load_image_from_webcam()

def load_image_from_screenshot_via_mss(monitor=0,*,cursor:bool=False):
    """
    Grabs a screenshot from the main monitor using the 
    Multiple Screen Shots (MSS) Library
    Returns it as a RGB byte image

    cursor: If true, included the cursor in the image

    EXAMPLE:
        
        >>> while True:
        ...     image = load_image_from_screenshot_via_mss(cursor=True)
        ...     image = cv_resize_image(image, 0.25)
        ...     display_image(image)
        
    """
    pip_import('mss')
    import mss
    
    with mss.mss(with_cursor=cursor) as sct:
        img = sct.grab(sct.monitors[monitor])
        img = rp.as_numpy_array(img)
        img = rp.cv_bgr_rgb_swap(img, copy=False)

        return img

def load_image_from_screenshot():
    """
    Grabs a screenshot from the main monitor
    Returns it as a RGB byte image

    EXAMPLE:
        
        >>> while True:
        ...     image = load_image_from_screenshot()
        ...     image = cv_resize_image(image, 0.25)
        ...     display_image(image)
        
    """
    return load_image_from_screenshot_via_mss()

def load_screenshot_stream():
    """

    EXAMPLE:

        >>> while True:
        ...     display_video(cv_resize_images(load_screenshot_stream(), size=.25, lazy=True))

    """
    while True:
        yield load_image_from_screenshot()

def _load_image_from_screenshot_via_pyscreenshot():
    """
    REPLACED BY load_image_from_screenshot WHICH IS MUCH FASTER
    (This still works as a slow version though so I'll keep it as a private fallback)

    Take a screeshot, and return the result as a numpy-array-style image
    EXAMPLE: display_image(load_image_from_screenshot())
    TODO: Make this faster. the 'mss' package from pypi got much better performance, so if you need higher FPS try using that for the inner workings of this function.
    """
    pyscreenshot=pip_import('pyscreenshot')
    im = pyscreenshot.grab(childprocess=False)
    return np.asarray(im)

def _load_image_from_webcam_in_jupyter_notebook():
    from IPython.display import HTML, Image
    from google.colab.output import eval_js
    from base64 import b64decode

    VIDEO_HTML = """
    <video autoplay
     width=800 height=600></video>
    <script>
    var video = document.querySelector('video')
    navigator.mediaDevices.getUserMedia({ video: true })
      .then(stream=> video.srcObject = stream)

    var data = new Promise(resolve=>{
      video.onclick = ()=>{
        var canvas = document.createElement('canvas')
        var [w,h] = [video.offsetWidth, video.offsetHeight]
        canvas.width = w
        canvas.height = h
        canvas.getContext('2d')
              .drawImage(video, 0, 0, w, h)
        video.srcObject.getVideoTracks()[0].stop()
        video.replaceWith(canvas)
        resolve(canvas.toDataURL('image/jpeg', %f))
      }
    })
    </script>
    """
    def take_photo(filename='photo.jpg', quality=0.8):
      display(HTML(VIDEO_HTML % quality))
      data = eval_js("data")
      binary = b64decode(data.split(',')[1])
      with open(filename, 'wb') as f:
        f.write(binary)
      return len(binary)

# endregion
# region  Audio Recording: ［record_mono_audio］
default_audio_stream_chunk_size=1024  # chunk_size determines the resolution of time_in_seconds as the samplerate. Look in the code for more explanation idk how to describe it.
default_audio_mono_input_stream=None  # Initialized in the record_mono_audio function
def record_mono_audio(time_in_seconds,samplerate=default_samplerate,stream=None,chunk_size=default_audio_stream_chunk_size) :
    """
    You can count on this method having a delay (between when you call the method and when it actually starts recording) on the order of magnitude of 10⁻⁵ seconds
    PLEASE NOTE: time_in_seconds is not interpreted precisely
    EXAMPLE: play_sound_from_samples(record_mono_audio(2))
    """
    pip_import('pyaudio')
    if stream is None:  # then use default_audio_mono_input_stream instead
        global default_audio_mono_input_stream
        if default_audio_mono_input_stream is None:  # Initialize it.
            import pyaudio  # You need this module to use this function. Download it if you don't have it.
            default_audio_mono_input_stream=pyaudio.PyAudio().open(format=pyaudio.paInt16,channels=1,rate=default_samplerate,input=True,frames_per_buffer=default_audio_stream_chunk_size)
        stream=default_audio_mono_input_stream
    number_of_chunks_needed=np.ceil(time_in_seconds * samplerate / chunk_size)  # Rounding up.
    out=np.hstack([np.fromstring(stream.read(num_frames=chunk_size,exception_on_overflow=False),dtype=np.int16) for _ in [None] * int(number_of_chunks_needed)])  # Record the audio
    out=np.ndarray.astype(out,float)  # Because by default it's an integer (not a floating point thing)
    out/=2 ** 15  # --> ∈［﹣1，1］ because we use pyaudio.paInt16. I confirmed this by banging on the speaker loudly and seeing 32743.0 as the max observed value.  ﹙# out/=max([max(out),-min(out)]) ⟵ originally this﹚
    # stream.stop_stream();stream.close() ⟵ Is slow. Takes like .1 seconds. I profiled this method so that it runs very, very quickly (response time is about a 1% of a millisecond)
    return out
# endregion
# region MIDI Input/Output: ［MIDI_input，MIDI_output］
__midiout=None
def MIDI_output(message: list):
    """
    Key:
    NOTE_OFF = [0x80, note, velocity]
    NOTE_ON = [0x90, note, velocity]
    POLYPHONIC_PRESSURE = [0xA0, note, velocity]
    CONTROLLER_CHANGE = [0xB0, controller, value]
    PROGRAM_CHANGE = [0xC0, program]
    CHANNEL_PRESSURE = [0xD0, pressure]
    PITCH_BEND = [0xE0, value-lo, value-hi]
    For more: see http://pydoc.net/Python/python-rtmidi/0.4.3b1/rtmidi.midiconstants/
    """
    pip_import('rtmidi')
#     try:
#         # Can control applications like FL Studio etc
#         # Use this for arduino etc
#         global __midiout
#         if not __midiout:
#             import rtmidi  # pip3 install python-rtmidi
#             __midiout=rtmidi.RtMidiOut()
#             try:
#                 available_ports=__midiout.get_ports()
#             except AttributeError:#AttributeError: 'midi.RtMidiOut' object has no attribute 'get_ports': See https://stackoverflow.com/questions/38166344/attributeerror-in-python-rtmidi-sample-code
#                 available_ports=__midiout.ports
#             if available_ports:
#                 __midiout.open_port(0)
#                 print("r.MIDI_output: Port Output Name: '" + __midiout.get_ports()[0])
#             else:
#                 __midiout.open_virtual_port("My virtual output")
#         __midiout.send_message(message)  # EXAMPLE MESSGES: # note_on = [0x90, 98, 20] # channel 1, middle C, velocity 112   note_off = [0x80, 98, 0]
#     except OverflowError as e:
#         fansi_print("ERROR: r.MIDI_Output: " + str(e) + ": ",'red',new_line=False)
#         fansi_print(message,'cyan')


    import rtmidi
    global __midiout
    if not __midiout:
        __midiout = rtmidi.MidiOut()

        if __midiout.get_port_count():
            __midiout.open_port(0)
            print("MIDI_output: Port Output Name: '" + __midiout.get_port_name(0) + "'")
        else:
            __midiout.open_virtual_port("My virtual output")

    try:
        __midiout.send_message(message)
    except OverflowError as e:
        print("ERROR: MIDI_Output: " + str(e) + ": ", end="")
        print(message)

def MIDI_control(controller_number: int,value: float):
    """Sends a MIDI control change message to control various parameters.
    
    Transmits a MIDI Control Change (CC) message with the specified controller number
    and value. This is used to control synthesizer parameters, effects, and other
    MIDI-controllable aspects of music software and hardware.
    
    Enhanced Documentation:
    
    MIDI Control Change messages are fundamental for real-time parameter control in
    music production. Common controller numbers include:
    - 1: Modulation wheel
    - 7: Volume
    - 10: Pan
    - 11: Expression
    - 64: Sustain pedal
    - 71-74: Filter parameters
    - 91-93: Reverb/Chorus/Delay levels
    
    The function automatically clamps the value to [0, 1] range and scales it to
    MIDI's 7-bit range (0-127). It uses MIDI message format [176, controller, value]
    where 176 (0xB0) is the Control Change status byte.
    
    Args:
        controller_number (int): MIDI controller number (0-127). Determines which
                               parameter is being controlled.
        value (float): Control value between 0.0 and 1.0. Values outside this range
                      are automatically clamped. 0.0 = minimum, 1.0 = maximum.
    
    Returns:
        None: Function sends MIDI message and returns nothing.
    
    Examples:
        >>> # Control modulation wheel (CC 1)
        >>> MIDI_control(1, 0.5)  # 50% modulation
        
        >>> # Control volume (CC 7)
        >>> MIDI_control(7, 1.0)  # Maximum volume
        >>> MIDI_control(7, 0.0)  # Minimum volume
        
        >>> # Control pan (CC 10)
        >>> MIDI_control(10, 0.5)  # Center pan
        >>> MIDI_control(10, 0.0)  # Full left
        >>> MIDI_control(10, 1.0)  # Full right
        
        >>> # Values outside [0,1] are clamped
        >>> MIDI_control(11, 1.5)  # Treated as 1.0
        >>> MIDI_control(11, -0.5) # Treated as 0.0
        
        >>> # Custom controller for plugin parameter
        >>> MIDI_control(20, 0.75)  # 75% of parameter range
    
    Related Functions:
        - MIDI_control_precisely: High-resolution control with 14-bit precision
        - MIDI_jiggle_control: Quickly toggles a controller between min and max
        - MIDI_output: Low-level MIDI message transmission
        - float_clamp: Used internally for value range validation
    
    MIDI Technical Details:
        - Status byte: 176 (0xB0) = Control Change on channel 1
        - Data byte 1: controller_number (0-127)
        - Data byte 2: scaled value (0-127)
    
    Tags: midi, control-change, cc, real-time, parameter-control, music
    """  # Controller_number is custom integer, and value is between 0 and 1
    MIDI_output([176,controller_number,int(clamp(value,0,1) * 127)])
def MIDI_control_precisely(coarse_controller_number: int,fine_controller_number: int,value: float):
    """Sends high-resolution 14-bit MIDI control change messages for precise parameter control.
    
    Transmits two MIDI Control Change messages to achieve 14-bit precision (16,384 values)
    instead of the standard 7-bit precision (128 values). This provides much finer control
    over parameters, which is essential for smooth parameter sweeps and precise automation.
    
    Enhanced Documentation:
    
    This function implements 14-bit MIDI control by sending both a coarse (MSB) and fine (LSB)
    control change message. The coarse controller carries the main value, while the fine
    controller carries the fractional precision. This technique is commonly used for:
    - Volume faders requiring smooth automation
    - Filter cutoff frequency sweeps  
    - Pitch bend-style continuous controllers
    - High-precision parameter automation in DAWs
    
    The value is split into:
    - Coarse: Integer part scaled to 0-127
    - Fine: Fractional part scaled to 0-127
    
    Common 14-bit controller pairs:
    - Volume: Coarse=7, Fine=39
    - Pan: Coarse=10, Fine=42  
    - Expression: Coarse=11, Fine=43
    - Custom: Any pair where fine_controller = coarse_controller + 32
    
    Args:
        coarse_controller_number (int): MIDI controller number for coarse/MSB value (0-127)
        fine_controller_number (int): MIDI controller number for fine/LSB value (0-127)
        value (float): Control value between 0.0 and 1.0, automatically clamped
    
    Returns:
        None: Function sends two MIDI messages and returns nothing.
    
    Examples:
        >>> # High-precision volume control (7=coarse, 39=fine)
        >>> MIDI_control_precisely(7, 39, 0.5)     # Exact 50% volume
        >>> MIDI_control_precisely(7, 39, 0.5001)  # Slightly higher than 50%
        
        >>> # Smooth filter sweep with maximum precision
        >>> for i in range(16384):  # All possible 14-bit values
        ...     value = i / 16383.0
        ...     MIDI_control_precisely(74, 106, value)  # Filter cutoff
        
        >>> # High-precision pan control
        >>> MIDI_control_precisely(10, 42, 0.5)    # Perfect center
        >>> MIDI_control_precisely(10, 42, 0.5078)  # Slightly right of center
        
        >>> # Custom parameter with fine control
        >>> MIDI_control_precisely(20, 52, 0.333333)  # Precise 1/3 value
    
    Technical Implementation:
        Input value 0.75 (75%) becomes:
        - Coarse: int(0.75 * 127) = 95
        - Fine: int((0.75 * 127) % 1 * 127) = int(0.25 * 127) = 31
        This gives 95 * 128 + 31 = 12,191 out of 16,384 possible values
    
    Related Functions:
        - MIDI_control: Standard 7-bit control change messages
        - MIDI_output: Low-level MIDI message transmission  
        - float_clamp: Used internally for value range validation
    
    MIDI Technical Details:
        Sends two messages:
        1. [176, coarse_controller_number, coarse_value] 
        2. [176, fine_controller_number, fine_value]
    
    Tags: midi, control-change, high-resolution, 14-bit, precision, automation
    """  # TWO bytes of data!!
    value=clamp(value,0,1)
    value*=127
    MIDI_output([176,coarse_controller_number,int(value)])
    MIDI_output([176,fine_controller_number,int((value % 1) * 127)])
def MIDI_jiggle_control(controller_number: int):  # Controller_number is custom integer, and value is between 0 and 1
    MIDI_control(controller_number,0)
    sleep(.1)
    MIDI_control(controller_number,1)
def MIDI_note_on(note: int,velocity: float = 1):  # velocity ∈ ［0，1］
    MIDI_output([144,clamp(note,0,255),int(velocity * 127)])  # Notes can only be between 0 and 255, inclusively
def MIDI_note_off(note: int,velocity: float = 0):
    MIDI_output([128,note,int(velocity * 127)])
MIDI_pitch_bend_min=-2  # Measured in Δsemitones.
MIDI_pitch_bend_max=6  # Note: These min/max numbers are Based on the limitations of the pitch bender, which is DAW dependent. This is what it appears to be in FL Studio on my computer. Note that these settings
def MIDI_pitch_bend(Δsemitones: float):
    """Sends MIDI pitch bend messages to bend note pitch up or down.
    
    Controls pitch bend for currently playing notes, specified in semitones.
    The range is constrained to [-2, +6] semitones based on FL Studio's default
    pitch bend range, though this may vary between DAWs and synthesizers.
    
    The function uses MIDI Pitch Bend Change messages (status 224/0xE0) with
    14-bit resolution. The implementation maps the semitone range to MIDI's
    0-16383 pitch bend value range, with 8192 representing no bend (center).
    
    The range limitation of [-2, +6] semitones reflects common DAW defaults:
    - FL Studio: -2 to +6 semitones  
    - Many others: -2 to +2 semitones
    - Hardware synths: varies by model
    
    Args:
        Δsemitones (float): Pitch bend amount in semitones. Automatically clamped
                          to [-2.0, +6.0] range. Positive values bend up, 
                          negative values bend down. 0.0 = no bend.
    
    Returns:
        None: Function sends MIDI pitch bend message and returns nothing.
    
    Examples:
        >>> # No pitch bend (center position)
        >>> MIDI_pitch_bend(0.0)
        
        >>> # Bend up one semitone (whole step)
        >>> MIDI_pitch_bend(1.0)
        
        >>> # Bend up maximum amount  
        >>> MIDI_pitch_bend(6.0)
        
        >>> # Bend down one semitone
        >>> MIDI_pitch_bend(-1.0) 
        
        >>> # Bend down maximum amount
        >>> MIDI_pitch_bend(-2.0)
        
        >>> # Quarter-tone bend (microtonal)
        >>> MIDI_pitch_bend(0.5)
        
        >>> # Values outside range are clamped
        >>> MIDI_pitch_bend(10.0)  # Treated as 6.0
        >>> MIDI_pitch_bend(-5.0)  # Treated as -2.0
        
        >>> # Guitar-style bend effect
        >>> for i in range(100):
        ...     bend = (i / 100.0) * 2.0  # 0 to 2 semitones
        ...     MIDI_pitch_bend(bend)
        ...     sleep(0.01)  # 10ms per step
    
    Technical Implementation:
        - Semitones mapped to 0-255 coarse value: ((Δsemitones + 2) / 8) * 255
        - Fine value set to 0 (could be used for higher precision)
        - MIDI message format: [224, fine, coarse] = [0xE0, LSB, MSB]
    
    Related Functions:
        - MIDI_control: For other continuous controllers like modulation
        - MIDI_note_on/MIDI_note_off: Play notes that will be pitch-bent
        - float_clamp: Used internally for range validation
    
    Global Constants:
        - MIDI_pitch_bend_min = -2  # Minimum semitones (down)
        - MIDI_pitch_bend_max = 6   # Maximum semitones (up)
    
    Tags: midi, pitch-bend, expression, semitones, continuous, real-time
    """  # Δsemitones ∈ [-2,6] ⟵ ACCORDING TO FL STUDIO
    Δsemitones=clamp(Δsemitones,MIDI_pitch_bend_min,MIDI_pitch_bend_max)
    coarse=int(((Δsemitones + 2) / 8) * 255)
    fine=0  # ∈ [0,255] Note that fine is...REALLY REALLY FINE...So much so that I can't really figure out a good way to use it
    MIDI_output([224,fine,coarse])
def MIDI_all_notes_off():
    for n in range(256):
        MIDI_note_off(n)
def MIDI_breath(value: float):
    """Sends MIDI breath controller messages for expressive wind instrument simulation.
    
    Transmits breath controller data (MIDI CC #2) to control breath-sensitive parameters
    in wind instrument synthesizers and samplers. This controller is commonly used to
    simulate the natural dynamics and expression of breath-controlled instruments.
    
    Args:
        value (float): Breath pressure value between 0.0 and 1.0, automatically clamped.
                      0.0 = no breath/silence, 1.0 = maximum breath pressure.
    
    Returns:
        None: Function sends MIDI message and returns nothing.
    
    Examples:
        >>> # No breath (silence)
        >>> MIDI_breath(0.0)
        
        >>> # Light breath for soft attack
        >>> MIDI_breath(0.3)
        
        >>> # Medium breath for normal playing
        >>> MIDI_breath(0.7)
        
        >>> # Maximum breath for forte passages
        >>> MIDI_breath(1.0)
        
        >>> # Simulate breath vibrato/tremolo
        >>> import math
        >>> for i in range(100):
        ...     breath_base = 0.6
        ...     vibrato = 0.1 * math.sin(i * 0.2)  # Subtle vibrato
        ...     MIDI_breath(breath_base + vibrato)
        ...     sleep(0.02)  # 50Hz update rate
        
        >>> # Gradual breath crescendo
        >>> for i in range(128):
        ...     breath = i / 127.0  # 0 to 1
        ...     MIDI_breath(breath)
        ...     sleep(0.05)  # Slow build over ~6 seconds
        
        >>> # Values outside [0,1] are clamped  
        >>> MIDI_breath(1.5)   # Treated as 1.0
        >>> MIDI_breath(-0.2)  # Treated as 0.0
    
    Technical Implementation:
        - Uses MIDI message format: [0x02, scaled_value]  
        - 0x02 is a system-level breath message (not standard CC)
        - Value scaled from [0,1] to [0,127] MIDI range
        - Different from standard CC #2 Control Change format
    
    Note: This implementation uses a non-standard MIDI message format (0x02 prefix)
    which may be specific to certain MIDI devices or software. For standard MIDI
    breath controller messages, consider using MIDI_control(2, value) instead.
    
    Tags: midi, breath-controller, cc2, wind-instruments, expression, dynamics
    """
    MIDI_output([0x02,int(clamp(value,0,1) * 127)])
#
__midiin=None  # This variable exists so the garbage collector doesn't gobble up your midi input if you decide not to assign a variable to the output (aka the close method)
def MIDI_input(ƒ_callback: callable = print) -> callable:
    # Perfect example:
    # close_midi=MIDI_input(MIDI_output) # ⟵ This simply regurgitates the midi-piano's input to a virtual output. You won't be able to tell the difference ;)
    # Then, when you're bored of it...
    # close_midi()# ⟵ This stops the midi from doing anything.
    print("r.MIDI_input: Please specify the details of your request:")
    pip_import('rtmidi')
    from rtmidi.midiutil import open_midiport  # pip3 install python-rtmidi
    global __midiin
    __midiin,port_name=open_midiport()
    __midiin.set_callback(lambda x,y:ƒ_callback(x[0]))
    return __midiin.close_port  # Returns the method needed to kill the thread
# endregion
# region  Comparators: ［cmp_to_key，sign］
def cmp_to_key(mycmp):
    """
    From: http://code.activestate.com/recipes/576653-convert-a-cmp-function-to-a-key-function/
    Must use for custom comparators in the 'sorted' builtin function!
    Instead of using sorted(ⵁ,cmp=x) which gives syntax error, use…
    …sorted(ⵁ,key=cmp_to_key(x))
    I.E., in rCode:
          sorted(ⵁ,cmp=x) ⭆ sorted(ⵁ,key=cmp_to_key(x))   ≣   cmp=x ⭆ key=cmp_to_key(x)
    'Convert a cmp= function into a key= function'
    """
    class K(object):
        def __init__(self,obj,*args): self.obj=obj
        def __lt__(self,other): return mycmp(self.obj,other.obj) < 0
        def __gt__(self,other): return mycmp(self.obj,other.obj) > 0
        def __eq__(self,other): return mycmp(self.obj,other.obj) == 0
        def __le__(self,other): return mycmp(self.obj,other.obj) <= 0
        def __ge__(self,other): return mycmp(self.obj,other.obj) >= 0
        def __ne__(self,other): return mycmp(self.obj,other.obj) != 0
    return K


    # noinspection PyShadowingNames
def sign(x,zero=0):
    # You can redefine zero depending on the context. It basically becomes a comparator.
    if x > zero:
        return 1
    elif x < zero:
        return -1
    return zero
# endregion
# region  Pickling:［load_pickled_value，save_pickled_value］
# Pickling is just a weird name the python devs came up with to descript putting the values of variables into files, essentially 'pickling' them for later use
def load_pickled_value(path: str):
    """
    Load a Python object from a pickle file.
    
    EXAMPLE:
        >>> import tempfile
        >>> # Create and save test data
        >>> test_data = {"numbers": [1, , 3], "name": "test", "nested": {"key": 42}}
        >>> save_pickled_value(test_data, 'data.pkl', test_data)
        >>> 
        >>> # Load the data back
        >>> loaded_data = load_pickled_value('data.pkl')
        >>> type(loaded_data)  # <class 'dict'>
        >>> loaded_data["name"]  # 'test'
        >>> loaded_data["nested"]["key"]  # 42
    
    Note: Pickle files are Python-specific and version-sensitive.
    """
    # Filenames are relative to the current file path
    return pickle.load(open(path,"rb"))

def save_pickled_value(value, path: str):
    """
    Load a Python object to a pickle file.
    
    Parameters:
        value: Variable to save
        path (str): Path to pickle file to create/overwrite
        
    Returns:
        The new file's path
        
    EXAMPLE:
        >>> import tempfile
        >>> # Create and save test data
        >>> test_data = {"numbers": [1, , 3], "name": "test", "nested": {"key": 42}}
        >>> save_pickled_value(test_data, 'data.pkl', test_data)
        >>> 
        >>> # Load the data back
        >>> loaded_data = load_pickled_value('data.pkl')
        >>> type(loaded_data)  # <class 'dict'>
        >>> loaded_data["name"]  # 'test'
        >>> loaded_data["nested"]["key"]  # 42

    """
    if path is None:
        path = get_unique_copy_path('object.pkl')

    pickle.dump(value,open(path,'wb'))
    return path

# endregion
# region  .txt ⟷ str: ［string_to_text_file，text_file_to_string］
def string_to_text_file(file_path: str,string: str,) -> None:
    """
    Writes a string to a text file
    
    Args:
        file_path (str): Target file path - supports ~ expansion
        string (str): Text content
        
    Returns:
        str: path to newly saved file
        
    Examples:
        >>> content = "Hello, World!\nThis is a test file."
        >>> print(string_to_text_file("/tmp/test.txt", content))
        /tmp/test.txt
        
    Notes:
        - Uses UTF-8 encoding by default
        - Creates parent directories if they don't exist and provides clear error messages.

    Alias: save_text_file

    See Also:
        - text_file_to_string(...) aka load_text_file(...): Read text files
        - append_line_to_file(): Append content instead of overwrite
        
    Tags: file-io, text-files, file-writing, path-handling
    """
    file_path=get_absolute_path(file_path)#Make sure it recognizes ~/.vimrc AKA with the ~ attached
    make_parent_directory(file_path)

    try:
        file=open(file_path,"w")
    except Exception:
        if not folder_exists(get_parent_folder(file_path)):
            raise FileNotFoundError("Parent folder does not exist: "+str(get_parent_folder(file_path)))
        raise

    try:
        file.write(string)
    except Exception:
        file=open(file_path,"w",encoding='utf-8')
        file.write(string,)

    file.close()
    return file_path

def save_text_file(string, file_path):
    """
    save_text_file(string, text_file) writes text file
    Please see rp.string_to_text_file for full documentation
    """
    return string_to_text_file(file_path, string)


_text_file_to_string_cache={}
def text_file_to_string(file_path: str, *, use_cache=False) -> str:
    """
    Reads a text file
    
    Args:
        file_path (str): Path to text file or URL to read from
            Local paths support ~ expansion (e.g., '~/.vimrc')
            URLs are automatically detected and fetched via curl()
        use_cache (bool, optional): Speeds up subsuquent reads.
            When True, caches file contents and refreshes if file is modified.
            Currently only applies to local files, not URLs.
    
    Returns:
        str: Contents of the text file or URL
    
    Examples:
        >>> # Read a local text file
        >>> content = text_file_to_string('/etc/passwd')
        >>> print(len(content))
        1234
        >>> 
        >>> # Read with tilde expansion
        >>> bashrc = text_file_to_string('~/.bashrc')
        >>> 
        >>> # Read from URL
        >>> robots = text_file_to_string('https://httpbin.org/robots.txt')
        >>> print(robots[:20])
        'User-agent: *\\nDisallo'
        >>> 
        >>> # Use caching for better performance
        >>> large_file = text_file_to_string('/large/file.txt', use_cache=True) #First time is slow
        >>> large_file = text_file_to_string('/large/file.txt', use_cache=True) #Second time is fast

    Alias: load_text_file
        
    See Also:
        - load_json(...): Loads JSON files
        - load_yaml_file(...): Loads YAML files
        
    Tags: file-io, text-processing, url-loading, caching
    """

    #Only reason not to use use_cache is if you're worried about memory consumption
    #   It will refresh the cache entry if it's out of date even if use_cache is True.
    #   TODO: Make this happen on load_image, etc...all other functions that read from a file and have use_cache as an option

    if is_valid_url(file_path):
        #TODO: Add caching for when we use urls
        return curl(file_path)

    file_path=get_absolute_path(file_path)#Make sure it recognizes ~/.vimrc AKA with the ~ attached. Also, don't cache the same file twice under a relative and absolute path

    assert file_exists(file_path),'File %s does not exist'%file_path

    if use_cache:
        file_path=get_absolute_path(file_path)
        current_date=date_modified(file_path)
        if file_path in _text_file_to_string_cache:
            cached_date,cached_text=_text_file_to_string_cache[file_path]
            if current_date<=cached_date:
                return cached_text
        current_text=text_file_to_string(file_path,use_cache=False)
        _text_file_to_string_cache[file_path]=current_date,current_text
        return current_text
            
    try:
        return open(file_path).read()
    except UnicodeDecodeError:
        #UnicodeDecodeError: 'ascii' codec can't decode byte 0xc3 in position 4781: ordinal not in range(128)
        return open(file_path, encoding='latin').read()

#Yes. This is a welcome alias.
load_text_file = text_file_to_string

def load_file_lines(file_path, use_cache=False):
    """ Returns all the lines in a file as a list of strings """
    return line_split(text_file_to_string(file_path, use_cache=use_cache))

def save_file_lines(lines, file_path):
    """
    Save an iterable of lines to a text file.

    Args:
        lines: Iterable of lines to save (list, tuple, etc.)
        file_path (str): Where to save it
        
    Returns:
        Result of string_to_text_file() - typically the file path
        
    Examples:
        >>> # Save list of strings
        >>> lines = ['Line 1', 'Line 2', 'Line 3']
        >>> save_file_lines(lines, 'output.txt')
        
        >>> # Works with any iterable
        >>> save_file_lines(range(5), 'numbers.txt')  # Saves '0', '1', '2', '3', '4'
        
    Note: Used by _omni_save for saving line-based data when using the ".lines" extension
    
    See Also: load_file_lines
    
    Tags: files, text, lines, save, writing
    """
    assert is_iterable(lines)
    lines = [str(x) for x in lines]
    string = line_join(lines)
    return string_to_text_file(file_path, string)

def load_text_files(*paths, use_cache=False, strict=True, num_threads=None, show_progress=False, lazy=False):
    """
    Plural of text_file_to_string
    Please see load_files and rp_iglob for more information
    Yields the strings as a generator
    """
    if folder_exists(detuple(paths)):
        paths = detuple(paths)+'/*'
    paths = rp_iglob(paths)
    load_file = lambda path: text_file_to_string(path, use_cache=use_cache)
    if show_progress in ['eta',True]: show_progress='eta:Loading text files'
    return load_files(load_file, paths, show_progress=show_progress, strict=strict, num_threads=num_threads, lazy=lazy)

def append_line_to_file(line:str,file_path:str):
    """
    Adds a line to the end of a text file, or creates a new text file if none exists.
    If given a multiline string, appends multiple lines.
    This is much faster than loading the whole string, adding a line, then resaving the whole file.
    
    Args:
        line (str): Text line to append to the file. Should not include trailing newline
            unless you want to add a second, empty line, as the function handles line separation automatically.
        file_path (str): Path to the target file. File will be created if it doesn't exist, as well as any missing parent folders.
    
    Returns:
        str: The file path modified
    
    Examples:
        >>> # Create a new file with first line
        >>> append_line_to_file("First line", "/tmp/test.txt")
        >>> 
        >>> # Append additional lines
        >>> append_line_to_file("Second line", "/tmp/test.txt") 
        >>> append_line_to_file("Third line", "/tmp/test.txt")
        >>> 
        >>> # Read back the result
        >>> content = text_file_to_string("/tmp/test.txt")
        >>> print(content)
        First line
        Second line
        Third line
        
    Tags: file-io, text-processing, logging, configuration
    """
    file_path = os.path.expanduser(file_path)
    if not file_exists(file_path):
        string_to_text_file(file_path,line)
    else:
        file=open(file_path, 'a')
        try:
            file.write('\n'+line)
        finally:
            file.close()
    return file_path

def append_line_to_files(line: str, *file_paths):
    """
    Append a line (or multiline string) to one or more files.
    Creates files and missing parent directories as needed.

    Args:
        line: Text to append. May contain newlines.
        *file_paths: One or more file paths. You can also pass a list.

    Returns:
        List of paths that were written, after ~ expansion etc.

    Raises:
        ValueError: If no paths are provided or a path refers to a directory.
        TypeError: If any path is not a string.
    """
    file_paths=_detuple_paths(file_paths)

    #Validation: Paths are strings
    non_str = {type(p).__name__ for p in file_paths if not isinstance(p, str)}
    if non_str:
        raise TypeError("rp.append_line_to_files: all paths must be str; got types: " + ", ".join(non_str))

    #Validation: Paths are not folders
    folder_paths = [p for p in file_paths if is_a_folder(p)]
    if folder_paths:
        raise ValueError("rp.append_line_to_files: these paths are directories, not files: " + ", ".join(map(repr,folder_paths)))

    written = []
    for path in file_paths:
        written_path = append_line_to_file(line, path)
        written.append(written_path)

    return written


def as_easydict(*args, **kwargs):
    """
    Converts a dictionary or dict-like object to an EasyDict for attribute-style access.
    Used throughout rp for convenience. A wrapper for easydict.EasyDict with lazy importing

    Parameters:
        *args: An optional dict, or dict-like object
        **kwargs: Additional keys passed as kwargs

    Returns:
        EasyDict: Enhanced dictionary with attribute-style access (obj.key instead of obj['key'])

    Examples:
        >>> config = as_easydict({'model': 'gpt-4', 'temp': 0.7})
        >>> config.new_param = 'added'  # Set via attribute
        >>> print(config.model)  # Access via attribute
        gpt-4
        >>> print(config['temp'])  # Still works as dict
        0.7

        >>> # From JSON-style nested data:
        >>> nested = as_easydict({'db': {'host': 'localhost', 'port': 5432}})
        >>> print(nested.db.host)  # Nested attribute access
        localhost
        
        >>> print(as_easydict(A=1,B=2,C=3))
        {'A': 1, 'B': 2, 'C': 3}

    Tags: dict, configuration, attribute-access, json, easydict, data-structure
    """
    pip_import('easydict') #I might make this a pip requirement of rp...its so useful!
    from easydict import EasyDict
    return EasyDict(*args, **kwargs)
    
def as_easydicts(*dictlikes):
    """ Plural of as_easydict """
    dictlikes = detuple(dictlikes)
    return [as_easydict(x) for x in dictlikes]

_load_json_cache={}
def load_json(path, *, use_cache=False):
    """
    Load JSON file and convert to EasyDict for convenient attribute access.
    
    Parameters:
        - path (str): File path to JSON file or URL to JSON endpoint
        - use_cache (bool, optional): Enable caching for faster repeated loads. Default False.
    
    Returns:
        - EasyDict or original type: For dict objects, returns EasyDict with dot notation access.
          For other JSON types (list, str, int, etc.), returns the original type.
      
    See Also:
        load_jsons(): Plural version for batch loading
        save_json(): Save objects as JSON
    
    Examples:

        >>> # Create test JSON file
        >>> data = {"name": "test", "config": {"debug": True, "port": 8080}}
        >>> json_path = temporary_file_path('.json')
        >>> save_json(data, json_path)
        >>> 
        >>> # Load and use with dot notation
        >>> result = load_json(json_path)
        >>> type(result)  # <class 'easydict.EasyDict'>
        >>> result.name  # 'test'
        >>> result.config.debug  # True
        >>> result.config.port  # 8080
        >>> 
        >>> # Also works with dictionary access
        >>> result['name']  # 'test'
        >>> result['config']['debug']  # True
        >>> delete_file(json_path)  # Cleanup
        
        >>> # Load JSON directly from web API
        >>> api_data = load_json("https://jsonplaceholder.typicode.com/users/1")
        >>> api_data.name  # 'Leanne Graham'
        >>> api_data.email  # 'Sincere@april.biz'
        >>> api_data.address.city  # 'Gwenborough'
    
    """

    if use_cache and path in _load_json_cache:
        output = _load_json_cache[path]

        if use_cache != 'nocopy':
            #Undocumented functionality for internal RP usage: skip copying the output
            #Only do this if we're certain it will be treated as read only
            #Only use this within r.py for now so I can change it later
            #In the future it might not be specified this way, I might have more elegant solution
            from copy import deepcopy
            output = deepcopy(output)

        return as_easydict(output)

    text=text_file_to_string(path, use_cache=use_cache)
    import json
    out = json.loads(text)
    if not isinstance(out, dict):
        return out

    if use_cache or path in _load_json_cache:
        _load_json_cache[path]=out

    return as_easydict(out)

def load_jsons(*paths, use_cache=False, strict=True, num_threads=None, show_progress=False, lazy=False):
    """
    Plural of load_json
    Please see load_files and rp_iglob for more information
    Yields the jsons as an iterator
    """
    # paths = rp_iglob(paths)
    paths = detuple(paths) #Not sure how to go between this and URL's...
    load_file = lambda path: load_json(path, use_cache=use_cache)
    if show_progress in ['eta',True]: show_progress='eta:Loading JSON files'
    return load_files(load_file, paths, show_progress=show_progress, strict=strict, num_threads=num_threads, lazy=lazy)

def save_json(data, path=None, *, pretty=False, default=None):
    """
    Save Python data structures as JSON files with formatting options.
    
    Args:
        data: Python object to serialize (dict, list, etc.) - must be JSON-serializable
        path (str): Output file path - will be created/overwritten. Defaults to None.
                    If None, defaults to "data.json", "data_copy.json", "data_copy1.json", etc
        pretty (bool): If True, format with indentation and spacing for readability
        default (callable, optional): Function to serialize non-JSON types
        
    Returns:
        (str) Path to saved file
        
    Examples:
        >>> data = {'name': 'test', 'values': [1, 2, 3], 'config': {'enabled': True}}
        >>> json_path = save_json(data)
        >>> print(load_text_file(json_path))
        {"name": "test", "values": [1, 2, 3], "config": {"enabled": true}}
        
        >>> pretty_json_path = save_json(data, pretty=True)
        >>> print(load_text_file(pretty_json_path))
        {
            "name": "test", 
            "values": [
                1,
                2,
                3
            ],
            "config": {
                "enabled": true
            }
        }
        
        >>> # Custom serialization for numpy arrays
        >>> array_data = {'matrix': np.array([[1, 2], [3, 4]])}
        >>> numpy_path = save_json(array_data, default=np.ndarray.tolist)
        >>> print(load_text_file(numpy_path))
        {"matrix": [[1, 2], [3, 4]]}
        
    See Also:
        - load_json(): Read JSON files back to Python objects
        
    Tags: json, serialization, file-io, data-export, configuration
    """
    import json

    if path is None:
        path = get_unique_copy_path('data.json')

    kwargs = dict(default=default)
    if pretty:
        kwargs.update(
            dict(
                indent='\t',
                separators=(",", ": "),
            )
        )

    text=json.dumps(data,**kwargs)

    return string_to_text_file(path,text)

_load_tsv_cache={}
_load_tsv_cache={}
def load_tsv(file_path, *, show_progress=False, header=0, use_cache=False, sep="\t", mode='normal', max_rows=None):
    """
    Read a TSV file with optional progress tracking and flexible header handling.

    By default tries to be robust - skipping all bad lines.

    Parameters:
        file_path (str): Path to the TSV file.
        show_progress (bool): Whether to display a progress bar. Default is True.
        header (str, int, list, or None): Header row handling.
            - 0 (default): Use the first row as column names.
            - None: Use no column names, only integer indices.
            - List: Use the provided list as column names, assuming no header row in the file.
            - str:  Like list, but uses str.split so you can specify headers like 'col1 col2name whatIcall_Col3' etc
        use_cache: If True, will cache the result so you only have to load from drive once
        sep (str): Column separator. Default is "\t" for tab-separated values.
        mode (str): File reading mode. Use 'robust' to skip bad lines, 'normal' for standard reading. Default is 'normal'.
        max_rows (int, optional): If specified, limits the table to this number of rows, reducing loading time. Default is None (load all rows).

    Returns:
        pandas.DataFrame: The loaded TSV data as a DataFrame.

    EXAMPLE:

        >>> load_tsv('urls_oct6.tsv', header='id size url title', use_cache=True, show_progress=True)
        ... ans =              id          size                             url                                              title
        ...       0       2RH8A49  7.088479e+08  https://video-previews.cont...    Joyful Excitement Dancing Woman Meme Expression
        ...       1       7LDJAUA  2.877922e+08  https://video-previews.cont...  funny robot in the background , children's bac...
        ...       2       DEOXGE2  1.256278e+09  https://video-previews.cont...              Hemp Extract in Hands Selective Focus
        ...       3       TY3VY9R  4.071201e+08  https://video-previews.cont...  Coconut palmtrees  on the most beautiful tropi...
        ...       4       S2HJ9Q2  2.214383e+08  https://video-previews.cont...                           Osteoporosis Diagnostics
        ...       ...         ...           ...                             ...                                                ...
        ...       699921  D58D677  1.960837e+07  https://video-previews.cont...  Scientist in PPE suit conducts research on the...
        ...       699922  BG9G364  5.516978e+08  https://video-previews.cont...  Professional Fishing Vessel, Shooting From Dro...
        ...       699923  PPEF3XB  2.731540e+07  https://video-previews.cont...  Rehabilitation Center for Bears in the Carpath...
        ...       699924  HQWAGMQ  1.771674e+09  https://video-previews.cont...  Desperate Stressful Arabic Hispanic Businessma...
        ...       699925  T686L7K  2.834678e+09  https://video-previews.cont...                     Dandelion Yellow Flowers Field
        ... 
        ...       [699926 rows x 4 columns]

    """

    #Future Parameters:
    #    mode (str): File reading mode. Use 'robust' to skip bad lines. Default is 'robust'.
    # mode = 'robust'
    
    assert mode in 'normal robust'.split()

    pip_import("pandas")
    import pandas as pd
    import csv

    args_hash = handy_hash((file_path, header, max_rows, sep, mode))
    if use_cache and args_hash in _load_tsv_cache:
        return _load_tsv_cache[args_hash]
    
    chunk_size = 1000

    if isinstance(header,str):
        header=header.strip().split()

    kwargs = {
        "sep": sep,
        "chunksize": chunk_size,
        "header": header if isinstance(header, int) else None,
        "names": header if isinstance(header, list) else None,
    }

    if mode == "robust":
        kwargs.update({"quoting": csv.QUOTE_NONE, "on_bad_lines": "skip"})

    iterator = pd.read_csv(file_path, **kwargs)

    if show_progress:
        pip_import("tqdm")
        from tqdm import tqdm

        total_lines = number_of_lines_in_file(file_path)
        if isinstance(header, int):
            total_lines -= 1
        
        if max_rows is not None:
            total_lines = min(total_lines, max_rows)
            
        iterator = tqdm(iterator, total=total_lines // chunk_size)

    chunks = []
    rows_collected = 0
    
    for chunk in iterator:
        if max_rows is not None and rows_collected >= max_rows:
            break
            
        chunks.append(chunk)
        rows_collected += len(chunk)

    df = pd.concat(chunks, ignore_index=True).head(max_rows)

    if show_progress:
        _erase_terminal_line()

    # def fix_dataframe_nans(dataframe):
    #     """
    #     Replace NaNs with empty strings in DataFrame columns that are otherwise entirely strings,
    #     improving performance by using pandas type detection.
    #     I use this in load_tsv because otherwise empty strings in a tsv line like "\t\t\t" might be interpereted as NaN's instead of strings
    #     """
    #     for column in dataframe.columns:
    #         # Use pandas API to check if the column's data type is 'string'
    #         if pd.api.types.is_string_dtype(dataframe[column]):
    #             dataframe[column] = dataframe[column].fillna('')
    #     return dataframe
    # df = fix_dataframe_nans(df)

    if use_cache:
        _load_tsv_cache[args_hash]=df

    return df


load_csv = partial(load_tsv, sep=',')


_load_parquet_cache={}
def load_parquet(file_path, *, show_progress=False, use_cache=False):
    """
    Read a Parquet file with optional progress tracking.

    Parameters:
        file_path (str): Path to the Parquet file.
        show_progress (bool): Whether to display a progress bar. Default is True.
        use_cache: If True, will cache the result so you only have to load from drive once

    Returns:
        pandas.DataFrame: The loaded Parquet data as a DataFrame.

    EXAMPLE:

        >>> load_parquet('data.parquet', use_cache=True, show_progress=True)
        ... ans =              id          size                             url                                              title  
        ...       0       2RH8A49  7.088479e+08  https://video-previews.cont...    Joyful Excitement Dancing Woman Meme Expression
        ...       1       7LDJAUA  2.877922e+08  https://video-previews.cont...  funny robot in the background , children's bac...
        ...       2       DEOXGE2  1.256278e+09  https://video-previews.cont...              Hemp Extract in Hands Selective Focus
        ...       3       TY3VY9R  4.071201e+08  https://video-previews.cont...  Coconut palmtrees  on the most beautiful tropi...
        ...       4       S2HJ9Q2  2.214383e+08  https://video-previews.cont...                           Osteoporosis Diagnostics
        ...       ...         ...           ...                             ...                                                ...
        ...       699921  D58D677  1.960837e+07  https://video-previews.cont...  Scientist in PPE suit conducts research on the...
        ...       699922  BG9G364  5.516978e+08  https://video-previews.cont...  Professional Fishing Vessel, Shooting From Dro...
        ...       699923  PPEF3XB  2.731540e+07  https://video-previews.cont...  Rehabilitation Center for Bears in the Carpath... 
        ...       699924  HQWAGMQ  1.771674e+09  https://video-previews.cont...  Desperate Stressful Arabic Hispanic Businessma...
        ...       699925  T686L7K  2.834678e+09  https://video-previews.cont...                     Dandelion Yellow Flowers Field
        ...
        ...       [699926 rows x 4 columns]

    """

    pip_import("pandas")
    pip_import("pyarrow")
    
    import pandas as pd
    import pyarrow.parquet as pq

    if use_cache:
        args_hash = handy_hash((file_path))
        if args_hash not in _load_parquet_cache:
            value = gather_args_call(load_parquet, use_cache=False)
            _load_parquet_cache[args_hash] = value
        return _load_parquet_cache[args_hash]

    parquet_file = pq.ParquetFile(file_path)
    num_row_groups = parquet_file.num_row_groups

    dfs = []
    indices = range(num_row_groups)

    if show_progress:
        pip_import("tqdm")
        from tqdm import tqdm
        #indices = eta(indices)
        indices = tqdm(indices)

    for i in indices:
        df = parquet_file.read_row_group(i, use_threads=True).to_pandas()
        dfs.append(df)

    df = pd.concat(dfs, ignore_index=True)

    return df


def load_yaml_file(path, use_cache=False):
    """
    EXAMPLE:
        >>> load_yaml_file('alphablock_without_ssim_256.yaml')
        ans = {'max_iter': 300000, 'batch_size': 5, 'image_save_iter': 250, ...(etc)... }
    """
    pip_import('yaml')
    import yaml
    assert file_exists(path)
    text=text_file_to_string(path, use_cache=use_cache)
    data=yaml.safe_load(text)
    data=as_easydict(data)
    return data

load_yaml = load_yaml_file #Alias

def load_yaml_files(*paths, use_cache=False, strict=True, num_threads=None, show_progress=False, lazy=False):
    """
    Plural of load_yaml_file
    Please see load_files and rp_iglob for more information
    Yields the jsons as an iterator
    """
    paths = rp_iglob(paths)
    load_file = lambda path: load_yaml_file(path, use_cache=use_cache)
    if show_progress in ['eta',True]: show_progress='eta:Loading YAML files'
    return load_files(load_file, paths, show_progress=show_progress, strict=strict, num_threads=num_threads, lazy=lazy)

def parse_yaml(string):
    pip_import('yaml')
    from yaml import safe_load
    output = safe_load(string)
    output = as_easydict(output)
    return output

def parse_dyaml(code:str)->dict:
    """
    This is like DJSON, except for YAML
    TODO: Migrate this function into its own module.
    Look at the test_parse_dyaml_junctions() function to see how this language works, it's pretty simple
    The only differences between this and YAML:
       - When a key has multiple colons in it, like a:b:c:, it's equivalent to multiple lines of keys
       - When a key has commas in it, its value is duplicated
    This was used in the TRITON codebase's config files!
    """

    assert isinstance(code,str)

            
    class Junction:
        def __init__(self,key,value):
            self.key  =key
            self.value=value
            
        def __repr__(self):
            #           u250c                           u2510
            #           u2502   u250c        u2510              u2502      u250c          u2510
            return fansi(str(self.key) + ":", "cyan") + str(self.value)
            #           u2502   u2514        u2518              u2502      u2514          u2518
            #           u2514                           u2518
        
        def __iter__(self):
            yield self.key
            yield self.value
          
        @property
        def is_leaf(self):  
            return not isinstance(self.value, JunctionList)
            
    class JunctionList(list):
        #In this module, every JunctionList created is a list of Junction instances
        pass

    def handle_key_colons(junction)->Junction:
        #If we have a key like "a:b:c",
        #Make [a:b:c: z] into [a:[b:[c:z]]]
        #EXAMPLE:
        #     >>> handle_key_colons(Junction('a:b:c','z'))
        #    ans = a:[b:[c:z]]
        key,value=junction
        assert isinstance(key,str)
        path=key.split(':')
        output=value
        for sub_key in path[::-1]:
            output=Junction(sub_key,output)
            output=[output]
            output=JunctionList(output)
        return output[0]

    def split_colon_keys(junctions)->JunctionList:
        output=JunctionList()
        for junction in junctions:
            if not junction.is_leaf:
                junction.value=split_colon_keys(junction.value)
            junction=handle_key_colons(junction)
            output.append(junction)
        return output
            

    def parse_dyaml_junctions(src)->JunctionList:
        # https://stackoverflow.com/questions/44904290/getting-duplicate-keys-in-yaml-using-python
        # We deliberately define a fresh class inside the function,
        # because add_constructor is a class method and we don't want to
        # mutate pyyaml classes.

        pip_import('yaml','PyYAML')
        
        import yaml
        
        class PreserveDuplicatesLoader(yaml.loader.Loader):
            pass

        def map_constructor(loader, node):
            """Walk the mapping, recording any duplicate keys."""
            
            deep=False
            mapping=JunctionList()
            for key_node, value_node in node.value:
                key = loader.construct_object(key_node, deep=deep)
                value = loader.construct_object(value_node, deep=deep)

                #mapping.setdefault(key,[]).append(value)
                mapping.append(Junction(key,value))

            return mapping

        PreserveDuplicatesLoader.add_constructor(yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, map_constructor)
        
        return yaml.load(src, PreserveDuplicatesLoader)

    def expand_comma_keys(junctions) -> JunctionList:
        #Note that there may be duplicate lists in multiple places
        #This saves memory
        
        assert isinstance(junctions, JunctionList)
        
        from copy import deepcopy

        output = JunctionList()

        for key,value in junctions:
            if isinstance(value, JunctionList):
                value=expand_comma_keys(value)
            for sub_key in key.split(','):
                value=deepcopy(value)
                junction = Junction(sub_key, value)
                output.append(junction)
                
        return output

    def apply_deltas_from_junctions(junctions:JunctionList,recipient:dict):
        #Apply all the junctions as deltas
        
        for key,value in junctions:
            if isinstance(value,JunctionList):
                apply_deltas_from_junctions(value,recipient.setdefault(key,{}))
            else:
                recipient[key]=value
        return recipient

    def junctions_to_dict(junctions:JunctionList)->dict:
        output={}
        return apply_deltas_from_junctions(junctions,{})

    def parse_dyaml(src)->dict:
        junctions=parse_dyaml_junctions(src)
        junctions=split_colon_keys(junctions)
        junctions=expand_comma_keys(junctions)
        return junctions_to_dict(junctions)

    def test_parse_dyaml_junctions():

        code="""
        a:
            b:
                c: boochy
            b,q:
                c,d: creepy
            b:
                c: cri
        a:b:
                e: {"Hil":87}
        w,x:y,z: pup
        """

        print(code)
        print(parenthesizer_automator(str(                                   parse_dyaml_junctions(code)  )))
        print(parenthesizer_automator(str(                  split_colon_keys(parse_dyaml_junctions(code)) )))
        print(parenthesizer_automator(str(expand_comma_keys(split_colon_keys(parse_dyaml_junctions(code))))))
        print(parenthesizer_automator(str(parse_dyaml(code))))

        # RESULT:
        #        a:
        #            b:
        #                c: boochy
        #            b,q:
        #                c,d: creepy
        #            b:
        #                c: cri
        #        a:b:
        #                e: {"Hil":87}
        #        w,x:y,z: pup
        #    
        #    ┌                                                                            ┐
        #    │  ┌                                         ┐      ┌          ┐             │
        #    │  │  ┌        ┐      ┌          ┐    ┌     ┐│      │  ┌      ┐│             │
        #    [a:[b:[c:boochy], b,q:[c,d:creepy], b:[c:cri]], a:b:[e:[Hil:87]], w,x:y,z:pup]
        #    │  │  └        ┘      └          ┘    └     ┘│      │  └      ┘│             │
        #    │  └                                         ┘      └          ┘             │
        #    └                                                                            ┘
        #    ┌                                                                                ┐
        #    │                                                 ┌              ┐               │
        #    │  ┌                                         ┐    │  ┌          ┐│               │
        #    │  │  ┌        ┐      ┌          ┐    ┌     ┐│    │  │  ┌      ┐││      ┌       ┐│
        #    [a:[b:[c:boochy], b,q:[c,d:creepy], b:[c:cri]], a:[b:[e:[Hil:87]]], w,x:[y,z:pup]]
        #    │  │  └        ┘      └          ┘    └     ┘│    │  │  └      ┘││      └       ┘│
        #    │  └                                         ┘    │  └          ┘│               │
        #    │                                                 └              ┘               │
        #    └                                                                                ┘
        #    ┌                                                                                                                                   ┐
        #    │                                                                               ┌              ┐                                    │
        #    │  ┌                                                                       ┐    │  ┌          ┐│                                    │
        #    │  │  ┌        ┐    ┌                  ┐    ┌                  ┐    ┌     ┐│    │  │  ┌      ┐││    ┌            ┐    ┌            ┐│
        #    [a:[b:[c:boochy], b:[c:creepy, d:creepy], q:[c:creepy, d:creepy], b:[c:cri]], a:[b:[e:[Hil:87]]], w:[y:pup, z:pup], x:[y:pup, z:pup]]
        #    │  │  └        ┘    └                  ┘    └                  ┘    └     ┘│    │  │  └      ┘││    └            ┘    └            ┘│
        #    │  └                                                                       ┘    │  └          ┘│                                    │
        #    │                                                                               └              ┘                                    │
        #    └                                                                                                                                   ┘
        #    ┌                                                                                                                                                            ┐
        #    │     ┌                                                                                       ┐                                                              │
        #    │     │     ┌                                           ┐                                     │                                                              │
        #    │     │     │                                ┌         ┐│       ┌                            ┐│       ┌                      ┐       ┌                      ┐│
        #    {'a': {'b': {'c': 'cri', 'd': 'creepy', 'e': {'Hil': 87}}, 'q': {'c': 'creepy', 'd': 'creepy'}}, 'w': {'y': 'pup', 'z': 'pup'}, 'x': {'y': 'pup', 'z': 'pup'}}
        #    │     │     │                                └         ┘│       └                            ┘│       └                      ┘       └                      ┘│
        #    │     │     └                                           ┘                                     │                                                              │
        #    │     └                                                                                       ┘                                                              │
        #    └                                                                                                                                                            ┘

    return parse_dyaml(code)

def load_dyaml_file(path:str)->dict:
    """ Load a dyaml file (a yaml file with some additional syntax features I added). Stands for "Delta Yaml" """
    assert file_exists(path)
    code=text_file_to_string(path)
    return parse_dyaml(code)

def touch_file(path):
    """Equivalent to the 'touch' command - creates a file if it doesnt exist and if it does updates its date_modified"""

    parent = get_parent_folder(path)
    make_folder(parent)

    from pathlib import Path
    Path(path).touch()
    return path

# endregion
# region Mini-Terminal: ［mini_terminal:str］
# PLEASE READ: This is not meant to be called from the r class.
# Example usage: import r;exec(r.mini_terminal)
# Intended for use everywhere; including inside other functions (places with variables that pseudo_terminal can't reach)
mini_terminal="""#from r import fansi,fansi_print,string_from_clipboard,fansi_syntax_highlighting
_history=[]
fansi_print("Ryan's Mini-Terminal: A miniature pseudo-terminal for running inside functions!",'blue','bold')
fansi_print("\\tValid commands: ［PASTE，END，HISTORY］",'blue')
while True:
    try:
        _header="--> "
        _s=input(fansi(_header,'cyan','bold')).replace(_header,"").lstrip()
        if not _s:
            continue
        if _s == "PASTE":
            fansi_print("PASTE ⟶ Entering command from clipboard",'blue')
            _s=string_from_clipboard
        if _s == 'END':
            fansi_print("END ⟶ Ending mini-terminal session",'blue')
            break
        elif _s == 'HISTORY':
            fansi_print("HISTORY ⟶ Printing out list of commands you entered that didn't cause errors",'blue')
            fansi_print(fansi_syntax_highlighting('\\n'.join(_history)))
        else:
            try:
                _temp=eval(_s)
                if _temp is not None:
                    _ans=_temp
                    fansi_print('_ans = ' + str(_ans),'green')
                _history.append(_s)
            except:
                try:
                    exec(_s)
                    _history.append(_s)
                except Exception as _error:
                    print(fansi("ERROR: ",'red','bold') + fansi(_error,'red'))
    except KeyboardInterrupt:
        print("Miniterminal: Caught keyboard interrupt (type END to exit)")
"""
# endregion
# Intended for use everywhere; including inside other functions (places with variables that pseudo_terminal can't reach)
# endregion
# Other stuff I don't know which category to put in:

def is_iterable(x):
    """
    Check if an object is iterable (can be looped over).
    
    Args:
        x: Object to test for iterability
        
    Returns:
        bool: True if object is iterable, False otherwise
        
    Examples:
        >>> # Lists and tuples are iterable
        >>> is_iterable([1, 2, 3])
        True

        >>> # Numbers are not iterable
        >>> is_iterable(42)
        False
        
        >>> # Numpy arrays are iterable
        >>> import numpy as np
        >>> is_iterable(np.array([1, 2, 3]))
        True
        
    """
    try:
        #MOST PROPER WAY:
        # from collections.abc import Iterable
        # return isinstance(x,Iterable)

        #PREVIOUS WAY:
        from collections.abc import Iterable
        if isinstance(x,Iterable) or hasattr(x,'__iter__') or hasattr(x,'__getitem__'):
            return True
        
        #OLDEST WAY:
        # for _ in x: pass
        # return True
    except:
        return False

# region Network Utilities: ［get_my_local_ip_address，get_my_ip，get_my_mac_address，get_my_public_ip_address］  
def get_my_local_ip_address() -> str:
    import socket
    s=socket.socket(socket.AF_INET,socket.SOCK_DGRAM)
    s.connect(("8.8.8.8",80))
    try:
        return s.getsockname()[0]
    finally:
        s.close()
        
get_my_ip=get_my_local_ip_address #Legacy: Some of my old code might depend on this function. It's deprecated because it's a bad name

def get_my_mac_address()->str:
    """
    EXAMPLE:
        >> get_my_mac_address()
       ans = 28:cf:e9:17:d9:a5
    """
    if currently_running_linux():
        #If we're running linux, this solution works - and we don't have to pip install get-mac
        #    (pip install get-mac     also works, but this saves you the trouble of installing another package)
        #Returned as a string
        def get_default_iface_name_linux():
            #https://stackoverflow.com/questions/20908287/is-there-a-method-to-get-default-network-interface-on-local-using-python3
            route = "/proc/net/route"
            with open(route) as f:
                for line in f.readlines():
                    try:
                        iface, dest, _, flags, _, _, _, _, _, _, _, =  line.strip().split()
                        if dest != '00000000' or not int(flags, 16) & 2:
                            continue
                        return iface
                    except Exception:
                        continue
        def getmac(interface):
            #https://stackoverflow.com/questions/159137/getting-mac-address
            try:
                mac = open('/sys/class/net/' + interface + '/address').readline()
            except Exception:
                mac = "00:00:00:00:00:00"
            return mac[0:17]
        return getmac(get_default_iface_name_linux())
    else:
        pip_import('getmac','get-mac')
        import getmac
        return getmac.get_mac_address()

def get_my_public_ip_address():
    """
    Get the current public IP address visible on the internet.
    
    Returns:
        str: The public IP address as a string (e.g., "192.168.1.1")
    
    Raises:
        AssertionError: If not connected to the internet
        Exception: If all IP address services fail to respond
    
    Examples:
        >>> ip = get_my_public_ip_address()
        >>> print("Public IP: " + ip)
        
    Related Functions:
        - get_my_local_ip_address
        - get_my_mac_address
        - get_all_local_ip_addresses
        - connected_to_internet
    """
    assert connected_to_internet(),'Cannot get our public IP address because we are not connected to the internet'
    pip_import('requests')
    from requests import get
    try:
        return get('https://icanhazip.com').text.strip()
        # return get('https://api.ipify.org').text
    except Exception:
        return get('http://ipgrab.io').text.strip()
# endregion

def get_my_zerotier_ip_addresses() -> dict:
    """Get ZeroTier network names and their assigned IP addresses.

    Returns:
        Dictionary mapping network name to IP address (with CIDR notation).

    Raises:
        RuntimeError: If zerotier-cli command fails.

    Examples:
        >>> addresses = get_zerotier_ip_addresses()
        >>> isinstance(addresses, dict)
        True
        >>> all(isinstance(k, str) and isinstance(v, str) for k, v in addresses.items())
        True
    """
    import subprocess
    import json
    import ipaddress
    import socket
    from concurrent.futures import ThreadPoolExecutor, as_completed


    result = subprocess.run(
        ['sudo', 'zerotier-cli', 'listnetworks'],
        capture_output=True,
        text=True,
    )

    if result.returncode != 0:
        raise RuntimeError("zerotier-cli failed: {0}".format(result.stderr))

    networks = {}
    for line in result.stdout.strip().split('\n'):
        if line.startswith('200 listnetworks '):
            parts = line.split()
            # Format: 200 listnetworks <nwid> <name> <mac> <status> <type> <dev> <ips>
            # MAC address is in format XX:XX:XX:XX:XX:XX
            if len(parts) >= 8:
                # Find the MAC address (contains colons)
                mac_idx = None
                for i, part in enumerate(parts):
                    if ':' in part and len(part.split(':')) == 6:
                        mac_idx = i
                        break

                if mac_idx and mac_idx >= 4:
                    # Name is everything between index 3 and mac_idx
                    name = ' '.join(parts[3:mac_idx])
                    # IP is the last part
                    ip_address = parts[-1]
                    networks[name] = ip_address

    return networks



def deepcopy_multiply(iterable,factor: int):
    """
    Used for multiplying lists without copying their addresses
    """
    out=[]
    from copy import deepcopy
    for i in range(factor):
        out+=deepcopy(iterable)
    return out

def assert_equality(*args,equality_check=identity):
    """
    When you have a,b,c,d and e and they're all equal and you just can't choose...when the symmetry is just too much symmetry!
    PLEASE NOTE: This does not check every combination: it assumes that equality_check is symmetric!
    """
    length=len(args)
    if length == 0:
        return None
    base=args[0]
    if length == 1:
        return base
    for arg in args:
        base_check=equality_check(base)
        arg_check=equality_check(arg)
        assert (base_check == arg_check)," assert_equality check failed, because " + str(base_check) + " ≠ " + str(arg_check)
        base=arg
    return base

def get_nested_value(list_to_be_accessed,*address_int_list,ignore_errors: bool = False):
    """
    Needs to be better documented. ignore_errors will simply stop tunneling through the array if it gets an error and return the latest value created.
    Also note: this could con
    a[b][c][d] ≣ get_nested_value(a,b,c,d)
    """
    for i in detuple(address_int_list):
        try:
            list_to_be_accessed=list_to_be_accessed[i]
        except Exception:
            if ignore_errors:
                break
            else:
                raise IndexError
    return list_to_be_accessed

def get_nested_attr(obj, attr):
    """
    Get a nested attribute from an object using dot notation.
    
    Args:
        obj: The object to get the attribute from
        attr: String with attribute names in dot notation (e.g., "attr1.attr2.attr3")
              Any numeric attrs, like attr.1.2 are treated as indexes - like obj.attr[1][2]
              Any ..'s are meant for dict accesses, so attr..key.value is treated as obj.attr["key"].value
              If an int is passed as attr, such as get_nested_attr(obj, 0), it is equivalent to obj[0]
              Likewise, get_nested_attr(obj, ".key") is equivalent to obj["key"]
        
    Returns:
        The value of the nested attribute
        
    Raises:
        AttributeError: If any attribute in the chain doesn't exist

    EXAMPLES:
        >>> class Person:
        ...     def __init__(self):
        ...         self.name = "Alice"
        ...         self.skills = ["Python", "SQL"]
        ...         self.metadata = {"status": "active"}
        ...         self.address = type('Address', (), {'city': 'New York'})()
        ... 
        >>> person = Person()

        >>> get_nested_attr(person, "name")             --> 'Alice'    # person.name
        >>> get_nested_attr(person, "address.city")     --> 'New York' # person.address.city
        >>> get_nested_attr(person, "skills.0")         --> 'Python'   # person.skills[0]
        >>> get_nested_attr(person, "metadata..status") --> 'active'   # person.metadata["status"]

        >>> get_nested_attr(["a", "b", "c"], 1)         --> 'b'        # ["a", "b", "c"][1]
        >>> get_nested_attr({"x": 123}, ".x")           --> 123        # {"x": 123}["x"]

        >>> nested = {"users": [{"profile": {"languages": ["English", "Spanish"]}}]}
        >>> get_nested_attr(nested, "users.0..profile..languages.1") --> 'Spanish' # nested["users"][0]["profile"]["languages"][1]

    """
    assert isinstance(attr, (str,int)), type(attr)

    attr=str(attr) #In case
    assert not '...' in attr, 'attr has syntax error - it has ... in it, only . and .. are allowed: '+repr(attr)

    attrs = str(attr).split('.')
    
    as_key = False
    for name in attrs:

        #Handle obj..name as obj["name"]
        if not name:
            # We had a .. separator
            as_key=True
            continue
        if as_key:
            as_key=False
            obj = obj[name]
            continue

        try:
            #Normal functionality - object attribute access
            obj = getattr(obj, name)
        except AttributeError:
            #Numeric index access
            if name.isnumeric():
                obj = obj[int(name)]
        
    return obj

# def shell_command(command: str,as_subprocess=False,return_printed_stuff_as_string: bool = True) -> str or None:
#     # region OLD VERSION: had an argument called return_printed_stuff_as_string, which I never really used as False, and run_as_subprocess when True might not return a string anyay. If I recall correctly, I implemented return_printed_stuff_as_string simply because it was sometimes annoying to see the output when using pseudo_terminal
#     #       def shell_command(command: str,return_printed_stuff_as_string: bool = True,run_as_subprocess=False) -> str or None:
#     #           if return_printed_stuff_as_string:
#     #               return (lambda ans:ans[ans.find('\n') + 1:][::-1])(os.popen(command).read()[::-1])  # EX: print(shell_command("pwd")) <-- Gets the current directory
#     #           from os import system
#     #           system(command)
#     # endregion
#     if as_subprocess:
#         from subprocess import run
#         if return_printed_stuff_as_string:
#             stdout=run(command,shell=True).stdout
#             if stdout is not None:
#                 return (lambda ans:ans[ans.find('\n') + 1:][::-1])(stdout[::-1])  # EX: print(shell_command("pwd")) <-- Gets the current directory
#         else:
#             run(command)
#     else:
#         if return_printed_stuff_as_string:
#             return (lambda ans:ans[ans.find('\n') + 1:][::-1])(os.popen(command).read()[::-1])  # EX: print(shell_command("pwd")) <-- Gets the current directory
#         else:
#             from os import system
#             system(command)

def shell_command(command: str, *, stdin: str = None) -> str:
    """
    Execute a shell command and return its output.

    Args:
        command (str): The shell command to execute
        stdin (str): Optional string to pipe into stdin

    Returns:
        (str) The command's stdout
    """
    from subprocess import run

    result = run(command, shell=True, capture_output=True, text=True, input=stdin)

    output = result.stdout
    
    if output.endswith('\n'):
        #Commands like "pwd" have a \n at the end. Annoying!
        output = output[:-1]

    return output


def get_system_commands(*,use_cache=False)->list:
    """
    Retrieve a list of executable commands available in the system's PATH.
    
    The function returns a list of command names that are executable by the os.system().
    It has been tested to work on UNIX-like systems (Mac and Linux) and Windows.

    If use_cache is True, it might be out of date since the last call! But it will eventually update, usually in under a second

    Returns:
        A list of strings representing the command names

    Example:
        get_system_commands() --> ["ls", "pwd", "python3.8", "man", ... ] (on Unix)
                                   ["cmd.exe", "notepad.exe", "python.exe", ... ] (on Windows)
    """

    if use_cache:
        return list(_get_cached_system_commands())
    
    import os
    import subprocess

    env_paths = os.environ['PATH']
    paths = env_paths.split(os.pathsep)
    
    commands = set()

    for path in paths:
        if os.path.isdir(path):
            entries = os.listdir(path)
            for entry in entries:
                entry_path = os.path.join(path, entry)
                try:
                    # Check if the entry is a file and it's executable
                    if os.path.isfile(entry_path):
                        # On Windows, check if the file has an executable extension
                        if os.name == 'nt':
                            filename, ext = os.path.splitext(entry)
                            if ext.lower() in ['.exe', '.bat', '.cmd']:
                                commands.add(entry)    #EXAMPLE: "ffmpeg.exe"
                                commands.add(filename) #EXAMPLE: "ffmpeg"    Can be executed without extension as well
                        # On Unix, check if the file has executable permissions
                        else:
                            if os.access(entry_path, os.X_OK):
                                commands.add(entry)
                except Exception:
                    # Permission errors - ignore them
                    pass
                    
    # Turn the set into a list by sorting it in a convenient way to view
    commands = sorted(commands)
    commands = sorted(commands, key=len)
    return commands

_get_sys_commands_cache=set()
def _get_cached_system_commands():
    """
    Meant for internal use in pterm! Both kibble and autocomplete.
    rp.get_system_commands can take .05 seconds to complete. Do it in a separate thread upon request.
    """
    global _get_sys_commands_cache

    import rp

    def set_sys_commands(commands):
        global _get_sys_commands_cache
        _get_sys_commands_cache|=set(commands)
        _get_sys_commands_cache&=set(commands)

    def update_sys_commands():
        #It doesn't delete anything - that should hopefully prevent any thread collision fuckyness
        commands = rp.get_system_commands(use_cache=False)
        set_sys_commands(commands)

    def update_sys_commands_to_file():
        update_sys_commands()
        save_file_lines(_get_sys_commands_cache, _cached_system_commands_path)

    def update_sys_commands_from_file():
        try:
            commands = load_file_lines(_cached_system_commands_path)
            set_sys_commands(commands)
        except Exception:
            #This function is used by RP heavily enough it warrants this feedback
            print_stack_trace()

            #We still need them...do it synchronously
            update_sys_commands()

            #Maybe it was corrupted
            try: delete_file(_cached_system_commands_path)
            except Exception: print_stack_trace()
        
        run_as_new_thread(update_sys_commands_to_file)

    if _get_sys_commands_cache:
        rp.run_as_new_thread(update_sys_commands)
    elif file_exists(_cached_system_commands_path):
        update_sys_commands_from_file()
    else:
        update_sys_commands_to_file()

    return _get_sys_commands_cache

_system_command_exists_cache = {}
def system_command_exists(command, *, use_cache=False):
    """
    Checks if a system command exists; returns True if it does, False otherwise.
    
    Args:
        command: The system command to check (string).
        use_cache: Whether to use cached results if available (default: True).
        
    Returns:
        bool: True if the command exists, False otherwise.
    Faster than 
        >>> command in get_all_system_commands() #Slow
        >>> system_command_exists(command) #Fast
        
    Examples:
        >>> system_command_exists("nonexistentcommand12345")
        False
        >>> system_command_exists("ls")
        True
        >>> system_command_exists("python3")
        True
        >>> system_command_exists('/opt/homebrew/bin/python3')
        True
        
    Benchmark Results (1000 iterations, use_cache=False):
        ┌──────────────────┬──────────┬──────────┬───────────┬─────────────────┐
        │ Method           │ Min (ms) │ Max (ms) │ Mean (ms) │ Performance     │
        ├──────────────────┼──────────┼──────────┼───────────┼─────────────────┤
        │ shutil.which     │ 0.025    │ 0.242    │ 0.038     │ baseline        │
        │ subprocess.run   │ 5.538    │ 19.546   │ 6.057     │ ~200x slower    │
        │ get_all_commands │ 36.220   │ 59.800   │ 43.680    │ ~1150x slower   │
        └──────────────────┴──────────┴──────────┴───────────┴─────────────────┘
    """
    import shutil
    if not isinstance(command, str):
        raise TypeError("Command must be a string.")
    
    if use_cache and command in _system_command_exists_cache:
        return _system_command_exists_cache[command]
    
    #METHOD 1: Super fast!
    exists = shutil.which(command) is not None

    ##METHOD 2: THIS WORKS, BUT BENCHMARKED OVER 100x SLOWER THAN shutil.which
    #try:
    #    exists = subprocess.run(['which', command], shell=False, check=False, capture_output=True).returncode == 0
    #except FileNotFoundError:
    #    exists = False

    ##METHOD 3: ORIGINAL, SLOWEST VERSION - DOESN'T HANDLE PATHS LIKE /opt/homebrew/bin/python3
    #exists = command in get_system_commands()
        
    _system_command_exists_cache[command] = exists
    return exists

def system_library_exists(name: str) -> bool:
    """
    Check if a system library can be loaded.

    Uses ctypes.CDLL to check if a shared library (.so, .dylib, .dll) can be dynamically loaded.
    This works across platforms and respects the system's library search paths.

    Args:
        name: The library name to check (e.g., 'libvulkan.so.1', 'libc.so.6')

    Returns:
        bool: True if the library can be loaded, False otherwise

    Examples:
        >>> system_library_exists('libc.so.6')  # Linux
        True
        >>> system_library_exists('libSystem.dylib')  # macOS
        True
        >>> system_library_exists('kernel32.dll')  # Windows
        True
        >>> system_library_exists('nonexistent.so.1')
        False

    Note:
        This is the library equivalent of system_command_exists()
    """
    import ctypes
    try:
        ctypes.CDLL(name)
        return True
    except (OSError, TypeError):
        return False

#DISABLED BECAUSE ITS NOT COMPREHENSIVE ENOUGH ON MAC - NOT EXHAUSTIVE AT ALL
# def get_system_libraries() -> list:
#     """
#     Retrieve a list of shared libraries available on the system.
#
#     Returns a list of library names that can be loaded with ctypes.CDLL().
#     Works on Linux (via ldconfig), macOS (via standard paths), and Windows (via PATH).
#
#     Returns:
#         list: A list of strings representing the library names
#
#     Examples:
#         >>> libs = get_system_libraries()
#         >>> 'libc.so.6' in libs  # Linux
#         True
#         >>> 'libSystem.dylib' in libs  # macOS
#         True
#         >>> 'kernel32.dll' in libs  # Windows
#         True
#
#     Note:
#         This is the library equivalent of get_system_commands()
#     """
#     libraries = []
#
#     if currently_running_linux():
#         # Use ldconfig to list all cached libraries
#         try:
#             import subprocess
#             result = subprocess.run(['ldconfig', '-p'], capture_output=True, text=True)
#             if result.returncode == 0:
#                 # Parse ldconfig output - skip first line (header)
#                 for line in result.stdout.splitlines()[1:]:
#                     # Format: "libname.so.X (arch) => /path/to/lib"
#                     if '=>' in line:
#                         lib_name = line.split()[0]
#                         libraries.append(lib_name)
#         except:
#             pass
#
#     elif currently_running_mac():
#         # On macOS, check common library paths
#         import glob
#         lib_paths = [
#             '/usr/lib/*.dylib',
#             '/usr/local/lib/*.dylib',
#             '/System/Library/Frameworks/*/Versions/*/*.dylib'
#         ]
#         for pattern in lib_paths:
#             for path in glob.glob(pattern):
#                 libraries.append(os.path.basename(path))
#
#     elif currently_running_windows():
#         # On Windows, look for DLLs in PATH and system directories
#         import glob
#         search_paths = os.environ.get('PATH', '').split(os.pathsep)
#         search_paths.extend(['C:\\Windows\\System32', 'C:\\Windows\\SysWOW64'])
#         for dir_path in search_paths:
#             if os.path.exists(dir_path):
#                 for dll in glob.glob(os.path.join(dir_path, '*.dll')):
#                     libraries.append(os.path.basename(dll))
#
#     # Remove duplicates and sort
#     return sorted(list(set(libraries)))

def add_to_env_path(path):
    """
    Adds a directory to the system's PATH environment variable.

    Appends path to $PATH using ':' for Unix, ';' for Windows

    If the provided `path` is a file, its parent directory is added to the PATH instead. 

    Args:
        path (str): The file or directory path to add to the PATH environment variable. Must be a string.

    Raises:
        TypeError: If `path` is not a string.
    """
    if not isinstance(path, str):
        raise TypeError("Path must be a string but got "+repr(type(path)))

    if not path:
        return

    if os.path.isfile(path):
        path = os.path.dirname(path)

    current_path = os.environ.get("PATH", "")

    if path not in current_path.split(os.pathsep):

        if current_path:
            os.environ["PATH"] += os.pathsep + path
        else:
            os.environ["PATH"] = path


def printed(message,value_to_be_returned=None,end='\n'):
    """Print a message and return a value. Useful for inline debugging.
    
    Example: result = some_function(printed("Debug info", complex_value))
    """
    print(str(value_to_be_returned if value_to_be_returned is not None else message),end=end)
    return value_to_be_returned or message


def get_plt():
    pip_import('matplotlib')
    global plt
    import matplotlib.pyplot as plt
    locals()['plt']=plt
    return plt

def display_dot(x,y=None,color='red',size=3,shape='o',block=False):
    """
    Used to be called 'dot', in-case any of my old code breaks...
    EXAMPLE: for theta in np.linspace(0,tau): display_dot(np.sin(theta),np.cos(theta));sleep(.1)
    """
    if y is None:
        x,y=as_points_array([x])[0]
    plt=get_plt()
    plt.plot([x],[y],marker=shape,markersize=size,color=color)
    display_update(block=block)

def display_path(path,*,color=None,alpha=1,marker=None,linestyle=None,block=False,**kwargs):
    """
    Displays a 'path' aka a series of 2d vectors
    If color is None, will plot as a different color every time
    """
    x, y = as_points_array(path).T #Get the x, y values of the path as two lists
    import matplotlib.pyplot as plt
    plt.plot(x, y,color=color,alpha=alpha,marker=marker,linestyle=linestyle,**kwargs)
    update_display(block)

def _translate_offline(text,to_language='ru'):
    """
    This method was made private because right now it only supports russian and nearby countries lol...this function is currently too niche to be exposed as a general translation function...
    ka Georgian
    sr Serbian
    mn Mongolian
    el Greek
    bg Bulgarian
    mk Macedonian
    ru Russian
    hy Armenian
    l1 Latin1Supplement
    uk Ukrainia
    TODO: Refine this
    This runs much faster than google...but I can't vouch for its quality
    Correction: this runs INSANELY fast - translating every line in RP to russian in just .6 seconds!
    """
    pip_import('transliterate')
    from transliterate import translit, get_available_language_codes

    return translit(text,to_language)

def translate(to_translate,to_language="en",from_language="auto"):
    # I DID NOT WRITE THIS!! I GOT IT FROM https://github.com/mouuff/mtranslate/blob/master/mtranslate/core.py
    """Returns the translation using google translate
    you must shortcut the language you define
    (French = fr, English = en, Spanish = es, etc...)
    if not defined it will detect it or use english by default
    Example:
    print(translate("salut tu vas bien?", "en"))
    hello you alright?
    """

    LANGUAGES={
            'af'    :'Afrikaans',
            'sq'    :'Albanian',
            'ar'    :'Arabic',
            'hy'    :'Armenian',
            'bn'    :'Bengali',
            'ca'    :'Catalan',
            'zh'    :'Chinese',
            'zh-cn' :'Chinese (Mandarin/China)',
            'zh-tw' :'Chinese (Mandarin/Taiwan)',
            'zh-yue':'Chinese (Cantonese)',
            'hr'    :'Croatian',
            'cs'    :'Czech',
            'da'    :'Danish',
            'nl'    :'Dutch',
            'en'    :'English',
            'en-au' :'English (Australia)',
            'en-uk' :'English (United Kingdom)',
            'en-us' :'English (United States)',
            'eo'    :'Esperanto',
            'fi'    :'Finnish',
            'fr'    :'French',
            'de'    :'German',
            'el'    :'Greek',
            'hi'    :'Hindi',
            'hu'    :'Hungarian',
            'is'    :'Icelandic',
            'id'    :'Indonesian',
            'it'    :'Italian',
            'ja'    :'Japanese',
            'ko'    :'Korean',
            'la'    :'Latin',
            'lv'    :'Latvian',
            'mk'    :'Macedonian',
            'no'    :'Norwegian',
            'pl'    :'Polish',
            'pt'    :'Portuguese',
            'pt-br' :'Portuguese (Brazil)',
            'ro'    :'Romanian',
            'ru'    :'Russian',
            'sr'    :'Serbian',
            'sk'    :'Slovak',
            'es'    :'Spanish',
            'es-es' :'Spanish (Spain)',
            'es-us' :'Spanish (United States)',
            'sw'    :'Swahili',
            'sv'    :'Swedish',
            'ta'    :'Tamil',
            'th'    :'Thai',
            'tr'    :'Turkish',
            'vi'    :'Vietnamese',
            'cy'    :'Welsh'
        }


    from_language=from_language.lower()
    to_language=to_language.lower()
    assert from_language in set(LANGUAGES)|{'auto'}
    assert to_language in set(LANGUAGES)|{'auto'}

    def translate(text,dest='en',src='auto'):
        pip_import('googletrans','googletrans==4.0.0-rc1')#https://stackoverflow.com/questions/52455774/googletrans-stopped-working-with-error-nonetype-object-has-no-attribute-group
        import googletrans
        return googletrans.Translator().translate(text,dest,src).text

    return translate(to_translate,to_language,from_language)

    #OLD VERSION (NO LONGER WORKS)
        # LANGUAGES['auto']='(automatic)'
        # valid_languages=set(LANGUAGES)
        # is_valid=lambda x:x in valid_languages
        # assert is_valid(to_language) and is_valid(from_language),'Invalid language! Cannot translate. Valid languages: \n'+strip_ansi_escapes(indentify(display_dict(LANGUAGES,print_it=False,arrow=' --> ')))
        # import sys
        # import re
        # if sys.version_info[0] < 3:
        #     # noinspection PyUnresolvedReferences
        #     import urllib2
        #     import urllib
        #     # noinspection PyUnresolvedReferences
        #     import HTMLParser
        # else:
        #     import html.parser
        #     import urllib.request
        #     import urllib.parse
        # agent={'User-Agent':
        #            "Mozilla/4.0 (\
        #              compatible;\
        #              MSIE 6.0;\
        #              Windows NT 5.1;\
        #              SV1;\
        #              .NET CLR 1.1.4322;\
        #              .NET CLR 2.0.50727;\
        #              .NET CLR 3.0.04506.30\
        #              )"}
        # def unescape(text):
        #     if sys.version_info[0] < 3:
        #         parser=HTMLParser.HTMLParser()
        #     else:
        #         parser=html.parser.HTMLParser()
        #     try:
        #         # noinspection PyDeprecation
        #         return parser.unescape(text)
        #     except:
        #         return html.unescape(text)
        # base_link="http://translate.google.com/m?hl=%s&sl=%s&q=%s"
        # if sys.version_info[0] < 3:
        #     # noinspection PyUnresolvedReferences
        #     to_translate=urllib.quote_plus(to_translate)
        #     link=base_link % (to_language,from_language,to_translate)
        #     request=urllib2.Request(link,headers=agent)
        #     raw_data=urllib2.urlopen(request).read()
        # else:
        #     to_translate=urllib.parse.quote(to_translate)
        #     link=base_link % (to_language,from_language,to_translate)
        #     request=urllib.request.Request(link,headers=agent)
        #     raw_data=urllib.request.urlopen(request).read()
        # data=raw_data.decode("utf-8")
        # expr=r'class="t0">(.*?)<'
        # re_result=re.findall(expr,data)
        # if len(re_result) == 0:
        #     result=""
        # else:
        #     result=unescape(re_result[0])
        # return result

def sync_sorted(*lists_in_descending_sorting_priority, key=None, reversed=False):
    """
    Sorts the first list and reorders all other lists to have the same order as the sorted first list.
    
    Parameters:
        *lists_in_descending_sorting_priority: One or more lists to be sorted.
                                               The first list is the main list based on which other lists will be reordered.
        key (function or list of functions, optional): A single key function or a list of key functions.
                                                       A key of None signifies the identity function - aka no key is applied.
                                                       If there's a tie in the first list, subsequent key functions can break the tie.
                                                       If 'None' is used in the list, the identity function will be used for that list.
                                                       Defaults to None, aka the identity function (lambda x: x).
        reversed (bool, optional): If set to True, sorts the lists in descending order. Defaults to False.
        
    Returns:
        tuple: A tuple of lists sorted and reordered in sync with the first list.

    Examples:
        #TODO: Make better examples

        >>> # Basic example with ties in the first list
        >>> sync_sorted([1, 1, 2], ['c', 'a', 'b'])
        ([1, 1, 2], ['a', 'c', 'b'])

        >>> # Sorting in descending order
        >>> sync_sorted([1, 1, 2], ['c', 'a', 'b'], reversed=True)
        ([2, 1, 1], ['b', 'a', 'c'])

        >>> # Using a list of key functions, with 'None' to denote identity function
        >>> sync_sorted([1, 1, 2], ['c', 'a', 'b'], [3, 2, 1], key=[None, str, None])
        ([1, 1, 2], ['a', 'c', 'b'], [2, 3, 1])

        >>> # Handling empty lists
        >>> sync_sorted([], [], [])
        ([], [], [])

        >>> # Using 'reversed' parameter with multiple keys
        >>> sync_sorted([1, 1, 2], ['c', 'a', 'b'], [3, 1, 1], key=[None, str, None], reversed=True)
        ([2, 1, 1], ['b', 'c', 'a'], [1, 3, 1])

    Notes:
        This used to be implemented as a one-liner, but it wasn't as readable and didn't handle they key as well.
        Old implementation:
            def sync_sorted(*lists_in_descending_sorting_priority,key=identity):
                # Sorts main_list and reorders all *lists_in_descending_sorting_priority the same way, in sync with main_list
                return tuple(zip(*sorted(zip(*lists_in_descending_sorting_priority),key=lambda x:tuple(map(key,x)))))
        It was refactored with GPT4: https://chat.openai.com/share/a8975a0c-3199-42f0-b4ad-ef9232ab6ef1
    """
    
    # Input assertions
    assert key is None or callable(key) or is_iterable(key) and all(callable(x) or x is None for x in key), 'The given key must be None, a key function, or a list of keys'
    assert len(set(map(len,lists_in_descending_sorting_priority))), 'All lists must have the same length'

    # Determine whether key is a single function or a list of functions
    if key is None or callable(key):
        keys = [key]
    else:
        keys = key

    # Combine lists element-wise into a list of tuples
    combined_lists = zip(*lists_in_descending_sorting_priority)

    # Sort the combined list of tuples based on the keys
    def sorting_key(x):
        for i in range(len(x)):
            if i < len(keys) and keys[i] is not None:
                yield keys[i](x[i])
            else:
                yield x[i]

    sorted_combined_lists = sorted(
        combined_lists, key=lambda x: tuple(sorting_key(x)), reverse=reversed
    )

    # Unpack the sorted tuples back into separate lists
    sorted_separate_lists = zip(*sorted_combined_lists)

    # Convert sorted lists from tuples to lists and return them as a tuple of lists
    return tuple(list(sorted_list) for sorted_list in sorted_separate_lists)
sync_sort=sync_sorted#For backwards compatiability

def by_number(x):
    """ 
    Used as a key for sorting 
    Example: paths=sorted(paths, key=by_number)
    """
    return (len(x), x)

def sorted_by_number(x, *, reverse=False):
    return sorted(x, key=by_number, reverse=reverse)

def sorted_by_len(x, *, reverse=False):
    return sorted(x, key=len, reverse=reverse)

def sorted_by_attr(x, attr, *, key=None, reverse=False):
    def new_key(e):
        e = get_nested_attr(e, attr)
        if key is not None:
            e = key(e)
        return e
    return sorted(x, key=new_key, reverse=reverse)

def sorted_dict(dict,*,key=None,reverse=False):
    """
    In python, dicts don't necessarily retain order, though they often do.
    This sorts a dict's by its keys and returns an OrderedDict as a result
    """
    from collections import OrderedDict
    output=OrderedDict()
    dict_keys=sorted(dict,key=key,reverse=reverse)
    for dict_key in dict_keys:
        output[dict_key] = dict[dict_key]
    return output

# def sync_sorted(*lists_in_descending_sorting_priority,key=identity):
#         # Sorts main_list and reorders all *lists_in_descending_sorting_priority the same way, in sync with main_list
#         return tuple(zip(*sorted(zip(*lists_in_descending_sorting_priority),key=lambda x:tuple(map(key,x)))))

def _string_with_any(string, substrings, match_func, return_match=False):
    "Helper function that checks if string matches any of the substrings using the given match_func."
    substrings = detuple(substrings)
    if isinstance(substrings, str):
        substrings = [substrings]
    for substring in substrings:
        if match_func(string, substring):
            if return_match:
                return substring
            else:
                return True
    return None if return_match else False

def starts_with_any(string, *prefixes, return_match=False):
    "Returns True if begins with any of the prefixes. If return_match, it returns that prefix if it exists - else None."
    return _string_with_any(string, prefixes, str.startswith, return_match)

def ends_with_any(string, *suffixes, return_match=False):
    "Returns True if ends with any of the suffixes. If return_match, it returns that suffix if it exists - else None."
    return _string_with_any(string, suffixes, str.endswith, return_match)

    
def _contains_func_y(y):
    #Used in contains_any, contains_all, in_any, in_all
    y=detuple(y)
    if not hasattr(y,'__contains__') or type(y) in [str, bytes]:
        #Without this, contains_any('abc','axyz')==True
        #Because it would iterate through the letters
        y=[y]
    return y
    

def contains_any(x,*y):
    """
    Returns True if x contains any of y.

    TODO: Add a return_match=False optional arg, like in starts_with_any and ends_with_any

    EXAMPLES:
        assert contains_any('texture','tex') == True
        assert contains_any('tex','texture') == False
        assert contains_any('texture',['tex']) == True
        assert contains_any('texture','abc') == False
        assert contains_any('texture','abc','tex') == True
        assert contains_any('texture',['abc','tex']) == True
        assert contains_any([1,2,3,4],1) == True
        assert contains_any([1,2,3,4],2) == True
        assert contains_any([1,2,3,4],5) == False
        assert contains_any([1,2,3,4],5,6) == False
        assert contains_any([1,2,3,4],5,6,1) == True
        assert contains_any([1,2,3,4],5,6,1,2) == True
        assert contains_any([1,2,3,4],[5,6,1,2]) == True
        assert contains_any([1,2,3,4],[1,2]) == True
        assert contains_any([1,2,3,4],[5,6]) == False
    """
    assert hasattr(x,'__contains__'),'x cannot contain anything. type(x)=='+repr(type(x))
    y=_contains_func_y(y)
    return any(z in x for z in y)

def contains_all(x,*y):
    """
    Returns True if x contains all of y.

    EXAMPLES:
        assert contains_all('texture','t', 'e', 'x') == True
        assert contains_all('texture','z') == False
        assert contains_all('texture',['t', 'e', 'x']) == True
        assert contains_all([1,2,3,4],1, 2) == True
        assert contains_all([1,2,3,4],1, 5) == False
        assert contains_all([1,2,3,4],[1,2]) == True
        assert contains_all([1,2,3,4],[5,6]) == False
    """
    assert hasattr(x,'__contains__'),'x cannot contain anything. type(x)=='+repr(type(x))
    y=_contains_func_y(y)
    return all(z in x for z in y)

def in_any(x,*y):
    """
    Returns True if x is in any of y.

    TODO: Add a return_match=False optional arg, like in starts_with_any and ends_with_any

    EXAMPLES:
        assert in_any('tex','texture', 'textbook') == True
        assert in_any('abc','texture', 'textbook') == False
        assert in_any(1,[1,2,3], [2,3,4]) == True
        assert in_any(5,[1,2,3], [2,3,4]) == False
    """
    y=_contains_func_y(y)
    assert all(hasattr(z,'__contains__') for z in y), 'Not all y can contain things: '+str(set(map(type,y)))
    return any(x in z for z in y)

def in_all(x,*y):
    """
    Returns True if x is in all of y.

    EXAMPLES:
        assert in_all('tex','texture', 'textbook') == False
        assert in_all('t','texture', 'textbook') == True
        assert in_all(1,[1,2,3], [1,3,4]) == True
        assert in_all(5,[1,2,3], [2,3,4]) == False
        assert in_all(5,[5,1,2,3], [2,3,4]) == False
        assert in_all(5,[5,1,2,3], [5,2,3,4]) == True
    """
    y=_contains_func_y(y)
    assert all(hasattr(z,'__contains__') for z in y), 'Not all y can contain things: '+str(set(map(type,y)))
    return all(x in z for z in y)

def contains_sort(array, *, key=lambda x: x, contains=lambda x, y: y in x, reverse=False):
    """
    Sorts a list of strings such that for every pair of indices i, j (i<=j),
    if S[i] is a substring of S[j], then S[i] comes before S[j] in the sorted list.
    If neither string is a substring of the other, the function falls back to
    lexicographic comparison.

    Parameters:
    S (list of str): List of strings to sort

    Returns:
    list of str: Sorted list of strings

    Example:
    >>> contains_sort(["abc", "ab", "abcd"])
    ['ab', 'abc', 'abcd']

    >>> contains_sort(["123", "23", "12"])
    ['12', '123', '23']

    >>> contains_sort(["rat", "cat", "animal", "bat"])
    ['rat', 'cat', 'bat', 'animal']

    >>> contains_sort(["abc", "aabc", "aaabc"])
    ['abc', 'aabc', 'aaabc']
    """

    import functools

    def cmp(a, b):
        if contains(b, a):
            return -1
        elif contains(a, b):
            return 1
        else:
            ka = key(a)
            kb = key(b)
            return (ka > kb) - (ka < kb)

    return sorted(array, key=functools.cmp_to_key(cmp),reverse=reverse)
contains_sorted=contains_sort

def sync_shuffled(*lists):
    """
    Shuffles lists in sync with one another
    EXAMPLE:
     >>> sync_shuffled([1,2,3,4,5],'abcde')
    ans = [(1, 3, 5, 2, 4), ('a', 'c', 'e', 'b', 'd')]
    """
    lists=detuple(lists)
    return list(zip(*shuffled(list(zip(*lists)))))
    
# noinspection PyAugmentAssignment
def full_range(x,min=0,max=1):
    try:
        if x.dtype==bool:
            x=x.astype(float)
    except AttributeError:
        pass
    try:
        x=x - np.min(x)
        x=x / np.max(x)  # Augmented Assignment, AKA x-= or x/= causes numpy errors. I don't know why I wonder if its a bug in numpy.
        x=x * (max - min)
        x=x + min
        return x
    except Exception:
        # Works with pytorch, numpy, etc
        x=x - x.min()
        x=x / x.max()  # Augmented Assignment, AKA x-= or x/= causes numpy errors. I don't know why I wonder if its a bug in numpy.
        x=x * (max - min)
        x=x + min
        return x

# region Math constants (based on numpy)
π=pi=3.14159265358979323846264338327950288419716939937510582097494459230781640628620899862
τ=tau=2 * π
# endregion

# region Tone Generators
# Note: All Tone Sample Generators have an amplitude of [-1,1]
def sine_tone_sampler(ƒ=None,T=None,samplerate=None):
    T=T or default_tone_seconds
    samplerate=samplerate or default_samplerate
    ƒ=ƒ or default_tone_frequency
    ↈλ=ƒ * T  # ≣number of wavelengths
    return np.sin(np.linspace(0,τ * ↈλ,int(T * (samplerate or default_samplerate))))

def triangle_tone_sampler(ƒ=None,T=None,samplerate=None):
    return 2 / π * np.arcsin(sine_tone_sampler(ƒ,T,samplerate))

def sawtooth_tone_sampler(ƒ=None,T=None,samplerate=None):
    T=T or default_tone_seconds
    samplerate=samplerate or default_samplerate
    ƒ=ƒ or default_tone_frequency
    ↈλ=ƒ * T  # ≣number of wavelengths
    return (np.linspace(0,ↈλ,int(T * (samplerate or default_samplerate))) % 1) * 2 - 1

def square_tone_sampler(ƒ=None,T=None,samplerate=None):
    return np.sign(sawtooth_tone_sampler(ƒ,T,samplerate))

default_tone_frequency=440  # also known as note A4
default_tone_sampler=sine_tone_sampler
default_tone_seconds=1
def play_tone(hz=None,seconds=None,samplerate=None,tone_sampler=None,blocking=False):  # Plays a sine tone
    ƒ,T=hz or default_tone_frequency,seconds or default_tone_seconds  # Frequency, Time
    play_sound_from_samples((tone_sampler or default_tone_sampler)(ƒ,T),samplerate or default_samplerate,blocking=blocking)
def play_semitone(ↈ_semitones_from_A4_aka_440hz=0,seconds=None,samplerate=None,tone_sampler=None,blocking=False):
    ↈ=ↈ_semitones_from_A4_aka_440hz
    play_tone(semitone_to_hz(ↈ),seconds,samplerate,tone_sampler,blocking)
def semitone_to_hz(ↈ):
    return 440 * 2 ** (ↈ / 12)
def play_chord(*semitones:list,t=1,block=True,sampler=triangle_tone_sampler):
    """Plays multiple semitone offsets simultaneously as a harmonic chord.
    
    Combines multiple tone generators to create rich harmonic chords. Uses triangle 
    wave sampler by default for warm, musical character suitable for chord playback.
    
    This function represents the harmonic synthesis capabilities of the RP audio system.
    It demonstrates several key RP design patterns:
    - Variable arguments (*semitones) for flexible chord sizes
    - Sensible defaults (triangle waves, 1-second duration, blocking playback)
    - Implementation multiplexing (built on existing tone generation functions)
    - Musical interface (semitone-based pitch specification)
    
    The chord generation process:
    1. Generate individual tone samples for each semitone using specified sampler
    2. Sum all individual waveforms sample-by-sample (additive synthesis)
    3. Normalize result using full_range() to prevent clipping
    4. Play combined audio buffer through sound system
    
    Args:
        *semitones (int): Variable number of semitone offsets from A4 (440 Hz).
        t (float): Duration of chord playback in seconds. Defaults to 1 second.
        block (bool): If True, wait for playback completion. If False, return immediately.
        sampler (callable): Tone generation function. Defaults to triangle_tone_sampler.
    
    Examples:
        >>> play_chord(0, 4, 7)  # A major triad (A4, C#5, E5)
        >>> play_chord(0, 3, 7)  # A minor triad (A4, C5, E5)  
        >>> play_chord(0, 4, 7, 11)  # A major 7th
        >>> play_chord(-12, -5, -2, 0, 4, 7, 11, t=3.0)  # Large complex chord
    
    Tags: chord, harmony, additive-synthesis, musical, multi-tone, semitones
    """
    play_sound_from_samples(full_range(min=-1,x=sum(sampler(semitone_to_hz(x),T=t)for x in semitones)),blocking=block)

def play_chords(*chord_sequences, gap=0.1, t=1, block=True, sampler=triangle_tone_sampler):
    """Plays a sequence of chords with optimized timing and seamless audio generation.
    
    Optimized plural version of play_chord that creates one continuous audio buffer
    for multiple chords, eliminating gaps and delays between chord changes.
    Duration broadcasting supported: single value or list of durations per chord.
    
    This function addresses the key limitation of calling play_chord() multiple times:
    audio system delays between individual playback calls. By pre-generating all chord
    audio into a single buffer, play_chords() enables:
    - Seamless chord progressions without audio system delays
    - Precise timing control between chords  
    - Single audio playback call for efficiency
    - Musical phrasing with controlled gaps between chords
    - Duration broadcasting: Different duration for each chord or single duration for all
    
    Args:
        *chord_sequences: Variable number of chord tuples/lists. Each element should be
                         an iterable of semitone offsets (e.g., [0, 4, 7] for A major).
        gap (float): Silence duration in seconds between chords. Defaults to 0.1 seconds.
        t (float or list): Duration(s) in seconds. Can be single value for all chords or 
                          list of durations matching number of chords. Defaults to 1 second.
        block (bool): If True, wait for complete sequence playback. If False, return immediately.
        sampler (callable): Tone generation function for all chords. Defaults to triangle_tone_sampler.
    
    Examples:
        >>> # Classic I-V-vi-IV progression in A major
        >>> play_chords([0, 4, 7], [7, 11, 14], [-4, -1, 3], [-5, -1, 2])
        
        >>> # Duration broadcasting - different durations for each chord
        >>> play_chords([0, 4, 7], [-3, 0, 4], [2, 5, 9], t=[1.0, 0.5, 2.0])
        
        >>> # Rhythmic pattern with varied durations
        >>> play_chords([0, 4, 7], [0, 4, 7], [-3, 0, 4], [-3, 0, 4],
        ...             t=[0.75, 0.25, 0.75, 0.25], gap=0.0)  # Syncopated rhythm
        
        >>> # Demo: "Roundtable Rival" - Complete song from MIDI transcription
        >>> # Beautiful electronic composition showcasing play_chords capabilities
        >>> # Main theme with higher register sections and rhythmic conclusion
        >>> play_chords(
        ...     # Main theme progression
        ...     [5, -7],   # D5, D4 - Opening motif
        ...     [12, 0],   # A5, A4 - Bright harmony
        ...     [5, -7],   # D5, D4 - Return to opening
        ...     [8, -4],   # F5, F4 - Ascending harmony
        ...     [10, -2],  # G5, G4 - Peak of phrase
        ...     [8, -4],   # F5, F4 - Descending
        ...     [7, -5],   # E5, E4 - Resolution approach
        ...     [5, -7],   # D5, D4 - Theme conclusion
        ...     
        ...     # Higher register section - soaring melodies
        ...     [17, 5],   # D6, D5 - High octave entry
        ...     [12, 0],   # A5, A4 - Connecting harmony  
        ...     [19, 7],   # E6, E5 - Brilliant high notes
        ...     [12, 0],   # A5, A4 - Harmonic anchor
        ...     [20, 8],   # F6, F5 - Climactic high point
        ...     [22, 10],  # G6, G5 - Ultimate peak
        ...     
        ...     # Return to main theme - varied rhythm
        ...     [5, -7],   # D5, D4 - Return with authority
        ...     [12, 0],   # A5, A4 - Familiar harmony
        ...     [5, -7],   # D5, D4 - Emphasized return
        ...     [8, -4],   # F5, F4 - Building tension
        ...     [10, -2],  # G5, G4 - Approaching climax  
        ...     [8, -4],   # F5, F4 - Tension release
        ...     [7, -5],   # E5, E4 - Final approach
        ...     [5, -7],   # D5, D4 - Theme resolution
        ...     
        ...     # Rhythmic conclusion - single note flourish
        ...     [5],       # D5 - Rhythmic punctuation
        ...     [8],       # F5 - Quick harmonic shift
        ...     [5],       # D5 - Return to root
        ...     [3],       # C5 - Neighbor tone
        ...     [5],       # D5 - Final resolution
        ...     
        ...     # Precise timing matching original MIDI performance
        ...     t=[0.4] * 8 +     # Main theme - steady quarter notes
        ...       [0.3] * 6 +     # Higher section - faster eighth notes
        ...       [0.4] * 8 +     # Return theme - back to quarters
        ...       [0.2] * 5,      # Conclusion - rapid sixteenth notes
        ...     gap=0.05,          # Tight spacing for electronic feel
        ...     sampler=triangle_tone_sampler)  # Warm synthetic timbre
        
        >>> # Simplified version highlighting key harmonic moments
        >>> play_chords([5, -7], [8, -4], [17, 5], [20, 8], [5, -7],
        ...             t=[0.8, 1.0, 0.6, 1.2, 1.5], gap=0.1)
    
    Performance Benefits:
        - Single audio buffer allocation vs multiple allocations
        - No audio system startup/shutdown delays between chords
        - Precise timing control independent of system audio latency
        - Memory efficient: pre-calculates only what's needed
    
    Tags: chord-progression, sequence, optimized, seamless, musical-timing, harmony
    """
    samplerate = 44100  # Could be made configurable
    chord_parts = []
    
    # Handle duration broadcasting
    if isinstance(t, (list, tuple)):
        durations = t
        if len(durations) != len(chord_sequences):
            raise ValueError("Duration list length ({0}) must match number of chords ({1})".format(len(durations), len(chord_sequences)))
    else:
        # Single duration value - broadcast to all chords
        durations = [t] * len(chord_sequences)
    
    for i, chord_semitones in enumerate(chord_sequences):
        # Get duration for this specific chord
        chord_duration = durations[i]
        
        # Generate chord samples with individual duration
        chord_samples = sum(sampler(semitone_to_hz(s), T=chord_duration, samplerate=samplerate) 
                          for s in chord_semitones)
        normalized_chord = full_range(min=-1, x=chord_samples)
        chord_parts.append(normalized_chord)
        
        # Add gap between chords (except after last chord)
        if i < len(chord_sequences) - 1:
            gap_samples = np.zeros(int(gap * samplerate))
            chord_parts.append(gap_samples)
    
    # Concatenate all parts into single audio buffer
    complete_sequence = np.concatenate(chord_parts)
    
    # Play the entire sequence at once
    play_sound_from_samples(complete_sequence, blocking=block)
# endregion

def mini_editor(out: str = "",namespace=(),message=""): 
    """
    Has syntax highlighting. Creates a curses pocket-universe where you can edit text, and then press fn+enter to enter the results. It's like like a normal input() except multiline and editable.
    message=message or "Enter text here and then press fn+enter to exit. Supported controls: Arrow keys, backspace, delete, tab, shift+tab, enter"
    Please note: You must be using a REAL terminal to run this! Just using pycharm's "run" is not sufficient. Using apple's terminal app, for example, IS however.
    """
    import curses
    stdscr=curses.initscr()

    # region Initialize curses colors:
    curses.start_color()
    curses.use_default_colors()

    curses.init_pair(0,curses.COLOR_BLACK,curses.COLOR_BLACK)
    black=curses.color_pair(0)
    curses.init_pair(1,curses.COLOR_RED,curses.COLOR_BLACK)
    red=curses.color_pair(1)
    curses.init_pair(2,curses.COLOR_GREEN,curses.COLOR_BLACK)
    green=curses.color_pair(2)
    curses.init_pair(3,curses.COLOR_YELLOW,curses.COLOR_BLACK)
    yellow=curses.color_pair(3)
    curses.init_pair(4,curses.COLOR_BLUE,curses.COLOR_BLACK)
    blue=curses.color_pair(4)
    curses.init_pair(5,curses.COLOR_CYAN,curses.COLOR_BLACK)
    cyan=curses.color_pair(5)
    curses.init_pair(6,curses.COLOR_MAGENTA,curses.COLOR_BLACK)
    magenta=curses.color_pair(6)
    curses.init_pair(7,curses.COLOR_WHITE,curses.COLOR_BLACK)
    gray=curses.color_pair(7)
    # endregion
    def main(stdscr):
        print(message,end='',flush=True)
        # region http://colinmorris.github.io/blog/word-wrap-in-pythons-curses-library
        class WindowFullException(Exception):
            pass

        def addstr_wordwrap(window,s,mode=0):
            """ (cursesWindow, str, int, int) -> None
            Add a string to a curses window with given dimensions. If mode is given
            (e.g. curses.A_BOLD), then format text accordingly. We do very
            rudimentary wrapping on word boundaries.

            Raise WindowFullException if we run out of room.
            """
            # TODO Is there really no way to get the dimensions of a window programmatically?
            # passing in height and width feels ugly.

            height,width=window.getmaxyx()
            height-=1
            width-=1
            (y,x)=window.getyx()  # Coords of cursor
            # If the whole string fits on the current line, just add it all at once
            if len(s) + x <= width:
                window.addstr(s,mode)
            # Otherwise, split on word boundaries and write each token individually
            else:
                for word in words_and_spaces(s):
                    if len(word) + x <= width:
                        window.addstr(word,mode)
                    else:
                        if y == height - 1:
                            # Can't go down another line
                            raise WindowFullException()
                        window.addstr(y + 1,0,word,mode)
                    (y,x)=window.getyx()

        def words_and_spaces(s):
            import itertools
            """
            >>> words_and_spaces('spam eggs ham')
            ['spam', ' ', 'eggs', ' ', 'ham']
            """
            # Inspired by http://stackoverflow.com/a/8769863/262271
            return list(itertools.chain.from_iterable(zip(s.split(),itertools.repeat(' '))))[:-1]  # Drop the last space

        # endregion
        nonlocal out
        cursor_shift=0
        while True:
            # region  Keyboard input:
            stdscr.nodelay(1)  # do not wait for input when calling getch
            c=stdscr.getch()  # get keyboard input
            typing=False
            updown=None
            if c != -1:  # getch() returns -1 if none available
                # text_to_speech(c)
                if chr(c) in "":  # ⟵ Up/Down/Left/Right arrow keys (Up/Down ≣ Scroll up down) are not currently implemented. I don't know how.
                    pass
                elif c == ord("Ą"):  # left arrow key
                    cursor_shift+=1
                    cursor_shift=min(len(out),cursor_shift)
                elif c == ord("ą"):  # right arrow key
                    cursor_shift-=1
                    cursor_shift=max(0,cursor_shift)
                elif c == ord("ă"):  # up arrow key
                    updown='up'
                elif c == ord("Ă"):  # down arrow key
                    updown='down'
                elif c == ord('ŗ') == 343:  # fn+enter was pressed# c==10:# Enter key was pressed
                    return out
                else:
                    typing=True
                    # out+=chr(c)

            # out_lines=out.split("\n")
            # cursor_y=len(out_lines)-1
            # while cursor_x<0:
            #     cursor_x+=len(out_lines[cursor_y])
            #     cursor_y-=1

            out_lines=out.split("\n")
            cursor_y=0
            cursor_x=len(out) - cursor_shift
            assert cursor_x >= 0

            if updown:
                if updown == 'up':
                    i0=out[:cursor_x].rfind("\n")
                    i1=out[:i0].rfind("\n")
                    cursor_x=min(len(out) - 1,max(0,min(cursor_x - i0,i0 - i1) + i1))
                    cursor_shift=len(out) - cursor_x

                else:
                    assert updown == 'down'
                    i0=out[:cursor_x].rfind("\n")
                    i1=out.find("\n",i0 + 1)
                    cursor_x=min(len(out) - 1,max(0,min(cursor_x - i0,i1 - i0) + i1))
                    cursor_shift=len(out) - cursor_x

            elif typing:
                if c == 127:  # Backspace key was pressed
                    if cursor_x:
                        out=out[:cursor_x - 1] + out[cursor_x:]
                elif c == ord("Ŋ"):  # Delete key was pressed
                    if cursor_x < len(out):
                        out=out[:cursor_x] + out[cursor_x + 1:]
                        cursor_shift-=1
                        cursor_x+=1
                elif c == ord('\t'):  # tab
                    out=out[:cursor_x] + "    " + out[cursor_x:]  # 4 spaces per tab
                elif c == ord('š'):  # shift+tab
                    if cursor_x:
                        out=out[:max(0,cursor_x - 4)] + out[cursor_x:]  # 4 backspaces
                else:
                    out=out[:cursor_x] + chr(c) + out[cursor_x:]

            for i in range(len(out_lines) - 1):
                out_lines[i]+="\n"  # So that ∑out_lines ＝ out
            while cursor_x > len(out_lines[cursor_y]):
                cursor_x-=len(out_lines[cursor_y])
                cursor_y+=1
            try:
                if out[len(out) - cursor_shift - 1] == "\n":  # c_x+1?
                    cursor_x=0
                    cursor_y+=1
            except:
                pass

            # endregion
            # region Real-time display:
            stdscr.erase()
            stdscr.move(0,0)  # return curser to start position to re-print everything
            height,width=stdscr.getmaxyx()
            height-=1
            width-=1
            def print_fansi_colors_in_curses(stdscr,s: str):  # Only supports text colors; DOES NOT support anything else at the moment. Assumes we are given a fansi sequence.
                text_color=None
                while True:  # Until string is empty.
                    if s.startswith("\x1b["):
                        while s.startswith("["):  # Oddly without this I got -------...... ⭆ ^[[0;33m-^[[0;33m-^[[0;33m-^[[0;33m-^[[0;33m-^[.......
                            s=s[1:]
                        i=s.find('m')  # there should always be a m somewhere, print(repr(fansi_print("h",'red','bold'))) for example.
                        ss=s[:i].split(';')
                        s=s[i + 1:]  # +1 to take care of the m which is gone now
                        if '30' in ss:  # black
                            text_color=black
                        elif '31' in ss:  # red
                            text_color=red
                        elif '32' in ss:  # green
                            text_color=green
                        elif '33' in ss:  # yellow
                            text_color=yellow
                        elif '34' in ss:  # blue
                            text_color=blue
                        elif '35' in ss:  # magenta
                            text_color=magenta
                        elif '36' in ss:  # cyan
                            text_color=cyan
                        elif '37' in ss:  # gray
                            text_color=gray
                        else:  # if'0'in ss:# clear style
                            text_color=None
                    if not s:
                        break  # avoid trying to access indexes in an empty string
                    if text_color is not None:
                        # stdscr.addstr(s[0],text_color)
                        addstr_wordwrap(stdscr,s[0],text_color)
                    else:
                        # stdscr.addstr(s[0])
                        addstr_wordwrap(stdscr,s[0])
                    s=s[1:]
            print_fansi_colors_in_curses(stdscr,fansi_syntax_highlighting(out,namespace))
            assert isinstance(out,str)

            while cursor_x > width:
                cursor_y+=1
                cursor_x-=width
            cursor_y=min(height,cursor_y)
            stdscr.move(cursor_y,cursor_x)
            stdscr.refresh()
            # endregion
    curses.wrapper(main)
    return out


def get_terminal_size():
    """
    From http://stackoverflow.com/questions/566746/how-to-get-linux-console-window-width-in-python/14422538#14422538
    Adapted for windows via GPT4: https://chat.openai.com/share/976384cd-69fe-46fa-bc75-9f1ed97c51ef
    Returns a (width:int, height:int) tuple
    """

    import os
    import platform

    current_os = platform.system()
    if current_os == 'Windows':
        # For Windows
        try:
            import struct
            from ctypes import windll, create_string_buffer
            # stdin handle is -10
            # stdout handle is -11
            # stderr handle is -12
            h = windll.kernel32.GetStdHandle(-12)
            csbi = create_string_buffer(22)
            res = windll.kernel32.GetConsoleScreenBufferInfo(h, csbi)
            if res:
                (bufx, bufy, curx, cury, wattr,
                 left, top, right, bottom,
                 maxx, maxy) = struct.unpack("hhhhHhhhhhh", csbi.raw)
                sizex = right - left + 1
                sizey = bottom - top + 1
                return sizex, sizey
        except:
            # Default value if above method fails
            return 80, 25
    else:
        # For Linux
        def ioctl_GWINSZ(fd):
            try:
                import fcntl
                import termios
                import struct
                cr = struct.unpack('hh', fcntl.ioctl(fd, termios.TIOCGWINSZ, '1234'))
            except:
                return
            return cr
        cr = ioctl_GWINSZ(0) or ioctl_GWINSZ(1) or ioctl_GWINSZ(2)
        if not cr:
            try:
                fd = os.open(os.ctermid(), os.O_RDONLY)
                cr = ioctl_GWINSZ(fd)
                os.close(fd)
            except:
                pass
        if not cr:
            # Default size or try environment variables
            cr = (os.environ.get('LINES', 25), os.environ.get('COLUMNS', 80))
        return int(cr[1]), int(cr[0])



def get_terminal_width():
    """ Attempts to return the width of the current TTY in characters - otherwise it will return 80 by default """
    return get_terminal_size()[0]
def get_terminal_height():
    """ Attempts to return the height of the current TTY in characters - otherwise it will return 25 by default """
    return get_terminal_size()[1]

def is_namespaceable(c: str) -> bool:  
    """ Returns True if the given string can be used as a python variable name """
    return str.isidentifier(c) or c==''#Maintaining original functionality but doing it much much faster
    import re
    try:
        c+=random_permutation("ABCDEFGHIJKLMNOPQRSTUVWXYZ")  # Just in case this overrides some other variable somehow (I don't know how it would do that but just in case)
        exec(c + "=None")
        exec("del " + c)
        return True
    except Exception:
        return False

def is_literal(c: str) -> bool:  
    """ If character can be used as the first of a python variable's name """
    return c==":" or (is_namespaceable(c) or c.isalnum())and not c.lstrip().rstrip() in ['False','def','if','raise','None','del','import','return','True','elif','in','try','and','else','is','while','as','except','lambda','with','assert','finally','nonlocal','yield','break','for','not','class','from','or','continue','global','pass']

def clip_string_width(x: str,max_width=None,max_wraps_per_line=1,clipped_suffix='…'):  
    """ clip to terminal size. works with multi lines at once. """
    max_width=(max_width or get_terminal_width()) * max_wraps_per_line
    return '\n'.join((y[:max_width - len(clipped_suffix)] + clipped_suffix) if len(y) > max_width else y for y in x.split('\n'))

def properties_to_xml(src_path,target_path):  # Found this during my 219 hw4 assignment when trying to quickly convert a .properties file to an xml file to get more credit
    """
    SOURCE: https://www.mkyong.com/java/how-to-store-properties-into-xml-file/
    Their code was broken so I had to fix it. It works now.
    """
    src=open(src_path)
    target=open(target_path,'w')
    target.write('<?xml version="1.0" encoding="utf-8" standalone="no"?>\n')
    target.write('<!DOCTYPE properties SYSTEM "http://java.sun.com/dtd/properties.dtd">\n')
    target.write('<properties>\n')

    for line in src.readlines():
        word=line.split('=')
        key=word[0]
        message='='.join(word[1:]).strip()  # .decode('unicode-escape')
        # message=unicode('='.join(word[1:]).strip(),'unicode-escape')
        target.write('\t<entry key="' + key + '"><![CDATA[' + message.encode('utf8').decode() + ']]></entry>\n')

    target.write('</properties>')
    target.close()

def split_including_delimiters(input: str, delimiter: str):
    """
    Splits an input string, while including the delimiters in the output
    
    Unlike str.split, we can use an empty string as a delimiter
    Unlike str.split, the output will not have any extra empty strings
    Conequently, len(''.split(delimiter))== 0 for all delimiters,
       whereas len(input.split(delimiter))>0 for all inputs and delimiters
    
    INPUTS:
        input: Can be any string
        delimiter: Can be any string

    EXAMPLES:
         >>> split_and_keep_delimiter('Hello World  ! ',' ')
        ans = ['Hello ', 'World ', ' ', '! ', ' ']
         >>> split_and_keep_delimiter("Hello**World**!***", "**")
        ans = ['Hello', '**', 'World', '**', '!', '**', '*']
    EXAMPLES:
        assert split_and_keep_delimiter('-xx-xx-','xx') == ['-', 'xx', '-', 'xx', '-'] # length 5
        assert split_and_keep_delimiter('xx-xx-' ,'xx') == ['xx', '-', 'xx', '-']      # length 4
        assert split_and_keep_delimiter('-xx-xx' ,'xx') == ['-', 'xx', '-', 'xx']      # length 4
        assert split_and_keep_delimiter('xx-xx'  ,'xx') == ['xx', '-', 'xx']           # length 3
        assert split_and_keep_delimiter('xxxx'   ,'xx') == ['xx', 'xx']                # length 2
        assert split_and_keep_delimiter('xxx'    ,'xx') == ['xx', 'x']                 # length 2
        assert split_and_keep_delimiter('x'      ,'xx') == ['x']                       # length 1
        assert split_and_keep_delimiter(''       ,'xx') == []                          # length 0
        assert split_and_keep_delimiter('aaa'    ,'xx') == ['aaa']                     # length 1
        assert split_and_keep_delimiter('aa'     ,'xx') == ['aa']                      # length 1
        assert split_and_keep_delimiter('a'      ,'xx') == ['a']                       # length 1
        assert split_and_keep_delimiter(''       ,''  ) == []                          # length 0
        assert split_and_keep_delimiter('a'      ,''  ) == ['a']                       # length 1
        assert split_and_keep_delimiter('aa'     ,''  ) == ['a', '', 'a']              # length 3
        assert split_and_keep_delimiter('aaa'    ,''  ) == ['a', '', 'a', '', 'a']     # length 5
    """

    # I made this question an answer at https://stackoverflow.com/questions/2136556/in-python-how-do-i-split-a-string-and-keep-the-separators/73562313#73562313

    # Input assertions
    assert isinstance(input,str), "input must be a string"
    assert isinstance(delimiter,str), "delimiter must be a string"

    if delimiter:
        # These tokens do not include the delimiter, but are computed quickly
        tokens = input.split(delimiter)
    else:
        # Edge case: if the delimiter is the empty string, split between the characters
        tokens = list(input)
        
    # The following assertions are always true for any string input and delimiter
    # For speed's sake, we disable this assertion
    # assert delimiter.join(tokens) == input

    output = tokens[:1]

    for token in tokens[1:]:
        output.append(delimiter)
        if token:
            output.append(token)
    
    # Don't let the first element be an empty string
    if output[:1]==['']:
        del output[0]
        
    # The only case where we should have an empty string in the output is if it is our delimiter
    # For speed's sake, we disable this assertion
    # assert delimiter=='' or '' not in output
        
    # The resulting strings should be combinable back into the original string
    # For speed's sake, we disable this assertion
    # assert ''.join(output) == input

    return output

def split_letters_from_digits(s: str) -> list:
    """
    Splits letters from numbers into a list from a string.
    EXAMPLE: "ads325asd234" -> ['ads', '325', 'asd', '234']
    SOURCE: http://stackoverflow.com/questions/28290492/python-splitting-numbers-and-letters-into-sub-strings-with-regular-expression
    """
    import re
    return re.findall(r'[A-Za-z]+|\d+',s)

def split_camel_case(s: str) -> list:
    """ Split camel case names into lists. Example: camel_case_split("HelloWorld")==["Hello","World"] """
    from re import finditer
    matches=finditer('.+?(?:(?<=[a-z])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])|$)',s)
    return [m.group(0) for m in matches]

def split_python_tokens(code: str):
    """
    Should return a list of all the individual python tokens, INCLUDING whitespace and newlines etc
    When summed together, the token-strings returned by this function should equal the original inputted string
    """
    pip_import('pygments')

    from pygments.lexers import Python3Lexer
    from pygments.lexer import Lexer

    def get_all_pygments_tokens(string:str,pygments_lexer:Lexer=Python3Lexer()):
        return pygments_lexer.get_tokens_unprocessed(string)

    def get_all_token_strings(string:str):
        #Returns all the string-value of all tokens parsed from the string, including whitespace and comments
        token_string_generator = (token[2] for token in get_all_pygments_tokens(string))
        return token_string_generator

    return list(get_all_token_strings(code))

def clamp(x, min_value, max_value):
    """Clamps a value between min_value and max_value.
    
    Examples:
        >>> clamp(5.0, 0.0, 10.0)   # 5.0
        >>> clamp(-2.0, 0.0, 10.0)  # 0.0
        >>> clamp(15.0, 0.0, 10.0)  # 10.0
    """
    return min([max([min_value,x]),max_value])




#region stack traces
def get_current_exception():
    'Returns the current exception if there is one, else None'
    _, error, _ = sys.exc_info()
    return error

def pop_exception_traceback(exception,n=1):
    """
    Takes an exception, mutates it, then returns it
    Often when writing my repl, tracebacks will contain an annoying level of function calls (including the 'exec' that ran the code)
    This function pops 'n' levels off of the stack trace generated by exception
    For example, if print_stack_trace(exception) originally printed:
       Traceback (most recent call last):
       File "<string>", line 2, in <module>
       File "<string>", line 2, in f
       File "<string>", line 2, in g
       File "<string>", line 2, in h
       File "<string>", line 2, in j
       File "<string>", line 2, in k
    Then print_stack_trace(pop_exception_traceback(exception),3) would print:
       File "<string>", line 2, in <module>
       File "<string>", line 2, in j
       File "<string>", line 2, in k
    (It popped the first 3 levels, aka f g and h off the traceback)
    Edge case: If we start with let's say only 4 levels, but n=1000, only pop 4 levels (trying to more would result in an error)
    """
    for _ in range(n):
        if exception.__traceback__ is None:
            break
        exception.__traceback__=exception.__traceback__.tb_next
    return exception

def print_verbose_stack_trace(exception=None):
    if exception is None: exception=get_current_exception()
    stackprinter=pip_import('stackprinter')
    try:
        if _disable_fansi:
            stackprinter.show(exception,file=sys.stdout)
        else:
            stackprinter.show(exception,style='darkbg2',file=sys.stdout)
    except ValueError as e:#ERROR: ValueError: Can't format KeyboardInterrupt(). Expected an exception instance, sys.exc_info() tuple,a frame or a thread object.
        fansi_print("Stackprinter failed to print your verbose stack trace using rp.print_verbose_stack_trace():",'magenta','underlined')
        print_stack_trace(e)
        fansi_print("Here's your error's traceback:",'magenta','underlined')
        print_stack_trace(exception)#Fallback when this fails


def print_stack_trace(error:BaseException=None,full_traceback: bool = True,header='r.print_stack_trace: ERROR: ',print_it=True):
    from traceback import format_exception,format_exception_only
    if error is None: error=get_current_exception()
    #                                       ┌                                                                                                                                                                                                ┐
    #                                       │                                  ┌                                                                                                                                                            ┐│
    #                                       │                                  │       ┌                                                           ┐                               ┌                                            ┐           ││
    #      ┌                               ┐│     ┌                   ┐        │       │                ┌                                         ┐│                               │                     ┌                     ┐│┌   ┐      ││
    return (print if print_it else identity)(fansi(header,'red','bold') + fansi(''.join(format_exception(error.__class__,error,error.__traceback__)) if full_traceback else ''.join(format_exception_only(error.__class__,error))[:-1],'red'))
    #      └                               ┘│     └                   ┘        │       │                └                                         ┘│                               │                     └                     ┘│└   ┘      ││
    #                                       │                                  │       └                                                           ┘                               └                                            ┘           ││
    #                                       │                                  └                                                                                                                                                            ┘│
    #                                       └                                                                                                                                                                                                ┘

def print_highlighted_stack_trace(error:BaseException=None):
    """
    Uses pygments to print a stack trace with syntax highlighting
    """
    from traceback import format_exception
    from pygments import highlight
    from pygments.lexers import Python3TracebackLexer
    from pygments.formatters import TerminalTrueColorFormatter
    from pygments.formatters.terminal import TerminalFormatter
    if error is None: error=get_current_exception()
    error_string=''.join(format_exception(error.__class__,error,error.__traceback__))
    highlighted_error_string=highlight(error_string, Python3TracebackLexer(), TerminalFormatter())
    # highlighted_error_string=highlight(error_string, Python3TracebackLexer(), TerminalTrueColorFormatter())
    print(highlighted_error_string)    

def print_rich_stack_trace(error_or_frames_back=None, *, extra_lines=5, show_locals=False, width=None, print_output=True):
    """
    Use the 'rich' library to print or return a stack trace.
    
    This function can handle both exceptions and current execution frames.
    
    Args:
        error_or_frames_back: Either an exception to display or an integer representing 
                             how many frames to go back from current execution point.
                             If None, uses current exception or current frame.
        extra_lines (int): Number of extra lines of code to show around the trace point.
        show_locals (bool): Whether to display local variables.
        width (int, optional): Width of the traceback output. If None, uses terminal width.
        print_output (bool): Whether to print the traceback (True) or return it as a string (False).
    
    Returns:
        str or None: If print_output is False, returns the traceback as a string.
                    If print_output is True, prints to the console and returns None.
    """
    pip_import('rich')
    import inspect
    import io
    import types
    from rich.console import Console
    from rich.traceback import Traceback, LOCALS_MAX_LENGTH, LOCALS_MAX_STRING
    
    if width is None:
        width = get_terminal_width()
    
    # For backward compatibility: if error_or_frames_back is None, try to get current exception
    if error_or_frames_back is None:
        error = get_current_exception()
        # If no current exception, we'll use the current frame (frames_back=0)
        if error is not None:
            error_or_frames_back = error
        else:
            error_or_frames_back = 0
    
    # Determine if we're dealing with an exception or a frame depth
    if isinstance(error_or_frames_back, BaseException):
        # Handle exception traceback
        error = error_or_frames_back
        exc_type = type(error)
        traceback = error.__traceback__
        
        # Create the traceback object
        rich_tb = Traceback.from_exception(
            exc_type=exc_type,
            exc_value=error,
            traceback=traceback,
            width=width,
            extra_lines=extra_lines,
            theme=None,
            show_locals=show_locals,
            locals_max_length=LOCALS_MAX_LENGTH,
            locals_max_string=LOCALS_MAX_STRING,
        )
        
        if print_output:
            import rich
            rich.print(rich_tb)
            return None
        else:
            # Get string representation
            string_io = io.StringIO()
            console = Console(file=string_io, width=width, force_terminal=True, color_system="standard")
            console.print(rich_tb)
            return string_io.getvalue()
            
    else:
        # Handle current frame traceback (synthetic)
        frames_back = error_or_frames_back
        if not isinstance(frames_back, int):
            frames_back = 0
            
        # Get the caller's frame
        current_frame = inspect.currentframe()
        
        # Add 1 to frames_back to account for this function's frame
        total_frames_to_skip = frames_back + 1
        
        # Skip the desired number of frames
        for _ in range(total_frames_to_skip):
            if current_frame.f_back is not None:
                current_frame = current_frame.f_back
            else:
                # No more frames to skip, so we use the last available frame
                break
        
        # Now create a synthetic traceback that includes all frames from this point upward
        # We need to capture the frames in reverse order (innermost first)
        frames = []
        frame = current_frame
        while frame:
            frames.append(frame)
            frame = frame.f_back
        
        # Build the traceback chain from the bottom up (outermost to innermost)
        synthetic_tb = None
        for frame in reversed(frames):
            synthetic_tb = types.TracebackType(
                tb_next=synthetic_tb,
                tb_frame=frame,
                tb_lasti=frame.f_lasti,
                tb_lineno=frame.f_lineno
            )
        
        # Create a dummy exception with our synthetic traceback
        exc_value = Exception("Traceback capture point")
        
        # Create a Traceback object
        traceback = Traceback.from_exception(
            Exception, 
            exc_value, 
            synthetic_tb,
            width=width,
            extra_lines=extra_lines,
            show_locals=show_locals,
            locals_max_length=LOCALS_MAX_LENGTH,
            locals_max_string=LOCALS_MAX_STRING
        )
        
        # Get string representation
        string_io = io.StringIO()
        console = Console(file=string_io, width=width, force_terminal=True, color_system="standard")
        console.print(traceback)
        
        # Get the rendered string and filter out the exception message
        traceback_str = string_io.getvalue()
        traceback_lines = traceback_str.split('\n')
        filtered_lines = [line for line in traceback_lines if '─' in line or '│' in line]
        result = '\n'.join(filtered_lines)
        
        if print_output:
            print(result, end='')
            return None
        else:
            return result

# Helper function for those who prefer a more explicit API
def get_rich_traceback_string(frames_back=0, *, extra_lines=5, show_locals=True, width=None):
    """
    Get the current execution frame and format it as a pretty ANSI-colored traceback string.
    
    Args:
        frames_back (int): Number of frames to go back in the call stack. Defaults to 0.
        extra_lines (int): Number of extra lines of code to show around the trace point.
        show_locals (bool): Whether to display local variables.
        width (int, optional): Width of the traceback output. If None, uses terminal width.
    
    Returns:
        str: ANSI-formatted traceback string
    """
    return print_rich_stack_trace(frames_back+1, extra_lines=extra_lines, 
                                  show_locals=show_locals, width=width, 
                                  print_output=False)

#Private right now because it feels a bit redundant. maybe expose it in the future. used be web_evaluator
#https://chatgpt.com/share/ee550199-4242-41c6-88dd-f2a72c8d4c84
def _get_stack_trace_string(exc):
    import traceback
    # Get the traceback object from the exception
    tb = exc.__traceback__
    
    # Create a TracebackException object
    traceback_exception = traceback.TracebackException(type(exc), exc, tb)
    
    # Format the traceback as a string
    stack_trace_string = ''.join(traceback_exception.format())
    return stack_trace_string

#endregion

def audio_stretch(mono_audio, new_number_of_samples):# Does not take into account the last bit of looping audio
    """
    >>> audio_stretch([1,10],10)
    ans = [1,2,3,4,5,6,7,8,9,10]
    """
    return [ linterp(mono_audio,x) for x in np.linspace(0,len(mono_audio)-1,new_number_of_samples)]

def cartesian_to_polar(x, y, ϴ_unit=τ)->tuple:
    """Input conditions: x，y ∈ ℝ ⨁ x﹦［x₀，x₁，x₂……］⋀ y﹦［y₀，y₁，y₂……］
    returns: (r, ϴ) where r ≣ radius，ϴ ≣ angle and 0 ≤ ϴ < ϴ_unit. ϴ_unit﹦τ --> ϴ is in radians，ϴ_unit﹦360 --> ϴ is in degrees"""
    return np.hypot(x,y),np.arctan2(y,x)/τ%1*ϴ_unit  # Order of operations: % has same precedence as * and /
def complex_to_polar(complex,ϴ_unit=τ)->tuple:
    """returns: (r, ϴ) where r ≣ radius，ϴ ≣ angle and 0 ≤ ϴ < ϴ_unit. ϴ_unit﹦τ --> ϴ is in radians，ϴ_unit﹦360 --> ϴ is in degrees.
    Input conditions: c ≣ complex ⋀ c ∈ ℂ ⨁ c﹦［c₀，c₁，c₂……］
    Returns r and ϴ either as numbers OR as two lists: all the r's and then all the ϴ's"""
    return np.abs(complex),np.angle(complex)# np.abs is calculated per number, not vector etc
default_left_to_right_sum_ratio=0# By default, take a left hand sum
def riemann_sum(f,x0,x1,N,left_to_right_sum_ratio=None):# Verified ✔
    """
    Desmos: https://www.desmos.com/calculator/tgyr42ezjq
    left_to_right_sum_ratio﹦0  --> left hand sum
    left_to_right_sum_ratio﹦.5 --> midpoint hand sum
    left_to_right_sum_ratio﹦1  --> right hand sum
    The x1 bound MUST be exclusive as per definition of a left riemann sum
    """
    c=left_to_right_sum_ratio or default_left_to_right_sum_ratio
    w=(x1-x0)/N# Width of the bars
    return sum(f(x0+w*(i+c))*w for i in range(N))
def riemann_mean(f,x0,x1,N,left_to_right_sum_ratio=None):# To prevent redundancy of the N parameter
    return riemann_sum(f,x0,x1,N,left_to_right_sum_ratio) / (x1-x0)

def fourier(cyclic_function,freq,cyclic_period=τ,ↈ_riemann_terms=100):
    # Can enter a vector of frequencies to two vectors of outputs if you so desire
    # Returns polar coordinates representing amplitude,phase  (AKA r,ϴ)
    # With period=τ, sin(x) has a freq of 1.
    # With period=1, sin(x) has a freq of 1/τ.
    # ⁠⁠⁠⁠                     ⎧                                                                                                        ⎫
    # ⁠⁠⁠⁠                     ⎪            ⎧                                                                                          ⎫⎪
    # ⁠⁠⁠⁠                     ⎪            ⎪               ⎧                 ⎫                  ⎧               ⎫                     ⎪⎪
    return complex_to_polar(riemann_mean(lambda x:np.exp(freq * τ * x * 1j) * cyclic_function(x*cyclic_period),0,1,ↈ_riemann_terms))
    # ⁠⁠⁠                     ⎪            ⎪               ⎩                 ⎭                  ⎩               ⎭                     ⎪⎪
    # ⁠⁠⁠                     ⎪            ⎩                                                                                          ⎭⎪
    # ⁠⁠⁠                     ⎩                                                                                                        ⎭
def discrete_fourier(cyclic_vector,freq):# Assuming that cyclic_vector is a single wave-cycle, freq represents the number of its harmonic
    # Can enter a vector of frequencies to two vectors of outputs if you so desire
    # Returns polar coordinates representing amplitude,phase  (AKA r,ϴ)
    return fourier(cyclic_function=lambda x:linterp(x,cyclic_vector,cyclic=True),freq=freq,cyclic_period=len(cyclic_vector),ↈ_riemann_terms=len(cyclic_vector))
def matrix_to_tuples(m,filter=lambda r,c,val:True):# Filter can significantly speed it up
    # ⁠⁠⁠⁠             ⎧                                                                                        ⎫
    # ⁠⁠⁠⁠             ⎪⎧                                                                                      ⎫⎪
    # ⁠⁠⁠⁠             ⎪⎪⎧                                                             ⎫                       ⎪⎪
    # ⁠⁠⁠⁠             ⎪⎪⎪                            ⎧         ⎫                      ⎪                       ⎪⎪
    # ⁠⁠⁠⁠             ⎪⎪⎪⎧           ⎫               ⎪   ⎧    ⎫⎪          ⎧          ⎫⎪               ⎧      ⎫⎪⎪
    return list_flatten([[(r,c,m[r][c]) for c in range(len(m[r])) if filter(r,c,m[r,c])] for r in range(len(m))])# Creates list of coordinates, (x,y,value). WARNING: Can be very slow
    #              ⎪⎪⎪⎩           ⎭               ⎪   ⎩    ⎭⎪          ⎩          ⎭⎪               ⎩      ⎭⎪⎪
    #              ⎪⎪⎪                            ⎩         ⎭                      ⎪                       ⎪⎪
    #              ⎪⎪⎩                                                             ⎭                       ⎪⎪
    #              ⎪⎩                                                                                      ⎭⎪
    #              ⎩                                                                                        ⎭
def perpendicular_bisector_function(x0,y0,x1,y1):
    A,B=x0,y0
    Y,X=x1,y1
    def linear_function(x):
        return ((B+Y)/2)-(X-A)/(Y-B)*(x-(A+X)/2)  # https://www.desmos.com/calculator/1ykebsqtoa
    return linear_function

def harmonic_analysis_via_least_squares(wave,harmonics:int):
    """
    My attempt to analyze frequencies by taking the least-squares fit of a bunch of sinusoids to a signal instead of using the fourier transform. It had interesting results, but it's not nearly as fast as a FFT.
    """
    prod=np.matmul
    inv=np.linalg.inv
    b=wave  # In terms of linear algebra in Ax~=b
    samples=len(b)
    m=np.asmatrix(np.linspace(1,harmonics,harmonics)).T*np.matrix(np.linspace(0,tau,samples,endpoint=False))
    A=np.asmatrix(np.concatenate([np.sin(m),np.cos(m)])).T
    Api=prod(inv(prod(A.T,A)),A.T)  # Api====A pseudo inverse
    out=np.asarray(prod(Api,b))[0]
    out=np.reshape(out,[2,len(out)//2])  # First vector is the sin array second is the cos array
    amplitudes=sum(out**2)**.5
    phases=np.arctan2(*out)
    return np.asarray([amplitudes,phases])  # https://www.desmos.com/calculator/fnlwi71n9x

def cluster_by_key(iterable,key,*,as_dict=False)->list:
    """
    Iterable is a list of values
    Key is a function that takes a value from iterable and returns a hashable
    """
    assert callable(key)
    assert is_iterable(iterable)
    from collections import OrderedDict
    outputs=OrderedDict()
    for value in iterable:
        k=key(value)
        if k not in outputs:
            outputs[k]=[]
        outputs[k].append(value)
    if as_dict:
        return outputs
    return list(outputs.values())

def cluster_by_attr(iterable,attr,*,as_dict=False)->list:
    return cluster_by_key(
        iterable,
        lambda x: get_nested_attr(x, attr),
        as_dict=as_dict,
    )

def chunk_by_attr(iterable,attr,*,as_dict=False)->list:
    return chunk_by_key(
        iterable,
        lambda x: get_nested_attr(x, attr),
        compare=lambda x, y: x == y,
    )
    
def chunk_by_key(iterable, key=lambda x: x, compare=lambda x, y: x == y):
    """
    Divides an iterable into chunks based on the equality of elements, as defined by the compare function.
    The key function is applied to each element to determine what should be compared.

    Args:
    - iterable (iterable): The input iterable to divide into chunks
    - key (function): A function to extract comparison key from each element in the iterable
    - compare (function): A function to compare equality of two consecutive keys, should return bool

    Returns:
    - A list of lists where each sublist is a chunk of equal elements from the iterable.

    Example:
    ```
    >>> list(chunk_by_key('aAAbccdEEefeee'))
    ['a', 'AA', 'b', 'cc', 'd', 'EE', 'e', 'f', 'eee']    

    >>> list(chunk_by_key(iter('aAAbccdEEefeee')))
    [['a'] , ['A','A'] , ['b'] , ['c','c'] , ['d'] , ['E','E'] , ['e'] , ['f'] , ['e','e','e']]

    >>> list(chunk_by_key(iter('aAAbccdEEefeee'),key=str.lower))
    [['a','A','A'] , ['b'] , ['c','c'] , ['d'] , ['E','E','e'] , ['f'] , ['e','e','e']]

    >>> list(chunk_by_key([0,5,2,3,7,5,3,4,7,4,2,3,2,1,2,2,5,6],key=lambda x:x%2))
    [[0] , [5] , [2] , [3,7,5,3] , [4] , [7] , [4,2] , [3] , [2] , [1] , [2,2] , [5] , [6]]

    >>> list(chunk_by_key([4,5,6,7,7,8,9,0,3,4,5,6,5,6,7],compare=lambda x,y: x<y))
    [[4,5,6,7] , [7] , [7,8,9] , [0,3,4,5,6] , [5,6,7]]
    ```
    """
    
    #Input Assertions
    assert is_iterable(iterable)
    assert callable(key)
    assert callable(compare)

    if hasattr(iterable,'__getitem__'):
        #Attempt to chunk via slicing
        #Chunking this way is preferable if possible
        #It yields slices of the original input, instead of a new list
        #That way it's both faster and returns the same type
        #For example, if iterable is a str and we chunk via slices,
        #it will yield substrings instead of lists of chars
        #This method is slightly more complex than the fallback
        #If you don't want to use slicing with your input, 
        #wrap it in iter() as seen in the examples

        try:
            chunk_start = 0
            chunk_end = 0
            for element in iterable:
                if chunk_start < chunk_end and not compare(
                    key(iterable[chunk_start]), key(element)
                ):
                    yield iterable[chunk_start:chunk_end]
                    chunk_start = chunk_end
                chunk_end += 1
        
            if chunk_start < chunk_end:
                yield iterable[chunk_start:chunk_end]

        except Exception:
            return chunk_by_key(iter(iterable), key=key, compare=compare)

    else:
        #Basic chunking - works with any iterable
        #Will yield lists

        chunk = []
        for element in iterable:
            if not chunk or compare(key(chunk[-1]), key(element)):
                chunk.append(element)
            else:
                yield chunk
                chunk = [element]
        if chunk:
            yield chunk

def cluster_filter(vec,filter=identity):  # This has a terrible name...I'm not sure what to rename it so if you think of something, go for it!
    """
    EXAMPLE: cluster_filter([2,3,5,9,4,6,1,2,3,4],lambda x:x%2==1) --> [[3, 5, 9], [1], [3]]  <---- It separated all chunks of odd numbers
    region Unoptimized, much slower version (that I kept because it might help explain what this function does):
    def mask_clusters(vec,filter=identity):
     out=[]
     temp=[]
     for val in vec:
       if filter(val):
         temp.append(val)
       elif temp:
         out.append(temp)
         temp=[]
     return out
    endregion
    """
    out=[]
    s=None  # start
    for i,val in enumerate(vec):
        if filter(val):
            if s is None:
                s=i
        elif s is not None:
            out.append(vec[s:i])
            s=None
    if s is not None:
        out.append(vec[s:])
    return out

# region Originally created for the purpose of encoding 3 bytes of precision into a single image via r,g,b being three digits
def proportion_to_digits(value,base=256,number_of_digits=3):  # Intended for values between 0 and 1
    digits=[]
    x=value
    while len(digits)<number_of_digits:
        x*=base
        temp=np.floor(x)
        digits.append(temp)
        x-=np.floor(x)
    return digits

def digits_to_proportion(digits,base=256):  # Intended for values between 0 and 1
    return np.sum(np.asarray(digits)/base**np.linspace(1,len(digits),len(digits)),0)

#def encode_float_matrix_to_rgb_image(m):
#    # Encoded precision of values between 0 and 1 as r,g,b (in 8-bit color) values where r g and b are each digits, with b being the most precise and r being the least precise
#    #Formerly called 'rgb_encoded_matrix'
#    m=np.matrix(m)
#    assert len(m.shape)==2,"r.encode_float_matrix_to_rgb: Input should be a matrix of values between 0 and 1, which is not what you gave it! \n m.shape = \n"+str(m.shape)
#    r,g,b=proportion_to_digits(m,base=256,number_of_digits=3)
#    out=np.asarray([r,g,b])
#    out=np.transpose(out,[1,2,0])
#    out=out.astype(np.uint8)
#    return out

def encode_float_matrix_to_rgba_byte_image(float_matrix):
    """
    Can encode a 32-bit float into the 4 channels of an RGBA image
    The values should be between 0 and 1
    This output can be saved as a .png file
    Formerly called 'rgb_encoded_matrix'
    It's useful for reading and storing floating-point matrices in .png files
    """
    m=float_matrix
    m=np.matrix(m)
    assert is_grayscale_image(m)
    assert is_a_matrix(m),'Please input a two-dimensional floating point matrix with values between 0 and 1. The input you gave is not a matrix.'
    assert len(m.shape)==2,"r.encode_float_matrix_to_rgb: Input should be a matrix of values between 0 and 1, which is not what you gave it! \n m.shape = \n"+str(m.shape)

    r,g,b,a=proportion_to_digits(m,base=256,number_of_digits=4)
    out=np.asarray([r,g,b,a])
    out=np.transpose(out,[1,2,0])
    out=out.astype(np.uint8)
    return out

def decode_float_matrix_from_rgba_byte_image(image):
    """
    This function is the inverse of encode_float_matrix_to_rgba_image
    Takes an rgba byte-image (that was created with encode_float_matrix_to_rgba_image) and turns it back into a float image
    It's useful for reading and storing floating-point matrices in .png files
    Formerly called 'matrix_decoded_rgb'
    """

    assert is_rgba_image(image)
    assert is_byte_image(image)
    # assert len(image.shape)==3 and image.shape[-1]==3,"r.encode_float_matrix_to_rgba_image: Input should be an rgb image (with 3 color channels), which is not what you gave it! \n m.shape = \n"+str(image.shape)
    r,g,b,a=image.transpose([2,0,1])
    return r/256**1 + g/256**2 + b/256**3 + a/256**4


def print_all_git_paths():
    fansi_print("Searching for all git repositories on your computer...",'green','underlined')
    tmp = shell_command("find ~ -name .git")# Find all git repositories on computer
    dirpaths=[x[:-4]for x in tmp.split('\n')]
    aliasnames=[(lambda s:(s[:s.find("/")])[::-1])((x[::-1])[1:])for x in dirpaths]
    dirpaths,aliasnames=sync_sort(dirpaths,aliasnames)
    for x in sorted(zip(aliasnames,dirpaths)):
        print(fansi(x[0],'cyan')+" "*(max(map(len,aliasnames))-len(x[0])+3)+fansi(x[1],None))
    return dirpaths,aliasnames

def is_int_literal(s:str):
    if s[0] in ('-', '+'):
        return s[1:].isdigit()
    return s.isdigit()

def is_string_literal(s:str):
    try:
        s=eval(s)
        assert isinstance(s,str)
        return True
    except Exception:
        return False

random_unicode_hash=lambda l:int_list_to_string([randint(0x110000-1)for x in range(l)])

def search_replace_simul(s:str,replacements:dict):
    """
    Attempts to make multiple simultaneous string .replace() at the same time
    WARNING: This method is NOT perfect, and sometimes makes errors. TODO: Fix it for all input cases
    """

    if not replacements:
        return s
    # search_replace_simul("Hello world",{"Hello":"world","world":"Hello"})
    l1 = replacements.keys()
    l2 = replacements.values()
    l3 = [random_unicode_hash(10) for x in replacements]
    ⵁ,l1,l2,l3=sync_sort([-len(x)for x in l1],l1,l2,l3)# Sort the keys in descending number of characters     # Safe replacements: f and fun as keys: f won't be seen as in 'fun'
    for a,b in zip(l1,l3):
        s=s.replace(a,b)
    for a,b in zip(l3,l2):
        s=s.replace(a,b)
    return s

def shorten_url(url:str)->str:
    import contextlib
    try:
        from urllib.parse import urlencode
    except ImportError:
        from urllib import urlencode
    try:
        from urllib.request import urlopen
    except ImportError:
        from urllib2 import urlopen
    import sys
    request_url=('http://tinyurl.com/api-create.php?' + urlencode({'url':url}))
    with contextlib.closing(urlopen(request_url)) as response:
        return response.read().decode('utf-8')
    # Update: The following commented code is deprecated, since Google discontinued the ability to create new goo.gl URL's
    #   # goo.gl links are supposed to last forever, according to https://groups.google.com/forum/#!topic/google-url-shortener/Kt0bc5hx9HE
    #   # SOURCE: https://stackoverflow.com/questions/17357351/how-to-use-google-shortener-api-with-python
    #   # API Key source: https://console.developers.google.com/apis/credentials?project=dark-throne-182400
    #   #  >>> goo_shorten_url('ryan-central.org')
    #   # ans = https://goo.gl/Gkgp86
    #   import requests
    #   import json
    #   post_url = 'https://www.googleapis.com/urlshortener/v1/url?key=AIzaSyBbNJ4ZPCAeDBGAVQKDikwruo3dD4NcsU4'# AIzaSyBbNJ4ZPCAeDBGAVQKDikwruo3dD4NcsU4 is my account's API key.
    #   payload = {'longUrl': url}
    #   headers = {'content-type': 'application/json'}
    #   r = requests.post(post_url, data=json.dumps(payload), headers=headers)
    #   # RIGHT NOW: r.text==
    #   # '''{
    #   #     "kind":"urlshortener#url",
    #   #     "id":"https://goo.gl/ZNp1VZ",
    #   #     "longUrl":"https://console.developers.google.com/apis/credentials?project=dark-throne-182400"
    #   # }'''
    #   out=eval(r.text)
    #   assert isinstance(out,dict)
    #   return out['id']

# def gist(gist_body="Body",gist_filename="File.file",gist_description="Description"):
#     # Older version:
#     # def gist(code:str,file_name:str='CodeGist.code',username='sqrtryan@gmail.com',password='d0gememesl0l'):
#     #     # Posts a gist with the given code and filename.
#     #     #  >>> gist("Hello, World!")
#     #     # ans = https://gist.github.com/b5b3e404c414f7974c4ccb12106c4fe7
#     #     import requests,json
#     #     r = requests.post('https://api.github.com/gists',json.dumps({'files':{file_name:{"content":code}}}),auth=requests.auth.HTTPBasicAuth(username, password))
#     #     try:
#     #         return r.json()['html_url']# Returns the URL
#     #     except KeyError as e:
#     #         fansi_print("r.gist ERROR:",'red','bold',new_line=False)
#     #         fansi_print(" "+str(e)+" AND r.json() = "+str(r.json()),'red')

#     from urllib.request import urlopen
#     import json
#     gist_post_data={'description':gist_description,
#                     'public':True,
#                     'files':{gist_filename:{'content':gist_body}}}

#     json_post_data=json.dumps(gist_post_data).encode('utf-8')

#     def upload_gist():
#         # print('sending')
#         url='https://api.github.com/gists'
#         json_to_parse=urlopen(url,data=json_post_data)

#         # print('received response from server')
#         found_json=(b'\n'.join(json_to_parse.readlines()))
#         return json.loads(found_json.decode())['html_url']
#     return upload_gist()

# sgist=lambda *x:seq([gist,printed,open_url,shorten_url],*x)# Open the url of a gist and print it

def unshorten_url(shortened_url):
    """
    Takes a shortened URL and returns the long one
    EXAMPLE: unshorten_url('bit.ly/labinacube')  -->  'https://oneoverzero.pythonanywhere.com/'
    https://stackoverflow.com/questions/3556266/how-can-i-get-the-final-redirect-url-when-using-urllib2-urlopen/3556287
    """

    if not is_valid_url(shortened_url):
        shortened_url='https://'+shortened_url
    assert is_valid_url(shortened_url),'Please input a valid URL!'
    
    from urllib.request import urlopen
    return urlopen(shortened_url).url

def load_gist(gist_url:str):
    """
    Takes the URL of a gist, or the shortened url of a gist (by something like bit.ly), and returns the content inside that gist as a string
    EXAMPLE:
         >>> save_gist('AOISJDIO')
        ans = https://git.io/JI2Ez
         >>> load_gist(ans)
         ans = AOISJDIO
    """

    gist_url=unshorten_url(gist_url) #If we shortened the url, unshorten it first. Otherwise, this function will leave it alone.

    gist_id=[x for x in gist_url.split('/') if len(x)==32 and set(x)<=set('0123456789abcdef')] # A gist_id is like 162d6a7e7f0386208d323d35dd86a669 -- it has 40 characters
    assert len(gist_id)>0,'This is not a valid github GIST url'
    gist_id=gist_id[0] #Assume there's only one key in the url...
    gist_url='https://gist.githubusercontent.com/raw/'+gist_id

    gist_url+='/raw'

    pip_import('requests')
    import requests,json
    response=requests.get(gist_url)
    return response.content.decode()
    

def save_gist(content:str,*,
              shorten_url=False,
              description:str='',
              filename:str='',
              token:str=None):
    """
    This function takes an input string, posts it as a gist on Github, then returns the URL of the new gist
    I've included a token that anybody using this library is allowed to use. Have fun, but please don't abuse it!
    
    EXAMPLE:
         >>> save_gist('AOISJDIO')
        ans = https://git.io/JI2Ez
         >>> load_gist(ans)
         ans = AOISJDIO
    
    You can't post the api_token in a gist on github. If you do, github will disable that api_token.
    To make sure that github doesn't revoke the api_token, we have to make sure it's not in the content string.
    NOTE: if you get a SSL Error that looks like
           URLError: <urlopen error [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: unable to get local issuer certificate (_ssl.c:1123)>
       Then try running rp.r._fix_CERTIFICATE_VERIFY_FAILED_errors()
    """

    if token is None:
        #Token can't be in this code or github will revoke it
        token = 'g h p _ w d e m 3 K P j U G z N V h 7 G c c M J Y b J b s 6 z U 6 i 0 Y z X s o'.replace(' ','')

    import urllib.request, urllib.error, urllib.parse
    import json
    import datetime
    import time
    
    access_url = "https://api.github.com/gists"
    
    data = {
      "description": description,
      "public": True,
      "files": {
        filename: {
          "content": content
        }
      }
    }
    
    json_data=json.dumps(data)
    assert token not in json_data,'You cannot put the github API token anywhere in your gist, or else the API token will be revoked!'
    
    req = urllib.request.Request(access_url)
    req.add_header("Authorization", "token {}".format(token))
    req.add_header("Content-Type", "application/json")
    response=urllib.request.urlopen(req, data=json_data.encode())
    response=json.loads(response.read())
    gist_url=response['html_url']

    if gist_url is None:
        print("Save Gist: Failed! Returning response...")
        return response

    if shorten_url:
        gist_url=rp.shorten_url(gist_url)

    try:
        #Try to keep track of all the gists we've created, in case we ever want to go back for some reason
        try:
            old_gists=open(_old_gists_path,'a+')
            old_gists.write(gist_url+'\n')
        finally:
            old_gists.close()
    except Exception as e:
        print(e)
        #It's no big deal if we can't though
        raise
        pass

    _get_all_github_gists_info.cache.expire()

    return gist_url

def _fix_CERTIFICATE_VERIFY_FAILED_errors():
    #https://stackoverflow.com/questions/50236117/scraping-ssl-certificate-verify-failed-error-for-http-en-wikipedia-org
    import os
    import os.path
    import ssl
    import stat
    import subprocess
    import sys

    STAT_0o775 = ( stat.S_IRUSR | stat.S_IWUSR | stat.S_IXUSR
                 | stat.S_IRGRP | stat.S_IWGRP | stat.S_IXGRP
                 | stat.S_IROTH |                stat.S_IXOTH )


    def main():
        openssl_dir, openssl_cafile = os.path.split(
            ssl.get_default_verify_paths().openssl_cafile)

        print(" -- pip install --upgrade certifi")
        subprocess.check_call([sys.executable,
            "-E", "-s", "-m", "pip", "install", "--upgrade", "certifi"])

        import certifi

        # change working directory to the default SSL directory
        os.chdir(openssl_dir)
        relpath_to_certifi_cafile = os.path.relpath(certifi.where())
        print(" -- removing any existing file or link")
        try:
            os.remove(openssl_cafile)
        except FileNotFoundError:
            pass
        print(" -- creating symlink to certifi certificate bundle")
        os.symlink(relpath_to_certifi_cafile, openssl_cafile)
        print(" -- setting permissions")
        os.chmod(openssl_cafile, STAT_0o775)
        print(" -- update complete")

    if __name__ == '__main__':
        main()

# 2025-07-24 21:30:57.084169
def get_arxiv_bibtex(paper: str) -> str:
    """
    Gets the bibtex citation for a given arxiv paper.

    OLD VERSION: https://gist.github.com/SqrtRyan/4c3f1ba7a31ac4a36228b6ce2cd45edf
        (Adapted from http://www.thamnos.de/misc/look-up-bibliographical-information-from-an-arxiv-id/)

    EXAMPLES:
        >>> print(get_arxiv_bibtex('2312.03817'))
        @misc{burgert2023diffusionillusionshidingimages,
              title={Diffusion Illusions: Hiding Images in Plain Sight},
              author={Ryan Burgert and Xiang Li and Abe Leite and Kanchana Ranasinghe and Michael S. Ryoo},
              year={2023},
              eprint={2312.03817},
              archivePrefix={arXiv},
              primaryClass={cs.CV},
              url={https://arxiv.org/abs/2312.03817},
        }
        >>> print(get_arxiv_bibtex('https://arxiv.org/abs/2309.15091'))
        @misc{lin2024videodirectorgptconsistentmultiscenevideo,
              title={VideoDirectorGPT: Consistent Multi-scene Video Generation via LLM-Guided Planning},
              author={Han Lin and Abhay Zala and Jaemin Cho and Mohit Bansal},
              year={2024},
              eprint={2309.15091},
              archivePrefix={arXiv},
              primaryClass={cs.CV},
              url={https://arxiv.org/abs/2309.15091},
        }
    """

    # Get the Arxiv ID
    paper = paper.strip()
    if "arxiv.org/abs/" in paper:
        xid = paper.split("arxiv.org/abs/")[-1]
    elif paper.startswith("arxiv:"):
        xid = paper[6:]  # Remove "arxiv:" prefix
    else:
        xid = paper

    # Remove version number if present
    xid = xid.split("v")[0].strip()

    bibtex_file = download_to_cache("https://arxiv.org/bibtex/" + xid)
    return load_text_file(bibtex_file, use_cache=True)

def latex_get_used_images(main_tex_file: str = 'main.tex') -> list:
    """
    Return list of image assets used when compiling a LaTeX file.

    Used to clean up latex projects for Arxiv

    Compiles the LaTeX file with -recorder flag to track file usage,
    then extracts image files from the generated .fls file.

    Parameters:
        main_tex_file (str):Path to main .tex file (e.g., 'main.tex')

    Returns:
        list[str]
            Sorted list of image file paths used in compilation.
            Paths are normalized (no './' prefix).

    EXAMPLE USAGE:
        >>> # Cleaning up before an arxiv release...
        >>> unused_images = set(rp_glob(path_join('*/','*.jpg *.png *.pdf'.split())))-set(latex_get_used_images())
        >>> delete_all_files(unused_images)

    EXAMPLE:
        >>> assets = get_used_assets('main.tex')
        >>> 'fig/teaserv2.png' in assets
        True
        >>> len(assets) > 0
        True

    Notes
        - Runs pdflatex with -recorder and -interaction=nonstopmode
        - Looks for common image extensions: .pdf, .png, .jpg, .jpeg, .eps, .svg
        - .fls file is created alongside the .tex file

    """
    assert file_exists(main_tex_file), get_absolute_path(main_tex_file)

    import subprocess
    from pathlib import Path

    tex_path = Path(main_tex_file)
    fls_file = tex_path.with_suffix('.fls')

    # Compile with recorder to generate .fls file
    subprocess.run(
        ['pdflatex', '-recorder', '-interaction=nonstopmode', str(tex_path)],
        capture_output=True,
        check=False
    )

    # Extract image files from .fls
    result = subprocess.run(
        ['grep', '-E', r'INPUT.*(\.pdf|\.png|\.jpg|\.jpeg|\.eps|\.svg)$', str(fls_file)],
        capture_output=True,
        text=True,
        check=True
    )

    # Parse and normalize paths
    assets = []
    for line in result.stdout.strip().split('\n'):
        if line.startswith('INPUT '):
            path = line[6:].strip().lstrip('./')
            # Only keep local files (not system TeX files)
            if not path.startswith('/'):
                assets.append(path)

    return sorted(set(assets))


def random_namespace_hash(n:int=10,chars_to_choose_from:str="abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890"):
    """
    EXAMPLE:
        >>> random_namespace_hash(10)
        ans=DZC7B8GV74
    """
    out=''
    for n in [None]*n:
        out+=random_element(chars_to_choose_from)
    return out

def random_passphrase():
    """
    Generates an easy-to-spell easy-to-remember passphrase

    EXAMPLE:
        >>> for _ in range(10): print(random_passphrase())
        happy_hug
        help_dart
        crave_crib
        cozy_drone
        shush_chump
        enter_ivory
        equal_essay
        react_morse
        curvy_koala
        blog_lid
    """
    #TODO: Upgrade this to more words
    
    prefixes="""agile ahead ajar alien alive alone amend ample amuse argue arise ashen avert avoid baggy baked balmy bask bleak bless blog blunt boned bony botch both bring brisk broke busy calm carve chief chomp
        civil clad clap clean clink clump come cozy crave crisp cure curvy cushy darn debug dense dice dizzy drab dried droop drown dusk dwell early eject elope elude emit enter equal erase erupt etch evade
        even evict false fax fend flaky flame fray fresh fried froth glad gooey grasp greet halt happy harm hasty heap hefty help humid hurt icy juicy lance late lazy legal lend lurk marry mousy musky nag
        near neat nutty outer petty plead plus poach polar prank pry quiet quote rant react relax repel rich rigid ripen ritzy romp same savor scoff scowl send shady shaky shine shout showy shun shush sift
        skew sleek slurp snort speak spend spew spoof spool stark stuck swear swim tacky task thank thud tint try tutor tweak twine twirl uncut undo unify untie utter vocal wavy widen wipe wired wiry yelp zoom
        """.split()

    nouns="""acid acorn acre affix aged agent aging agony aide aids aim alarm alias alibi aloe aloha amino angel anger angle ankle apple april apron area arena armor army aroma array arson art atlas atom attic
        audio award axis bacon badge bagel baker banjo barge barn bash basil batch bath baton blade blank blast blaze blend blimp blink bloat blob blot blush boast boat body boil bolt bonus book booth boss
        boxer breed bribe brick bride brim brink broad broil brook broom brush buck bud buggy bulge bulk bully bunch bunny bunt bush bust buzz cable cache cadet cage cake cameo canal candy cane canon cape
        card cargo carol carry case cash cause cedar chain chair chant chaos charm chase cheek cheer chef chess chest chew chili chill chip chop chow chuck chump chunk churn chute cider cinch city claim clamp
        clash clasp class claw clay clear cleat cleft clerk click cling clip cloak clock clone cloud coach coast coat cod coil coke cola cold colt coma comma cone cope copy coral cork cost cot couch cough
        cover craft cramp crane crank crate crawl crazy crepe crib crook crop cross crowd crown crumb crush crust cub cult cupid curl curry curse curve cut cycle dab dad daily dairy daisy dance dandy dart
        dash data date dawn deaf deal dean debit debt decal decay deck decoy deed delay denim dent depth desk dial diary dig dill dime diner disco dish disk ditch dock dodge doll dome donor dose dot dove down
        dowry doze drama draw dress drift drill drive drone drove drum dry duck duct dug duke dust duty dwarf eagle earth easel east ebony echo edge eel elbow elder elf elk elm elves empty emu entry envoy
        error essay evil exit fable fact fade fall fancy fang feast feed femur fence ferry fetch fever fiber fifth fifty film filth final finch fit five flag flap flask flick fling flint flip flirt float
        flock flop floss foam foe fog foil folk food fool found fox frail frame frill frisk front frost frown fruit gag gala game gap gas gear gecko geek gem genre gift gig given giver glass glide gloss glove
        glow glue goal going golf gong good goofy gore gown grab grain grant grape graph grass grave gravy gray green grid grief grill grip grit groom grope growl grub grunt guide gulf gulp guru gut guy habit
        half halo hash hatch hate haven hazel heat heave hedge hub hug hula hull hunk hunt hurry hush hut ice icing icon igloo image ion iron islam issue item ivory ivy jab jam jazz jeep jelly jet job jog
        jolly jolt joy judge juice july jump juror jury keep keg kick kilt king kite kitty kiwi knee koala ladle lady lair lake land lapel large lash lasso last latch left lemon lens lent level lever lid life
        lift lilac lily limb line lint lion lip list liver lunch lung lurch lure lying lyric mace maker malt mama mango manor map march mash match mate mocha mold moody morse motor motto mount mouse mouth
        move movie mud mug mulch mule mull mummy mural muse music mute nacho nail name nanny nap navy neon nerd nest net niece ninth oak oasis oat ocean oil old olive omen onion opal open opera otter ounce
        oven owl ozone pace pagan palm panic paper park party pasta patch path patio payer pecan penny pep perch perm pest petal plank plant plaza plot plow pluck plug pod poem poet point poise poker polka
        polo pond pony poppy pork poser pouch pound pout power press print prior prism prize probe prong proof props prude prune pug pull pulp pulse punch punk pupil puppy purr purse push putt quack quill
        quilt quota race rack radar radio raft rage raid rail rake rally ramp ranch range rank rash raven reach ream rebel relay relic reply rerun reset rhyme rice ride rinse riot rise risk rival river roast
        robe robin rock rogue roman rope rover royal ruby rug ruin rule rush rust rut sage saint salad salon salsa salt satin sauna sax say scale scam scan scare scarf scold scoop scope score scout scrap
        scrub scuff sect sedan self sepia serve set seven shade shaft shape share sharp shed sheep sheet shelf shell ship shirt shock shop shore shove shred shrug shy silk silly silo sip siren sixth size
        skate skid skier skip skirt skit sky slab slack slain slam slang slash slate sled sleep sleet slice slick sling slip slit slob slot slug slum slush small smash smell smile smirk smog snap snare snarl
        sneak sneer sniff snore snout snub snuff speed spill spoil spoke spoon sport spot spout spray spree spur squad squat squid stack staff stage stain stall stamp stand start state steam steep stem step
        stew stick sting stir stock stole stomp stool stoop stop storm stout stove straw stray strut stud stuff stump stunt suds sugar sulk surf sushi swab swan swarm sway sweat sweep swell swing swipe swoop
        syrup taco tag take tall talon tamer tank taper taps tart taste thaw theme thigh thing think thong thorn throb thumb thump tiara tidy tiger tile tilt trace track trade train trait trap trash tray
        treat tree trek trial tribe trick trio trout truck trump trunk tug tulip turf tusk tutu tweet twins twist uncle union unit upper user usher value vapor vegan venue verse vest veto vice video view
        virus visa visor vixen voice void volt voter vowel wad wafer wages wagon wake walk wand wasp watch water wheat whiff whole whoop wick widow width wife wilt wimp wind wing wink wise wish wok wolf wool
        word work worry wound wrath wreck wrist xerox yahoo yam yard year yeast yield yo-yo yodel yoga zebra zero zone
        """.split()
    
    noun = random_element(nouns)
    prefix = random_element(prefixes)
        
    return prefix+'_'+noun


def latex_image(equation: str):
    """
    Returns an rgba image with the rendered latex string on it in numpy form
    """
    import os,requests
    def formula_as_file(formula,file,negate=False):  # Got this code off the web somewhere but i dont remember where now
        tfile=file
        if negate:
            tfile='tmp.png'
        r=requests.get(r'http://latex.codecogs.com/png.latex?\dpi{300} \huge %s' % formula)
        f=open(tfile,'wb')
        f.write(r.content)
        f.close()
        if negate:
            os.system('convert tmp.png -channel RGB -negate -colorspace rgb %s' % file)
    formula_as_file(equation,'temp.png')
    return load_image('temp.png')

def display_image_in_terminal(image, dither=True, auto_resize=True, bordered=False):
    """
    Display image in terminal using Unicode characters (black-and-white).

    EXAMPLE:
        >>> display_image_in_terminal('https://www.giantbomb.com/a/uploads/scale_medium/0/5497/300679-myst_linking_book.jpg')
        ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣀⣄⡀
        ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣀⡠⡤⡤⡦⡶⡯⣿⢽⡾⣞⡷⣄⡀
        ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⢀⣄⢤⢤⡲⡮⡯⣻⢮⢯⢯⣟⡽⡯⡿⡽⡯⡿⣽⣽⣳⣳⣄
        ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⠀⡀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⢀⡀⡀⡀⡀⡀⡀⢀⠀⠀⠀⠀⡀⡠⢠⢰⢰⢕⢝⢮⢳⢕⡯⣏⢯⣫⢯⡳⡯⣯⡻⡮⣯⢿⣝⣯⣟⣟⠷⠻⢺⣳⢽⢵⣄
        ⠀⠀⠀⠀⠀⡴⣾⢽⡽⣯⢯⣯⢯⣗⣗⣗⣗⣗⣗⡷⣳⣳⣳⡳⡵⣳⢝⡞⡮⡺⡜⣜⢜⢜⢌⠊⠄⠂⢌⠢⡃⡇⡗⡝⣎⢏⣗⢽⣪⢟⡮⣯⣫⢯⣞⢽⢝⡚⡝⡪⠨⠠⠠⡀⡀⠀⠘⢫⣗⣗⣗⡄
        ⠀⢰⣞⣟⣯⢯⢿⣽⢿⣽⢿⣾⣻⣗⣿⣺⣳⣻⣺⣽⣳⢯⣞⡽⣽⡺⣵⣫⢞⡽⣺⢜⡎⡖⡅⡇⢅⢁⠂⢌⠢⢣⢓⡝⡼⡕⣗⠽⠮⠛⠚⡑⡡⡑⠔⢕⠢⡪⣢⡪⡪⡪⣂⠢⠠⠁⠀⠀⠑⢗⣗⡽⣕⡄
        ⠠⣞⣗⣿⣽⣺⢯⣿⣻⣽⣟⣷⢿⣞⣷⢯⡿⣽⣳⢷⡯⣟⡾⣝⣗⡯⡷⡽⡽⣺⡳⣽⡪⣏⢮⢪⠢⡂⠨⢀⢊⠢⢣⢳⢹⢜⡀⢀⠂⠀⢂⢂⠂⢌⠪⣨⢊⡜⡔⡆⡣⡣⡃⡫⡘⡌⠄⠂⠀⠀⠱⣻⡺⣪⢧⡀
        ⠀⡷⡽⣾⣻⢼⣻⣽⣯⣷⢿⣯⣿⣻⡾⣟⣿⢯⣯⣯⡯⣷⣻⣳⣗⡯⣯⢯⢯⣗⣟⢮⢯⢮⡳⡕⡇⡎⡂⡂⢐⠨⡂⢇⢳⢕⢧⠀⡐⠈⠀⠀⡀⢣⠨⡊⢋⢮⢕⡔⢅⢎⢎⠆⢇⢳⢨⠠⢁⠂⢄⢐⠹⣳⢽⡺⣢⡀
        ⢀⢯⣻⣽⣻⢽⣽⢾⡷⣿⣻⣷⣻⡷⣿⣻⣽⣯⣷⢷⡿⣽⣞⣷⣳⢯⡯⣯⣗⡷⡽⡽⣵⡳⣝⢮⡪⡪⡢⢊⢀⠂⢌⠪⡸⡘⣎⢗⠄⠀⠄⢀⠀⢂⠱⢑⠥⡪⡨⢪⢸⢱⠱⡱⡑⢅⠣⣑⣐⣬⢴⢴⢾⢵⣻⣪⢗⡽⣢⡀
        ⠐⢽⣺⣳⣿⢽⣞⣿⣻⣯⣿⢾⣯⡿⣯⣿⣳⡿⣾⣟⣿⣽⣾⣳⡯⡿⣝⣗⣗⡯⡯⣟⣮⣻⣪⢗⣕⢇⢧⠱⡐⡈⠄⢊⠔⡱⡱⡍⣗⢄⠀⠀⠐⠐⡐⠄⡂⡐⠌⡊⠌⣂⣣⣥⢮⡾⡯⣟⣾⢽⡯⣿⢽⣽⡺⣮⣻⡪⣗⢵⡢⡀
        ⢈⢜⢞⣾⣽⣺⣳⢿⣽⣾⣟⣯⣷⣿⣻⡾⣯⣿⣻⣾⢿⡾⣷⣻⣽⣻⣽⣺⣞⡽⣽⣳⣳⣳⢽⢝⡮⣳⡱⡕⡅⡢⢈⠐⠨⠢⡱⡱⢕⡳⣂⠀⠀⣀⣀⡠⣤⣔⢶⢽⢯⢯⣗⡯⣯⢯⡿⣽⢾⡯⣿⢽⣻⢾⢽⣺⣺⡺⡽⣕⢯⡺⡤⡀
        ⠀⡘⣗⢷⣻⢼⣾⣻⣽⡾⣯⣿⣳⣟⣷⢿⣻⣽⢿⡾⣟⣿⣯⢿⣽⣗⣿⣺⢾⣝⣗⣗⡷⡽⡽⣝⢾⢵⢕⢵⢱⡘⡐⡈⠌⢂⠪⡨⡣⡣⡫⡮⡺⡕⣗⢽⡺⣪⢯⢯⢯⣗⡯⡯⣗⣯⢿⢽⡽⡯⣿⢽⣻⣻⣟⣾⣺⢽⢽⣺⢝⣮⡳⡳⣄
        ⠀⡊⡮⡿⣽⡳⣷⣻⣽⣟⣿⢾⣯⢿⣽⢿⣻⣽⣿⣻⡿⣷⣟⣿⢷⣻⡾⣞⣯⣞⣗⡯⣯⢟⣽⢽⣝⡮⡯⣪⡣⡎⡎⠔⡈⠄⡑⢌⠪⡊⡇⣗⢝⢮⡳⣝⢮⢯⣫⢯⢗⣗⡯⡯⣗⡯⣯⣻⣺⢯⡿⡽⣯⢷⣟⣷⣻⣽⣻⣺⣝⣞⢞⡽⣪⡳⡄⡀
        ⠀⠄⡯⡯⣷⣫⡷⣟⣷⢿⣽⢿⣽⢿⣽⣿⣻⣽⣾⢿⣽⢿⣾⣻⡿⣽⣻⣽⣞⡷⡯⣯⢷⣻⣺⣳⡳⣝⡮⣇⢧⢣⢣⠣⡂⡂⠂⠅⢅⠕⡕⡕⣝⢜⡞⣜⢗⡽⣪⢯⣫⣞⡽⡽⡵⡯⣗⡷⡯⣯⢿⢽⢯⣟⣾⣳⣟⣾⣞⣾⡺⣮⣻⣪⢗⣝⢮⠮⡄⡀
        ⠀⠄⡯⡯⣷⣳⣟⣯⣿⣻⣽⣟⣿⣻⡷⣿⣽⢿⡾⣿⣻⣯⣷⣿⣻⣯⢿⣳⡯⣿⢽⢯⣻⣞⣗⣗⣯⡳⣝⢮⢎⢧⢣⢫⠢⡊⠌⠨⢐⢑⢜⢸⢸⢱⡹⣜⢵⡫⣞⡵⣳⢵⣫⢯⢯⢯⣗⡯⣯⢷⣻⣽⣻⣞⣷⣻⣞⣷⣻⣞⣽⣺⡺⣮⣻⡪⣗⢯⡺⣔⢄
        ⠀⠂⢹⣺⣳⣳⣟⣾⣳⡿⣯⣿⣽⣯⡿⣿⢾⣿⣻⣿⣽⢷⡿⣾⣻⡾⣿⡽⣯⡯⣿⢽⣳⣗⢷⣳⣳⢽⡺⣝⣝⢎⢧⢣⡣⡣⡑⡁⡂⢂⠢⡱⢱⢱⢕⡕⡗⣝⢮⡺⡵⣫⢾⢽⢝⣗⣗⡯⣯⣻⣺⢞⣾⣺⣞⣗⣿⣺⣗⣿⣺⣳⢯⣗⣗⡽⡮⣳⢝⢮⡪⡣⢄
        ⠀⢈⢸⢮⣗⢷⣻⡾⣯⣿⢿⡾⣷⣟⣿⣻⣿⡽⣟⣾⣻⣟⣿⢯⣿⣽⡷⣿⣗⣿⢽⣻⣺⢾⢽⣳⢯⡻⣮⡳⣵⣫⡳⣕⢕⢕⢌⠆⡂⡂⡑⢌⠪⡸⡸⡸⡺⡸⡵⡹⣝⢮⡳⡽⣝⣞⢮⣻⣺⣺⢾⢽⣞⣾⣺⣳⣻⣺⣳⣟⣾⢽⣳⣗⢷⢝⣝⠮⠫⡃⠃⠅⠁
        ⠀⠠⡘⡧⣻⢽⣯⢿⣻⣾⢿⣻⣯⣿⣽⣟⣾⣟⣿⢿⣽⢿⡾⣿⣻⣾⣻⣽⣾⡽⣯⢷⢯⡯⣟⡾⡽⣝⣞⢮⡳⣕⢗⣕⢧⢫⢪⢊⠆⡂⡐⡐⢅⢕⢱⢹⢸⢕⢝⢮⢮⢳⢝⡽⣺⡪⣟⣞⣞⢾⣝⣗⡷⣳⣗⣟⡾⣽⢞⡷⣻⢽⠳⢓⠍⢊⢐⠨⢐⠠⠑⠈
        ⠀⠀⡊⣽⢝⣗⣿⣟⣿⣽⣿⣻⡷⣿⢾⣯⣷⣿⣻⣟⣿⣻⣟⣿⣽⣾⢿⣽⢾⡯⣿⢽⢯⡯⣗⣯⢯⢗⣗⢯⡫⣞⣝⢮⢮⢳⡱⡕⡕⡡⢂⢐⠐⢔⠱⡸⡸⡪⣣⢳⢕⢯⡳⣝⢮⢯⢞⣞⢮⣗⣗⣗⡯⣗⢷⡳⣫⠳⡙⠩⢈⠂⠅⡂⢌⠐⠄⠢⠐⠄⠁
        ⠀⠀⠅⡯⡯⣺⡷⣿⣽⣾⣯⣿⣻⣟⣿⣽⣾⣯⣿⣽⣯⣿⣽⢿⣾⣻⣯⡿⣯⡿⣯⢿⡽⣽⣳⢽⢽⢝⡮⣳⣝⢞⡼⣕⣏⢧⡣⡇⡇⡎⣂⠢⢈⠢⡑⠜⡌⡎⡮⡪⡳⡳⣹⡪⣏⣗⢯⡺⣕⡗⣗⠗⡝⠪⢑⢁⠂⡂⢂⠑⠄⠅⠅⡂⡂⠅⠡⠁⠅⠁
        ⠀⠀⠡⢻⡪⡷⣿⢿⡾⣷⢿⡾⣯⣿⣽⢷⡿⣾⢷⡿⣾⢷⣿⣻⡷⣟⣷⢿⡯⣿⢽⢯⡯⣗⡯⡯⣯⡳⡯⣳⢵⣫⢞⡵⣕⣗⢵⢣⡣⡣⡢⡑⠔⡐⢈⢊⢆⢣⢣⢫⢪⡺⡸⡪⣞⠼⠕⢏⢊⠊⢄⠡⠠⡑⠐⠄⡡⠐⠄⠅⠅⠅⠅⠂⠄⠈⠀⠁
        ⠀⠀⠪⢸⡕⣯⢿⣻⣟⣿⣻⣟⣯⣷⡿⣟⣿⣻⣟⣿⣻⣿⣽⣯⣿⣻⣽⢿⡽⣯⢿⡽⣽⢽⢽⢽⢮⢯⢯⡳⡯⣺⢵⡫⣞⢼⢕⢧⢳⢱⢱⢑⢕⢐⢁⠂⠆⢕⠜⡜⠜⠜⢑⠩⢀⢊⠨⢐⠐⡈⡐⠨⢐⠠⠡⠡⠠⡑⡨⠈⠊
        ⠀⠀⠨⢨⠧⡯⣿⢯⣿⣽⣯⣿⣽⡷⣿⣟⣿⣯⣿⡽⣟⣾⢷⣻⡾⣯⢿⡽⣯⢿⡽⣽⢽⡽⣯⣻⢽⣝⣗⡯⣯⡳⣝⢮⡳⣝⢮⢳⢕⢇⠧⢣⢑⢐⢐⠨⢈⢂⠅⡂⠅⡑⠄⡑⡐⠠⡁⡂⡂⠢⠨⠨⡠⠌⠂⠃⠁
        ⠀⠀⠈⡂⡯⡯⣟⣯⡷⣿⣞⣷⢿⣽⢿⣽⢷⣻⣾⣻⢿⡽⣿⢽⡯⣿⢽⢯⢿⢽⡽⣽⢽⣞⢷⢽⣳⣳⡳⡯⡞⡞⣝⢕⢽⢸⠸⠸⠘⠘⠁⠁⠀⠸⠊⠐⡁⠢⢈⢐⢁⢂⢁⠂⢌⢐⢐⠠⠢⠑⠁⠁
        ⠀⠀⠀⢃⡗⡽⡯⡷⣟⣷⢿⣽⢿⣽⢿⡽⣿⣽⢾⡯⣿⢽⢯⢿⢽⢽⣝⢯⡻⡽⢝⢞⣳⡳⡽⡵⡵⡲⡳⠝⢎⠙⡌⢌⠢⠂⠁⠂⠀⠀⠀⠀⢀⠊⠀⠐⢌⢈⢂⠂⡂⡐⢄⠑⠐⠀⠁
        ⠀⠀⠀⠃⡇⡿⣽⣻⢯⣟⣯⣟⣯⡿⣽⣻⢗⡯⡯⡫⣏⢯⣫⣳⢽⡱⡮⡺⡺⡝⡽⠽⠜⡚⠪⡉⡌⡢⠈⠂⠁⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠨⠀⠀⡈⡂⠂⠂⠁
        ⠀⠀⠀⠀⢕⡽⣳⢽⣫⣗⡟⡞⡗⣏⣗⣧⡯⣾⡺⡫⡗⡯⣺⢪⢇⠗⡓⡙⡡⢑⠤⠕⠑⠐⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈
        ⠀⠀⠀⠀⢘⢎⣏⡧⣗⡶⣻⢯⢿⠽⣝⣎⢧⢇⢗⠝⡙⠜⠌⣂⠢⠱⠐⠁⠈
        ⠀⠀⠀⠄⠨⡮⡺⡝⣎⢯⢮⡳⡳⠋⠚⠈⠊⠪⠂⠂⠈⠊⠁
        ⠀⠀⠀⢀⠀⡯⡺⠚⠊⠋⠀⠀⠀⠀⠐
        ⠀⠀⠀⠀⡢⠥⠀⠂⠀
    
        >>> display_image_in_terminal('https://www.giantbomb.com/a/uploads/scale_medium/0/5497/300679-myst_linking_book.jpg', bordered=True)
        ⡏⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⢉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⢹
        ⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣀⣀⣠⣠⣴⣲⣖⣯⣯⢿⣄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸
        ⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⢀⣀⡠⣤⣲⡲⣮⣻⣺⣺⢞⣾⣺⣞⣷⣻⣞⣟⡾⡽⣄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸
        ⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡀⡀⡄⣔⢔⢦⡳⣝⢵⣳⢻⡺⣺⡺⣵⣳⡳⣯⣻⣺⣞⢾⣺⢾⢾⡽⡯⡿⣝⣗⣄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸
        ⡇⠀⠀⠀⠀⠀⢀⣤⣖⣖⣶⣲⣳⣺⣺⢽⢽⢽⢽⢽⢽⡽⡽⡽⡽⣝⡵⣝⢮⢮⡪⣎⢎⢆⢇⢆⠢⢂⠨⢈⠢⡱⢱⢱⢕⢧⢳⢳⢝⡮⣳⢯⣳⡻⣺⣪⢟⣞⠮⢗⠯⡋⡋⡉⡁⠀⠀⠉⠷⣳⢽⢵⢄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸
        ⡇⠀⠀⣤⢶⢶⣺⣺⣗⣿⣞⣷⣯⡷⣿⢽⡯⡿⡽⡯⣟⡾⣽⣫⢟⣞⡮⣗⡽⣪⢞⡮⣎⢧⢣⢕⢱⢐⠐⠠⢁⠪⡸⡸⢜⢎⣗⢽⢕⣯⡳⠯⠞⠝⡑⢍⢕⠥⡩⡪⡨⡢⡢⡢⠨⡈⡐⠀⠀⠙⢽⢽⢝⣖⢄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸
        ⡇⠀⣠⢿⢽⣟⡧⣷⣻⡷⣟⣷⢿⣽⣯⢿⣽⣻⣟⡿⣽⣽⣳⢯⣟⢾⢽⣺⣝⣗⡯⡾⣕⡯⣺⢪⡪⡢⡡⠁⡂⠡⠢⡱⡹⡜⣎⠎⠁⠠⠀⠠⠨⠨⢈⠢⡂⡣⢊⢎⢭⠸⡸⡸⠱⡂⢆⢂⠠⠀⠀⠙⡽⡮⡯⣲⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸
        ⡇⠀⢸⣝⣯⡿⣏⡷⣟⣿⣻⣽⣿⣳⡿⣯⣷⢿⣾⣻⣳⣻⣺⡽⡾⣽⢽⣺⣺⣺⣺⢽⢮⣻⣪⡳⣕⢵⢨⠢⠠⠁⠅⡢⢱⠱⣕⢽⡀⠁⠄⠂⠁⠁⠰⡁⡪⠳⢫⣚⢌⢜⢨⢪⢪⢸⠸⡄⡂⡐⠠⢀⠈⠝⡾⣕⡯⡦⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸
        ⡇⠀⢸⡺⣞⡿⣗⣟⣯⣿⣽⡷⣿⡽⣿⣽⡾⣟⣷⣻⣽⣽⣞⣯⢿⡽⣽⣺⣳⣳⢽⣝⣗⣗⡵⣝⢮⡪⡪⡪⡨⠂⠡⢈⠢⡃⡇⢧⢳⢌⠀⢀⠀⠈⠠⠑⡜⢔⡡⡃⡫⡂⡗⡕⡕⡔⢕⠱⡑⠄⢅⣂⣅⣥⣞⡵⡯⣺⢝⡦⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸
        ⡇⠀⢕⡯⡿⣽⣗⣯⢷⡿⣾⣻⣯⣿⣻⣾⣻⡿⣽⣯⣷⢿⣞⣯⡿⣽⣳⣟⢾⢽⢽⣺⣺⢮⡻⡮⣳⢝⢼⢸⡐⡅⠅⢂⠡⢊⠜⡜⣕⢳⢄⠀⠀⠁⠄⠅⢄⠡⠈⢌⠢⢃⠣⠱⣘⣨⣢⣷⣺⢯⣟⣷⣳⣗⣗⡯⣯⡳⡯⣺⢝⡤⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸
        ⡇⠀⢂⢯⢯⣟⡧⡿⣽⣟⣯⣿⢷⣻⣽⡾⣯⣿⣻⡾⣯⣿⣯⣿⡽⣟⡾⣞⡯⣯⢟⣞⡾⡽⡽⣝⣗⢯⡳⡕⣕⢜⠨⡀⠂⠅⡪⡘⡜⣜⠵⡥⠀⠀⠀⠈⠀⣈⣈⢤⣜⣶⣺⣻⢽⣳⣻⣺⣞⣯⣷⣻⣞⡷⣯⣟⡮⡯⡯⣞⣗⢽⡪⣆⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸
        ⡇⠀⠁⢷⢽⡽⣏⣯⡷⣟⣯⣿⣻⡿⣽⢿⣽⡾⣟⣿⣯⣷⢿⣾⣻⣟⣿⢽⡯⣯⣟⢾⢽⢽⣝⣗⣗⢯⣞⣝⢜⡜⡌⠆⠅⢂⠢⠨⡊⡲⡹⡸⣣⡢⣞⢽⢝⡮⡯⣳⣳⣳⣳⢽⣻⣺⢵⣻⣞⣗⣿⣺⣗⡿⡷⣷⢯⡯⣟⣞⡮⣗⢯⡺⡼⣄⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸
        ⡇⠀⠨⢪⣳⣟⡷⣽⡽⣟⣿⢾⣯⣿⣻⣟⣷⡿⣟⣷⡿⣾⣿⣽⢷⣿⡽⣯⣯⢷⣫⢿⢽⣻⣺⢞⣞⣗⢧⣳⡣⡳⣩⢪⢊⠄⢂⠑⢌⠪⡪⢺⢸⢜⢮⡺⣕⢯⣺⡳⣳⣳⢽⢽⣺⣺⢽⣳⡳⡯⣾⣺⣗⣿⣻⣽⢿⡽⣗⡷⡯⣗⢯⢯⢞⡮⡳⣄⠀⠀⠀⠀⠀⠀⠀⢸
        ⡇⠀⢈⢸⣺⣺⡽⣺⣽⢿⣽⣟⣷⣟⣷⣟⣷⡿⣟⣯⣿⣟⣾⣯⡿⣷⣟⡿⣞⣯⢿⣽⣻⣺⡵⡯⡷⡽⣝⢮⣺⢹⡸⡸⡰⡨⠠⠨⢐⢑⢘⢜⢜⢕⢇⢯⢎⢷⢕⡯⣳⡳⣫⢟⣞⣞⣽⣺⢽⣽⣳⣻⣞⣾⣳⢯⡿⣽⢯⣯⢯⡯⣯⡳⡯⣺⢝⢮⣪⣂⠀⠀⠀⠀⠀⢸
        ⡇⠀⢀⢸⣺⣺⡽⣽⢾⣻⡷⣟⣷⢿⡷⣿⣽⣟⣿⣯⣷⡿⣷⣻⣽⡿⣾⣻⡿⣽⣻⣞⣾⡳⣯⢿⢽⢽⡺⣝⢮⣣⢳⡱⡱⡪⡨⢂⠂⡂⠕⢌⠎⡎⡗⡝⣎⢗⡽⣪⢗⡽⣝⡽⣺⣺⣺⣺⢽⣺⣺⡵⣗⡷⣯⢿⡽⣯⢿⡽⣯⢟⡮⡯⣯⡳⡯⣳⢵⡪⣎⢄⠀⠀⠀⢸
        ⡇⠀⠠⠘⡮⡾⡽⣽⢯⡿⣽⣿⣻⣟⣿⣻⣾⣯⣷⡿⣾⣟⣿⣽⣯⡿⣯⣷⣟⣿⣺⣳⣗⡿⣽⢽⡽⡽⣝⡮⣗⢗⢧⢳⡱⡱⡸⡐⠔⠠⠡⠡⡑⡕⡕⣝⢜⢵⢝⢮⡫⣞⡵⣫⣗⣗⢷⢽⢽⣺⡳⡯⣯⢯⡯⣯⢿⢽⡯⣿⢽⡯⡿⣽⣺⢽⢝⣮⡳⣝⢮⡣⡳⣀⠀⢸
        ⡇⠀⠀⠂⣯⣻⢽⡽⣯⣿⣻⣾⣯⣿⣽⢿⡾⣷⣟⣿⢯⡿⣷⢿⣾⣻⣟⣾⣽⣾⢽⣞⡷⡯⣯⣗⡿⣽⡳⣯⡺⣝⡵⡳⣕⢝⢜⢌⢪⠨⠠⠑⢌⠢⡃⡇⡏⡮⡎⣗⡝⣞⢮⡳⣕⣗⢯⢯⡻⡮⡯⣯⣗⣯⢯⡯⡿⡽⡯⡿⣽⢯⣟⡷⣽⢽⣝⢞⢮⡳⡳⠹⢘⠐⠁⢸
        ⡇⠀⠀⡁⢷⡹⣗⣿⣻⡾⣯⣷⡿⣾⣻⣟⣿⢯⣿⢾⣿⣟⣿⣯⣷⡿⣯⡿⣞⣯⣟⣷⣻⣽⣳⣳⢯⣗⣟⢮⢯⡺⣝⢽⢜⢵⡱⡣⡣⢪⠨⠈⠄⢕⢘⠜⡜⡎⣞⢜⢮⣪⡳⣝⢞⡮⡯⣳⢯⢯⢯⣗⢷⢽⡽⣽⢽⢯⣟⣯⢿⡽⣗⡿⡽⢕⠣⠋⠅⡂⠌⡐⠄⠂⠀⢸
        ⡇⠀⠀⠨⢚⣞⢷⢽⣷⢿⣟⣷⡿⣿⣽⣯⣿⣻⣽⡿⣷⢿⡷⣿⢾⣟⣯⣿⣟⣯⣿⣺⣗⣷⣻⣺⢽⣺⣺⢽⢵⡻⣪⢗⢯⣣⡳⡕⣕⢕⠜⡨⠈⠄⢅⠕⡅⡇⡧⡳⡱⣕⢵⡳⣝⢮⣻⣪⢯⢯⣳⢽⢽⢽⣺⢽⡽⡽⡺⡕⠟⡊⠣⢑⠠⢁⠂⢅⠡⡀⢅⠐⠀⠀⠀⢸
        ⡇⠀⠀⢘⢸⣳⡫⣿⣽⣟⣯⣿⣻⡿⣾⢷⣟⣯⣿⣻⣟⣿⣻⣟⣿⣯⡿⣷⣻⣷⣻⣾⣳⣗⣟⡾⣝⣗⣗⢯⡳⣝⢮⢏⣗⢵⢳⡹⣸⢸⢨⠢⠡⡁⠢⡑⢌⢎⢪⢪⡣⡳⡵⡝⡮⣳⢳⢵⡫⣗⢽⢽⢽⢝⠮⡓⠝⠨⠡⠨⠐⢄⢑⢐⠨⠠⢑⠐⡐⢐⠐⠀⠀⠀⠀⢸
        ⡇⠀⠀⠐⡸⡮⣺⣽⣾⣯⣿⣽⣯⣿⣻⣟⣿⣽⣯⣿⣽⣯⣿⣽⡷⣿⣽⢿⣽⣾⣻⣞⣷⣻⣺⢽⣳⣳⡳⣽⡺⣝⡵⣫⢞⣝⢵⢝⡼⡸⡸⡨⡑⢄⠅⠌⠢⢪⠸⡸⡸⡪⡪⡎⡯⣪⢏⣗⢽⠪⠫⢊⠑⡈⡐⠄⢅⠑⡈⢄⢑⢐⢐⢐⠨⢈⠀⠂⠐⠀⠀⠀⠀⠀⠀⢸
        ⡇⠀⠀⠠⡂⣟⢼⣺⡷⣿⢾⡷⣿⢾⣻⣽⣷⢿⡾⣷⢿⡾⣷⣟⣿⣻⡾⣟⣷⣟⣾⣳⣗⣟⣞⣟⣞⣮⣻⣺⡺⣵⡫⣗⡽⣪⢏⣗⢵⡹⡜⡜⡜⢔⠌⠔⠡⢡⢑⢅⢇⢇⢏⠞⠜⡊⠡⠂⡂⠅⠅⠂⠅⡂⠌⡐⡐⡈⡐⠄⡂⠢⠂⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸
        ⡇⠀⠀⠀⡂⣻⣸⣳⣿⣻⣟⣿⣻⣟⣿⣽⣾⢿⣿⣻⣟⣿⢯⣿⡽⣯⣿⣻⣗⣿⣺⣗⣟⣞⣗⣷⡳⣗⣗⢷⢽⣺⡺⣕⢯⡺⣕⢧⡳⣕⢵⢱⡱⡑⠅⠅⠅⡂⠢⢑⠨⢐⠐⢄⠑⠄⠅⡑⠠⠡⠨⡈⡂⡂⣁⠢⠰⠐⠈⠈⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸
        ⡇⠀⠀⠀⠢⢱⣺⣺⢷⣻⣽⣯⢿⣽⣟⣾⣟⣿⡽⣯⡿⣾⣟⣷⣟⣿⣺⣗⣿⣺⣗⣟⣞⣗⣯⢾⣝⡷⡽⡽⣽⣪⢯⢞⢗⢝⡎⡇⡇⡇⠇⠗⠐⠁⠁⡧⠃⠔⠡⡁⠊⠔⠨⠐⠨⢈⠂⠌⠌⡨⡐⠔⠐⠈⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸
        ⡇⠀⠀⠀⠸⢸⢜⣾⣻⣽⢾⣽⣟⣷⣟⣷⣟⣷⣟⣯⣿⣳⣟⣾⣳⣟⣞⣗⢷⡳⣗⣟⢮⠷⡽⣕⣗⣝⡭⡽⣜⢮⠚⢎⢃⠣⡡⠑⠠⠀⠀⠀⠀⠀⠠⠂⠀⢌⠂⠢⠡⠡⠁⢅⠑⠄⠅⠑⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸
        ⡇⠀⠀⠀⠰⢹⢸⣞⡾⣾⣻⢷⣻⢷⣻⣗⡿⣾⢽⣳⡻⣺⢳⡳⡳⣕⡗⣭⡣⡯⣞⢖⣗⣏⠯⠮⡚⢊⠪⡈⠢⠐⠑⠈⠀⠁⠀⠀⠀⠀⠀⠀⠀⠀⡃⠀⠀⠢⠡⠡⠑⠈⠈⠀⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸
        ⡇⠀⠀⠀⠀⢘⢜⡾⣝⡷⣻⢽⢯⢿⢽⢺⢽⣱⣫⡮⡾⣺⢵⡫⡯⣳⢹⠼⠜⠞⡘⢌⣐⠔⠌⠔⠈⠈⠈⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠊⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸
        ⡇⠀⠀⠀⠀⠀⢯⢺⢳⣹⢭⡽⣾⣺⣞⣟⢯⢻⣸⢹⢜⠞⢎⢎⠣⠡⡑⡌⠜⠈⠂⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸
        ⡇⠀⠀⠀⢀⠀⣣⡫⣟⢮⢻⣪⡳⡵⡵⠪⠏⠣⠣⡣⠡⠈⠢⠒⠈⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸
        ⡇⠀⠀⠀⠀⠀⢸⡪⡮⠮⠳⠃⠉⠈⠀⠀⠄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸
        ⡇⠀⠀⠀⠀⠡⣘⡈⠠⠀⠀⠂⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸
        ⠧⠤⠤⠤⠤⠬⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠼

        >>> display_image_in_terminal('https://www.giantbomb.com/a/uploads/scale_medium/0/5497/300679-myst_linking_book.jpg', dither=False)
        ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣀⣠⡀
        ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣀⣀⣤⣤⣴⣶⣿⣿⣿⣿⣿⣿⣄
        ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣀⣠⣤⣤⣶⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣄
        ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣤⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠿⠿⢻⣿⣿⣷⣄
        ⠀⠀⠀⠀⣴⣶⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣆⡀⠀⠀⠀⠀⠀⠀⠈⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠿⠛⠛⠋⠉⠀⠀⠀⠀⠀⠘⢻⣿⣿⣷⣄
        ⢠⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣄⠀⠀⠀⠀⠀⠀⠀⣿⣿⣿⣿⣿⡿⠿⠟⠛⠋⠉⠀⠀⠀⠀⠠⣠⡤⣤⣤⣀⠀⠀⠀⠀⠀⠙⢿⣿⣿⣷⡄
        ⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣇⠀⠀⠀⠀⠀⠀⠘⣿⣿⣯⡀⠀⠀⠀⠀⠀⠀⠀⠀⣀⣀⣤⠤⡀⣀⡀⠈⠂⢀⠀⠀⠀⠀⠀⠙⣿⣿⣿⣧⡀
        ⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣧⠀⠀⠀⠀⠀⠀⠈⢿⣿⣧⡀⠀⠀⠀⠀⠀⠀⠀⠙⠛⣿⣥⡀⠠⢐⡊⠁⠉⠓⠀⠀⠀⠀⠀⠈⠻⣿⣿⣿⣦⡀
        ⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣇⠀⠀⠀⠀⠀⠀⠀⢻⣿⣷⡄⠀⠀⠀⠀⠀⠀⠢⠤⠄⠀⠁⠉⠛⠋⠀⠀⠀⠀⠀⣀⣤⣤⣴⣾⣿⣿⣿⣿⣿⣦⡀
        ⢻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣦⠀⠀⠀⠀⠀⠀⠀⢿⣿⣷⡄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⣁⣠⣤⣴⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣦
        ⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣦⠀⠀⠀⠀⠀⠀⠘⢻⣿⣿⣆⠀⠀⠀⣀⣀⣤⣤⣶⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣤⡀
        ⠸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣦⠀⠀⠀⠀⠀⠀⠀⠹⣿⣿⣶⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣄
        ⠀⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣦⠀⠀⠀⠀⠀⠀⠀⠸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣄
        ⠀⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣧⠀⠀⠀⠀⠀⠀⠀⠘⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣄
        ⠀⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣄⠀⠀⠀⠀⠀⠀⠀⠸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣶⣄
        ⠀⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣄⠀⠀⠀⠀⠀⠀⠀⠘⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⠄
        ⠀⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣦⠀⠀⠀⠀⠀⠀⠀⠈⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠿⠛⠉⠁
        ⠀⠈⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣧⠀⠀⠀⠀⠀⠀⠀⠘⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠿⠟⠋⠉
        ⠀⡁⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣇⠀⠀⠀⠀⠀⠀⠀⠈⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠿⠟⠋⠉
        ⠀⠀⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡆⠀⠀⠀⠀⠀⠀⠀⠀⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠟⠛⠉⠁
        ⠀⠀⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣄⠀⠀⠀⠀⠀⠀⠀⠀⠻⠿⣿⣿⣿⣿⣿⡿⠿⠟⠋⠉
        ⠀⠐⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡄⠀⠀⠀⠀⠀⠀⠀⠀⠜⠿⠛⠛⠉⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⠠
        ⠀⠀⠸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠿⠁
        ⠀⠀⠀⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠭⠽⠓⠒⠋⠁⠀⠀⢸⠊
        ⠀⠀⠀⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⢿⣿⣿⣿⣿⣿⡿⣿⣿⠿⠟⠛⠋⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠂
        ⠀⠀⠀⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠿⠛⠛⠉⠉⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈
        ⠀⠀⠀⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡯⠿⠛⠛⠉⠁⠠⠴⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈
        ⠀⠀⠀⠸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠿⠟⠛⠋⠉⢀⠀⠄
        ⠀⠀⠀⠈⣿⣿⡿⣿⣿⣿⣷⠿⠟⠛⠉⠙⠃⠀⠀⠀⠈⠁
        ⠀⠀⠀⠀⣿⣶⠿⠛⠋⠁
        ⠀⠀⠀⠢⠩

    """
    if isinstance(image,str):
        image=load_image(image)
    image=as_numpy_image(image,copy=False)
    def width(image) -> int:
        return len(image)
    def height(image) -> int:
        return len(image[0])
    pip_import('drawille')
    from drawille import Canvas

    if get_image_width(image)>get_terminal_width()*2 and auto_resize==True:
        scale_factor=(max(1,get_terminal_width()*2))/get_image_width(image)
        image=resize_image(image,scale_factor,'nearest')

    i=as_binary_image(as_grayscale_image(image),dither=dither)
    if bordered:
        #This prevents drawille from cropping the image zeros
        i=bordered_image_solid_color(i)
        i=as_binary_image(as_grayscale_image(i))

        

    c=Canvas()
    for x in range(width(i)):
        for y in range(height(i)):
            if i[x,y]:
                c.set(y,x)
    print(c.frame())


_use_rp_timg=True
def display_image_in_terminal_color(image,*,truecolor=True):
    """
    Will attempt to draw a color image in the terminal
    This is slower than display_image_in_terminal, and relies on both unicode and terminal colors
    EXAMPLE:
        >>> while True:
        ...     _terminal_move_cursor_to_top_left()
        ...     image=load_image_from_webcam()
        ...     display_image_in_terminal_color(image)
    EXAMPLE:
        display_image_in_terminal_color(load_image('https://i.guim.co.uk/img/media/faf20d1b2a98cbca9f5eb2946254566527394e15/78_689_3334_1999/master/3334.jpg?width=1200&height=900&quality=85&auto=format&fit=crop&s=69707184a1b38f36fc077f7cafba1130'))#Display Kim Petras in the terminal
    """

    import rp.libs.timg as timg #A c-optimized version of timg

    if file_exists(image) or is_valid_url(image):
        image=load_image(image)

    assert is_image(image)
    image=as_numpy_image(image,copy=False)
    image=as_rgb_image(image)
    image=as_byte_image(image)
    image=resize_image_to_fit(image,width=get_terminal_width(),allow_growth=False)

    if get_image_height(image)%2:
        #We can only display pixel heights of 2,4,6,8 etc.
        #To prevent it from cutting off the bottom pixel, add some black if it's odd...
        #For example, display_image_in_terminal_color(uniform_float_color_image(5,10,(255,0,255,255)))
        image = bordered_image_solid_color(image, thickness=0, bottom=1, color="black")

    def _helper(timg):
        # Get the timg module and use its functionality directly
        timg_renderer = timg.Renderer()
        
        # Load the image
        #timg_renderer._load_image_from_file(temp_file)
        timg_renderer.load_image(rp.as_pil_image(image))
        
        # Choose the appropriate rendering method based on truecolor flag
        #    ┌─────────┬──────────────────────────────────────────────┐
        #    │ Method  │ Description                                  │
        #    ├─────────┼──────────────────────────────────────────────┤
        #    │ sixel   │ use sixels - best quality but lowest support │
        #    │ a8f     │ low-resolution ANSI 8-bit palette            │
        #    │ a24f    │ low-resolution ANSI 24-bit palette           │
        #    │ a8h     │ high-resolution ANSI 8-bit palette           │
        #    │ a24h    │ high-resolution ANSI 24-bit palette          │
        #    │ ascii   │ ASCII art                                    │
        #    └─────────┴──────────────────────────────────────────────┘
        method = 'a24h' if truecolor else 'a8h'
        
        # Render the image with the selected method
        timg_renderer.render(timg.METHODS[method]['class'])

    global _use_rp_timg

    if _use_rp_timg:
        try:
            _helper(timg)
        except PermissionError:
            #On XCloud, it fails to build because we have no write permission in that directory for the c compilation of my optimized version
            _use_rp_timg=False

    if not _use_rp_timg:
        pip_import('timg')
        import timg
        _helper(timg)


def display_image_in_terminal_imgcat(image, *, pixels_per_line=24):
    """
    Can display images in some terminals as actual images
    
    Works in:
        iterm2
        wezterm
        tmux (if configured properly)
        hyper (with plugin: https://github.com/Rasukarusan/hyper-imgcat)
    Does not work in:
        alacritty
        kitty
        terminal.app
        
    EXAMPLE:
        while True:
            display_image_in_terminal_imgcat(cv_resize_image(load_image_from_webcam(), 0.1))

    """
    pip_import("imgcat")
    import imgcat

    if isinstance(image, str):
        image = open(image)
    else:
        assert is_image(image)
        
        image = as_rgb_image(image)
        image = as_byte_image(image)
        
        #I don't know the maximum size, but I'm sure this will do just fine
        #After a certain size, it just won't display...
        # image = resize_image_to_fit(image, width=1024, height=1024, allow_growth=False)
        # image = resize_image_to_fit(image, width=2048, height=2048, allow_growth=False)
        
        image = as_pil_image(image)

    imgcat.imgcat(image, pixels_per_line=pixels_per_line)
        

def display_video_in_terminal_color(frames, *, loop=True, framerate=None, display_image=display_image_in_terminal_color):
    """
    Display a video in the terminal with a progress bar.

    Args:
        frames (list): List of frames to display
        loop (bool): Whether to loop the video indefinitely
        framerate (int, optional): Target frames per second. If set, will sleep to maintain this rate.

    EXAMPLE:
        display_video_in_terminal(load_webcam_stream(), framerate=30)

    EXAMPLE:
        display_video_in_terminal_imgcat(resize_images(load_webcam_stream(),lazy=True,size=.25))
             
    """
    import time

    if isinstance(frames, str):
        assert is_video_file(frames),frames
        frames = load_video(frames,show_progress=True)

    while True:
        for i, f in enumerate(frames):
            start_time = time.time() if framerate else None

            h = get_terminal_height()*2-4
            f = resize_image_to_fit(f, height=h, allow_growth=False)

            display_image(f)
            _terminal_move_cursor_to_top_left()
            w = get_terminal_width()
            if has_len(frames):
                fansi_print(
                    unicode_loading_bar(
                        i / (len(frames) - 1) * w * 8, chars="▏▎▍▌▋▊▉█"
                    ).ljust(w),
                    "white green on black blue white",
                )

            # Sleep to maintain target framerate if specified
            if framerate:
                elapsed = time.time() - start_time
                target_frame_time = 1.0 / framerate
                sleep_time = max(0, target_frame_time - elapsed)
                if sleep_time > 0:
                    time.sleep(sleep_time)

        if not loop:
            break

display_video_in_terminal_imgcat = partial(display_video_in_terminal_color, display_image = display_image_in_terminal_imgcat)

def auto_canny(image,sigma=0.33,lower=None,upper=None):
    """ Takes an image, returns the canny-edges of it (a binary matrix) """
    pip_import('cv2')
    cv2=pip_import('cv2')
    image=as_numpy_image(image,copy=False)

    if image.dtype!=np.uint8:
        image=full_range(image,0,255).astype(np.uint8)

    # compute the median of the single channel pixel intensities
    v=np.median(image)

    # apply automatic Canny edge detection using the computed median
    lower=int(max(0,(1.0 - sigma) * v)) if lower is None else lower
    upper=int(min(255,(1.0 + sigma) * v)) if upper is None else upper

    edged=cv2.Canny(image,lower,upper)

    # return the edged image
    return edged


def skeletonize(image):
    try:
        return _skimage_skeletonize(image)
    except Exception:
        #Warning: The current _cv_skeletonize method produces different and inferior results than that of _skimage_skeletonize
        return _cv_skeletonize(image)

def _skimage_skeletonize(image):
    # https://scikit-image.org/docs/dev/auto_examples/edges/plot_skeleton.html
    image=as_binary_image(as_grayscale_image(image))
    pip_import('skimage')
    from skimage.morphology import skeletonize
    return skeletonize(image)

def _cv_skeletonize(img):
    """ OpenCV function to return a skeletonized version of img, a Mat object"""
    cv2=pip_import('cv2')
    # Found this on the web somewhere
    #  hat tip to http://felix.abecassis.me/2011/09/opencv-morphological-skeleton/
    img=img.astype(np.uint8)
    img=img.copy()  # don't clobber original
    skel=img.copy()

    skel[:,:]=0
    kernel=cv2.getStructuringElement(cv2.MORPH_CROSS,(3,3))

    while True:
        eroded=cv2.morphologyEx(img,cv2.MORPH_ERODE,kernel)
        temp=cv2.morphologyEx(eroded,cv2.MORPH_DILATE,kernel)
        temp=cv2.subtract(img,temp)
        skel=cv2.bitwise_or(skel,temp)
        img[:,:]=eroded[:,:]
        if cv2.countNonZero(img) == 0:
            break

    return skel

def get_edge_drawing(image):
    """
    Alternative to Canny Edges that's more robust

    Extract edges from an image using EdgeDrawing (ED) algorithm.

    EdgeDrawing is a real-time edge detection algorithm developed by Cihan Topal and Cuneyt Akinlar.
    It works by first identifying anchor points in the image (pixels with high gradient magnitude),
    then linking these anchor points to form continuous edge segments.

    When use_parameter_free=True, it uses the Parameter-Free EdgeDrawing (PF-ED) variant, which
    automatically determines gradient and anchor thresholds from the image.

    Reference:
        Topal, C., & Akinlar, C. (2012). Edge drawing: A combined real-time edge and segment
        detector. Journal of Visual Communication and Image Representation, 23(6), 862-872.

    Args:
        image: numpy.ndarray in HW3 format (height, width, 3 channels)

    Returns:
        Binary edge map as numpy.ndarray
        
    EXAMPLE:
        >>> #Live Demo
        ... stream = load_webcam_stream()
        ... for frame in stream:
        ...     can = auto_canny(frame)
        ...     edg = get_edge_image(frame)
        ...     display_image(horizontally_concatenated_images(frame,can, edg))

    """
    import numpy as np
    import cv2

    gray = rp.as_byte_image(rp.as_grayscale_image(image))

    # Initialize edge detector
    ed = cv2.ximgproc.createEdgeDrawing()
    params = cv2.ximgproc.EdgeDrawing.Params()
    params.PFmode = True  # use Parameter-Free mode

    ed.setParams(params)

    # Detect edges and get binary edge map
    edges = ed.detectEdges(gray)
    edge_map = ed.getEdgeImage(edges)

    return edge_map


def remove_background_via_apple(image):
    """
    Remove background from an image using Apple's Vision framework (macOS 14+).

    Uses Apple's native VNGenerateForegroundInstanceMaskRequest to detect and extract
    the foreground subject, replacing the background with transparency.

    Args:
        image: Input image in any format accepted by is_image() (PIL, numpy array HWC, or path str)

    Returns:
        RGBA image (numpy array HW4) with transparent background

    Raises:
        OSError: If not running on macOS or macOS < 14 (Sonoma)
        Exception: If Vision framework fails to detect foreground or generate mask

    Examples:
        >>> photo = load_image('portrait.jpg')
        >>> cutout = remove_background_via_apple(photo)
        >>> save_image(cutout, 'cutout.png')

        >>> # Composite on new background
        ... subject = remove_background_via_apple(load_image('person.jpg'))
        ... new_bg = load_image('sunset.jpg')
        ... result = alpha_blend(subject, new_bg)

    Platform Requirements:
        - macOS 14 (Sonoma) or newer
        - pyobjc-framework-Vision
        - pyobjc-framework-Quartz

    Tags: segmentation, background-removal, vision, apple, macos, image-processing
    """
    import platform

    if isinstance(image, str):
        image = load_image(image)

    assert is_image(image), 'remove_background_via_apple: Input must be an image as defined by is_image()'

    if not currently_running_mac():
        raise OSError('remove_background_via_apple requires macOS')

    # Check macOS version
    # VNGenerateForegroundInstanceMaskRequest requires macOS 14+
    mac_version = platform.mac_ver()[0]
    major_version = int(mac_version.split('.')[0]) if mac_version else 0
    if major_version < 14:
        raise OSError(
            'remove_background_via_apple requires macOS 14 (Sonoma) or newer. '
            'Currently on macOS ' + str(mac_version) + '.'
        )

    # Import dependencies
    Vision = pip_import('Vision')
    Quartz = pip_import('Quartz')
    from Quartz import CIImage, CIContext, CIFilter, CGColorSpaceCreateDeviceRGB, kCIFormatRGBA8
    Foundation = pip_import('Foundation')

    # Convert to RGBA and encode to PNG bytes
    rgba_image = as_rgba_image(image, copy=False)
    png_bytes = encode_image_to_bytes(rgba_image, 'png')

    # Convert PNG bytes to CIImage
    ns_data = Foundation.NSData.dataWithData_(png_bytes)
    ci_image = CIImage.imageWithData_(ns_data)
    if ci_image is None:
        raise ValueError("Failed to create CIImage from image data")

    # Create and perform Vision request for foreground mask
    request = Vision.VNGenerateForegroundInstanceMaskRequest.alloc().init()
    handler = Vision.VNImageRequestHandler.alloc().initWithCIImage_options_(ci_image, None)

    success, error = handler.performRequests_error_([request], None)
    if not success:
        error_msg = error.localizedDescription() if error else "Unknown error"
        raise Exception("Vision request failed: " + str(error_msg))

    # Get mask from results
    results = request.results()
    if not results:
        raise Exception("No foreground instances detected in image")

    # Generate mask for all detected instances
    first_result = results[0]
    all_instances = first_result.allInstances()
    mask_pixel_buffer, error = first_result.generateScaledMaskForImageForInstances_fromRequestHandler_error_(
        all_instances, handler, None
    )
    if error:
        error_msg = error.localizedDescription() if hasattr(error, 'localizedDescription') else str(error)
        raise Exception("Failed to generate mask: " + str(error_msg))

    # Convert mask CVPixelBuffer to CIImage
    mask_ci_image = CIImage.imageWithCVPixelBuffer_(mask_pixel_buffer)

    # Apply mask using CIBlendWithMask filter
    # Combines original image with mask, transparent background
    blend_filter = CIFilter.filterWithName_("CIBlendWithMask")
    blend_filter.setValue_forKey_(ci_image, "inputImage")
    blend_filter.setValue_forKey_(mask_ci_image, "inputMaskImage")
    blend_filter.setValue_forKey_(CIImage.emptyImage(), "inputBackgroundImage")
    output_ci_image = blend_filter.outputImage()

    # Render CIImage to PNG bytes
    context = CIContext.context()
    color_space = CGColorSpaceCreateDeviceRGB()
    png_data = context.PNGRepresentationOfImage_format_colorSpace_options_(
        output_ci_image, kCIFormatRGBA8, color_space, None
    )
    if png_data is None:
        raise Exception("Failed to render output image to PNG")

    # Decode PNG bytes back to numpy RGBA image
    return decode_bytes_to_image(bytes(png_data))
# matte_foreground_via_apple = remove_background_via_apple
select_foreground_via_apple = remove_background_via_apple


# noinspection PyTypeChecker
def print_latex_image(latex: str):
    r"""
     >>> print_latex_image("\sum_{n=3}^7x^2")
     ⠀⠀⠀⠀⠠⠟⢉⠟
     ⠀⠀⠀⠀⠀⠀⡏
     ⠀⠀⠀⠀⠀⠀⠃
     ⢀⢀⣀⣀⣀⣀⣀⣀⣀⣀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣠⡀
     ⠀⠙⠄⠀⠀⠀⠀⠀⠀⠈⠉⢦⠀⠀⠀⠀⠀⠀⠀⠛⠀⡸
     ⠀⠀⠈⢢⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢠⡞⣡
     ⠀⠀⠀⠀⠑⡀⠀⠀⠀⠀⠀⠀⠀⠀⠰⠋⣹⠉⠃⠈⠉⠉
     ⠀⠀⠀⢀⡔⠀⠀⠀⠀⠀⠀⠀⠀⠀⢠⣠⣏⣠⠆
     ⠀⠀⡠⠊⠀⠀⠀⠀⠀⠀⠀⣠
     ⢀⢼⣤⣤⣤⣤⣤⡤⠤⠤⠴⠁
    
     ⢀⠀⣀⠀⠀⠀⠀⠀⠀⠐⠏⢹
     ⢣⠏⢨⠃⢘⣛⣛⣛⣋⢀⠈⠙⡄
     ⠘⠀⠘⠊⠀⠀⠀⠀⠀⠘⠒⠚
     Prints it in the console
    """
    # @formatter:off
    image=latex_image(latex)
    image=inverted_image(image)
    display_image_in_terminal(image,dither=False)
    


    #DisplayThin=   lambda latex:display_image_in_terminal(((resize_image(skeletonize(255 - latex_image(latex)[:,:,0]),scale) > threshold) * 1.0).squeeze(),dither=False)
    #DisplayRegular=lambda latex:display_image_in_terminal(((resize_image(           (255 - latex_image(latex)[:,:,0]),scale) > threshold) * 1.0).squeeze(),dither=False)
    ##@formatter:on
    #if thin:
    #    DisplayThin(latex)
    #else:
    #    DisplayRegular(latex)

# cd=os.chdir
image_acro="""di=display_image
li=load_image
dgi=display_grayscale_image
lg=line_graph
cv2=pip_import('cv2')
"""

# def remove_alpha_channel(image:np.ndarray,shutup=False):
#     # Strips an image of its' alpha channel if it has one, otherwise basically leaves the image alone.
#     sh=image.shape
#     l=len(sh)
#     if l==2 and not shutup:
#         # Don't break the user's script but warn them: this image is not what they thought it was.
#         print("r.remove_alpha_channel: WARNING: You fed in a matrix; len(image.shape)==2")
#         return image
#     if
#     assert l==3,'Assuming that it has color channels to begin with, and that its not just a matrix of numbers'
#     assert 3<=sh[2]<=4,'Assuming it has R,G,B or R,G,B,A'
#
#     return image[:,:,:2]

# def is_valud_url(url: str) -> bool:
#     # PROBLEM:
#     #     >>> ivu("google.com")
#     # ans=False
#     # I DID NOT WRITE THIS WHOLE FUNCTION ∴ IT MIGHT NOT WORK PERFECTLY. THIS IS FROM: http://stackoverflow.com/questions/452104/is-it-worth-using-pythons-re-compile
#     import re
#     regex=re.compile(
#         r'^(?:http|ftp)s?://'  # http:// or https://
#         r'(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+(?:[A-Z]{2,6}\.?|[A-Z0-9-]{2,}\.?)|'  # domain...
#         r'localhost|'  # localhost...
#         r'\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})'  # ...or ip
#         r'(?::\d+)?'  # optional port
#         r'(?:/?|[/?]\S+)$',re.IGNORECASE).match(url)
#     return regex is not None and (lambda ans:ans.pos == 0 and ans.endpos == len(url))(g.fullmatch(url))

ps.__all__+=("PseudoTerminalPrompt",)

_get_prompt_style_cached=None
def _get_prompt_style():
    global _get_prompt_style_cached
    if _get_prompt_style_cached is None:
        try:
            out=text_file_to_string(_prompt_style_path)
        except Exception:
            out=' >>> '

        _get_prompt_style_cached=out
    return _get_prompt_style_cached


def _get_cdh_back_names():
    # For autocompletion of CDH or B
    return [
        x
        for x in unique(
            reversed(
                get_path_names(_get_cd_history())
            ),
            key=str.lower,
        )
        if x.strip()
    ]

def _user_path_ans(ans):
    """
    EXAMPLE:
        >>> ans = /Users/burgert/miniconda3/lib/python3.12/site-packages/rp
        >>> _user_path_ans(ans)
        ans = ~/miniconda3/lib/python3.12/site-packages/rp
    """
    if isinstance(ans, (list, tuple)):
        return list(map(_user_path_ans,ans))

    ans=str(ans)
    ans=get_absolute_path(ans)
    if ans.startswith(get_home_directory()):
        ans='~'+ans[len(get_home_directory()):]
    return ans

def _cdh_back_query(query):
    assert isinstance(query, str)
    # Given we CDH to the same query over and over again, we should cycle between matches. That's what all the below logic is to ensure.
    lines = _get_cd_history()
    lines = lines[::-1]

    def matches(line):
        name = get_path_name(line)
        return fuzzy_string_match(query, name, case_sensitive=False)

    consecutive_matches = []
    other_matches = []
    non_matches = []
    deleted_matches = []
    for line in lines:
        if matches(line):
            if not non_matches:
                consecutive_matches.append(line)
            else:
                other_matches.append(line)
        else:
            non_matches.append(line)

    if other_matches:
        # Try to choose the first non-consecutive match so we can cycle
        while other_matches and not folder_exists(other_matches[0]):
            deleted_matches.append(other_matches[0])
            del other_matches[0]

        if other_matches:
            return other_matches[0]

    if consecutive_matches:
        # Let us cycle through history - if we choose the first we'll be stuck
        while consecutive_matches and not folder_exists(consecutive_matches[0]):
            deleted_matches.append(consecutive_matches[0])
            del consecutive_matches[0]

        if consecutive_matches:
            return consecutive_matches[-1]

    if deleted_matches:
        raise IndexError("None of the CDH matches for " + repr(query) + " still exist on your filesystem. Use CDH CLEAN to delete them.")
    else:
        raise IndexError("No CDH matches for " + repr(query))




_cd_history_size_limit=100000#To avoid spamming the console when we use CDH, limit the number of recent directories to this amount #UPDATE: I decided to make this effectively limitless (100000 is very big lol). Why limit it?
def _get_cd_history():
    try:
        output = line_split(text_file_to_string(_cd_history_path))
        output = output[:_cd_history_size_limit]
        return output
    except Exception as e:
        # print_verbose_stack_trace(e)
        return []
def _add_to_cd_history(path:str):
    if path=='.':
        return
    def unique(l:list):
        o=[]
        for e in reversed(l):
            if e not in o:
                o.append(e)
        return o[::-1] 

    # #OLD
    # string_to_text_file(_cd_history_path,line_join(entries))

    #NEW (SEPARATE THREAD)
    import rp.prompt_toolkit.history as h
    @h.run_task
    def task():
        entries=_get_cd_history()
        entries.append(path)
        entries=unique(entries)
        string_to_text_file(_cd_history_path,line_join(entries))

_last_dir=None
def _update_cd_history():
    # print()
    # print("OLD HISTORY")
    # fansi_print(text_file_to_string(_cd_history_path),'magenta')
    # fansi_print(_get_cd_history(),'magenta')
    global _last_dir
    if _last_dir!=get_current_directory():
        _last_dir=get_current_directory()
        try:
            _add_to_cd_history(get_current_directory())
            from rp.rp_ptpython.completer import get_all_importable_module_names
            get_all_importable_module_names()#Refresh
        except FileNotFoundError:
            #This will happen if the folder we're currently working in is deleted. Just skip updating the history...
            pass
        # print("NEW HISTORY")
        # fansi_print(text_file_to_string(_cd_history_path),'magenta')
        # fansi_print(_get_cd_history(),'magenta')
        # print()

cdc_protected_prefixes=[]
def _cdh_folder_is_protected(x):
    return  (any(x.startswith(prefix) for prefix in cdc_protected_prefixes)) and not folder_exists(x)

def _clean_cd_history():
    #Removes all nonexistant paths from CDH
    #It removes all the red entries
    entries=_get_cd_history()
    entries=[entry for entry in entries if path_exists(entry) or _cdh_folder_is_protected(entry)]
    entries=[entry for entry in entries if not '/rp/outputs/claudecode/workspace_' in entry] #These get spammy...
    string_to_text_file(_cd_history_path,line_join(entries))

def set_prompt_style(style:str=None):
    print('Running rp.set_prompt_style:')
    default_prompt_styles=[' ⮤ ',' >>> ',' >> ',' > ',' ▶ ',' ▶▶ ',' ►► ' ,' ▷▷ ',' ▷ ',' --> ',' ––> ',' 🠥 ','',' 🡩 ',' ➤ ',' ⮨ ']
    cancel_message='Cancelled setting new prompt style.'

    if style is None:
        custom_option='(custom prompt style)'
        cancel_option='(cancel)'

        option=input_select('No style was specified. Please select a new prompt style:',[custom_option,cancel_option]+default_prompt_styles)
        if option==custom_option:
            print('Enter a custom prompt style:')
            style=input()
        elif option==cancel_option:
            print(cancel_message)
            return
        else:
            style=option

    assert isinstance(repr(style),str)
    fansi_print("Displaying current prompt style:",'blue')
    print(repr(_get_prompt_style()))
    fansi_print("Displaying new prompt style:",'blue')
    print(repr(style))
    print(fansi('Some other styles you might want to consider: ','blue')+repr(default_prompt_styles)[1:-1])

    if input_yes_no("Are you sure you want to switch?"):
        try:
            string_to_text_file(_prompt_style_path,style)
            global _get_prompt_style_cached
            _get_prompt_style_cached=None#Invalidate the cache, forcing it to reload
        except BaseException as e:
            print("Failed to save new prompt...displaying error")
            print_stack_trace(e)
    else:
        print('...ok. Will not save new prompt style')
        if input_yes_no('Would you like to select a different style instead?'):
            set_prompt_style()
        else:
            print(cancel_message)
            return

class PseudoTerminalPrompt(ps.ClassicPrompt):
    def in_tokens(self,cli):
        pip_import('pygments')
        from pygments.token import Token
        return [(Token.Prompt,_get_prompt_style())]
setattr(ps,'PseudoTerminalPrompt',PseudoTerminalPrompt)
default_python_input_eventloop = None  # Singleton for python_input

def split_into_sublists(l, sublist_len: int, *, strict=False, keep_remainder=True, lazy=False):
    """
    Parameters:
        l: Sequence to split (list, tuple, string, or any iterable).
           If it's a generator without length, such as an infinite stream, best to use lazy=True
        sublist_len (int): Size of each sublist/chunk
        strict (bool, default=False): If True, input length must be evenly divisible by sublist_len
        keep_remainder (bool, default=True): If True, include partial final chunk; ignored if strict=True
        lazy (bool, default=False): If True, returns generator; if False, returns list
    
    Returns:
        List or generator of sublists. For strings, returns list of string chunks.
        For other iterables, returns list of tuples (unless lazy=True).
    
    Raises:
        ValueError: If strict=True and len(l) is not evenly divisible by sublist_len
    
    EXAMPLES:
        >>> split_into_sublists([1,2,3,4,5,6,7,8,9], 3)
        [(1, 2, 3), (4, 5, 6), (7, 8, 9)]
        >>> split_into_sublists([1,2,3,4,5,6,7,8,9], 4)
        [(1, 2, 3, 4), (5, 6, 7, 8), (9,)]
        >>> split_into_sublists([1,2,3,4,5,6,7,8,9], 4, keep_remainder=False)
        [(1, 2, 3, 4), (5, 6, 7, 8)]
        >>> split_into_sublists([1,2,3,4,5,6,7,8,9], 4, strict=True) #ERROR! Can't evenly divide 9 elements into length-4 chunks
        >>> split_into_sublists("abcdefghijklm", 4)
        ['abcd', 'efgh', 'ijkl', 'm']

    If strict: sublist_len MUST evenly divide len(l)
    It will return a list of tuples, unless l is a string, in which case it will return a list of strings
    keep_remainder is not applicable if strict
    if not keep_remainder and sublist_len DOES NOT evenly divide len(l), we can be sure that all tuples in the output are of len sublist_len, even though the total number of elements in the output is less than in l.
    
    """

    assert is_number(sublist_len),'sublist_len should be an integer, but got type '+repr(type(sublist_len))
    if strict and has_len(l):
        if len(l) % sublist_len != 0:
            raise ValueError('len(l)=={0} and sublist_len=={1}: strict mode is turned on but the sublist size doesnt divide the list input evenly. len(l)%sublist_len=={2}!=0'.format(len(l), sublist_len, len(l)%sublist_len))

    #OLD VERSION, DIDNT SUPPORT LAZY: This line is rather dense, but it makes sense.
    #n=sublist_len
    #output=(zip(*(iter(l),) * n))+([tuple(l[len(l)-len(l)%n:])] if len(l)%n and keep_remainder else [])
    
    #NEW VERSION: INPUT CAN BE ANY GENERATOR
    def helper():
        sublist = []
        for x in l:
            sublist.append(x)
            if len(sublist)==sublist_len:
                yield sublist
                sublist = []
        if sublist:
            if strict:
                raise ValueError('strict mode is turned on but the sublist size doesnt divide the list input evenly. remaining elements: {0}'.format(len(sublist)))
            elif keep_remainder:
                yield sublist
                
    output = helper()
    
    if not lazy:
        output=list(output)

    if isinstance(l,str):
        output=[''.join(substring) for substring in output]
    elif not lazy:
        output=[tuple(sublist) for sublist in output]

    return output


def split_into_n_sublists(l, n):
    """
    Splits the input sequence `l` into `n` sublists as evenly as possible.
    Supports any sequence `l` that implements slicing.

    Parameters:
    l (sequence): The sequence to be split.
    n (int): The number of sublists to split `l` into.

    Returns:
    list: A list containing `n` sublists.

    Raises:
    ValueError: If `n` is not a positive integer.

    Examples:
    >>> split_into_n_sublists([1, 2, 3, 4, 5], 3)
    [[1, 2], [3, 4], [5]]

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

    >>> split_into_n_sublists([1, 2, 3, 4, 5], 10)
    [[1], [], [2], [], [3], [], [4], [], [5], []]

    >>> split_into_n_sublists([], 3)
    [[], [], []]

    >>> split_into_n_sublists('abdef',2)
    ['ab', 'def']

    >>> split_into_n_sublists('abdef',3)
    ['a', 'bd', 'ef']
    """

    if n <= 0:
        raise ValueError("rp.split_into_n_sublists: n must be greater than 0 but n is "+str(n))

    if isinstance(l, str):
        return [''.join(x) for x in split_into_n_sublists(list(l), n)]

    L = len(l)
    indices = [int(i * L / n) for i in range(n + 1)]
    return [l[indices[i]:indices[i + 1]] for i in range(n)]



def split_into_subdicts(d, subdict_size: int, strict=False, keep_remainder=True):
    """
    Splits a dictionary into a list of subdictionaries based on the specified subdict size.
    
    If strict: subdict_size MUST evenly divide len(d)
    keep_remainder is not applicable if strict
    if not keep_remainder and subdict_size DOES NOT evenly divide len(d), we can be sure that all subdictionaries in the output are of size subdict_size, even though the total number of elements in the output is less than in d.
    
    EXAMPLES:
    >>> split_into_subdicts({'a': 1, 'b': 2, 'c': 3, 'd': 4, 'e': 5, 'f': 6}, 2)
    [{'a': 1, 'b': 2}, {'c': 3, 'd': 4}, {'e': 5, 'f': 6}]
    
    >>> split_into_subdicts({'a': 1, 'b': 2, 'c': 3, 'd': 4, 'e': 5}, 3)
    [{'a': 1, 'b': 2, 'c': 3}, {'d': 4, 'e': 5}]
    
    >>> split_into_subdicts({'a': 1, 'b': 2, 'c': 3, 'd': 4, 'e': 5}, 3, strict=True)
    AssertionError: len(d)==5 and subdict_size==3: strict mode is turned on but the subdict size doesn't divide the dictionary evenly. len(d)%subdict_size==2!=0
    
    >>> split_into_subdicts({'a': 1, 'b': 2, 'c': 3, 'd': 4, 'e': 5}, 3, keep_remainder=False)
    [{'a': 1, 'b': 2, 'c': 3}]
    """


    assert isinstance(subdict_size, int), 'subdict_size should be an integer, but got type ' + repr(type(subdict_size))
    if strict:
        assert not len(d) % subdict_size, 'len(d)==' + str(len(d)) + ' and subdict_size==' + str(subdict_size) + ': strict mode is turned on but the subdict size doesn\'t divide the dictionary evenly. len(d)%subdict_size==' + str(len(d) % subdict_size) + '!=0'
    
    keys = list(d)
    key_sublists = split_into_sublists(keys, subdict_size, strict=strict, keep_remainder=keep_remainder)

    return [{key:d[key] for key in key_sublist} for key_sublist in key_sublists]


def split_into_n_subdicts(d, n):
    """
    Splits a dictionary into a list of n subdictionaries as evenly as possible.
    
    Parameters:
    d (dict): The dictionary to be split.
    n (int): The number of subdictionaries to split `d` into.

    Returns:
    list: A list containing `n` subdictionaries.

    Raises:
    ValueError: If `n` is not a positive integer.

    Examples:
    >>> split_into_n_subdicts({'a': 1, 'b': 2, 'c': 3, 'd': 4, 'e': 5}, 3)
    [{'a': 1, 'b': 2}, {'c': 3}, {'d': 4, 'e': 5}]
    
    >>> split_into_n_subdicts({'a': 1, 'b': 2, 'c': 3, 'd': 4, 'e': 5, 'f': 6}, 4)  
    [{'a': 1}, {'b': 2, 'c': 3}, {'d': 4}, {'e': 5, 'f': 6}]

    >>> split_into_n_subdicts({'a': 1, 'b': 2, 'c': 3}, 5)
    [{'a': 1}, {}, {'b': 2}, {}, {'c': 3}]

    >>> split_into_n_subdicts({}, 3)
    [{}, {}, {}]
    """

    if n <= 0:
        raise ValueError("rp.split_into_n_subdicts: n must be greater than 0, but n is "+str(n))

    keys = list(d)
    keys_sublists = split_into_n_sublists(keys, n)

    return [{key:d[key] for key in key_sublist} for key_sublist in keys_sublists]

def join_with_separator(iterable, separator, *, lazy=False, expand_separator=False):
    """
    Intersperse a separator between elements of an iterable.

    Args:
        iterable (iterable): The iterable to intersperse.
        separator: The separator to intersperse between elements.
        lazy (bool, optional): If True, return a generator. If False, return a list. Defaults to False.
        expand_separator (bool, optional): If True, the separator is expanded into its individual elements
            and interspersed between the elements of the iterable. Defaults to False.

    Returns:
        list or generator: A list or generator with the separator interspersed between elements.

    Examples:
        >>> join_with_separator([], None)
        []
        >>> join_with_separator([1], None)
        [1]
        >>> join_with_separator([1, 2], None)
        [1, None, 2]
        >>> join_with_separator([1, 2, 3, 4, 5], None)
        [1, None, 2, None, 3, None, 4, None, 5]
        >>> gen = join_with_separator([1, 2, 3, 4, 5], None, lazy=True)
        >>> list(gen)
        [1, None, 2, None, 3, None, 4, None, 5]
        >>> join_with_separator(['a', 'b', 'c'], '...')
        ['a', '...', 'b', '...', 'c']
        >>> join_with_separator(['a', 'b', 'c'], '...', expand_separator=True)
        ['a', '.', '.', '.', 'b', '.', '.', '.', 'c']
    """
    
    def generator():
        for index, value in enumerate(iterable):
            if index:
                if expand_separator:
                    yield from separator
                else:
                    yield separator
            yield value

    output = generator()

    if not lazy:
        output = list(output)
        
    return output

def rotate_image(image, angle_in_degrees, interp="bilinear"):
    """
    Returns a rotated image by angle_in_degrees, clockwise
    The output image size is usually not the same as the input size, unless the angle is 180 (or in the case of a square image, 90, 180, or 270)
    Usually, the output image size is larger than the input image size

    Args:
        image: Input image (as defined by rp.is_image - can be numpy, PIL, etc)
        angle_in_degrees (float): Rotation angle in degrees, clockwise positive
        interp (str): Interpolation method - "bilinear", "nearest", "bicubic"
        
    Returns:
        numpy.ndarray: Rotated image with expanded dimensions to fit rotation
                      - RGB input -> RGBA output (adds alpha channel)
                      - RGBA input -> RGBA output (preserves alpha)

    EXAMPLE:

        >>> def create_checkerboard_animation(image_url, D=3):
        ...    img = crop_image_to_square(load_image(image_url, use_cache=True))
        ...    tiles = split_tensor_into_regions(img, D, D)
        ...    frames = crop_images_to_max_size(
        ...        [
        ...            tiled_images(
        ...                [
        ...                    rotate_image(tile, angle * (1 if (i // D + i % D) % 2 else -1))
        ...                    for i, tile in enumerate(tiles)
        ...                ],
        ...                border_thickness=0,
        ...            )
        ...            for angle in [*[0] * 15, *range(91), *[90] * 15]
        ...        ],
        ...        origin="center",
        ...    )
        ...    display_video((frames + frames[::-1]) * 50, framerate=60)

        >>> create_checkerboard_animation(
        ...    "https://upload.wikimedia.org/wikipedia/en/7/7d/Lenna_%28test_image%29.png"
        ... )

    """
    image=as_numpy_image(image,copy=False)
    image=as_rgba_image(image,copy=False)
    alpha=get_image_alpha(image)
    rotate=lambda x: _rotate_rgb_image(x, angle_in_degrees, interp)
    alpha=rotate(alpha)
    rgb=rotate(as_rgb_image(image,copy=False))
    return with_alpha_channel(rgb,alpha,copy=False)

def rotate_images(*images, angle, interp='bilinear', show_progress=False):
    """ 
    Plural of rotate_image. Arguments are broadcastable. 
    Angles are measured in degrees!

    EXAMPLE:

        >>> url = "https://hips.hearstapps.com/hmg-prod/images/dog-puppy-on-garden-royalty-free-image-1586966191.jpg?crop=0.752xw:1.00xh;0.175xw,0&resize=1200:*"
        ... image = load_image(url, use_cache=True)
        ... image = resize_image_to_fit(image, height=256, width=256)
        ... display_video(
        ...     crop_images_to_max_size(
        ...         rotate_images(image, angle=range(360), show_progress=True),
        ...         origin="center",
        ...     ),
        ...     loop=True,
        ... )

    """

    images = detuple(images)

    #Prepare for list-based broadcasting. Todo: Make it possible for the broadcasted args to be lazy too!
    if is_image(images)           : images = [images]
    if is_iterable(angle)         : angle = list(angle)
    if not isinstance(interp, str): interp = list(interp)

    images = list(images)

    kwarg_sets = broadcast_kwargs(
        dict(
            image=images,
            angle_in_degrees=angle,
            interp=interp,
        )
    )

    if show_progress:
        kwarg_sets = eta(kwarg_sets, title='rp.rotate_images')

    output = (rotate_image(**kwarg_set) for kwarg_set in kwarg_sets)

    output = list(output)
    
    return output


def _rotate_rgb_image(image, angle_in_degrees, interp='bilinear'):
    """
    Will return an RGB image, not an RGBA one
    """
    image=as_numpy_image(image,copy=False)
    assert is_image(image)

    #Handle the edge cases: 0, 90, 180, 270, 360, etc - we don't need OpenCV for this
    if angle_in_degrees%360==0:
        return image.copy()
    if angle_in_degrees%360==180:
        return horizontally_flipped_image(vertically_flipped_image(image))
    if angle_in_degrees%90==0:
        if is_grayscale_image(image):
            if angle_in_degrees%360==270:
                return vertically_flipped_image(image.copy().T)
            else:
                assert angle_in_degrees%360==90
                return horizontally_flipped_image(image.copy().T)
        else:
            assert is_rgb_image(image) or is_rgba_image(image)
            if angle_in_degrees%360==270:
                return vertically_flipped_image(image.transpose(1,0,2))
            else:
                assert angle_in_degrees%360==90
                return horizontally_flipped_image(image.transpose(1,0,2))
        
    #ALTERNATIVE Implementation that doesn't use OpenCV and instead uses PILLOW (not used in this function):
    #    https://pythonexamples.org/python-pillow-rotate-image-90-180-270-degrees/#:~:text=You%20can%20rotate%20an%20image,to%20the%20size%20of%20output.
    # GOT CODE FROM URL: https://www.pyimagesearch.com/2017/01/02/rotate-images-correctly-with-opencv-and-python/
    #TODO: Make a cv_rotate_image version of this function that handles making the black pixels instead reflected images (there's an option for that in cv2.warp_affine). This is better for data augmentation purposes.
    angle=angle_in_degrees
    cv2=pip_import('cv2')

    interp_methods={'bilinear':cv2.INTER_LINEAR,'cubic':cv2.INTER_CUBIC,'nearest':cv2.INTER_NEAREST}
    assert interp in interp_methods, 'cv_resize_image: Interp must be one of the following: %s'%str(list(interp_methods))
    interp_method=interp_methods[interp]

    # grab the dimensions of the image and then determine the
    # center
    (h, w) = image.shape[:2]
    (cX, cY) = (w // 2, h // 2)

    # grab the rotation matrix (applying the negative of the
    # angle to rotate clockwise), then grab the sine and cosine
    # (i.e., the rotation components of the matrix)
    M = cv2.getRotationMatrix2D((cX, cY), -angle, 1.0)
    cos = np.abs(M[0, 0])
    sin = np.abs(M[0, 1])

    # compute the new bounding dimensions of the image
    nW = int((h * sin) + (w * cos))
    nH = int((h * cos) + (w * sin))

    # adjust the rotation matrix to take into account translation
    M[0, 2] += (nW / 2) - cX
    M[1, 2] += (nH / 2) - cY

    # perform the actual rotation and return the image
    return cv2.warpAffine(image, M, (nW, nH), flags=interp_method)


def open_url_in_web_browser(url:str):
    """ Assuming you're able to, will open the given website in a browser. On terminals this might be a TUI, and on desktops usually a GUI like Chrome """
    from webbrowser import open
    open(url)

def google_search_url(query:str)->None:
    """
    Returns the URL for google-searching the given query

    EXAMPLE:
        >>> google_search_url('What is a dog?')
        https://www.google.com/search?q=What%20is%20a%20dog%3F
    """
    query=str(query)
    import urllib.parse
    url='https://www.google.com/search?q='+urllib.parse.quote(query)
    return url

def open_google_search_in_web_browser(query:str):
    """
    Opens up the web browser to a google search of a given query
    """
    url=google_search_url(query)
    open_url_in_web_browser(url)
    return url

# def restart_python():
#     from os import system
#     print("killall Python\nsleep 2\npython3 "+shlex.quote(__file__))
#     system("killall Python\nsleep 2\npython3 "+shlex.quote(__file__))

def reload_module(module):
    import importlib
    importlib.reload(module)

def reload_rp():
    """
    If rp changes mid-notebook, here's a convenient way to reload it
    """
    import rp
    reload_module(rp)
    import rp
    return rp
    
#OLD CODE. WORKS FINE! BUT I WANTED TO REFACTOR IT. THIS CODE IS ANCIENT!!!! SOOO OLD!!!!! LIKE OVER 9 YEARS OLD....2016-esque
# def _eta(total_n,*,min_interval=.3,title="r.eta"):
#     """
#     Example:
#         >>> a = eta(2000,title='test')
#         ... for i in range(2000):
#         ...     sleep(.031)
#         ...     a(i)
#
#     This method is slopily written, but works perfectly.
#     """
#     timer=tic()
#     interval_timer=[tic()]
#     title='\r'+title+": "
#     def display_eta(proportion_completed,time_elapsed_in_seconds,TOTAL_TO_CIMPLET,COMPLETSOFAR,print_out=True):
#         if interval_timer[0]()>=min_interval:
#             interval_timer[0]=tic()
#             # Estimated time of arrival printer
#             from datetime import timedelta
#             out_method=(lambda x:print(x,end='') if print_out else identity)
#             temp=timedelta(seconds=time_elapsed_in_seconds)
#             completerey="\tProgress: " + str(COMPLETSOFAR) + "/" + str(TOTAL_TO_CIMPLET)
#             if proportion_completed<=0:
#                 return out_method(title +"NO PROGRESS; INFINITE TIME REMAINING. T=" +str(temp) +(completerey))
#             # exec(mini_terminal)
#             eta=float(time_elapsed_in_seconds) / proportion_completed  # Estimated time of arrival
#             etr=eta- time_elapsed_in_seconds # Estimated time remaining
#             return out_method(title+(("ETR=" + str(timedelta(seconds=etr)) + "\tETA=" + str(timedelta(seconds=eta)) + "\tT="+str(temp) + completerey if etr > 0 else "COMPLETED IN " + str(temp)+completerey+"\n")))
#     def out(n,print_out=True):
#         return display_eta(n/total_n,timer(),print_out=print_out,TOTAL_TO_CIMPLET=total_n,COMPLETSOFAR=n)
#     return out


_print_status_prev_len = 0
def _print_status(x):
    """ 
    Print a single line in such a way that it will be overwritten if we call _print_status again 
    
    Args:
        x (Any): The message to display (will be converted to string)
    """
    global _print_status_prev_len

    x = str(x)

    if not running_in_jupyter_notebook():
        _erase_terminal_line()

    print(
        "\r" + " " * _print_status_prev_len + "\r" + x,
        end="",
        flush=True,
    )

    _print_status_prev_len = len(x)

def _eta(total_n,*,min_interval,title,format_value=str,completion_verb=None,completion_unit="items",start_n=0,format_rate=None):
    """
    Internal ETA implementation that creates a progress tracking function.

    Args:
        total_n (int or None): Total number of items to process. If None, only tracks elapsed time without ETA.
        min_interval (float): Minimum seconds between display updates
        title (str): Display title for progress messages
        format_value (callable): Function to format progress values (default: str)
        completion_verb (str): Verb for completion message (default: "Did")
        completion_unit (str): Unit name for completion message (default: "items")
        start_n (int): Starting count for resumed operations (default: 0)

    Returns:
        callable: Progress function that accepts current count and updates display

    Example:
        >>> a = eta(2000,title='test')
        ... for i in range(2000):
        ...     sleep(.031)
        ...     a(i)
    """
    from datetime import timedelta

    if min_interval is None: min_interval=.05
    if title is None: title="r.eta"

    if format_rate is None: 
        if format_value is str:
            format_rate = partial(str.format, '{:.2f}')
        else:
            format_rate=format_value

    if completion_verb is None:
        completion_verb = "Did"

    timer = tic()
    interval_timer = tic()
    title = title + ": "
    shown_done = False
    style = 'invert'

    def fansi_progress(string, proportion):
        """ Used to show a progress bar under the ETA text! """
        string = string.expandtabs() #Jupyter doesn't render underlines over tabs
        num_chars = round(len(string) * proportion)
        return fansi(string[:num_chars], style) + string[num_chars:]

    if total_n is None:
        # Unknown total - just track progress without ETA, but show rate
        def display_progress_only(time_elapsed_in_seconds, COMPLETED_SO_FAR):
            nonlocal interval_timer

            if interval_timer()>=min_interval:
                interval_timer=tic()
                temp=timedelta(seconds=time_elapsed_in_seconds)

                # Calculate average rate
                if time_elapsed_in_seconds > 0:
                    rate = COMPLETED_SO_FAR / time_elapsed_in_seconds
                    rate_str = "\tRATE={0}/s".format(format_value(rate))
                else:
                    rate_str = ""

                return _print_status(title + "T=" + str(temp) + rate_str + "\t" + format_value(COMPLETED_SO_FAR))

        def out(n):
            return display_progress_only(timer(), COMPLETED_SO_FAR=n)

        return out

    def display_eta(proportion_for_eta,time_elapsed_in_seconds,TOTAL_TO_COMPLETE,COMPLETED_SO_FAR):
        nonlocal interval_timer
        nonlocal shown_done

        done = COMPLETED_SO_FAR >= TOTAL_TO_COMPLETE

        if shown_done:
            return  # Already printed completion, don't print again

        if interval_timer()>=min_interval or done and not shown_done:
            interval_timer=tic()

            # Estimated time of arrival printer
            temp=timedelta(seconds=time_elapsed_in_seconds)
            progress = "\tProgress: " + format_value(COMPLETED_SO_FAR) + "/" + format_value(TOTAL_TO_COMPLETE)

            # Calculate transfer rate (only count items transferred in this session)
            if time_elapsed_in_seconds > 0:
                items_transferred = COMPLETED_SO_FAR - start_n
                rate = items_transferred / time_elapsed_in_seconds
                rate_str = "\tRATE=" + format_rate(rate) + "/s"
            else:
                rate_str = ""

            # Visual progress bar shows actual completion
            proportion_for_bar = COMPLETED_SO_FAR / TOTAL_TO_COMPLETE if TOTAL_TO_COMPLETE > 0 else 1.0

            if proportion_for_eta <= 0:
                return _print_status(
                    title
                    + "NO PROGRESS; INFINITE TIME REMAINING. T="
                    + str(temp)
                    + (progress)
                )

            eta = float(time_elapsed_in_seconds) / proportion_for_eta
            # Estimated time of arrival
            etr = eta - time_elapsed_in_seconds  # Estimated time remaining

            if done:
                shown_done = True

            return _print_status(
                fansi_progress(
                    title
                    + "ETR="
                    + str(timedelta(seconds=etr))
                    + "\tETA="
                    + str(timedelta(seconds=eta))
                    + "\tT="
                    + str(temp)
                    + progress
                    + '   '
                    + rate_str,
                    proportion_for_bar,
                )
                if not done
                # else title + "COMPLETED IN " + str(temp) + progress + "\n"
                else title + "Done! %s %s %s in %s" % (completion_verb, format_value(COMPLETED_SO_FAR).strip(), completion_unit, temp) + "\n"
            )


    def out(n):
        # Calculate proportion based on work done in this session
        if total_n - start_n > 0:
            proportion = (n - start_n) / (total_n - start_n)
        else:
            proportion = 1.0  # Already at or past total

        return display_eta(
            proportion,
            timer(),
            TOTAL_TO_COMPLETE=total_n,
            COMPLETED_SO_FAR=n,
        )

    return out

class eta:
    """Progress bar with ETA data. RP's alternative to tqdm with time estimates.
    Works as iterator wrapper or manual counter.
    
    Args:
        x: Total count (int) or iterable to wrap
        title: Display title (default: 'r.eta') 
        min_interval: Min seconds between updates (default: 0.05)
        length: Override length for generators
        
    Examples:
        >>> # Manual counter - call eta(i) to update
        >>> progress = eta(10, title='Processing')
        >>> for i in range(10):
        ...     sleep(0.1)  # work done here
        ...     progress(i)
        Processing: ETR=0:00:00.828947  ETA=0:00:01.657894  T=0:00:00.828947  Progress: 5/10
        
        >>> # Iterator wrapper - automatic updates  
        >>> for i in eta(range(8)):
        ...     sleep(0.1)  # work done here
        r.eta: ETR=0:00:00.408119  ETA=0:00:00.816238  T=0:00:00.408119  Progress: 4/8
        r.eta: Done! Did 8 items in 0:00:00.825149
        
        >>> # With custom title
        >>> for file in eta(['a.txt', 'b.txt', 'c.txt'], title='Converting'):
        ...     sleep(0.1)  # convert file here  
        Converting: ETR=0:00:00.203035  ETA=0:00:00.507588  T=0:00:00.304553  Progress: 3/5
        Converting: Done! Did 5 items in 0:00:00.510275
        
    Note: Uses ANSI color inversion so text and progress bar share the same space - no width competition.
    """

    def __init__(self, x:int, title:str=None, min_interval:float=None, length:int=None):
        assert isinstance(x, int) or hasattr(x, '__len__') or length is not None

        if length is not None:
            length = int(length)
            self.elements = IteratorWithLen(x, length)
            x = length
        elif has_len(x):
            self.elements = x
            x = len(x)
        else:
            assert is_number(x)
            self.elements = range(x)

        self.display_eta = _eta(x, title=title, min_interval=min_interval)

    def __call__(self, n):
        self.display_eta(n)

    def __iter__(self):
        if len(self):
            for i,e in enumerate(self.elements):
                self(i)
                yield e
            self(i+1)

    def __len__(self):
        return len(self.elements)
            




# @memoized
def get_all_submodule_names(module):
    """
    Takes a module and returns a list of strings.
    Example:
       >>> all_submodule_names(np)
       ans = ['numpy.core', 'numpy.fft', 'numpy.linalg', 'numpy.compat', 'numpy.conftest', ...(etc)... ]
    This function is NOT recursive
    This function IS safe to run (unlike get_all_submodules)
    """
    import types,pkgutil
    assert isinstance(module,types.ModuleType),'This function accepts a module as an input, but you gave it type '+repr(type(module))

    if not hasattr(module,'__path__'):
        return []

    submodule_names=[]
    prefix = module.__name__ + "."
    path   = module.__path__
    for importer, modname, ispkg in pkgutil.iter_modules(path, prefix):
        submodule_names.append(modname)
    return submodule_names

# def get_all_submodules(module,recursive=True):
#     #NOTE: This function is dangerous and may have unintended side-effects if importing a certain module runs unwanted code.
#     #Attempt to return as many imported modules as we can...skip all the ones that have errors when importing...
#     import types,pkgutil,importlib
#     assert isinstance(module,types.ModuleType),'This function accepts a module as an input, but you gave it type '+repr(type(module))

#     def recursion_helper(module):
#         yield module
#         for submodule in all_submodules(module,recursive=False):
#             yield from recursion_helper(submodule)

#     for submodule_name in all_submodule_names(module):
#         try:
#             submodule=importlib.import_module(submodule_name)
#         except Exception:
#             pass
#         else:
#             if recursive:
#                 yield from recursion_helper(submodule)
#             else:
#                 yield submodule

# def get_all_submodule_names(module):  #OLD CODE
#     # SOURCE: https://stackoverflow.com/questions/832004/python-finding-all-packages-inside-a-package
#     return [x.split('.')[1] for x in get_all_submodule_names(module)]
#     dir = os.path.dirname(module.__file__)
#     def is_package(d):
#         d = os.path.join(dir, d)
#         return os.path.isdir(d) and glob.glob(os.path.join(d, '__init__.py*'))
#     return list(filter(is_package, os.listdir(dir)))

# def merged_dicts(*dict_args):
#     """
#     SOURCE: https://stackoverflow.com/questions/38987/how-to-merge-two-dictionaries-in-a-single-expression
#     Given any number of dicts, shallow copy and merge into a new dict,
#     precedence goes to key value pairs in latter dicts.
#     """
#     # dict_args=detuple(dict_args)
#     result = {}
#     for dictionary in dict_args:
#         result.update(dictionary)
#     return result

def merged_dicts(*dicts, precedence='last', mutate=False):
    """
    Merge given dictionaries into a new dictionary or mutate the first one.
    The type of the resulting dictionary will be the same as the type of the first dictionary provided
    if mutate is False. Precedence determines which dictionary's values will take priority in case of key conflicts.

    Args:
        *dicts: Variable length dictionary list or a single iterable of dictionaries.
        precedence (str): Determines precedence. 'first' means dictionaries listed first take precedence,
                          'last' means dictionaries listed last take precedence.
        mutate (bool): If True, the first dictionary provided will be mutated instead of creating a new one.

    Returns:
        A new dictionary that is a merge of the given dictionaries, or the first dictionary if mutate is True.
        If non-dicts are given, such as EasyDict, the type of the first dict given will be used.

    Raises:
        ValueError: If 'precedence' is not 'first' or 'last', or if any provided argument is not a dictionary.

    Examples:
        Example 1: merged_dicts({'a': 1}, {'b': 2}, {'a': 3})  # Output: {'a': 3, 'b': 2}
        Example 2: merged_dicts({'a': 1}, {'b': 2}, {'a': 3}, precedence='first')  # Output: {'a': 1, 'b': 2}
        Example 3: merged_dicts(dict1, {'b': 2}, mutate=True)  # `dict1` is now {'a': 1, 'b': 2}
        Example 4: merged_dicts(dict1, {'b': 2})  # `dict1` remains {'a': 1}, `result` is {'a': 1, 'b': 2}
        Example 5: merged_dicts()  # Output: {}
        Example 6: merged_dicts([{'a': 1}, {'b': 2}])  # Output: {'a': 1, 'b': 2}
        Example 7: Handling non-dict Mapping objects
            from easydict import EasyDict
            edict1 = EasyDict({'a': 1})
            edict2 = EasyDict({'b': 2})
            merged_dicts(edict1, edict2)  # Expected output: EasyDict({'a': 1, 'b': 2})
        
    """
    from collections.abc import Mapping, Iterable
    
    # Validate precedence argument
    if precedence not in ('first', 'last'):
        raise ValueError("Invalid precedence value: '{}'. Precedence must be either 'first' or 'last'.".format(precedence))

    # Flatten the input if a single iterable is provided.
    if len(dicts) == 1 and isinstance(dicts[0], Iterable) and not isinstance(dicts[0], Mapping):
        dicts = dicts[0]

    # Check to make sure all the dicts are there
    if not all(isinstance(d, Mapping) for d in dicts):
        raise ValueError("All arguments must be Mapping objects, but they weren't. We got types "+repr(list(map(type,dicts))))

    # Determine the order of dictionary processing based on precedence.
    dicts = list(reversed(dicts)) if precedence == 'first' else dicts

    # Find the first dict if there is one, otherwise make a new one
    first_dict = dicts[0] if len(dicts) else {}

    # If mutate is True, the first dictionary is updated directly.
    result = first_dict if mutate else type(first_dict)()

    # Merge the dicts into result
    for d in dicts:
        result.update(d)

    return result

def merged_prefixed_dicts(**kwargs):
    """
    Useful for destructuring from multiple dicts
    EXAMPLE:
        >>> first_output = dict(a=1,b=2,c=3)
        >>> second_output = dict(a=4,b=5,c=6)
        >>> merged_prefixed_dicts(first_=first_output,second_=second_output)
        ans = {'first_a': 1, 'first_b': 2, 'first_c': 3, 'second_a': 4, 'second_b': 5, 'second_c': 6}
        >>> first_a, second_a = destructure(merged_prefixed_dicts(first_=first_output,second_=second_output))
    """
    out_dict={}
    for prefix in kwargs:
        dict=kwargs[prefix]
        for key,value in dict.items():
            new_key=str(prefix)+str(key)
            out_dict[new_key]=value
    return out_dict
    
def merged_suffixed_dicts(**kwargs):
    """
    Useful for destructuring from multiple dicts by using suffixed keys from each dictionary.
    EXAMPLE:
        >>> first_output = dict(a=1, b=2, c=3)
        >>> second_output = dict(a=4, b=5, c=6)
        >>> merged_suffixed_dicts(first_=first_output, second_=second_output)
        ans = {'a_first': 1, 'b_first': 2, 'c_first': 3, 'a_second': 4, 'b_second': 5, 'c_second': 6}
    """
    out_dict={}
    for prefix in kwargs:
        dict=kwargs[prefix]
        for key, value in dict.items():
            new_key=str(key)+'_'+str(prefix)
            out_dict[new_key]=value
    return out_dict


def keys_and_values_to_dict(keys,values):
    """
    EXAMPLE:
     >>> keys_and_values_to_dict([1,2,3,4],['a','b','c','d'])
    ans = {1: 'a', 2: 'b', 3: 'c', 4: 'd'}
     >>> {x:y for x,y in zip(keys,values)} #Equivalent
    ans = {1: 'a', 2: 'b', 3: 'c', 4: 'd'}
    """
    out={}
    for key,value in zip(keys,values):
        out[key]=value
    return out

def get_source_code(object):
    """
    EXAMPLE:
     >>> get_source_code(get_source_code)
     ans = def get_source_code(object):
         import inspect
         return inspect.getsource(object)
    """
    import inspect
    try:
        return inspect.getsource(object)
    except TypeError:
        return inspect.getsource(type(object))


def get_source_file(object):
    """
    Might throw an exception
    """
    getter=lambda x:inspect.getfile(inspect.getmodule(x))
    import inspect
    try:
        return getter(object)
    except TypeError:#ERROR: TypeError: None is not a module, class, method, function, traceback, frame, or code object
        return getter(type(object))


# region Editor Launchers
def edit(file_or_object,editor_command='atom'):
    if isinstance(file_or_object,str):
        return os.system(editor_command +" " + shlex.quote(file_or_object))# Idk if there's anything worth returning but maybe there is? run_as_subprocess is true so we can edit things in editors like vim, suplemon, emacs etc.
    else:
        return edit(get_source_file(object=file_or_object),editor_command=editor_command)
sublime=lambda x:edit(x,'sublime')
subl   =lambda x:edit(x,'subl'   )
vscode =lambda x:edit(x,'code'   )
gedit  =lambda x:edit(x,'gedit'  )
atom   =lambda x:edit(x,'atom'   )
# vim=lambda x:edit(x,'vim') # Later we define a special, custom function for vim

def _static_calldefs(modpath):
    pip_import('xdoctest')
    from xdoctest import static_analysis as static
    calldefs = dict(static.parse_calldefs(fpath=modpath))
    return calldefs

def _get_object_lineno(obj):
    #TODO: Make this still work even if the source file was changed (right now, if you use VIMORE and then edit the file then use VIMORE again, it will bring you to the wrong place the second time because of how python works)

    #If a function is wrapped, don't show us the wrapper idc about that
    while hasattr(obj, '__wrapped__'):
        obj = obj.__wrapped__

    try:
        # functions just 
        lineno = obj.__code__.co_firstlineno
    except Exception:
        module_code=text_file_to_string(get_source_file(obj))
        obj_code=get_source_code(obj)
        first_line=obj_code.splitlines()[0]
        index=module_code.find(first_line)
        lineno=module_code[:index].count('\n')
        lineno+=1


    # except AttributeError:
    #     attrname = obj.__name__
    #     modpath = sys.modules[obj.__module__].__file__
    #     calldefs = _static_calldefs(modpath)
    #     ub.modpath_to_modname(modpath)
    #     calldef = calldefs[attrname]
    #     lineno = calldef.lineno

    return lineno

def vim(file_or_object=None,line_number=None):
    """
    Opens files or objects in Vim. Jumps to source location for Python objects.
    
    Args:
        file_or_object: File path, list of paths, Python object, or None for empty Vim
        line_number: Line to jump to (auto-detected for objects)
        
    Examples:
        >>> vim("script.py")  # Open file
        >>> vim("script.py", 42)  # Open at line 42
        >>> vim(["a.py", "b.py"])  # Open multiple files
        >>> vim(resize_image)  # Open function's source file at definition
        >>> vim()  # Empty Vim session
        
    Note: Requires terminal (not Jupyter) and Vim installed.
    """
    import subprocess
    args=['vim']

    assert currently_in_a_tty(),'Cannot start Vim because we are not running in a terminal' #In Jupyter Notebook, launching Vim might force you to restart the kernel...very annoying

    if isinstance(file_or_object,str):
        path=file_or_object
        path=get_absolute_path(path)
        args.append(path)
    elif isinstance(file_or_object, list):
        #Can specify a list of objects or files and vim will edit all at once
        for path in file_or_object:
            if isinstance(path, str):
                path = get_absolute_path(path)
            else:
                path=get_source_file(file_or_object)
            args.append(path)
    elif file_or_object is None:
        path=None
        pass
    else:
        path=get_source_file(file_or_object)
        args.append(path)

        if line_number is None and not is_a_module(file_or_object):
            try:
                line_number=_get_object_lineno(file_or_object)
            except Exception:
                pass

    if line_number is not None:
        #https://stackoverflow.com/questions/3313418/starting-vim-at-a-certain-position-line-and-column-of-a-file
        column_number=0
        args+=['+call cursor(%i,%i)'%(line_number,column_number),'+normal zz']

    if is_a_folder(path):
        folder=path
    else:
        folder=get_parent_directory(path)
        
    original_directory=get_current_directory()

    try:
        set_current_directory(folder) # This step is just for convenience; it's completely optional (might be removed if I don't like it). When editing a file, set vim's pwd to it's folder
        subprocess.call(args) 
    finally:
        set_current_directory(original_directory)
    

# # initialize editor methods. Easier to understand when analyzing this code dynamically; static analysis might be really confusing
# __known_editors=['emacs','suplemon','atom','sublime','subl']# NONE of these names should intersect any methods or varables in the r module or else they will be overwritten!
# # for __editor in __known_editors:
#     exec("""
# def X(file_or_object):
#     _edit(file_or_object,editor_command='X')""".replace('X',__editor))
# del __known_editors,__editor# This is just a setup section to create methods for us, so get rid of the leftovers. __known_editors and __editor are assumed to be unused anywhere else in our current namespace!dz


# endregion


namespace="set(list(locals())+list(globals())+list(dir()))"  # eval-uable
xrange=range  # To make it more compatiable when i copypaste py2 code

term='pseudo_terminal(locals(),globals())'# For easy access: exec(term). Can use in the middle of other methods!

def is_valid_python_syntax(code,mode='exec',version=None,return_error=False):
    """
    Returns True if the code is valid python syntax, False otherwise (when return_error=False).
    If return_error=True, returns False for valid syntax or the error object for invalid syntax.
    The 'mode' specifies the type of python code - 'exec' is a superset of 'eval'.
    The 'version' if specified (e.g., '3.5'), checks if code is valid for that Python version using ast.parse with feature_version.

    EXAMPLE:
        >>> is_valid_python_syntax('x = 5')
        ans = True
        >>> is_valid_python_syntax('x = ')
        ans = False
    EXAMPLE:
        >>> is_valid_python_syntax('x = 5', return_error=True)
        ans = False
        >>> is_valid_python_syntax('x = ', return_error=True)
        ans = SyntaxError('invalid syntax', ('<unknown>', 1, 5, 'x = \n', 1, 5))
    EXAMPLE:
        >>> is_valid_python_syntax('f"hello"', version='3.5')
        ans = False
        >>> is_valid_python_syntax('f"hello"', version='3.5', return_error=True)
        ans = SyntaxError('Format strings are only supported in Python 3.6 and greater', ('<unknown>', 1, 9, 'f"hello"\n', 1, 9))
    """
    assert isinstance(code,str),'Code should be a string'
    import ast, traceback

    if version is None:
        try:
            ast.parse(code,mode=mode)
            out = False
        # except SyntaxError: #ValueError: source code string cannot contain null bytes
        except Exception as e:
            out = e
    else:
        # Use pip-installable parso for version-specific parsing
        parso = pip_import('parso')
        import os

        # Auto-download grammar files for older Python versions if needed
        grammar_file = path_join(os.path.dirname(parso.__file__), 'python', 'grammar' + version.replace(".", "") + '.txt')
        if not file_exists(grammar_file):
            url = 'https://raw.githubusercontent.com/davidhalter/parso/v0.7.1/parso/python/grammar' + version.replace(".", "") + '.txt'
            try:
                download_url(url, grammar_file)
            except:
                pass

        try:
            grammar = parso.load_grammar(version=version)
            module = grammar.parse(code)
            errors = list(grammar.iter_errors(module))

            if errors:
                first_error = errors[0]
                err = SyntaxError(first_error.message)
                err.lineno = first_error.start_pos[0]
                err.offset = first_error.start_pos[1]
                lines = code.split('\n')
                err.text = lines[err.lineno - 1] if err.lineno <= len(lines) else None
                err.filename = '<string>'
                out = err
            else:
                out = False
        except (NotImplementedError, FileNotFoundError):
            # Grammar not available, use ast.parse fallback
            try:
                feature_ver = tuple(map(int, version.split('.')))
                ast.parse(code, mode=mode, feature_version=feature_ver)
                out = False
            except Exception as e:
                out = e
        except Exception as e:
            out = e

    if not return_error:
        return not bool(out)
    return out

def _is_valid_exeval_python_syntax(code, mode='exec'):
    code, _ = _parse_exeval_code(code)
    return is_valid_python_syntax(code)


def is_valid_shell_syntax(code,*, silent=True, command=None):
    """
    Returns True if the code is valid shell syntax for your default shell. If command is specified (such as '/bin/zsh' or rp.get_default_shell()), checks that shell instead.

    EXAMPLE:
        >>> is_valid_shell_syntax('asoidj')
        ans = True
        >>> is_valid_shell_syntax('asoidj("')
        ans = False
    """
    import subprocess
    
    if command is None:
        command=get_default_shell()
    else:
        assert isinstance(command,str)

    try:
        # Running the shell code with 'sh -n' which checks for syntax without execution
        process = subprocess.run(
            [command, "-n"], input=code, text=True, stderr=subprocess.PIPE, check=True
        )
        # If the shell command succeeds without error, the syntax is valid
        return True
    except subprocess.CalledProcessError as e:
        # If there's a syntax error, print the error and return False
        if not silent:
            print("Syntax error:", e.stderr)
        return False

def is_valid_sh_syntax(code, *,silent=True, command="sh"):
    """Returns True if the code is valid bash syntax, False otherwise. If silent=False, will print out more information."""
    return is_valid_shell_syntax(code, silent=silent, command=command)

def is_valid_bash_syntax(code, *,silent=True, command="bash"):
    """Returns True if the code is valid bash syntax, False otherwise. If silent=False, will print out more information."""
    return is_valid_shell_syntax(code, silent=silent, command=command)


def is_valid_zsh_syntax(code, *,silent=True, command="zsh"):
    """Returns True if the code is valid bash syntax, False otherwise. If silent=False, will print out more information."""
    return is_valid_shell_syntax(code, silent=silent, command=command)


def get_default_shell():
    """Returns the path to the user's default shell."""
    return os.environ.get('SHELL', '/bin/sh')  # Fallback to '/bin/sh' if SHELL is not set

def _bash_quote_cstring(code):
    r"""
    Like shlex.quote, but creates a cstring $'like\nthis' where newlines are escaped as \n
    """

    if not '\n' in code:
        return code

    # Map of safe ASCII
    SAFE_CHARS = set(
        b" !\"#$%&()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[]^_`"
        b"abcdefghijklmnopqrstuvwxyz{|}~"
    )
    ESCAPE_MAP = {
        0x07: r"\a",
        0x08: r"\b",
        0x09: r"\t",
        0x0A: r"\n",
        0x0B: r"\v",
        0x0C: r"\f",
        0x0D: r"\r",
    }

    def encode_bytes(data: bytes) -> str:
        """Encode arbitrary bytes into a Bash $'…' ANSI-C string literal."""
        out = []
        for b in data:
            if b in ESCAPE_MAP:
                out.append(ESCAPE_MAP[b])
            elif b == 0x5C:  # backslash
                out.append(r"\\")
            elif b == 0x27:  # single quote
                out.append(r"\'")
            elif b in SAFE_CHARS:
                out.append(chr(b))
            else:
                out.append("\\x{0:02x}".format(b))
        return "$'" + "".join(out) + "'"

    data = code.encode("utf-8", "surrogateescape")  # preserve all bytes
    payload = encode_bytes(data)

    return payload

def format_bash_to_oneliner(code: str) -> str:
    r'''
    Turn a Bash code string into a one-liner that runs it with 100% correctness.
    The returned line is human-readable (ANSI-C $'..' style, no base64).
    
    EXAMPLE:

        >>> format_bash_to_oneliner("""
        ...         setopt AUTO_PUSHD
        ...         alias -- -='popd'
        ... 
        ...         killport()
        ...         {
        ...             lsof -ti tcp:$1 | xargs kill
        ...         }
        ... """)
        eval $'\n        setopt AUTO_PUSHD\n        alias -- -=\'popd\'\n\n        killport()\n        {\n            lsof -ti tcp:$1 | xargs kill\n        }\n'

    '''
    payload = _bash_quote_cstring(code)

    # Return a runnable one-liner
    return "eval " + payload    #Does not work in sh, only bash and zsh etc
    return "bash -c " + payload #Safer - but starts as subprocess



def _ipython_exeval_maker(scope={}):
    pip_import('IPython','ipython')#Make sure we have ipython
    from IPython.terminal.embed import InteractiveShellEmbed as Shell
    shell=Shell(user_ns=scope)
    shell.showtraceback = lambda *args,**kwargs:None
    def ipython_exeval(code,_ignored_1,_ignored_2):
        # fansi_print(scope,'yellow')
        result=shell.run_cell(code)#,silent=True)#silent=True avoids making variables like _,__ etc that ipython typically does
        exception=result.error_before_exec or result.error_in_exec
        if exception:
            if not isinstance(exception,SyntaxError):#If it is a syntaxerror, ipython will print its own error...and then we would print 2 errors...
                raise exception
        return result.result
    return ipython_exeval
_ipython_exeval=None


# region This section MUST come last! This is for if we're running the 'r' class as the main thread (runs pseudo_terminal)―――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――


class _ExevalDirective:
    def __init__(self, line: str):
        self.command, self.args = self.parse(line)

    @staticmethod
    def parse(directive_line):
        """
        Validates that all directives are supported by exeval.
        Currently supported:
            %return <var namet
            %private_scope
        """

        assert isinstance(directive_line, str)

        try:
            if directive_line == "private_scope":
                return directive_line, ""

            command, args = directive_line.split(maxsplit=1)

            if command == "return":
                assert args.strip(), "The '%return ...' directive must specify a variable name"
                return command, args

            if command in "prepend_code append_code".split():
                assert args.strip(), "The '%s ...' directive must specify a python expression, not an empty string"%command
                assert is_valid_python_syntax(args, 'eval'), "The '%%command ...' directive was given invalid python eval syntax: "%command+repr(args)
                return command, args

        except Exception:
            raise ValueError("Invalid exeval directive line: " + repr('%'+directive_line))

        raise ValueError("Invalid exeval directive line: " + repr('%'+directive_line))

    def __str__(self):
        return (self.command + " " + self.args).strip()

    def __repr__(self):
        return "rp.r._ExevalDirective(%s)" % repr(str(self))

    def __eq__(self, x):
        """
        Allows checks such as 
            assert 'private_scope' in [_ExevalDirective('private_scope')]
        To make code more concise
        """
        return str(self) == str(x)


def _parse_exeval_code(code:str):
    """
    Used to allow exeval to use python code with lines that start with %, called 'directives'

    Directives are a set of single-line commands at the beginning of the code that start with a '%' symbol.
    The function extracts these directives and the remaining code, returning both.

    Parameters:
        - code (str): A string containing the full code block, starting with directives prefixed by '%'.

    Returns:
        - tuple: A length-2 tuple containing a string and a list
            1. The python code after the directives (a string)
            2. A list of _ExevalDirective objects

    Examples:
        >>> code_block = '''
            %return 123
            print('Hello, world!')
            for i in range(5):
                print(i)
            '''
        >>> code, directives = _parse_exeval_code(code_block)
        >>> print(directives)
        ['return 123']
        >>> print(code)
        print('Hello, world!')
        for i in range(5):
            print(i)
    """

    #Iterate over each line exactly once
    lines = code.splitlines()

    directives = []
    code_out = lines[:]

    for line in lines:
        strip = line.strip()

        if strip: #Ignore empty directive lines
            if not strip.startswith('%'):
                break

            directive_line = strip[1:]

            if directive_line: #Ignore empty directives
                directive = _ExevalDirective(directive_line)
                directives.append(directive)

        del code_out[0]

    code_out = '\n'.join(code_out)

    return code_out, directives


def exeval(code:str, scope=None):
    """
    Performs either exec(code) or eval(code) and returns the result
    The code will be patched into the linecache - so you can get informative stack traces from it!
    By default it uses the scope of the caller

    The function supports directives at the top of the code block, prefixed with a '%' symbol.
    Supported directives:
        - 'return <variable_name>': Allows specifying a variable to be returned from the executed code's scope.
              This allows you to use exec code 
        - 'private_scope': Creates a private copy of the scope before executing the code for better concurrency.
        - 'prepend_code <python_expression>': Prepends some code to your command, specified by a given python expression
        - 'append_code <python_expression>': Just like like prepend_code, except the code is added to the end instead of the beginning

    Parameters:
        - code (str): The python code string to be executed or evaluated, with possible additional directive lines at the top.
        - scope (dict, optional): The scope in which to execute or evaluate the code.
                                  If not provided, the caller's scope is used.

    Returns:
        - The result of the executed or evaluated code.
          If the 'return' directive is used, the value of the specified variable is returned.

    Raises:
        - KeyError: If the 'return' directive is used and the specified variable is not found in the scope.
        - Any exception raised during the execution or evaluation of the code.

    Example (return <variable_name> directive):
        >>> code = '''
            %return z
            a = 1
            b = 2
            z = a + b
            '''
        >>> result = exeval(code)
        >>> print(result)
        3

    Example (private_scope directive):
        >>> scope = {'a' : 0}
        >>> exeval('''
            %private_scope
            a = 1
            print(a)
            ''', scope)
        1
        >>> exeval('a', scope)
        0
        >>> exeval('a = 1', scope)
        >>> exeval('a', scope)
        1

    Example (prepend_code directive):
        >>> exeval('%prepend_code rp.load_text_file("code.py")')

    Example (prepend_code directive):
        >>> code = '''
            %prepend_code "def greet(name): return 'Hello, ' + name"
            print(greet("Alice"))
            '''
        >>> exeval(code)
        Hello, Alice

    Example (append_code directive):
        >>> exeval('%prepend_code rp.load_text_file("code.py")')

    Example (append_code directive):
        >>> code = '''
            %append_code "result = multiply(3, 4)"
            %return result
            def multiply(a, b):
                return a * b
            '''
        >>> result = exeval(code)
        >>> print(result)
        12
    """

    # When using exeval in code, its often convenient to be able to do
    # >>> exeval("""
    # ...     for x in y:
    # ...         print(x)
    # ... """)
    # But note how that introduces whitespace? Lets remove that safely in the next two lines:
    code = strip_blank_lines(code)
    code = unindent(code)
    # And now, exeval('    print(123)') does not error.

    code, directives = _parse_exeval_code(code)

    if scope is None:
        #Execute code in the scope of the caller
        scope=get_scope(1)

    for directive in directives:
        if directive == 'private_scope':
            # Create a private copy of the scope so that we don't change variables. Good for concurrency when returning results
            scope = scope.copy()
        if directive.command in 'prepend_code append_code'.split():
            sourced_code = exeval(directive.args, scope)

            assert isinstance(sourced_code, str), "The %s directive returned a non-string result: %s"%(repr(directive), type(sourced_code))
            if   directive.command=='prepend_code': code = sourced_code + "\n" + code
            elif directive.command=='append_code' : code = code + "\n" + sourced_code

    result, error = _pterm_exeval(code, scope)

    if error is not None:
        raise error

    for directive in directives:
        if directive.command == 'return':
            return_directive_var_name = directive.args
            if return_directive_var_name not in scope:
                raise KeyError("rp.exeval return directive: cannot find variable "+repr(return_directive_var_name))
            result = scope[return_directive_var_name]

    return result

_prev_pterm_profiler = None
_prev_line_profiler = None

def _display_pterm_flamechart(local=False):
    if _prev_pterm_profiler is None:
        fansi_print("RP Flamechart: Nothing was profiled yet, try running a command after setting PROF ON", 'yellow bold')
        return
    
    fansi_print("RP Flamechart: Generating HTML...", 'green bold')
    html = _prev_pterm_profiler.output_html()

    fansi_print("RP Flamechart: Uploading HTML...", 'green bold')

    if not local and connected_to_internet():
        _web_copy(html.encode(), show_progress=True)
        output_location = _web_clipboard_url
    else:
        output_location = "file://" + save_text_file(html, temporary_file_path("html"))

    fansi_print(
        "RP Flamechart: Visit flamechart at " + output_location,
        "green bold underdouble",
        link=output_location,
    )

    return html, output_location

def _truncate_string_floats(s, num_sigfigs=4) -> str:
    """
    Truncate floating point numbers in a string to the specified number of significant figures.
    Is robust - doesn't care about syntax of the given string. Does it to ALL numbers.

    Args:
        s (str): Input string containing floating point numbers
        num_sigfigs (int): Number of significant figures to keep

    Returns:
        str: String with floating point numbers truncated

    >>> truncate_floats('''"time": 0.001026,"attributes": {"l29952": 0.0010257080430164933},''')
    ans = "time": 0.001026,"attributes": {"l29952": 0.001026},

    """
    import re
    pattern = r'(\d+\.\d+)'

    def replace(match):
        num = float(match.group(0))
        format_str = '{{:.{}g}}'.format(num_sigfigs)
        return format_str.format(num)

    return re.sub(pattern, replace, s)

def _pterm_exeval(code,*dicts,exec=exec,eval=eval,tictoc=False,profile=False,line_profile=False,ipython=False):
    """
    Evaluate or execute within descending hierarchy of dicts
    merged_dict=merged_dicts(*reversed(dicts))# # Will merge them in descending priority of dicts' namespaces
    region HOPEFULLY just a temporary patch
    assert len(dicts)<=1
    if len(dicts)<=1:
    print("exeval")
    """
    global _prev_pterm_profiler
    global _prev_line_profiler

    if len(dicts)==0:
      dicts=[get_scope(1)]
    merged_dict=dicts[0]
    # endregion

    if line_profile:
        # Simple debugger-style line profiler: measure time from line to line
        import sys
        import time
        line_times = {}
        line_hits = {}
        prev_line_key = None
        last_time = None
        
        # Track function call timing separately
        call_stack = []  # Stack of (calling_line, start_time) for function calls
        
        def trace_lines(frame, event, arg):
            nonlocal last_time, line_times, line_hits, prev_line_key, call_stack
            
            filename = frame.f_code.co_filename
            lineno = frame.f_lineno
            
            if event == 'call' and filename.startswith('<rp-input-'):
                # REPL function call - track the calling line for total timing
                if prev_line_key is not None:
                    call_stack.append((prev_line_key, time.perf_counter()))
                return trace_lines
                
            elif event == 'return' and filename.startswith('<rp-input-'):
                # REPL function return - attribute total time to calling line
                if call_stack:
                    calling_line, start_time = call_stack.pop()
                    current_time = time.perf_counter()
                    elapsed = current_time - start_time
                    if 0 <= elapsed < 10.0:  # Sanity check
                        line_times[calling_line] = line_times.get(calling_line, 0) + elapsed
                return trace_lines
                
            elif event == 'line':
                current_time = time.perf_counter()
                current_key = (filename, lineno)
                
                # Track ALL REPL lines for line-to-line timing
                if filename.startswith('<rp-input-'):
                    # Track this REPL line
                    line_hits[current_key] = line_hits.get(current_key, 0) + 1
                    
                    # If we have a previous REPL line, attribute elapsed time to it
                    if prev_line_key is not None and last_time is not None:
                        elapsed = current_time - last_time
                        if 0 <= elapsed < 10.0:  # Sanity check
                            line_times[prev_line_key] = line_times.get(prev_line_key, 0) + elapsed
                    
                    # Update tracking for this REPL line
                    prev_line_key = current_key
                    last_time = current_time
                    
            return trace_lines
        
        # Store original tracer
        original_tracer = sys.gettrace()
        sys.settrace(trace_lines)

    if profile:
        pyinstrument=pip_import('pyinstrument')#https://github.com/joerick/pyinstrument
        profiler = pyinstrument.Profiler()
        profiler.start()

    if ipython:
        exec=eval=_ipython_exeval
    import rp.rp_ptpython.patch_linecache as patch
    from time import time as _time
    _end_time=None
    try:
        try:
            if is_valid_python_syntax(code,mode='eval'):
                _start_time=_time()
                ans=patch.run_code(code,'eval',merged_dict,eval)
                _end_time=_time()
            else:
                _start_time=_time()
                ans=patch.run_code(code,'exec',merged_dict,exec)
                _end_time=_time()
        finally:
            if tictoc:
                fansi_print("TICTOC: "+('%.5f'%((_end_time or _time())-_start_time)).ljust(10)[:10]+' seconds','blue','bold')
        for d in dicts:# Place updated variables back in descending order of priority
            temp=set()
            for k in d.copy():

                if k in merged_dict:
                    d[k]=merged_dict.pop(k)
                else:
                    temp.add(k)
            for k in temp:
                del d[k]
        for k in merged_dict:# If we declared new variables, put them on the top-priority dict
            dicts[0][k]=merged_dict[k]
        return ans,None
    except SystemExit as e:
        # Convert SystemExit to _PseudoTerminalReturnException to integrate with existing exit handling
        fansi_print("Caught SystemExit({0}) - converting to pseudo_terminal RETURN".format(e.code), 'cyan', 'bold')
        raise _PseudoTerminalReturnException()
    except BaseException as e:
        if ipython:
            pop_exception_traceback(e,1)
        pop_exception_traceback(e,2)
        return None,e
    finally:
        if profile:
            if (_end_time or _time())-_start_time>1/1000:#Only show profiler data if it takes more than a millisecond to run your code...
                profiler.stop()
                prof_display_start_time=_time()
                fansi_print("Preparing the PROF display (the profiler, toggle with PROF)...",'blue','underlined')
                print(profiler.output_text(unicode=True, color=True,timeline=False,show_all=_PROF_DEEP).replace('\n\n','\n')[1:-1])#show_all is useful but SOOO verbose it's almost unbearable...
                fansi_print("...took "+str(_time()-prof_display_start_time)+" seconds to diplay the PROF output",'blue','underlined')
            else:
                profiler.stop()#Something tells me its not a good idea to leave stray _pterm_profilers running...
            global _prev_pterm_profiler
            _prev_pterm_profiler = profiler
        
        if line_profile:
            # Cleanup: handle any remaining function calls and final line timing
            current_time = time.perf_counter()
            
            # Complete any remaining function calls on the stack
            while call_stack:
                calling_line, start_time = call_stack.pop()
                elapsed = current_time - start_time
                if 0 <= elapsed < 10.0:
                    line_times[calling_line] = line_times.get(calling_line, 0) + elapsed
            
            # Attribute remaining time to the last REPL line
            if prev_line_key is not None and last_time is not None:
                final_elapsed = current_time - last_time
                if 0 <= final_elapsed < 10.0:
                    line_times[prev_line_key] = line_times.get(prev_line_key, 0) + final_elapsed
            
            # Restore original tracer
            sys.settrace(original_tracer)
            
            if (_end_time or _time())-_start_time>1/1000:#Only show line profiler data if it takes more than a millisecond to run your code...
                lp_display_start_time=_time()
                fansi_print("Preparing the LINEPROF display (the line profiler, toggle with LINEPROF)...",'blue','underlined')
                
                if line_times:
                    print(line_times)
                    _rp_show_custom_line_profile(line_times, line_hits, _start_time, _end_time)
                else:
                    fansi_print("No line profiling data collected",'yellow','bold')
                    
                fansi_print("...took "+str(_time()-lp_display_start_time)+" seconds to display the LINEPROF output",'blue','underlined')
            
            global _prev_line_profiler
            # Store results in a more structured format for external access
            _prev_line_profiler = {
                'line_times': line_times,
                'line_hits': line_hits,
                'total_time': sum(line_times.values()),
                'total_hits': sum(line_hits.values()),
                'files_profiled': list(set(filename for filename, lineno in line_times.keys())),
                'num_lines_profiled': len(line_times)
            }

_PROF_DEEP=True
def _rp_show_custom_line_profile(line_times, line_hits, start_time, end_time):
    """
    Display custom line profiler results with visual enhancements
    TODO: This is a work in progress. Currently made with AI (Claude Sonnet 4)
    While it does work nicely, the coloring seems to be a bit off and the code is a bit hard to read
    TODO: FINISH THIS LINEPROF
    """
    import sys
    import time
    import linecache
    
    # Group by filename - include ALL lines that have either hits or timing data
    files = {}
    all_lines = set(line_times.keys()) | set(line_hits.keys())
    for (filename, lineno) in all_lines:
        if filename not in files:
            files[filename] = []
        hits = line_hits.get((filename, lineno), 0)
        time_spent = line_times.get((filename, lineno), 0.0)
        files[filename].append((lineno, hits, time_spent))
    
    # Use wall clock time - handle case where end_time is None (e.g., Ctrl+C)
    actual_end_time = end_time if end_time is not None else time.time()
    total_time = actual_end_time - start_time
    
    print('Timer unit: 1 s\n')
    print('Total time: {0:.6f} s\n'.format(total_time))
    
    for filename in sorted(files.keys()):
        # Only show REPL input files, skip internal Python library code
        if not filename.startswith('<rp-input-'):
            continue
        print('File: {0}'.format(filename))
        
        # Get source lines
        all_lines = linecache.getlines(filename)
        
        # Sort by line number
        line_data = sorted(files[filename])
        
        if not line_data:
            continue
            
        # Calculate max values for heat mapping
        max_hits = max(ld[1] for ld in line_data) if line_data else 1
        max_time = max(ld[2] for ld in line_data) if line_data else 1
        
        # Calculate column widths
        max_line = max(ld[0] for ld in line_data)
        line_width = max(6, len(str(max_line)))
        hits_width = max(9, len(str(max_hits)))
        
        # Header with visual bar column
        header = "{:<{lw}} {:<{hw}} {:<12} {:<8} {:<8} {:<20}  {}".format(
            "Line #", "Hits", "Time", "Per Hit", "% Time", "Bar", "Line Contents",
            lw=line_width, hw=hits_width
        )

        print(header)
        print('=' * len(header))
        
        # Calculate relative bar lengths using full_range  
        all_times = [ld[2] for ld in line_data]
        if all_times and max(all_times) > 0:
            if len(all_times)<=1:
                relative_bar_lengths = [18*8]
            else:
                relative_bar_lengths = full_range(all_times, min=0, max=18*8)
        else:
            relative_bar_lengths = [0] * len(line_data)
        
        # Show lines with timing data
        for i, (lineno, hits, time_spent) in enumerate(line_data):
            if total_time > 0:
                percent = 100 * time_spent / total_time
            else:
                percent = 0
                
            per_hit = time_spent / hits if hits > 0 else 0
            
            # Use the properly normalized bar length (with NaN safety check)
            bar_value = relative_bar_lengths[i]
            bar_length = int(bar_value) if not (bar_value != bar_value) else 0  # NaN check
            time_bar = unicode_loading_bar(bar_length)
            
            # Calculate heat intensity based on hits and time
            hit_intensity = hits / max_hits if max_hits > 0 else 0
            time_intensity = time_spent / max_time if max_time > 0 else 0
            heat_intensity = (hit_intensity + time_intensity) / 2
            
            # Get heat color using continuous spectrum interpolation
            # Define color spectrum from cool to hot using English color names
            color_names = [
                'dark blue',          # Cool start
                'navy',               # Dark blue
                'blue cyan',          # Blue-cyan
                'dark cyan',          # Dark cyan  
                'teal',               # Blue-green
                'dark green',         # Dark green
                'olive',              # Dark yellow-green
                'orange',             # Orange
                'dark orange',        # Dark orange
                'maroon'              # Dark red - hot end
            ]
            
            # Convert color names to RGB tuples
            color_spectrum = [as_rgb_float_color(color_name) for color_name in color_names]
            
            # Use linterp to get smooth color transition
            # Define custom blend function for RGB tuples
            def blend_rgb(color1, color2, alpha):
                r1, g1, b1 = color1
                r2, g2, b2 = color2
                return (
                    (1 - alpha) * r1 + alpha * r2,
                    (1 - alpha) * g1 + alpha * g2,
                    (1 - alpha) * b1 + alpha * b2
                )
            
            spectrum_index = heat_intensity * (len(color_spectrum) - 1)
            heat_color = linterp(color_spectrum, spectrum_index, blend_func=blend_rgb)
            
            # Get the actual line content
            if lineno <= len(all_lines):
                line_content = all_lines[lineno - 1].rstrip()
                # Apply syntax highlighting to the code
                try:
                    highlighted_code = fansi_pygments(line_content, language='python', style='monokai')
                    # Apply heat map background color
                    final_code = fansi(highlighted_code, background_color=heat_color)
                except:
                    # Fallback if syntax highlighting fails
                    final_code = fansi(line_content, background_color=heat_color)
            else:
                final_code = ""
            
            # Format the row with visual bar
            print("{:<{lw}} {:<{hw}} {:<12.6f} {:<8.6f} {:<8.1f} {:<20}  {}".format(
                lineno, hits, time_spent, per_hit, percent, time_bar, final_code,
                lw=line_width, hw=hits_width
            ))

        
        print()  # Empty line between files


# def get_last_line_profile_results():
#     """Get the results from the most recent line profiling session"""
#     global _prev_line_profiler
#     return _prev_line_profiler


# def parse(code):
#     # Takes care ofmillisecond to run your code...:
#     #   - Lazy parsers
#     #   - Indentation fixes
#     #   -
#     pass

def dec2bin(f):
    """
    Works with fractions
    SOURCE: http://code.activestate.com/recipes/577488-decimal-to-binary-conversion/
    """
    import math
    if f >= 1:
        g = int(math.log(float(f), 2))
    else:
        g = -1
    h = g + 1
    ig = math.pow(2, g)
    st = ""
    while f > 0 or ig >= 1:
        if f < 1:
            if len(st[h:]) >= 10: # 10 fractional digits max
                break
        if f >= ig:
            st = st + "1"
            f = f - ig
        else:
            st += "0"
        ig /= 2
    st = st[:h] + "." + st[h:]
    return sxt

def run_until_complete(x):
    from asyncio import get_event_loop
    return get_event_loop().run_until_complete(x)

if __name__=='__main__':fansi_print("Booting rp...",'blue','bold',new_line=False)

default_python_input_eventloop = None  # Singleton for python_input
default_ipython_shell = None  # Singleton for python_input
pyin=None# huge speed increase when using this as a singleton
_iPython=False
_printed_a_big_annoying_pseudo_terminal_error=False

# default_pseudo_terminal_settings_file=__file__+".pseudo_terinal_settings"
# _pseudo_terminal_settings={
# "pyin.enable_history_search":True,
# "pyin.highlight_matching_parenthesis":True,
# "pyin.enable_input_validation":False,
# "pyin.enable_auto_suggest":True,
# "pyin.show_line_numbers":True,
# "pyin.show_signature":True,
# }
# def _load_pseudo_terminal_settings_from_file(file=None):
#     import ast
#     global _pseudo_terminal_settings
#     _pseudo_terminal_settings=eval(text_file_to_string(file or default_pseudo_terminal_settings_file))
#     return None
# def _save_pseudo_terminal_settings_to_file(file=None):
#     import ast
#     global _pseudo_terminal_settings
#     string_to_text_file(file or default_pseudo_terminal_settings_file,repr(_pseudo_terminal_settings))
#     return None


def _multi_line_python_input(prompt):
    #Enter '/' to enter after entering a multiline prompt, and '\' to delete the previous line
    def mli(p):#multilineinput
        from re import fullmatch as re
        ol=[]#output lines
        mlm= lambda x: re(r'( +.*)|(.*[\;\:] *)',x)#multi line marker
        bkl= '\\'#back line
        ent= '/'#enter
        st=True#started
        while True:
            try:
                i=input(p if not ol else '')#input
            except ValueError:
                fansi_print("RP INPUT ERROR: Standard-Input file has been closed, which means you can't input any more text!","red","bold")
                raise BaseException("This exception is being raised to shut down RP so you don't get an infinite loop of spam. Please don't use quit() to exit rp, use control+d or the RETURN command")
                return ""
            if i!=ent and i!=bkl:
                if set(i)<={';',' '}:#Just spaces and ';'s will just be used to create a new line; nothing more
                    ol.append('')
                else:
                    ol.append(i)
            if i==ent or not mlm(i) and st:
                break
            st=False
            if i==bkl and ol:
                ol=ol[:-1]
                if ol:
                    print(p+ol[0])
                    if len(ol)>1:
                        for l in ol[1:]:
                            print(l)
                if not ol:
                    return ''
        return line_join(ol)
    return mli(prompt)

    #Simple version:
    out=input(prompt)
    if out!=out.lstrip() or out.endswith(':') or out.endswith(';'):
        return out+'\n'+_multi_line_python_input(' '*len(prompt))
    return out

_default_pyin_settings=dict(
    enable_mouse_support=False,
    enable_history_search=True,
    highlight_matching_parenthesis=True,
    enable_input_validation=False,
    enable_auto_suggest=True,
    show_line_numbers=True,
    show_signature=True,
    # _current_ui_style_name='stars',
    _current_ui_style_name='adventure',
    # _current_code_style_name='default',
    _current_code_style_name='monokai',

    show_docstring=False,
    show_realtime_input=False,
    show_vars=False,
    show_meta_enter_message=True,
    completion_visualisation='multi-column' if not currently_running_windows() else 'pop-up',
    completion_menu_scroll_offset=1,

    show_status_bar=True,
    wrap_lines=True,
    complete_while_typing=True,
    allow_jedi_dynamic_imports=False,  # When False, prevent Jedi from importing unloaded modules (faster on NFS)
    enable_semantic_highlighting=True,  # When True, use Jedi to add semantic highlighting (callables, modules)
    gray_unreachable_code=True,  # When True, dim unreachable code (requires enable_semantic_highlighting)
    enable_linting=True,  # DEPRECATED: Use linting_mode instead. When True, equivalent to linting_mode='on'
    linting_mode='on',  # Linting mode: 'off' (no linting), 'on' (critical errors only), 'verbose' (all errors including style nazis)
    vi_mode=False,
    paste_mode=False  ,
    confirm_exit=True  ,
    accept_input_on_enter=2  ,
    enable_open_in_editor=True,
    enable_system_bindings=True,
    show_all_options=False,
    show_last_assignable=False,
    # Background settings
    ui_bg_fg_contrast=0.0,      # Minimum contrast between foreground and background (0.0-1.0)
    background_mode='Default',  # Background mode: "Nowhere", "Default", "Black", or "White"
    show_battery_life=False,
    enable_microcompletions=True,
    history_syntax_highlighting=False,
    history_number_of_lines=2500,
    min_bot_space=15,
    top_space=0,
    true_color=False,
    indent_guides_mode='Regular',
    highlight_cursor_line=False  ,# Highlight the background of the cursor line
    highlight_cursor_column=False ,# Highlight the background of the cursor column
    highlight_matching_words=True  ,# Underline all occurrences of the word under cursor
    show_whitespace=True,
    
    # Color customization settings
    code_invert_colors=False,     # Invert colors for code syntax highlighting
    code_invert_brightness=False, # Invert brightness for code syntax highlighting
    ui_invert_colors=False,       # Invert colors for UI elements (hidden by default)
    ui_invert_brightness=False,   # Invert brightness for UI elements (hidden by default)
    code_hue_shift=0,             # Hue shift for code syntax highlighting (0-355 degrees in 5-degree increments)
    ui_hue_shift=0,               # Hue shift for UI elements (0-355 degrees in 5-degree increments, hidden by default)
    code_min_brightness=0.0,      # Minimum brightness for code elements (0.0-1.0)
    code_max_brightness=1.0,      # Maximum brightness for code elements (0.0-1.0)
    code_ui_min_brightness=0.0,   # Minimum brightness for code UI elements (whitespace, indent guides, row/column highlights) (0.0-1.0)
    code_ui_max_brightness=1.0,   # Maximum brightness for code UI elements (whitespace, indent guides, row/column highlights) (0.0-1.0)
    ui_min_brightness=0.0,        # Minimum brightness for UI elements (0.0-1.0)
    ui_max_brightness=1.0,        # Maximum brightness for UI elements (0.0-1.0)
    code_min_saturation=0.0,      # Minimum saturation for code elements (0.0-1.0)
    code_max_saturation=1.0,      # Maximum saturation for code elements (0.0-1.0)
    ui_min_saturation=0.0,        # Minimum saturation for UI elements (0.0-1.0)
    ui_max_saturation=1.0,        # Maximum saturation for UI elements (0.0-1.0)

    session_title='',
)

_default_pyin_settings.update(dict(
    #Looks really cool. Gonna make that default.
    # _current_ui_style_name='anna',
    code_ui_max_brightness=.75,
    ui_hue_shift=330,
    ui_min_brightness=.1,
    ui_bg_fg_contrast=.56,
    indent_guides_mode='Propagate',
    show_whitespace="Lead+Trail",
    highlight_cursor_column=True,
    highlight_cursor_line=True,
))

_globa_pyin=[None]

def _get_pyin_settings():
    """Get current pyin settings with smart caching"""
    try:
        if _globa_pyin[0] is not None:
            # Always read from live pyin object - this is current truth
            pyin = _globa_pyin[0]
            settings = {}
            for attr in _default_pyin_settings:
                if hasattr(pyin, attr):
                    settings[attr] = getattr(pyin, attr)
            return settings
        else:
            # Use smart caching that auto-invalidates on file changes
            settings = eval(text_file_to_string(_pyin_settings_file_path, use_cache=True))
            settings.update(_rprc_pterm_settings_overrides)
            return settings
    except Exception:
        return _default_pyin_settings.copy()


_rprc_pterm_settings_overrides={}#Modify this as you wish in an rprc.

def _load_pyin_settings_file():
    # print("BOOTLEGER",pyin)
    
    # Load settings from file before assigning pyin to global
    # (so _get_pyin_settings uses file path, not uninitialized pyin object)
    settings = _get_pyin_settings()
    
    # Now assign pyin to global after we've loaded the settings
    _globa_pyin[0]=pyin

    def _load_pyin_settings_from_dict(d):
        pyin.use_ui_colorscheme(d['_current_ui_style_name'])
        pyin.use_code_colorscheme(d['_current_code_style_name'])
        pyin.true_color=d['true_color'] if 'true_color' in d else False
        for attr in _default_pyin_settings:
            if attr in d:
                setattr(pyin,attr,d[attr])
        
        # Make sure style settings like background_mode and ui_bg_fg_contrast take effect
        pyin._update_style()

    _load_pyin_settings_from_dict(settings)
    _set_default_session_title()

def _save_pyin_settings_file():
    settings = _get_pyin_settings()
    string_to_text_file(_pyin_settings_file_path,repr(settings))

def _delete_pyin_settings_file():
    delete_file(_pyin_settings_file_path)

def _set_session_title(title=None):
    pyin=_globa_pyin[0]
    if pyin is None:
        #So we can do: rp exec "pterm(level_title='cheese')"
        @run_as_new_thread
        def do_when_ready():
            while _globa_pyin[0] is None:
                sleep(.1)
            _set_session_title(title)
        return
    if title is None:
        if hasattr(pyin,'session_title'):
            current_title=pyin.session_title
            print("Current session title:",repr(current_title))
        print("Please enter a new session title:")
        title=input_default(" > ", _get_session_title())
    pyin.session_title=title

def _get_session_title():
    if hasattr(pyin,'session_title'):
        current_title=pyin.session_title
    else:
        current_title=""
    os.environ['RP_SESSION_TITLE']=current_title
    return current_title

def _get_default_session_title():
    current=_get_session_title()

    if current:
        return current
    if running_in_conda():
        return ' '+get_conda_name()+' '
    if running_in_venv():
        return ' '+get_venv_name()+' '

    return ""

def _set_default_session_title():
    _set_session_title(_get_default_session_title())

def _set_pterm_theme(ui_theme_name=None,code_theme_name=None):
    #EXAMPLE:
    #    _set_pterm_theme('saturn','gruvbox-dark')
    import rp.r as r    
    import rp.rp_ptpython.style as s
    
    settings = _get_pyin_settings()
    if ui_theme_name   is None:ui_theme_name=settings['_current_ui_style_name']
    if code_theme_name is None:code_theme_name=settings['_current_code_style_name']
    
    #r.pyin._current_code_style_name='vim'
    #r.pyin._current_code_style_name='gruvbox-dark'
    r.pyin._current_code_style_name=code_theme_name
    r.pyin.use_ui_colorscheme(ui_theme_name)


_pt_pseudo_terminal_init_settings=False

def _python_input(scope,header='',enable_ptpython=True,iPython=False):
    """Enhanced interactive Python input with completion and features.

    Enhanced Documentation:
    ========================
    Provides an enhanced Python input interface using ptpython or IPython with
    advanced features like syntax highlighting, auto-completion, and multi-line
    editing. Falls back to basic input() if enhanced features fail.

    Args:
        scope: Function returning globals dict for auto-completion context
        header (str): Optional header text to display
        enable_ptpython (bool): Enable ptpython features, defaults to True
        iPython (bool): Use IPython interface instead of ptpython, defaults to False

    Returns:
        str: The entered Python code as a string, or "RETURN" if interrupted

    Examples:
        Basic usage:
        >>> # This would normally be interactive
        >>> code = _python_input(lambda: globals())  # doctest: +SKIP

        With header:
        >>> code = _python_input(globals, "Enter Python code:")  # doctest: +SKIP

        Using IPython mode:
        >>> code = _python_input(globals, iPython=True)  # doctest: +SKIP

    Related Functions:
        - pseudo_terminal(): Full interactive Python terminal
        - _multi_line_python_input(): Fallback input function
        - input(): Basic Python input function

    Usage Patterns:
        - Enhanced REPL interfaces with syntax highlighting
        - Interactive code input in custom applications
        - Auto-completing Python input with context awareness
        - Advanced multi-line code editing in terminal

    Features:
        - Syntax highlighting for Python code
        - Context-aware auto-completion using provided scope
        - Multi-line editing with proper indentation
        - History support with persistent storage
        - Graceful fallback to basic input on errors
        - Support for both ptpython and IPython backends

    Notes:
        - Caches completion data for performance optimization
        - Disables garbage collection during input for speed
        - Handles terminal disruption gracefully
        - Falls back to basic input() in non-terminal environments
        - Maintains session state between calls

    Dependencies:
        - ptpython: Enhanced Python REPL
        - IPython: Alternative enhanced Python shell
        - prompt_toolkit: Terminal UI framework

    Tags: input, repl, interactive, python, completion, terminal
    """
    import rp.rp_ptpython.completer as completer
    # print(completer.completion_cache_pre_origin_doc.keys())
    completer.completion_cache_pre_origin_doc={'':tuple(scope())}#clear the cache because variables might change between inputs (in fact, almost certainly they will). BUT for a speed boost, we'll pre-calculate the initial autocompletion now, because we know it starts with an empty string and should be the scope when doing that.
    from rp.rp_ptpython.python_input import PythonCommandLineInterface,PythonInput as Pyin
    global Pyin
    global pyin,_iPython
    global _printed_a_big_annoying_pseudo_terminal_error
    if not enable_ptpython or _printed_a_big_annoying_pseudo_terminal_error:
        return _multi_line_python_input(header)
    try:
        if iPython:
            from rp.rp_ptpython.ipython import IPythonInput as Pyin,InteractiveShellEmbed
            global default_ipython_shell
            if default_ipython_shell is None:
                default_ipython_shell=InteractiveShellEmbed()
            if not pyin or _iPython!=iPython:
                pyin=Pyin(default_ipython_shell,get_globals=scope,history_filename=history_filename)
        else:
            if not pyin or _iPython!=iPython:
                # exec(mini_terminal)
                from rp.rp_ptpython.python_input import PythonCommandLineInterface,PythonInput as Pyin
                pyin=Pyin(get_globals=scope,history_filename=history_filename)
        _iPython=iPython
        global default_python_input_eventloop
        # global _pseudo_terminal_settings
        global _pt_pseudo_terminal_init_settings
        if not _pt_pseudo_terminal_init_settings:
            _load_pyin_settings_file()
            _pt_pseudo_terminal_init_settings=True

        pyin.all_prompt_styles['default']=ps.PseudoTerminalPrompt()
        if not currently_running_windows():
            pyin.prompt_style='default'
        # ps.PseudoTerminalPrompt=PseudoTerminalPrompt

        import warnings
        with warnings.catch_warnings():
            #I don't want anything printed to the console while we're typing...it's super annoying
            #Usually these warnings come from autocomplete stumbling upon some property of some library which is deprecated
            #I don't care about this, and it interrupts the typing experience
            from rp.prompt_toolkit.shortcuts import create_eventloop
            default_python_input_eventloop=default_python_input_eventloop or PythonCommandLineInterface(create_eventloop(),python_input=pyin)

            with no_gc(): #One of the bottlenecks of prompt-toolkit is that it triggers the gc so much, something to do with redraws or somethin' idk. But during input let's disable garbage collection.
                #If this causes memory leaks, make a new context like reduce_gc_frequency(scale_factor=10) etc
                pyin._completer.clear_cache()  # Clear completion cache at start of each input
                try:
                    code_obj = default_python_input_eventloop.run()
                except ValueError as e:
                    if "I/O operation on closed file" in str(e):
                        fansi_print("Stdin disrupted by nested terminal exit - returning gracefully", 'cyan', 'bold')
                        return "RETURN"
                    raise
                

        return code_obj.text
    except EOFError:
        fansi_print("Caught Control+D; preparing to exit rp.pseudo_terminal()  ",'blue','bold')# Presumably in ptpython when you use control+d and then select yes; AKA the exit prompt they built
        return "RETURN"
    except Exception as E:
        if not _printed_a_big_annoying_pseudo_terminal_error:

            if sys.stdout.isatty():#No reason to scare
                try:
                    print_verbose_stack_trace(E)
                except:
                    print_stack_trace(E)
                fansi_print("The prompt_toolkit version of pseudo_terminal crashed; reverting to the command-line version...",'cyan','bold')
                fansi_print("Type 'PT ON' to restore the enhanced TUI interface.",'yellow','bold')
            else:
                if running_in_google_colab():
                    reason="you're running in Google Colab, and not in a terminal."
                elif running_in_ipython():
                    reason="you're running in a Jupyter notebook, and not in a terminal."
                else:
                    reason="you're not running in a terminal"
                fansi_print("Defaulting to the command-line (aka PT OFF) version because "+reason,'cyan','bold')

            _printed_a_big_annoying_pseudo_terminal_error=True
            # Reset the event loop singleton to allow recovery via PT ON
            default_python_input_eventloop = None

        return input(header)

class _pseudo_terminal_style:
    def __init__(self):
        self.message=lambda:"pseudo_terminal() --> Entering interactive session! "
        import datetime
        timestamp=lambda:datetime.datetime.now().strftime("%B %d, %Y at %I:%M:%S %p")
        import sys,platform
        version=platform.python_implementation()+' '+str(sys.version_info.major)+'.'+str(sys.version_info.minor)+'.'+str(sys.version_info.micro)
        self.message=lambda:"rp.pterm() in %s: Welcome! "%version+timestamp()

def _dhistory_helper(history:str)->list:
    #Take some python code, rip out just the function definitions, and return them in a list
    def get_all_function_names(code:str):        
        #Return all the names of all functions defined in the given code, in the order that they appear
        from rp import line_split,strip_all_lines
        lines=line_split(strip_all_lines(code))
        import re
        defs=[line for line in lines if re.fullmatch(r'\s*def\s+\w+\s*\(.*',line)]
        func_names=[d[len('def '):d.find('(')].strip() for d in defs]
        return func_names

    def _get_function_name(code):
        all_func_names=get_all_function_names(code)
        if all_func_names:
            return all_func_names[0]
        return None

    from collections import OrderedDict
    defstate=True
    #defstate=False
    nondefchunks=[]
    defchunks=[]
    chunk=[]
    defs=OrderedDict()
    decorators=[]
    import re
    for line in line_split(history):
        if line.lstrip()==line and line:
            if defstate==True:
                defcode=line_join(decorators+chunk)
                defchunks.append(defcode)
                defname=_get_function_name(defcode)
                #assert defname is not None
                if defname is not None:
                    defs[defname]=defcode
                decorators=[]
                defstate=False
            if defstate==False:
                if line.startswith('@'):
                    # print(decorators)
                    decorators.append(line)
                else:
                    nondefchunks.append(line_join(chunk))
                    if line.strip() and not bool(re.fullmatch(r'def\s+\w+\s*\(.*',line)):
                        decorators=[]
            chunk=[]
            chunk.append(line)
            defstate = bool(re.fullmatch(r'def\s+\w+\s*\(.*',line))
        else:
            chunk.append(line)    
    if defstate and chunk:
        defcode=line_join(decorators+chunk)
        defchunks.append(defcode)
        defname=_get_function_name(defcode)
        #assert defname is not None
        if defname is not None:
            defs[defname]=defcode 
    
    return defs.values()
       
class _Module:
    def __init__(self,name,module):
        from inspect import getsourcefile
        self.name=name
        self.module=module
        self.path=getsourcefile(module)
        self.date_last_updated=get_current_date()
        if not isinstance(self.path,str):
            raise TypeError()
    def update(self):
        #Will check to see if the module is out of date. If it is, it will reload it.
        if date_modified(self.path)>self.date_last_updated:
            try:
                #We should reload this module
                from time import time as __time__
                starttime=__time__()
                fansi_print('RELOAD: Reloading module '+repr(self.name)+'...','blue','bold',new_line=False)
                from importlib import reload
                reload(self.module)
                fansi_print('done in '+str(__time__()-starttime)[:10]+' seconds!','blue','bold')
            except BaseException as e:
                fansi_print('RELOAD: ERROR: Failed to reload module '+repr(self.name)+"\nStack trace shown below:",'blue','bold')
                print_stack_trace(e)
            self.date_last_updated=get_current_date()
    def __hash__(self):
        return self.name
_modules={}
def _reload_modules():
    """Reload modified modules for development. Internal helper for pseudo_terminal."""
    #Re-import any modules that have been modified after the last time we called _reload_modules
    for name,module in sys.modules.items():
        if name not in _modules:
            try:
                _modules[name]=_Module(name,module)
            except TypeError:pass
            except Exception as e:
                print_stack_trace(e)
        else:
            _modules[name].update()

def launch_xonsh():
    """ Launch the xonsh shell """
    pip_import('xonsh')
    old_sys_argv=sys.argv.copy()
    try:
        sys.argv=old_sys_argv[:1]#Xonsh doesn't like it if we have custom arguments that don't fit xonsh, probably set by using ARG 
        import xonsh.main
        try:
            xonsh.main.main()
        except SystemExit as error:
            #This happens when we press control+d to exit the shell; we get "SystemExit: 0"
            pass
        sys.path.append(".")
    finally:
        #We definitely want to restore the old arguments
        sys.argv=old_sys_argv
    
def with_line_numbers(string, prefix="%i. ", *, start_from=0, align=False):
    """
    EXAMPLES: 
        >>> with_line_numbers('a\nb\nc')
        ans = 0. a
              1. b
              2. c
        >>> with_line_numbers('a\nb\nc', start_from=1)
        ans = 1. a
              2. b
              3. c

        >>> print(poem)
        In the Land of
        Mordor where
        the Shadows
        lie. One ring
        to rule them
        all, One ring
        to find them,
        One ring to
        bring them all,
        and in the
        darkness bind
        them, In the
        land of mordor
        where the
        shadows lie.

        >>> print(with_line_numbers(poem))
        0. In the Land of
        1. Mordor where
        2. the Shadows
        3. lie. One ring
        4. to rule them
        5. all, One ring
        6. to find them,
        7. One ring to
        8. bring them all,
        9. and in the
        10. darkness bind
        11. them, In the
        12. land of mordor
        13. where the
        14. shadows lie.

        >>> print(with_line_numbers(poem,align=True))
         0. In the Land of
         1. Mordor where
         2. the Shadows
         3. lie. One ring
         4. to rule them
         5. all, One ring
         6. to find them,
         7. One ring to
         8. bring them all,
         9. and in the
        10. darkness bind
        11. them, In the
        12. land of mordor
        13. where the
        14. shadows lie.
    """
    if not string:
        #Don't error on align
        return ''

    lines=string.splitlines()
    prefixes=[prefix%(i+start_from) for i in range(len(lines))]

    if align:
        max_prefix_length=max(map(len,prefixes))
        prefixes = [prefix.rjust(max_prefix_length) for prefix in prefixes]

    lines=[prefix + line for prefix, line in zip(prefixes, lines)]
    return line_join(lines)
    
def number_of_lines(string):
    """
    Counts the number of lines in a string by counting newline characters.
    Uses an efficient counting approach rather than actually splitting the string.
    
    Args:
        string (str): Input string to count lines in
        
    Returns:
        int: Number of lines in the string (minimum 1, even for empty string)
        
    Examples:
        >>> number_of_lines('hello\nworld') # --> 2
        >>> number_of_lines('single_line')  # --> 1
        >>> number_of_lines('a\nb\nc\nd')   # --> 4
        >>> number_of_lines('\n')           # --> 2
        >>> number_of_lines(' ')            # --> 1
        >>> number_of_lines('')             # --> 1

    Alias: string_height()
        
    Related Functions:
        - line_split(): Split string into individual lines: len(line_split(string)) == number_of_lines(string)
        - string_width(): Get maximum line width  
        - string_height(): Alias for this function
        - number_of_lines_in_terminal(): Accounts for terminal wrapping
        
    Tags: string, text, lines, count, utility
    """
    return string.count("\n") + 1  # More Efficient
    # return len(line_split(string)) #Less efficient

def number_of_lines_in_terminal(string):
    """
    Gets the number of lines a string would appear to have when printed in a terminal, assuming the terminal wraps strings
    For example, the string '*'*1000 is technically only one line, but when printed print('*'*1000) might look like several lines in a terminal
    """
    if not currently_in_a_tty():
        #Perhaps just return 1 if we're not in a TTY? Some places, like jupyter notebooks, don't wrap lines
        #For now, we'll ignore this edge case. In the future this block might return something different.
        pass
    lines=line_split(string)
    width=get_terminal_width()
    out=0
    for line in lines:
        out+=len(line)//width+1
    return out

def number_of_lines_in_file(filename):
    """
    Quickly count the nubmer of lines in a given file.
    It's 5-10x faster than text_file_to_string(filename).count('\n')
    It also appears to take constant memory; my memory usage didn't flinch even when I threw a 2gb file at it.
    Note that it doesn't care if it's a text file or not; it just counts the number of \n bytes in the file!
       For example, number_of_lines_in_file('picture.jpg')==280 is a possibility.
    https://stackoverflow.com/questions/845058/how-to-get-line-count-of-a-large-file-cheaply-in-python
    """
    from itertools import (takewhile,repeat)
    f = open(filename, 'rb')
    bufgen = takewhile(lambda x: x, (f.raw.read(1024*1024) for _ in repeat(None)))
    return sum( buf.count(b'\n') for buf in bufgen )+1

def _all_files_listed_in_exception_traceback(exception:BaseException)->list:
    from traceback import format_exception,format_exception

    error=exception
    error_string=''.join(format_exception(error.__class__,error,error.__traceback__))
    ans=error_string
    import re
    ans=line_split(ans)
    ans=[line for line in ans if re.fullmatch(r'  File .*, line \d+.*',line)]
    #ans=[line for line in ans if re.fullmatch(r'  File .*, line \d+, in .*',line)]
    def process_line(line):
        #Usually a line will look like this:
        #    ans =   File "/home/ryan/anaconda3/lib/python3.7/copy.py", line 240, in _deepcopy_dict
        #     ➤ split_python_tokens(ans)
        #    ans = ['  ', 'File', ' ', '"', '/home/ryan/anaconda3/lib/python3.7/copy.py', '"', ',', ' ', 'line', ' ', '240', ',', ' ', 'in', ' ', '_deepcopy_dict']
        try:    
            tokens=split_python_tokens(line)
            path=tokens[4]
            assert path_exists(path)
            line=int(tokens[10])
            return path,line
        except Exception:
            return None
    ans=list(map(process_line,ans))
    ans=[x for x in ans if x is not None]
    return ans

    #Older version below (which sometimes missed a few files or got the linenumber wrong)
    tb=exception.__traceback__
    out=[]
    while hasattr(tb,'tb_next'):
        try:
            frame=tb.tb_frame
            code =frame.f_code
            tb=tb.tb_next
            #frame=frame.f_back
            # out.append((code.co_filename,code.co_firstlineno))
            out.append((code.co_filename,tb.tb_lineno))
        except Exception:
            pass
    return out


def read_symlink(path: str, *, recursive=False):
    """
    Resolves the path of a symlink up to a specified number of levels.

    Args:
        path: Path to the symlink.
        recursive: If True, will keep resolving symlinks until it hits a non-symlink

    Returns:
        Resolved path string.

    Raises:
        Exception: If strict=True and initial path is not a symlink
    """
    assert isinstance(path, str)
    if not is_symlink(path): raise AssertionError('Not a symlink: ' + path)

    path = path.rstrip('/')
    path = os.readlink(path)

    if recursive:
        while is_a_symlink(path):
            path = os.readlink(path)

    return path

def make_symlink_absolute(symlink_path, *, recursive=False, physical=True):
    """Replace the destination of a symlink with an absolute path instead of a relative one"""
    destination_path = read_symlink(symlink_path, recursive=recursive)
    destination_path = get_absolute_path(destination_path, physical=physical)
    return make_symlink(destination_path, symlink_path, relative=False, replace=True)


def make_symlink_relative(symlink_path, *, recursive=False):
    """Replace the destination of a symlink with a relative path instead of an absolute one"""
    destination_path = read_symlink(symlink_path, recursive=recursive)
    if starts_with_any(destination_path, "/", "~"):
        destination_path = get_absolute_path(destination_path, physical=False)
        make_symlink(destination_path, symlink_path, relative=True, replace=True)
    return symlink_path

def read_symlinks(
    *symlink_paths,
    recursive=False,
    strict=True,
    num_threads=None,
    show_progress=False,
    lazy=False
):
    """ Plural of rp.read_symlink """
    symlink_paths = detuple(symlink_paths)
    if show_progress == True: show_progress = "eta:" + get_current_function_name()
    return gather_args_call(load_files, read_symlink, symlink_paths)

def make_symlinks_relative(
    *symlink_paths,
    recursive=False,
    strict=True,
    num_threads=None,
    show_progress=False,
    lazy=False
):
    """ Plural of rp.make_symlink_relative """
    symlink_paths = detuple(symlink_paths)
    if show_progress == True: show_progress = "eta:" + get_current_function_name()
    return gather_args_call(load_files, make_symlink_relative, symlink_paths)

def make_symlinks_absolute(
    *symlink_paths,
    recursive=False,
    strict=True,
    num_threads=None,
    show_progress=False,
    lazy=False
):
    """ Plural of rp.make_symlink_absolute """
    symlink_paths = detuple(symlink_paths)
    if show_progress == True: show_progress = "eta:" + get_current_function_name()
    return gather_args_call(load_files, make_symlink_absolute, symlink_paths)

def symlink_is_broken(path:str):
    """ Returns True if the symlink points to a path that doesn't exist """
    assert is_symlink(path)
    if not path_exists(path):
        return True
    return False

# def symlink_works(path:str):
#     return not symlink_is_broken(path)

def make_hardlink(original_path, hardlink_path, *, recursive=False):
    """ Creates a hardlink to original_path at hardlink_path. If recursive, creates a nested folder structure of hardlinks - equivalent to 'cp -al' """
    import os

    if path_exists(hardlink_path) and not path_exists(original_path):
        # If the caller of this function gets the arguments backwards, fix it automatically
        hardlink_path, original_path = original_path, hardlink_path
        
    if is_a_folder(hardlink_path):
        hardlink_path = path_join(hardlink_path, get_file_name(original_path))

    assert path_exists(original_path), "Can't create hardlink to %s because that path does not exist!" % original_path
    assert not path_exists(hardlink_path), "Can't create hardlink at %s because a file already exists there!" % hardlink_path

    original_is_folder = is_a_folder(original_path)
    make_parent_folder(hardlink_path)
    if recursive and original_is_folder:
        assert currently_running_unix(), 'Recursive hardlinks not implemented in rp for windows yet'    
        command = 'cp -al '+shlex.quote(original_path)+' '+shlex.quote(hardlink_path)
        result = os.system(command)
        if result:
            raise RuntimeError("Error with command: "+command)
        return hardlink_path
    else:
        assert not original_is_folder or is_symbolic_link(original_path), "Can't create a hardlink to a folder, only to files: " + original_path
        os.link(original_path, hardlink_path)
        
    return hardlink_path

def replace_symlink_with_hardlink(symlink_path):
    """Replaces a symlink with a hardlink"""
    assert isinstance(symlink_path,str), 'replace_symlink_with_hardlink: Input path must be a string'
    assert is_symlink(symlink_path), 'replace_symlink_with_hardlink: Path is not a symlink: '+symlink_path
    read_path=read_symlink(symlink_path)
    assert not is_a_folder(read_path), 'Cannot hardlink to a folder from symlink '+symlink_path+'   -->   '+read_path
    delete_file(symlink_path)
    hardlink_path=symlink_path
    return make_hardlink(read_path,hardlink_path)

def replace_symlinks_with_hardlinks(
        *symlink_paths,
        num_threads: int = None,
        show_progress=False,
        strict=True,
        lazy=False
    ):
    """Plural of replace_symlink_with_hardlink. TODO: Parallelize this (maybe with load_files), and add strict, num_threads, etc"""
    symlink_paths = rp_iglob(symlink_paths)
    return load_files(replace_symlink_with_hardlink, symlink_paths, lazy=lazy, strict=strict, show_progress=show_progress, num_threads=num_threads)


def make_symlink(original_path, symlink_path=".", *, relative=False, replace=False, strict=True):
    """
    Creates a symbolic link.

    Creates a symlink at `symlink_path` pointing to `original_path`.

    Args:
        original_path: Path to the original file/directory.
        symlink_path: Path for the symlink (default: current directory).
            If a folder, symlink is created inside it with original's name.
        relative: Use a relative symlink, correctly pointing to original_path from the symlink_path (default: False).
        replace: Replace existing symlink if True (default: False, error if exists) (defualt: False)
        strict: If true, raises an error if the original_path does not exist. (default: True)

    Returns:
        Path to the created symlink.

    Raises:
        AssertionError: If `original_path` doesn't exist (and strict) or `symlink_path` already exists (and not replaced).
    """
    import os

    if path_exists(symlink_path) and not path_exists(original_path):
        #If the caller of this function gets the arguments backwards, fix it automatically
        symlink_path,original_path=original_path,symlink_path
        
    if is_a_folder(symlink_path):
        symlink_path=path_join(symlink_path,get_file_name(original_path))

    assert not strict or path_exists(original_path), "Can't create symlink to %s because that path does not exist!"%original_path
    assert replace or not path_exists(symlink_path), "Can't create symlink at %s because a file already exists there!"%symlink_path

    if relative:
        original_path = get_relative_path(original_path, root=get_parent_folder(symlink_path))

    if replace and is_symlink(symlink_path):
        os.remove(symlink_path)
    
    make_parent_folder(symlink_path)

    os.symlink(original_path,symlink_path)
    
    return symlink_path

def is_symbolic_link(path:str):
    """
    Checks if a given file system path points to a symbolic link (symlink).

    Args:
        path (str): File system path to check
        
    Returns:
        bool: True if path is a symbolic link, False otherwise
              Returns False if path doesn't exist or for non-string inputs
              (things that don't exist aren't symlinks)
    
    Examples:
        >>> is_symbolic_link('/tmp')              # --> True  :  Usually a symlink on Unix
        >>> is_symbolic_link('/home/user')        # --> False :  Not usually a symlink  
        >>> is_symbolic_link('/nonexistent/path') # --> False :  It doesn't exist so its not a symlink
        
        >>> # Handle non-string inputs - if it's not a path, it's not a symlink
        >>> is_symbolic_link(None) # --> False
        >>> is_symbolic_link(123)  # --> False
        
    Related Functions:
        - is_symlink: Alias for this function
        - read_symlink
        - read_symlinks
        - get_stats_string(): Get detailed file statistics
        
    Tags: filesystem, symlink, symbolic, link, path, detection, is_symlink
    """
    from pathlib import Path
    if not isinstance(path,str):
        return False
    try:
        return Path(path).is_symlink()
    except OSError:
        #OSError: [Errno 63] File name too long:
        return False

is_symlink=is_symbolic_link

def symlink_move(from_path,to_path,*,relative=False):
    """
    Move a file or folder, but leave a symlink behind so that programs that try to access the original file aren't affected
    """
    from_path=get_absolute_path(from_path)
    to_path=get_absolute_path(to_path)

    assert path_exists(from_path),from_path
    to_path=move_path(from_path,to_path)
    make_symlink(from_path,to_path,relative=relative)
    return to_path

def _guess_mimetype(file_path)->str:
    import mimetypes
    if not file_exists(file_path):
        return None
    mimetype=mimetypes.guess_type(file_path)[0] #mimetype should be something like 'image/jpeg'
    if mimetype is None:
        return None
    return mimetype.split('/')[0]

def is_image_file(file_path):
    """
    Check if given file is an image
    
    Args:
        file_path (str): The file to check
        
    Returns:
        bool: True if file is an image, False otherwise
              If the file doesn't exist, returns False
    
    Example filetypes:
        jpg, png, gif, svg, ico, bmp, jpeg, webp, exr
        
    See Also:
        - is_video_file
        - is_sound_file
        - is_text_file
        - is_utf8_file
    """
    if not isinstance(file_path,str): return False
    if get_file_extension(file_path) in 'exr'.split():
        return True
    return _guess_mimetype(file_path)=='image'

def is_video_file(file_path):
    """
    Check if given file is a video file
    
    Args:
        file_path (str): The file to check
        
    Returns:
        bool: True if file is a video file, False otherwise
              If the file doesn't exist, returns False
    
    Example filetypes:
        mp4, webm
        
    See Also:
        - is_image_file
        - is_sound_file
        - is_text_file
        - is_utf8_file
    """
    return _guess_mimetype(file_path)=='video'

def is_sound_file(file_path):
    """
    Check if given file is a sound/audio file
    
    Args:
        file_path (str): The file to check
        
    Returns:
        bool: True if file is a sound/audio file, False otherwise
              If the file doesn't exist, returns False
    
    Example filetypes:
        mp3, ogg, wav

    Alias: is_audio_file
        
    See Also:
        - is_image_file
        - is_video_file
        - is_text_file
        - is_utf8_file
    """
    return _guess_mimetype(file_path)=='audio'

is_audio_file = is_sound_file

def is_utf8_file(path):
    """
    Check if given file is a UTF-8 text file
    
    Args:
        path (str): The file to check
        
    Returns:
        bool: True if file is a UTF-8 text file, False otherwise
              If the file doesn't exist, returns False
    
    Example filetypes:
        css, csv, html, js, json, md, py, txt, xml
        
    See Also:
        - is_image_file
        - is_video_file
        - is_sound_file
        - is_text_file
    """
    if not file_exists(path):
        return False
    import codecs
    try:
        f = codecs.open(path, encoding='utf-8', errors='strict')
        next(f)
        return True
    # except UnicodeDecodeError:
    except Exception:
        return False
# is_text_file=is_utf8_file #TODO: Not sure if this is the right way to do it
        
def display_file_tree(root=None,*,all=False,only_directories=False,traverse_symlinks=False):
    """
    Display a visual tree structure of files and directories, similar to the Unix 'tree' command.
    
    Enhanced Documentation:
    - Usage patterns: Interactive file exploration, debugging directory structure, visual documentation
    - Displays colored output with file statistics (size, line count, dimensions for images)
    - Smart file type detection with appropriate metadata (CSV columns, image dimensions, etc.)
    - Handles permission errors gracefully by skipping inaccessible directories
    - Large outputs automatically use pager for better viewing experience
    - Used heavily in REPL system for directory navigation commands
    
    Parameters:
        root (str, optional): Directory path to display. Defaults to current directory if None.
        all (bool): Include hidden files (starting with '.'). Defaults to False.
        only_directories (bool): Show only directories, skip files. Defaults to False.
        traverse_symlinks (bool): Follow symbolic links during traversal. Defaults to False.
    
    Returns:
        None: Prints tree structure to stdout and optionally displays in pager
        
    Examples:
        >>> # Display current directory structure
        >>> display_file_tree()
        .
        ├── [1;34mdocumentation[0m    	[34m[18 files][0m
        ├── file1.txt    	[34m[1.2KB, 45 lines][0m
        └── image.png    	[34m[150.5KB, 1920x1080][0m
        
        >>> # Show only directories with hidden files
        >>> display_file_tree(all=True, only_directories=True)
        .
        ├── [1;34m.git[0m    	[34m[156 files][0m
        └── [1;34mdocumentation[0m    	[34m[18 files][0m
        
        >>> # Display specific directory
        >>> display_file_tree('documentation/claude')
        documentation/claude
        ├── breadcrumbs.md    	[34m[8.1KB, 297 lines][0m
        └── [1;34mrepl[0m    	[34m[17 .md files][0m
    
    Tags: files, tree, directory, visualization
    """
    #This code was ripped off of somewhere online, I don't remember where. Search the body of this code on google and you should find it in some github repo that implements the tree command in multiple languages
    import os
    import sys

    printed_lines=[]
    
    def print_line(line):
        print(line)
        printed_lines.append(line)

    def get_stats_string(path):
        def is_hidden_file(file):
            return get_file_name(file).startswith('.')

        stats=[]
        color='blue'
        image_file_extensions='png jpg jpeg bmp gif tiff tga exr png'.split()
        if is_a_folder(path):
            try:
                files=get_all_paths(path,include_files=True,include_folders=False,recursive=False)
            except PermissionError:
                #Skip directories we don't have access to, as opposed to crashing
                files=[]

            all_unhidden_file_extensions=([get_file_extension(file) for file in files if not is_hidden_file(file)])
            if len(set(all_unhidden_file_extensions))==1:
                extension=all_unhidden_file_extensions[0]
                if extension.strip():
                    stats.append('%i .%s file'%(len(all_unhidden_file_extensions),extension)+('s' if len(files)!=1 else ''))
                    if extension.lower() in image_file_extensions:
                        dims=None
                        try:
                            display_dims=True
                            for file in shuffled(files)[:15]:#Take only a random sample size of the image files for the sake of speed. Most likely it will be correct.
                                dim=get_image_file_dimensions(file)
                                if dims is None:
                                    dims=dim
                                if dims!=dim:
                                    display_dims=False
                                    break
                        except Exception:
                            display_dims=False
                        if display_dims:
                            stats.append('x'.join(map(str,dims)))
                        # else:
                        #     stats.append('(mixed sizes)')
                else:
                    stats.append('%i file'%(len(all_unhidden_file_extensions),)+('s' if len(files)!=1 else ''))
                    # stats.append('%i (no file extension) file'%(len(all_unhidden_file_extensions),)+('s' if len(files)!=1 else ''))
            else:
                if len(files)>0:
                    stats.append('%i file'%len(files)+('s' if len(files)!=1 else ''))#Number of files in the folder

            if is_symbolic_link(path):
                color='yellow'
                stats.append('is symlink')

        elif is_a_file(path):
            stats.append(get_file_size(path,human_readable=True))
            extension=get_file_extension(path)
            if extension.lower() in image_file_extensions:
                try:
                    stats.append('x'.join(map(str,get_image_file_dimensions(path))))
                except Exception:pass#Maybe it wasn't actually an image file...

            if get_file_size(path,human_readable=False) < 1024*1024*16 and is_utf8_file(path): #If the file is under 16 megabytes large (an arbitrary threshold I use to make sure it's not too slow)
                #TODO: Check to see if is a UTF-8 file
                # import codecs
                # codecs.open(filename, encoding='utf-8', errors='strict')
                #For small files, display the number of lines in the file (assume it's a text file)
                stats.append('%i lines'%number_of_lines_in_file(path))
            if is_utf8_file(path) and get_file_extension(path)=='csv':
                #If it's a CSV file, display the number of columns in that file
                try:
                    import csv
                    number_of_columns=len(next(csv.reader(open(path,'r'), delimiter=',')))
                    stats.append('%i cols'%number_of_columns)
                except Exception:
                    pass
            #Getting number of lines was too slow on large files;
            #else:
            #    try:
            #        #if it's a text file, say how many lines it has
            #        string=text_file_to_string(path)
            #        sum(1 for i in open(path, 'rb'))#https://stackoverflow.com/questions/9629179/python-counting-lines-in-a-huge-10gb-file-as-fast-as-possible

            #        # stats.append(str(number_of_lines(string))+' lines')
            #    except Exception:pass

        if stats:       
            return ' '*4 + '\t' + fansi('['+', '.join(stats)+']',color)
        else:
            return ''

    def highlight_child(child,absolute):
        if is_a_folder(absolute):
            return fansi(child,'blue','bold')
        else:
            return child

    class Tree:
        def __init__(self):
            self.dirCount = 0
            self.fileCount = 0

        def register(self, absolute):
            if os.path.isdir(absolute):
                self.dirCount += 1
            else:
                self.fileCount += 1

        def summary(self):
            return str(self.dirCount) + " directories, " + str(self.fileCount) + " files"

        def walk(self, directory, prefix = ""):
            if not is_a_folder(directory):
                return#??? This hack shouldn't be nessecary...
            try:
                filepaths = sorted([filepath for filepath in os.listdir(directory)])
                if only_directories:
                    # fansi_print("all filepaths:"+str(filepaths),'yellow')
                    filepaths=[filepath for filepath in filepaths if is_a_folder(path_join(directory,filepath))]
            except PermissionError:
                #Just in case we get some access-denied error
                filepaths = []
            for index in range(len(filepaths)):
                if not all and filepaths[index][0] == ".":
                    continue

                absolute = os.path.join(directory, filepaths[index])
                self.register(absolute)
    
                recurse=os.path.isdir(absolute) and traverse_symlinks or not is_symbolic_link(absolute)

                entry= highlight_child(filepaths[index],absolute)+get_stats_string(absolute)
                if index == len(filepaths) - 1:
                    print_line(prefix + "└── " + entry)
                    if recurse:
                        self.walk(absolute, prefix + "    ")
                else:
                    print_line(prefix + "├── " + entry)
                    if recurse:
                        self.walk(absolute, prefix + "│   ")

    try:
        directory = "." if root is None else root
        #if len(sys.argv) > 1:
            #directory = sys.argv[1]
        print_line(directory)

        tree = Tree()
        tree.walk(directory)
        print_line("\n" + tree.summary())

    except KeyboardInterrupt:
        #If the user gets tired of waiting and just wants the half-baked results, let them have it...
        print_line(fansi("...(incomplete due to a keyboard interrupt, probably because you pressed Control+C before we finished dipslaying the file tree)...",'red','underlined'))
    _maybe_display_string_in_pager(line_join(printed_lines))
    # if len(printed_lines)>get_terminal_height()*.75 and sys.stdout.isatty():
    #     display=(line_join(printed_lines))
    #     display=_line_numbered_string(display)
    #     display=(fansi("TREE: There were a lot of lines in the output (%i), so we're using rp.string_pager() to show them all. Press 'q' to exit, or press 'h' for more opttions."%len(printed_lines),'blue','bold'))+'\n'+displa
    #     string_pager(display)

def _line_numbered_string(string,foreground='cyan',style='bold',background='blue'):
    lines=line_split(string)
    nlines=len(lines)
    numwidth=len(str(nlines))
    newlines=[fansi(str(i+1).rjust(numwidth)+' '*0,foreground,style,background)+e for i,e in enumerate(lines)]
    return line_join(newlines)

def _vimore(exception):
    try:
        files_and_line_numbers = _all_files_listed_in_exception_traceback(exception)
    except Exception as e:
        pass
    # print("JOLLY")
        # print_verbose_stack_trace(e)
    files_and_line_numbers = [(lineno,file) for file,lineno in files_and_line_numbers if file_exists(file)]

    if not files_and_line_numbers:
        fansi_print('   (There are no editable files in the error\'s traceback)','red')
    
    def localized_path(path):
        #Return either the global or the local path, whichever is more concise
        rel=get_relative_path(path)
        #if rel.startswith('..'):
        if rel.count('/')<path.count('/'):
            return rel
        else:
            return path
        
    colno=0 #I'm not sure how to tell which column number an error occured on
    lineno,path= input_select(
            question =fansi('Please choose a linenumber/file pair from the last traceback:',None,'bold') + '\n' + '    ' + \
                      fansi('pwd: ') + fansi(get_current_directory(),'yellow')
                ,
            options  =files_and_line_numbers,
            stringify=lambda item: fansi(str(item[0]).rjust(6),'cyan') +'  '+ localized_path(item[1])
            )
    
    import subprocess
    #https://stackoverflow.com/questions/3313418/starting-vim-at-a-certain-position-line-and-column-of-a-file
    #              ┌                                                              ┐
    #              │┌                                                            ┐│
    subprocess.call(["vim",path,'+call cursor(%i,%i)'%(lineno,colno),'+normal zz'])
    #              │└                                                            ┘│
    #              └                                                              ┘
    return path

def _load_text_from_file_or_url(location):
    if is_valid_url(location):
        pip_import('requests')
        import requests
        url=location
        response=requests.request('GET',url)
        text=response.text
    elif file_exists(location):
        text=text_file_to_string(location)
    else:
        assert False,"Neither a text file nor a url: "+repr(location)+"\nERROR: This is neither a valid url nor a text file"
    return text

_warning_ignore_filter=('ignore',None,Warning,None,0)
def _warnings_on():
    import warnings
    warnings.filters=[x for x in warnings.filters if x!=_warning_ignore_filter]
def _warnings_off():
    import warnings
    warnings.filters=[_warning_ignore_filter]+warnings.filters
def _warnings_are_off():
    import warnings
    return _warning_ignore_filter in warnings.filters



def _mv(from_path=None,to_dir=None):
    if from_path is None: 
        fansi_print("Please select a file or folder to be moved",'yellow','bold')
        from_path=input_select_path()

    if to_dir    is None: 
        print('\n')
        fansi_print("Please select a destination folder to move %s into"%from_path,'yellow','bold')
        to_dir=input_select_folder()

    print(fansi('Moving','blue','bold'),fansi(from_path,'green'),fansi('to directory','blue'),fansi(to_dir,'green'))
    return move_file(from_path,to_dir)

def _absolute_path_ans(ans):
    #Absolute Path Ans
    if isinstance(ans,str):
        return get_absolute_path(ans)
    else:
        return [get_absolute_path(x) for x in ans]

def _relative_path_ans(ans):
    #Relative Path Ans
    if isinstance(ans,str):
        if ans.startswith('~'):
            ans=os.path.expanduser(ans)
        return get_relative_path(ans)
    else:
        return [get_relative_path(x) for x in ans]


def _rma(ans):
    if not isinstance(ans,str) and not isinstance(ans,list):
        raise TypeError('RMA: ans should be a str pointing to a file path or a list of file paths, but ans is a '+str(type(ans)))
    if isinstance(ans,list):
        bad_paths=[x for x in ans if not path_exists(x)]

        if bad_paths:
            print("The following paths don't exist:\n"+['    '+x for x in bad_paths])
        if input_yes_no("Are you sure you want to delete the below paths?\n"+line_join(['    '+str(x) for x in ans])):
            for x in ans:
                delete_path(x)
    else:
        if not path_exists(ans):
            raise FileNotFoundError(ans)
        ans=get_absolute_path(ans)
        if input_yes_no("Are you sure you want to delete %s?"%ans):
            delete_path(ans)
            print('Deleted path: '+ans)
        else:
            print('Deletion cancelled.'+ans)

def _cpah(paths,method=None):
    if method is None:
        method=copy_path
    if isinstance(paths,str) and '\n' in paths:
        paths=line_split(paths)
    if isinstance(paths,str):
        paths=[paths]

    # Show progress if we're copying files (not just symlinks or moves)
    show_progress = method == copy_path or method == copy_file or method == copy_directory

    if show_progress:
        if len(paths) == 1:
            # Single file/directory - show progress for the copy operation if it's a file
            path = paths[0]
            if file_exists(path):
                # It's a file, use copy_file with show_progress
                output = [copy_file(path, '.', show_progress=True)]
            else:
                # It's a directory or doesn't exist, use the regular method
                output = [method(path, '.')]
        else:
            # Multiple files - use eta to show overall progress
            output = []
            for path in eta(paths, title='Copying %s paths' % len(paths)):
                output.append(method(path, '.'))
    else:
        # Not copying files, just do it without progress
        output = [method(path, '.') for path in paths]

    if len(output)==1:
        return get_only(output)
    return output

def _common_path_prefix_refactoring(list_of_str):
    """
    EXAMPLE (non-string):
         >>> [[1,2,3],[1,2,2]]
        ans = [[1, 2, 3], [1, 2, 2]]
         >>> cppr
        Transformed input to 'r._common_path_prefix_refactoring(ans)' because variable 'cppr' doesn't exist but is a shortcut in SHORTCUTS
        ans = [[1, 2] + x for x in [[3], [2]]]

    EXAMPLE (string-based):
        PASTE: Pasted 13 file path(s) from clipboard!
        ans = ['/Users/ryan/CleanCode/Projects/Google2025_Paper/UserStudy/video_pairs_20/Cheerleader.mp4ATI_0019--[Seed 5819] Cheerleader.mp4_film_strip_5.png', '/Users/ryan/CleanCode/Projects/Google2025_Paper/UserStudy/video_pairs_20/City Biker.mp4ATI_0065--[Seed 9567] City Biker.mp4', '/Users/ryan/CleanCode/Projects/Google2025_Paper/UserStudy/video_pairs_20/City Biker.mp4ATI_0065--[Seed 9567] City Biker.mp4_film_strip_3.png', '/Users/ryan/CleanCode/Projects/Google2025_Paper/UserStudy/video_pairs_20/City Biker.mp4ATI_0065--[Seed 9567] City Biker.mp4_film_strip_4.png', '/Users/ryan/CleanCode/Projects/Google2025_Paper/UserStudy/video_pairs_20/City Biker.mp4ATI_0065--[Seed 9567] City Biker.mp4_film_strip_5.png', '/Users/ryan/CleanCode/Projects/Google2025_Paper/UserStudy/video_pairs_20/Hot Air Baloons_ Slow camera, make baloons rise.mp4ATI_0089--[Seed 875] Hot Air Baloons_ Slow camera, make baloons rise.mp4', '/Users/ryan/CleanCode/Projects/Google2025_Paper/UserStudy/video_pairs_20/Hot Air Baloons_ Slow camera, make baloons rise.mp4ATI_0089--[Seed 875] Hot Air Baloons_ Slow camera, make baloons rise.mp4_film_strip_3.png', '/Users/ryan/CleanCode/Projects/Google2025_Paper/UserStudy/video_pairs_20/Hot Air Baloons_ Slow camera, make baloons rise.mp4ATI_0089--[Seed 875] Hot Air Baloons_ Slow camera, make baloons rise.mp4_film_strip_4.png', '/Users/ryan/CleanCode/Projects/Google2025_Paper/UserStudy/video_pairs_20/Hot Air Baloons_ Slow camera, make baloons rise.mp4ATI_0089--[Seed 875] Hot Air Baloons_ Slow camera, make baloons rise.mp4_film_strip_5.png', '/Users/ryan/CleanCode/Projects/Google2025_Paper/UserStudy/video_pairs_20/Judge_ Walk In From Right + Zoom_copy1.mp4ATI_0045--[Seed 5176] Judge_ Walk In From Right + Zoom_copy1.mp4', '/Users/ryan/CleanCode/Projects/Google2025_Paper/UserStudy/video_pairs_20/Judge_ Walk In From Right + Zoom_copy1.mp4ATI_0045--[Seed 5176] Judge_ Walk In From Right + Zoom_copy1.mp4_film_strip_3.png', '/Users/ryan/CleanCode/Projects/Google2025_Paper/UserStudy/video_pairs_20/Judge_ Walk In From Right + Zoom_copy1.mp4ATI_0045--[Seed 5176] Judge_ Walk In From Right + Zoom_copy1.mp4_film_strip_4.png', '/Users/ryan/CleanCode/Projects/Google2025_Paper/UserStudy/video_pairs_20/Judge_ Walk In From Right + Zoom_copy1.mp4ATI_0045--[Seed 5176] Judge_ Walk In From Right + Zoom_copy1.mp4_film_strip_5.png']
         >>> _common_path_prefix_refactoring(ans)
        ans = paths = [
            "/Users/ryan/CleanCode/Projects/Google2025_Paper/UserStudy/video_pairs_20/" + x
            for x in [
                "Cheerleader.mp4ATI_0019--[Seed 5819] Cheerleader.mp4_film_strip_5.png",
                "City Biker.mp4ATI_0065--[Seed 9567] City Biker.mp4",
                "City Biker.mp4ATI_0065--[Seed 9567] City Biker.mp4_film_strip_3.png",
                "City Biker.mp4ATI_0065--[Seed 9567] City Biker.mp4_film_strip_4.png",
                "City Biker.mp4ATI_0065--[Seed 9567] City Biker.mp4_film_strip_5.png",
                "Hot Air Baloons_ Slow camera, make baloons rise.mp4ATI_0089--[Seed 875] Hot Air Baloons_ Slow camera, make baloons rise.mp4",
                "Hot Air Baloons_ Slow camera, make baloons rise.mp4ATI_0089--[Seed 875] Hot Air Baloons_ Slow camera, make baloons rise.mp4_film_strip_3.png",
                "Hot Air Baloons_ Slow camera, make baloons rise.mp4ATI_0089--[Seed 875] Hot Air Baloons_ Slow camera, make baloons rise.mp4_film_strip_4.png",
                "Hot Air Baloons_ Slow camera, make baloons rise.mp4ATI_0089--[Seed 875] Hot Air Baloons_ Slow camera, make baloons rise.mp4_film_strip_5.png",
                "Judge_ Walk In From Right + Zoom_copy1.mp4ATI_0045--[Seed 5176] Judge_ Walk In From Right + Zoom_copy1.mp4",
                "Judge_ Walk In From Right + Zoom_copy1.mp4ATI_0045--[Seed 5176] Judge_ Walk In From Right + Zoom_copy1.mp4_film_strip_3.png",
                "Judge_ Walk In From Right + Zoom_copy1.mp4ATI_0045--[Seed 5176] Judge_ Walk In From Right + Zoom_copy1.mp4_film_strip_4.png",
                "Judge_ Walk In From Right + Zoom_copy1.mp4ATI_0045--[Seed 5176] Judge_ Walk In From Right + Zoom_copy1.mp4_film_strip_5.png",
            ]
        ]
    """
    ans = list_of_str
    if isinstance(ans,str):
        ans=ans.splitlines()
    prefix = longest_common_prefix(ans)
    if prefix:
        suffixes = [x[len(prefix) :] for x in ans]
        ans = "["+repr(prefix)+"+x for x in "+repr(suffixes)+"]"
    else:
        ans=repr(ans)
    ans = autoformat_python_via_black(ans)
    return ans


def _get_env_info():
    """
    Adapted from the pytorch github page
    This script gets information about your computer
    It's used in pseudo_terminal's LEVEL command
    The original code: https://gist.github.com/93795ffd6380c79ffc1a709500ed9118
    Returns a named tuple like:
        SystemEnv(cuda_runtime_version='10.1.243', nvidia_gpu_models='GPU 0: NVIDIA GeForce RTX 3090', nvidia_driver_version='470.103.01', os='Ubuntu 20.04.2 LTS (x86_64)')
      Or, on my macbook:
        ans = SystemEnv(cuda_runtime_version=None, nvidia_gpu_models=None, nvidia_driver_version=None, os='macOS 10.15.7 (x86_64)')
    """

    import locale
    import re
    import subprocess
    import sys
    import os
    from collections import namedtuple
    
    
    # System Environment Information
    SystemEnv = namedtuple('SystemEnv', [
        'cuda_runtime_version',
        'nvidia_gpu_models',
        'nvidia_driver_version',
        'os',
    ])
    
    
    def run(command):
        """Returns (return-code, stdout, stderr)"""
        p = subprocess.Popen(command, stdout=subprocess.PIPE,
                             stderr=subprocess.PIPE, shell=True)
        raw_output, raw_err = p.communicate()
        rc = p.returncode
        if get_platform() == 'win32':
            enc = 'oem'
        else:
            enc = locale.getpreferredencoding()
        output = raw_output.decode(enc)
        err = raw_err.decode(enc)
        return rc, output.strip(), err.strip()
    
    
    def run_and_read_all(run_lambda, command):
        """Runs command using run_lambda; reads and returns entire output if rc is 0"""
        rc, out, _ = run_lambda(command)
        if rc != 0:
            return None
        return out
    
    
    def run_and_parse_first_match(run_lambda, command, regex):
        """Runs command using run_lambda, returns the first regex match if it exists"""
        rc, out, _ = run_lambda(command)
        if rc != 0:
            return None
        match = re.search(regex, out)
        if match is None:
            return None
        return match.group(1)
    
    def get_nvidia_driver_version(run_lambda):
        if get_platform() == 'darwin':
            cmd = 'kextstat | grep -i cuda'
            return run_and_parse_first_match(run_lambda, cmd,
                                             r'com[.]nvidia[.]CUDA [(](.*?)[)]')
        smi = get_nvidia_smi()
        return run_and_parse_first_match(run_lambda, smi, r'Driver Version: (.*?) ')
    
    
    def get_gpu_info(run_lambda):
        if get_platform() == 'darwin':
            if TORCH_AVAILABLE and torch.cuda.is_available():
                return torch.cuda.get_device_name(None)
            return None
        smi = get_nvidia_smi()
        uuid_regex = re.compile(r' \(UUID: .+?\)')
        rc, out, _ = run_lambda(smi + ' -L')
        if rc != 0:
            return None
        # Anonymize GPUs by removing their UUID
        return re.sub(uuid_regex, '', out)
    
    
    def get_running_cuda_version(run_lambda):
        return run_and_parse_first_match(run_lambda, 'nvcc --version', r'release .+ V(.*)')
    
    
    def get_cudnn_version(run_lambda):
        """This will return a list of libcudnn.so; it's hard to tell which one is being used"""
        if get_platform() == 'win32':
            system_root = os.environ.get('SYSTEMROOT', 'C:\\Windows')
            cuda_path = os.environ.get('CUDA_PATH', "%CUDA_PATH%")
            where_cmd = os.path.join(system_root, 'System32', 'where')
            cudnn_cmd = '{} /R "{}\\bin" cudnn*.dll'.format(where_cmd, cuda_path)
        elif get_platform() == 'darwin':
            # CUDA libraries and drivers can be found in /usr/local/cuda/. See
            # https://docs.nvidia.com/cuda/cuda-installation-guide-mac-os-x/index.html#install
            # https://docs.nvidia.com/deeplearning/sdk/cudnn-install/index.html#installmac
            # Use CUDNN_LIBRARY when cudnn library is installed elsewhere.
            cudnn_cmd = 'ls /usr/local/cuda/lib/libcudnn*'
        else:
            cudnn_cmd = 'ldconfig -p | grep libcudnn | rev | cut -d" " -f1 | rev'
        rc, out, _ = run_lambda(cudnn_cmd)
        # find will return 1 if there are permission errors or if not found
        if len(out) == 0 or (rc != 1 and rc != 0):
            l = os.environ.get('CUDNN_LIBRARY')
            if l is not None and os.path.isfile(l):
                return os.path.realpath(l)
            return None
        files_set = set()
        for fn in out.split('\n'):
            fn = os.path.realpath(fn)  # eliminate symbolic links
            if os.path.isfile(fn):
                files_set.add(fn)
        if not files_set:
            return None
        # Alphabetize the result because the order is non-deterministic otherwise
        files = list(sorted(files_set))
        if len(files) == 1:
            return files[0]
        result = '\n'.join(files)
        return 'Probably one of the following:\n{}'.format(result)
    
    
    def get_nvidia_smi():
        # Note: nvidia-smi is currently available only on Windows and Linux
        smi = 'nvidia-smi'
        if get_platform() == 'win32':
            system_root = os.environ.get('SYSTEMROOT', 'C:\\Windows')
            program_files_root = os.environ.get('PROGRAMFILES', 'C:\\Program Files')
            legacy_path = os.path.join(program_files_root, 'NVIDIA Corporation', 'NVSMI', smi)
            new_path = os.path.join(system_root, 'System32', smi)
            smis = [new_path, legacy_path]
            for candidate_smi in smis:
                if os.path.exists(candidate_smi):
                    smi = '"{}"'.format(candidate_smi)
                    break
        return smi
    
    
    def get_platform():
        if sys.platform.startswith('linux'):
            return 'linux'
        elif sys.platform.startswith('win32'):
            return 'win32'
        elif sys.platform.startswith('cygwin'):
            return 'cygwin'
        elif sys.platform.startswith('darwin'):
            return 'darwin'
        else:
            return sys.platform
    
    
    def get_mac_version(run_lambda):
        return run_and_parse_first_match(run_lambda, 'sw_vers -productVersion', r'(.*)')
    
    
    def get_windows_version(run_lambda):
        system_root = os.environ.get('SYSTEMROOT', 'C:\\Windows')
        wmic_cmd = os.path.join(system_root, 'System32', 'Wbem', 'wmic')
        findstr_cmd = os.path.join(system_root, 'System32', 'findstr')
        return run_and_read_all(run_lambda, '{} os get Caption | {} /v Caption'.format(wmic_cmd, findstr_cmd))
    
    
    def get_lsb_version(run_lambda):
        return run_and_parse_first_match(run_lambda, 'lsb_release -a', r'Description:\t(.*)')
    
    
    def check_release_file(run_lambda):
        return run_and_parse_first_match(run_lambda, 'cat /etc/*-release',
                                         r'PRETTY_NAME="(.*)"')
    
    
    def get_os(run_lambda):
        from platform import machine
        platform = get_platform()
    
        if platform == 'win32' or platform == 'cygwin':
            return get_windows_version(run_lambda)
    
        if platform == 'darwin':
            version = get_mac_version(run_lambda)
            if version is None:
                return None
            return 'macOS {} ({})'.format(version, machine())
    
        if platform == 'linux':
            # Ubuntu/Debian based
            desc = get_lsb_version(run_lambda)
            if desc is not None:
                return '{} ({})'.format(desc, machine())
    
            # Try reading /etc/*-release
            desc = check_release_file(run_lambda)
            if desc is not None:
                return '{} ({})'.format(desc, machine())
    
            return '{} ({})'.format(platform, machine())
    
        # Unknown platform
        return platform
    
    def squelch(function,run_lambda):
        try:
            return function(run_lambda)
        except Exception:
            return None

    def get_env_info():
        run_lambda = run
    
        cuda_runtime_version =squelch(get_running_cuda_version ,run_lambda)
        nvidia_gpu_models    =squelch(get_gpu_info             ,run_lambda)
        nvidia_driver_version=squelch(get_nvidia_driver_version,run_lambda)
        os                   =squelch(get_os                   ,run_lambda)

        return SystemEnv(
                cuda_runtime_version =cuda_runtime_version ,
                nvidia_gpu_models    =nvidia_gpu_models    ,
                nvidia_driver_version=nvidia_driver_version,
                os                   =os                   ,
        )
    
    env_info_fmt = """
    CUDA runtime version: {cuda_runtime_version}
    GPU models and configuration: {nvidia_gpu_models}
    Nvidia driver version: {nvidia_driver_version}
    OS: {os}
    """
    
    return get_env_info()

def _view_image_via_textual_imageview(image):
    """
    Views image in a terminal using textual-imageview.
    It's cool becuase it's interactive - and works in any TTY that can display 256+ colors

    Args:
        image: Image file path (str) or image object (numpy array, PIL Image, etc.)

    Returns:
        None (displays image interactively in terminal)

    Examples:
        >>> # Internal usage (function is private)
        >>> location = 'https://github.com/RyannDaGreat/Diffusion-Illusions/blob/gh-pages/images/emma.png?raw=true'
        >>> image = load_image(image, use_cache=True)
        >>> _view_image_via_textual_imageview(image)    #This should work. Test it in a TTY.
        >>> _view_image_via_textual_imageview(location) #This should work too

    Tags: image, display, terminal, textual, private, viewer
    """
    assert isinstance(image, str) or is_image(image)
    
    pip_import('textual_imageview', 'textual-imageview')
    import textual_imageview.app
    
    if isinstance(image, str):
        original_colorterm = os.getenv('COLORTERM')
        try:
            os.environ['COLORTERM'] = 'truecolor'
            app = textual_imageview.app.ImageViewerApp(image)
            app.run()
        finally:
            if original_colorterm is None:
                del os.environ['COLORTERM']
            else:
                os.environ['COLORTERM'] = original_colorterm
    else:
        assert is_image(image)
        try:
            path = temporary_file_path('png')
            save_image(image, path)
            _view_image_via_textual_imageview(path)
        finally:
            delete_file(path)
def _ISM(ans,*,preview:str=None):
    """
    Input Select Multi
    TODO make it for things other than lists of strings, like lists of ints. To do this make it into a line-numbered string dict -> values then use those values to look up keys ->  get answer. Better yet create a fzf wrapper for this task - to select non-string things!
    """

    try:
        ans=dict(ans)
    except Exception:
        pass

    if isinstance(ans,dict):
        return _filter_dict_via_fzf(ans,preview=preview)
    else:
        if isinstance(ans,str):
            ans=line_split(ans)
            ans=ans[::-1] #Let us see the string properly
            return line_join(_ISM(ans,preview=preview))
        return _iterfzf(ans,multi=True,exact=True,preview=preview)

_which_cache={}
def _which(cmd):

    #Faster than using the which command...
    import shutil
    util_which = shutil.which(cmd)
    if util_which:
        return util_which
        
    def update(cmd):
        output=shell_command('which '+cmd)
        _which_cache[cmd]=output
    
    if cmd in _which_cache:
        run_as_new_thread(update,cmd)
        return _which_cache[cmd]
    else:
        update(cmd)
        return _which_cache[cmd]

_whiches_cache=None
def _whiches():
    global _whiches_cache

    def refresh(show_progress=False):
        global _whiches_cache
        keys = get_system_commands()
        values = load_files(_which, keys, show_progress=show_progress)
        _whiches_cache =  {k:v for k,v in zip(keys,values)}

    if _whiches_cache is None:
        refresh(show_progress='eta:r._whiches')

    else:
        run_as_new_thread(refresh)

    return _whiches_cache
def _ism_whiches():
    "A real which hunt if I do say so myself!"
    return _ISM(_whiches())



def _view_with_pyfx(data):
    """
    View JSON/YAML/nested data interactively using the pyfx terminal-based data explorer.

    Args:
        data: Data to view - can be JSON/YAML file path, or data structure

    Examples:
        >>> # Internal usage (function is private)
        >>> _view_with_pyfx({'key': 'value', 'nested': {'data': [1,2,3]}})
        >>> _view_with_pyfx('/path/to/data.json')
        >>> _view_with_pyfx('/path/to/config.yaml')  
    """
    from rp.libs.pyfx.app import PyfxApp
    from rp.libs.pyfx.model import DataSourceType

    if isinstance(data,str) and not '\n' in data and is_utf8_file(data):
        if data.endswith('.json'):
            data=load_json(data)
        elif data.endswith('.yml') or data.endswith('.yaml'):
            data=load_yaml_file(data)

    PyfxApp().run(DataSourceType.VARIABLE, data)

def _view_json_via_jtree(json):
    """
    View JSON data in an interactive tree viewer using the jtree library.
    
    Args:
        json: JSON data to visualize. Can be either:
              - dict/list/other JSON-serializable object: Will be temporarily saved to file
              - str: Treated as file path to existing JSON file (must exist)
    
    Examples:
        >>> # View a JSON object interactively
        >>> data = {"name": "test", "values": [1, 2, 3], "nested": {"key": "value"}}
        >>> _view_json_via_jtree(data)  # Opens interactive tree viewer
        >>> 
        >>> # View JSON from file
        >>> _view_json_via_jtree("/path/to/data.json")  # Opens file in viewer
        
    """
    pip_import('jtree')
    if isinstance(json,str):
        assert file_exists(json)    
        import jtree
        jtree.JSONTreeApp(open(json)).run()
    else:
        temp_json_path=temporary_file_path('json')
        try:
            save_json(json,temp_json_path)
            _view_json_via_jtree(temp_json_path)
        finally:
            delete_file(temp_json_path)
            
def _view_interactive_json(data):
    """ Used by pseudo_terminal for viewing nested json-like data """
    try:
        import json
        if not isinstance(data, str):
            json.dumps(data)
        _view_json_via_jtree(data)
    except Exception:
        _view_with_pyfx(data)


def _get_processor_name():
    import os, platform, subprocess, re
    #https://stackoverflow.com/questions/4842448/getting-processor-information-in-python
    if platform.system() == "Windows":
        return platform.processor()
    elif platform.system() == "Darwin":
        os.environ['PATH'] = os.environ['PATH'] + os.pathsep + '/usr/sbin'
        command ="sysctl -n machdep.cpu.brand_string"
        return shell_command(command)
        return subprocess.check_output(command).strip()
    elif platform.system() == "Linux":
        command = "cat /proc/cpuinfo"
        all_info = subprocess.check_output(command, shell=True).strip().decode()
        for line in all_info.split("\n"):
            if "model name" in line:
                return re.sub( ".*model name.*:", "", line,1)
    return ""

def _display_columns(entries,title=None):
    pip_import('rich')
    from rich import print
    from rich.columns import Columns
    columns = Columns(entries, equal=False, expand=False, title=title)
    print(columns)

def _input_select_multiple_history_multiline(history_filename=history_filename,old_code=None):
    #VERSION 1: WITH DATES AND IN SYNC WITH OTHER CONCURRENT RP'S BUT SLOWER
    # history=text_file_to_string(history_filename)
    # paragraphs=history.split('\n\n')
    # def process_paragraph(paragraph):
    #     lines=paragraph.splitlines()
    #     lines[1:]=[x[1:] for x in lines[1:]]
    #     return line_join(lines)

    #VERSION 2: NO DATES BUT MUCH FASTER
    paragraphs=_globa_pyin[0].history.strings
    process_paragraph = identity

    paragraphs=paragraphs[::-1]
    paragraphs=(process_paragraph(x) for x in paragraphs)
    return _input_select_multiple_paragraphs(paragraphs, old_code=old_code)


def _input_select_multiple_paragraphs(paragraphs,old_code=None):

    # if fansi_is_enabled():
    #     paragraphs=map(fansi_syntax_highlighting,paragraphs)

    import json
    # lines=map(repr,paragraphs)
    lines=map(json.dumps,paragraphs)
    lines=(x[1:-1] for x in lines)

    preview_width=get_terminal_width()//2-2-2

    #Older versions:
    # lines=pip_import('iterfzf').iterfzf(lines,multi=True,exact=True,preview='echo {} | fold -w %i'%preview_width)
    # lines=pip_import('iterfzf').iterfzf(lines,multi=True,exact=True,preview='echo {} | nl -v 0 | fold -w %i'%preview_width) #Have line numbers Start from 0


    #Ideally we would have a program take a pure python string and syntax highlight it with numbers and wrapping. However, rp loads too slowly for this to be decently fast - much less instant like fzf is
    #NOTE this syntax highlighting is hampered by a problem: the ansi escape codes are counted towards the line wrapping (done by the fold command). Idk how to ignore them in fold,
    #You can disable highlighting with fansi_off()
    # lines=pip_import('iterfzf').iterfzf(lines,multi=True,exact=True,preview='echo {} | nl -b a -v 0 -s\\|\ \  | fold -w %i'%preview_width) #
    if old_code is None:
        if fansi_is_enabled():
            lines=_iterfzf(lines,multi=True,exact=True,preview='echo {} | %s %s %i '%(
                shlex.quote(sys.executable),
                shlex.quote(get_module_path("rp.libs.stdin_python_highlighter")),
                preview_width),
            ) #
        else:
            lines=_iterfzf(lines,multi=True,exact=True,preview=r'echo {} | nl -b a -v 0 -s\\|\ \  | fold -w %i'%preview_width) #
        # lines=pip_import('iterfzf').iterfzf(lines,multi=True,exact=True,preview='echo {} | nl -v 0 | fold -w %i'%preview_width) #Have line numbers Start from 0
    else:
        assert old_code is not None
        #We're gonna do diffs and merge them
        #No support for fansi_disabled right now
        lines=_iterfzf(lines,multi=True,exact=True,preview='echo {} | %s %s %i %s %s'%(
                shlex.quote(sys.executable),
                shlex.quote(get_module_path("rp.libs.stdin_python_highlighter")),
                preview_width,
                "diff_mode",
                shlex.quote(json.dumps(old_code)),
                ),
        ) #
        if lines is None:
            #The user cancelled
            return None
        # return line_join(lines)

    if lines is None:
        #The user cancelled
        return None

    # highlighter_code=text_file_to_string(get_module_path("rp.libs.stdin_python_highlighter"))
    # highlighter_code=highlighter_code.replace("HARDCODED_WIDTH=None","HARDCODED_WIDTH=%i")%preview_width

    # lines=pip_import('iterfzf').iterfzf(lines,multi=True,exact=True,preview='echo {} | %s -i {} '%(
    #     # json.dumps(get_module_path("rp.libs.stdin_python_highlighter")),
    #     json.dumps(__file__),
    #     json.dumps(sys.executable),
    #     preview_width),
    # ) #
    
    import ast
    # selected_paragraphs=[ast.literal_eval(x) for x in lines]
    selected_paragraphs=[json.loads('"'+x+'"') for x in lines]
    # out=line_join(lines)
    out='\n\n'.join(selected_paragraphs)

    # if fansi_is_enabled():
    #     out=strip_ansi_escapes(out)

    out = out.splitlines()
    if len(out)>1 and out[1].startswith('!'):
        #If selecting a ! command put the ! before the date
        if True:
            #DELETE THE DATE
            out=out[1:]
        else:
            #KEEP THE DATE
            out[0]='!'+out[0]
            out[1]=out[1][1:]
    out=line_join(out)


    return out

def _autocomplete_lss_name(lss_name,command_name=None):
    """ 
    If there's an autocomplete thing in prompt-toolkit autocompletions thats a path return it otherwise dont change the input 
    That way, 'LSS co' --> 'LSS CogVideoX'
    """
    if command_name is not None:
        lss_name=lss_name[len(command_name+' '):]
    import rp.rp_ptpython.r_iterm_comm as ric
    if (
        ric.current_candidates
        and fuzzy_string_match(
            lss_name, ric.current_candidates[0], case_sensitive=False
        )
        and not path_exists(lss_name)
    ):
        #Don't need tab to autocomplete these paths, which is why it's fast...
        candidate_0 = ric.current_candidates[0]
        candidate_0 = candidate_0.strip('/').strip('\\')
        if path_exists(candidate_0):
            if candidate_0 != lss_name and command_name is not None:
                fansi_print(
                    command_name
                    + ": Completed "
                    + repr(lss_name)
                    + " to "
                    + repr(candidate_0),
                    "blue",
                )
            lss_name = candidate_0

    return lss_name

def _input_select_multiple_history(history_filename=history_filename):
    history=text_file_to_string(history_filename)
    lines=history.splitlines()

    preview_width=get_terminal_width()//2-2
    lines=_iterfzf(lines,multi=True,exact=True,preview='echo {} | fold -w %i'%preview_width)

    lines=[('#' if x.startswith('#') else '')+x[1:] for x in lines]

    # if len(lines)>1 and lines[1].startswith('!'):
    #     #If selecting a ! command put the ! before the date
    #     lines[0]='!'+lines[0]
    #     lines[1]=lines[1][1:]

    out=line_join(lines)

    return out

_need_module_refresh=False #Set to true if we do something with pip. Used by pterm

#def pudb_shell(_globals,_locals_):
#    #https://documen.tician.de/pudb/shells.html
#    pseudo_terminal(_globals,_locals)


def _pterm_fuzzy_cd(query_path, do_cd=False):
    def is_a_match(query_path, real_path, case_sensitive):
        query_name = get_path_name(query_path)
        real_name  = get_path_name(real_path )
        
        if query_name in ['','.','..','/']:
            #Special names
            return True
        # print(query_name, real_name)

        return fuzzy_string_match(query_name, real_name, case_sensitive=case_sensitive)

    query_path=os.path.expanduser(query_path)

    if query_path.startswith('/'):
        #Doesn't work for windows. Who cares lol
        root='/'
    elif query_path.startswith('~/'):
        root=get_home_directory()
    else:
        root='.'

    new_pwd = root
    failed=False


    def joined_names(names):
        names=sorted(names)
        max_len=5
        if len(names)>max_len:
            return joined_names(
                random_batch(names, max_len, retain_order=True)
            ) + "     ... %i more not shown ... " % (len(names) - max_len)
        return '   '.join(map(shlex.quote, names))
   
    subpaths = path_split(query_path) 
    if subpaths and subpaths[0]=='/':
        del subpaths[0]

    for query_name in subpaths:
        subfolders = _get_all_paths_fast(new_pwd, include_files=False)
        query_pwd = path_join(new_pwd, query_name)
        
        # from icecream import ic
        # ic(query_name,query_pwd,new_pwd)

        #If there's a direct match, don't try fuzzy searching
        if query_name in get_path_names(subfolders):
            new_pwd = path_join(new_pwd, query_name)
            continue

        #Do fuzzy matching
        case_sensitive_matches   = sorted(x for x in subfolders if is_a_match(query_pwd, x, True ))
        case_insensitive_matches = sorted(x for x in subfolders if is_a_match(query_pwd, x, False))

        #If we get multiple fuzzy matches with case-insensitive, try case sensitive
        if len(case_sensitive_matches)==1:
            matches = case_sensitive_matches
        else:
            matches = case_insensitive_matches

        #Handle each case
        if len(matches)==1:
            new_pwd = matches[0]
            continue
        elif len(matches)==0:
            import shlex
            print(
                fansi("Can't find any fuzzy matches for ", "red")
                + fansi(query_name, "cyan", "bold")
                + fansi(" in ", 'red')
                +_fansi_highlight_path(new_pwd)
                + "\n    "
                + fansi("Subfolders: ", "red")
                + fansi(
                    joined_names(get_folder_names(subfolders)),
                    "yellow",
                )
            )
            failed = True
            break
        elif len(matches)>1:
            if len(subpaths)==1:
                #Break the ambiguity with the current completion candidates if available...
                #Currently for simplicity of implementation checking  we're not going deep into paths aka len(subpaths)==1...
                can = _ric_current_candidate_fuzzy_matches(query_name)
                if can is not None:
                    new_pwd = can.strip('/')
                    if currently_running_windows():
                        new_pwd = new_pwd.strip("\\")
                    continue

            import shlex
            print(
                fansi("Multiple fuzzy matches for ", "red")
                + fansi(query_name, "cyan", "bold")
                + fansi(" in ", 'red')
                +_fansi_highlight_path(new_pwd)
                + "\n    "
                + fansi("Matches: ", "red")
                + fansi(
                    joined_names(matches),
                    "yellow",
                )
            )
            failed = True
            break
        else:
            assert False,'impossible'
    
    #Return the path and maybe cd into it
    if failed:
        return query_path
    else:
        if do_cd:
            _pterm_cd(new_pwd)
        return new_pwd


def _ric_current_candidate_fuzzy_matches(query):
    #Return the first pt completion candidate if they exist and match the query...
    import rp.rp_ptpython.r_iterm_comm as ric
    can = ric.current_candidates
    if can and fuzzy_string_match(query, can[0], case_sensitive=False):
        return can[0]
    else:
        return None







@cachetools.cached(cache=cachetools.TTLCache(maxsize=1000, ttl=10))
def get_number_of_github_gists(username="SqrtRyan"):
    return load_json("https://api.github.com/users/" + username).public_gists
_get_all_github_gists_info__prev_num_github_gists = {}
@cachetools.cached(cache=cachetools.TTLCache(maxsize=1000, ttl=60))
def _get_all_github_gists_info(username="SqrtRyan",use_cache=True):
    """
    Enhanced Documentation:
    Internal helper for retrieving comprehensive information about all GitHub gists for a user.
    
    This function fetches detailed information about all gists from a GitHub user's profile
    using the GitHub API. It handles pagination automatically and caches results for performance.
    Each gist's files are processed and returned with metadata including URLs, sizes, and content.
    
    Parameters:
        username (str, optional): GitHub username to fetch gists from. Defaults to "SqrtRyan".
        use_cache (bool, optional): Whether to use cached results for faster repeated access. Defaults to True.
    
    Returns:
        list: List of dictionaries containing gist file information with keys:
              - 'filename': Name of the file
              - 'type': MIME type or file type
              - 'language': Programming language detected
              - 'raw_url': Direct URL to raw file content
              - 'size': File size in bytes
              - 'gist_url': URL to the gist page
        
    EXAMPLE:
        >>> gists = _get_all_github_gists_info(use_cache=True)
        >>> len(gists)
        414
        >>> type(gists)
        <class 'list'>
        >>> gists[0]['filename']
        'gistfile1.txt'
        >>> gists[0]['size']
        3041
        
    Tags: internal, github, gist, api, cache
    """
    entries_per_page = 100

    num_gists = get_number_of_github_gists(username)
    num_pages = math.ceil(num_gists / entries_per_page)

    num_gists_changed = _get_all_github_gists_info__prev_num_github_gists.get(username,None)!=num_gists
    if num_gists_changed:
        _get_all_github_gists_info__prev_num_github_gists[username]=num_gists
        
    def load_page(page_index):
        #Url Explanation:   &direction=asc     makes it so that older entries come first! Important for caching reliably. DOESNT WORK THOUGH!! :(
        page_url = "https://api.github.com/users/{0}/gists?per_page={1}&page={2}&direction=asc".format(username, entries_per_page, page_index+1)
        is_last_page = page_index + 1 == num_pages
        do_cache = use_cache and not (is_last_page and num_gists_changed)
        
        #I was unable to sort pages by date. 
        # if do_cache:
        #     page_loc = download_url_to_cache(page_url)
        # else:
        #     page_loc = page_url
        page_loc = page_url
        
        page=load_json(page_loc,use_cache=do_cache and 'nocopy')
        return page
        
    page_indices = range(num_pages)
    
    pages = load_files(load_page, page_indices, show_progress='eta:'+get_current_function_name())
    pages = list_flatten(pages)
    files = []
    for page in pages:
        for file_info in page['files'].values():
            file_info['gist_url'] = page['html_url']
            files.append(file_info)
    return files

def _download_rp_gists(max_filesize='10mb'):
    max_filesize=_FilesizeFormTranslator.as_num_bytes(max_filesize)
    gists = [g for g in _get_all_github_gists_info("SqrtRyan") if g['size']<=max_filesize]
    paths = download_urls_to_cache([g['raw_url'] for g in gists], show_progress=True)
    return list(zip([g['gist_url'] for g in gists], paths))
    #return load_text_files(raw_gist_paths, show_progress=True, use_cache=True)

def _input_select_rp_gists(ans=None):
    return _input_select_multiple_paragraphs(
        (
            ("# " + url + "\n" + text_file_to_string(path))[:1000000]
            for url, path in _download_rp_gists()
        ),
        old_code=ans if isinstance(ans, str) else None,
    )

            



        

def _pterm_cd(dir,repeat=1):
    """Change directory in pseudo_terminal with history tracking. Internal helper."""
    dir=os.path.expanduser(dir)
    pwd=get_current_directory()
    if _cd_history and _cd_history[-1]!=pwd:
        _cd_history.append(pwd)
    for _ in range(repeat):
        set_current_directory(dir)
    sys.path.append(get_absolute_path(get_current_directory()))
    print(_fansi_highlight_path(get_current_directory()))

def _profile_vim_startup_plugins():
    """
    Profile Vim startup plugins to identify performance bottlenecks.
    Finds slow-loading plugins affecting Vim startup performance. 
    Uses the profile_vim_plugins library to generate performance reports.

    Args: None

    Returns: None (displays performance report)

    Examples:
        >>> # Internal usage (function is private)
        >>> _profile_vim_startup_plugins()  # doctest: +SKIP
        # Would display plugin timing analysis

    Tags: vim, profiling, performance, plugins, startup, private, analysis
    """
    from rp.libs.profile_vim_plugins import run
    run()

def _view_markdown_in_terminal(file_or_string):
    
    pip_import("frogmouth")

    path=file_or_string.strip()

    if file_exists(path):
        path=file_or_string
        temp_path=False
    else:
        path=temporary_file_path('md')
        path=string_to_text_file(path,file_or_string)
        temp_path=True

    os.system('frogmouth '+repr(path)) # Displays markdown

    if temp_path:
        delete_file(path)

def _get_function_names(ans):
    if isinstance(ans, str):
        return ans
    elif hasattr(ans,"__name__"):
        return ans.__name__
    elif is_iterable(ans):
        return [_get_function_names(x) for x in ans]
    else:
        raise ValueError("Is not a function: type(ans)="+str(type(ans)))

def _convert_powerpoint_file(path,message=None):
    if message is not None:
        fansi_print(message,'green','bold')
    from rp.libs.powerpoint_converter import process_powerpoint_file
    return process_powerpoint_file(path)

def _upfp(autoyes=False):
    file_bundle = web_paste(show_progress=True)
    assert type(file_bundle)==_BundledPath, type(file_bundle)
    assert get_file_name(file_bundle.path)=='r.py',file_bundle.path
    assert isinstance(file_bundle.data, bytes), type(file_bundle.data)
    assert file_bundle.data.startswith(b'""" Welcome to RP: Ryan Python """')
    new_code=file_bundle.data.decode()
    old_code=get_source_code(r)
    dunk_string_diff(old_code, new_code)
    if old_code==new_code:
        fansi_print("RP Update: No change needed, old = new",'green bold')
        return
    if autoyes or input_yes_no("Update rp to this?"):
        string_to_text_file(r.__file__, new_code)
        fansi_print("Updated "+r.__file__, 'green bold italic underlined')


def _get_gitignore_path():
    """Get the path to the .gitignore file in the current git repository."""
    git_repo = get_parent_folder(get_git_repo_root())
    return path_join(git_repo, '.gitignore')

def _git_add_ans(paths):
    """Helper to git add paths from ans, handling both single paths and lists."""
    import os
    import shlex

    # Handle newline-separated strings
    if isinstance(paths, str) and '\n' in paths:
        paths = line_split(paths)
    # Convert single string to list
    if isinstance(paths, str):
        paths = [paths]

    # Print what we're doing
    if len(paths) == 1:
        fansi_print('GADA/GADDA --> git add --> Adding '+str(paths[0])+' to git staging area', 'blue', 'bold')
    else:
        fansi_print('GADA/GADDA --> git add --> Adding '+str(len(paths))+' file(s) to git staging area', 'blue', 'bold')

    # Git add each path
    for path in paths:
        os.system('git add --verbose ' + shlex.quote(str(path)))

def _write_default_gitignore():
    types_to_ignore='pyc bak swh swi swj swk swl swm swn swo swp un~ gstmp ipynb_checkpoints DS_Store'.split()
    types_to_ignore=['*.'+x for x in types_to_ignore]

    new_lines = (
        ["#<RP Default Gitignore Start>"]
        + types_to_ignore
        + ["#<RP Default Gitignore End>"]
    )

    new_text = "\n" + line_join(new_lines) + "\n\n"

    file = _get_gitignore_path()

    if not file_exists(file) or new_text.strip() not in load_text_file(file):
        append_line_to_file(new_text,file)
        fansi_print("Wrote lines to "+file,'green','bold')
    else:
        fansi_print("Nothing written, "+file+" already has rp's list "+file,'green','bold')

    return file
    
def _add_pterm_prefix_shortcut(shortcut:str,replacement:str):
    """
    When using pterm, you can type commands like 'pi ' --> 'PIP install '
    This lets you add custom ones from your rprc file, like _add_prefix_shortcut('fu','!fileutil')
    """
    assert isinstance(shortcut,str), shortcut
    assert isinstance(replacement,str) or isinstance(replacement, list) and len(replacement)==2, replacement
    
    import rp.rp_ptpython.r_iterm_comm as ric
    ric.kibble_shortcuts[shortcut]=replacement

def _add_pterm_command_shortcuts(shortcuts:str):
    """ 
    EXAMPLE: 
        >>> _add_pterm_command_shortcuts('''
        >>>     CLC $r._pterm_cd("~/CleanCode")
        >>>     RZG $os.system(f"cd {$get_path_parent($get_module_path(rp)} ; lazygit")
        >>> ''')
    """
    shortcuts=shortcuts.splitlines()
    import rp.rp_ptpython.r_iterm_comm as ric
    ric.additional_command_shortcuts+=shortcuts


def _get_pterm_verbose():
    return False
    return True


class _PtermLevelTitleContext:
    def __init__(self, level_title):
        import rp.rp_ptpython.r_iterm_comm as ric
        self.level = ric.pseudo_terminal_level
        self.should_do = bool(level_title)# or bool(self.level)
        if self.should_do:
            self.level_title = level_title
            self.old_title = rp.r._get_session_title()
            # self.end = '] ' if not self.level else ' : LV%i]'%self.level
            self.end = ']'
            self.new_title = self.old_title + '[' + str(self.level_title) + self.end

    def __enter__(self):
        if self.should_do:
            rp.r._set_session_title(self.new_title)
            return self

    def __exit__(self, exc_type, exc_val, exc_tb):
        if self.should_do:
            rp.r._set_session_title(self.old_title)

# Global state
_no_gc_gc_lock = threading.RLock()
_no_gc_disable_count = 0
_no_gc_was_enabled = True

@contextlib.contextmanager
def no_gc():
    """
    Simple context manager to temporarily disable garbage collection.
    Thread-safe and supports nested calls.
    
    Example:
        with no_gc():
            # Critical code without garbage collection
            process_large_dataset()
    """
    import gc
    import threading
    import contextlib

    global _no_gc_disable_count, _no_gc_was_enabled
    
    with _no_gc_gc_lock:
        if _no_gc_disable_count == 0:
            _no_gc_was_enabled = gc.isenabled()
            gc.disable()
        _no_gc_disable_count += 1
    
    try:
        yield
    finally:
        with _no_gc_gc_lock:
            _no_gc_disable_count -= 1
            if _no_gc_disable_count == 0 and _no_gc_was_enabled:
                gc.enable()
 
class _PseudoTerminalReturnException(BaseException):
    pass


_user_created_var_names=set()
_cd_history=[]
def pseudo_terminal(
    *dicts,
    get_user_input=_python_input,
    modifier=None,
    style=None,
    enable_ptpython=True,
    eval=eval,
    exec=exec,
    rprc="",
    level_title="",
    on_return=identity
):
  """An interactive terminal session, powered by RP 
  
  Enhanced Documentation:
  RP's flagship interactive terminal/REPL system providing a powerful Python environment
  with enhanced features, custom commands, real-time evaluation, and extensive
  customization options. The core of RP's interactive experience.
  
  Args:
      *dicts: Variable dictionaries to inject into the terminal namespace
      get_user_input (callable): Input function - default uses ptpython integration
      modifier (callable, optional): Function to modify/transform user input
      style (dict, optional): Terminal styling configuration
      enable_ptpython (bool): Enable enhanced ptpython features if available
      eval (callable): Evaluation function for expressions
      exec (callable): Execution function for statements
      rprc (str): Additional startup commands to execute
      level_title (str): Title prefix for nested terminal levels
      on_return (callable): Function to call on terminal exit
      
  Returns:
      Result of on_return function (typically None)
      
  Features:
      - Advanced REPL with syntax highlighting and autocompletion
      - 200+ built-in command shortcuts and utilities
      - Real-time expression evaluation and variable tracking
      - History management with persistence
      - Customizable styling and behavior
      - Integration with RP's entire function ecosystem
      - Nested terminal sessions support
      
  Command System:
      - File operations (ls, cd, mv, cp, rm, etc.)
      - Image/video utilities (display, save, process)
      - Development tools (vim, git, grep, find)
      - System information and monitoring
      - RP-specific functionality access
      
  Examples:
      >>> from rp.r import pseudo_terminal
      >>> # Launch basic terminal
      >>> pseudo_terminal()
      >>> # Terminal with custom namespace
      >>> my_vars = {'data': [1, 2, 3], 'config': {'debug': True}}
      >>> pseudo_terminal(my_vars)
      >>> # Custom styling and behavior
      >>> pseudo_terminal(
      ...     style={'prompt_color': 'green'},
      ...     level_title='Analysis Session'
      ... )
      
  Related Functions:
      - _pterm(): Alias for pseudo_terminal
      - python_input(): Default input handler with ptpython integration
      - _pseudo_terminal_style(): Style configuration
      
  Note: This is RP's most complex function with 10000+ lines of implementation (including supporting files),
        including over 3000 for this function alone, handling command parsing, variable tracking, history, 
        and integration with the entire RP ecosystem.
        
  Tags: repl, terminal, interactive, command-system, ptpython
  """
  if style is None:style = _pseudo_terminal_style()
  with _PtermLevelTitleContext(level_title):
    try:
        import signal
        signal.signal(signal.SIGABRT,lambda:"rpy: pseudo terminal: sigabrt avoided!")
    except Exception as E:
        fansi_print("Warning: This pseudo_terminal is being started in a separate thread",'yellow')
        # print_stack_trace(E)
    import re
    
    import sys
    pwd=get_current_directory()
    if pwd not in sys.path:
        sys.path.append(pwd)

    # TODO: Make better error reports than are available by default in python! Let it debug things like nested parenthesis and show where error came from instead of just throwing a tantrum.
    # @author: Ryan Burgert 2016，2017，2018
    try:
        import readline# Makes pseudo_terminal nicer to use if in a real terminal (AKA if using pseudo_terminal on the terminal app on a mac); aka you can use the up arrow key to go through history etc.
        import rlcompleter
        readline.parse_and_bind("tab: complete")#Enable autocompletion even with PT OFF https://docs.python.org/2/library/rlcompleter.html
    except:
        pass# Not important if it fails, especially on windows (which doesn't support readline)
    # from r import fansi_print,fansi,space_split,is_literal,string_from_clipboard,mini_editor,merged_dicts,print_stack_trace# Necessary imports for this method to function properly.

    import rp.rp_ptpython.r_iterm_comm# Used to talk to ptpython
    def level_label(change=0):
        return (("(Level "+str(rp.rp_ptpython.r_iterm_comm.pseudo_terminal_level+change)+")")if rp.rp_ptpython.r_iterm_comm.pseudo_terminal_level else "")
    try:
        fansi_print(style.message() +' '+ level_label(),'blue','bold')
        rp.rp_ptpython.r_iterm_comm.pseudo_terminal_level+=1

        from copy import deepcopy,copy

        def dictify(d):# If it's an object and not a dict, use it's __dict__ attribute
            if isinstance(d,dict):
                return d
            return d.__dict__
        new_dicts=[get_scope(1)]
        for d in dicts:
            new_dicts[0].update(d)
        new_dicts[0]['ans']=None
        dicts=new_dicts


        # dicts=[{"ans":None,'blarge':1234}]#,*map(dictify,dicts)]# Keeping the 'ans' variable separate. It has highest priority

        def dupdate(d,key,default=None):  # Make sure a key exists inside a dict without nessecarily overwriting it
            if key not in d:
                d[key]=default
        try:
            dupdate(dicts[0],'ans')
        except Exception:pass

        def scope():
            return merged_dicts(*reversed(dicts))

        def equal(a,b):
            if a is b:
                return True

            try:
                #Uses the Dill library...
                if handy_hash(a)==handy_hash(b):
                    return True
                else:
                    return id(a)==id(b)
            except Exception as e:
                pass


            try:
                try:
                    import numpy as np
                    if isinstance(a,np.ndarray) or isinstance(b,np.ndarray):
                        if isinstance(a,np.ndarray) != isinstance(b,np.ndarray):
                            return False
                        if isinstance(a,np.ndarray) and isinstance(b,np.ndarray):
                            if not a.shape==b.shape:
                                return False
                        return np.all(a==b)
                except:
                    pass
                if a==b:
                    return True
                # else:
                #     exec(mini_terminal)
                return a==b # Fails on numpy arrays
            except Exception:pass
            return a is b # Will always return SOMETHING at least

        class UndoRedoStack():
            #TODO: This can be used for PREV, NEXT, CDB, UNDO, REDO, PREVMORE, NEXTMORE
            def __init__(self,clear_redo_on_do=True):
                self.history = []  # All items in order
                self.current_index = -1  # Index of current item (-1 means nothing)
                self.clear_redo_on_do=clear_redo_on_do

            def can_undo(self):
                # Can undo if we're not at the beginning
                return self.current_index > 0

            def can_redo(self):
                # Can redo if we're not at the end
                return self.current_index < len(self.history) - 1

            def undo(self):
                if not self.can_undo():
                    raise IndexError("Cannot undo: already at earliest error")
                self.current_index -= 1
                return self.history[self.current_index]

            def redo(self):
                if not self.can_redo():
                    raise IndexError("Cannot redo: already at latest error")
                self.current_index += 1
                return self.history[self.current_index]

            def do(self,value):
                if self.clear_redo_on_do:
                    # Remove everything after current position
                    self.history = self.history[:self.current_index + 1]
                self.history.append(value)
                self.current_index = len(self.history) - 1

            def do_if_new(self,value):
                if self.current_index >= 0 and self.history[self.current_index]==value:
                    return
                self.do(value)

            def get_current(self):
                """Get the current error without changing position"""
                if self.current_index >= 0:
                    return self.history[self.current_index]
                return None

        error_stack=UndoRedoStack(clear_redo_on_do=False)

        def deep_dark_dict_copy(d):
            # out={}
            # for k in d:
            #     out[k]=d[k]
            # return out
            out={}
            import warnings
            with warnings.catch_warnings():
                warnings.simplefilter("ignore")# /Library/Frameworks/Python.framework/Versions/3.5/lib/python3.5/copy.py:164: RuntimeWarning: use movie: No module named 'pygame.movie'
                for k in d:
                    try:
                        import types
                        if isinstance(d[k],types.ModuleType):
                            raise Exception# When copying xonsh, the process below was reallly really slow. These are just some special cases worth putting out there to optimize this method.
                        try:
                            q=deepcopy(d[k])
                            if equal(d[k],q):
                                out[k]=deepcopy(d[k])
                            else:
                                raise Exception
                        except:
                            # print("Deepcopy failed: "+k)
                            q=copy(d[k])
                            if equal(d[k],q):
                                out[k]=copy(d[k])
                            else:
                                raise Exception
                    except:
                        # print("Copy failed: "+k)
                        out[k]=d[k]# Failed to copy
            return out

        global _need_module_refresh

        def get_snapshot():# Snapshot of our dicts/scope
            # exec(mini_terminal)
            return list(map(deep_dark_dict_copy,dicts))
        def set_snapshot(snapshot):
            # snapshot is a list of dicts to replace *dicts
            for s,d in zip(snapshot,dicts):
                assert isinstance(d,dict)
                assert isinstance(s,dict)
                sk=set(s)  # snapshot keys
                dk=set(d)  # dict keys
                changed=False
                for k in dk-sk :  # -{'__builtins__'}:# '__builtins__' seems to be put there as a consequence of using eval or exec, no matter what we do with it. It also is confusing and annoying to see it pop up when reading the results of UNDO
                    # assert isinstance(k,str)
                    print(fansi("    - Removed: ",'red')+k)
                    changed=True
                    del d[k]
                for k in sk-dk :  # -{'__builtins__'}:
                    # assert isinstance(k,str)
                    print(fansi("    - Added: ",'green')+k)
                    changed=True
                    d[k]=s[k]
                for k in dk&sk :  # -{'__builtins__'}:
                    assert k in dk
                    assert k in sk
                    assert isinstance(k,str)
                    if not equal(s[k],d[k]):# To avoid spam
                        print(fansi("    - Changed: ",'blue')+k)
                        changed=True
                        d[k]=s[k]
                return changed
        gave_undo_warning=False
        def take_snapshot():
            nonlocal gave_undo_warning
            import time
            start=time.time()
            if snapshots_enabled:
                snapshot_history.append(get_snapshot())
            if not gave_undo_warning and time.time()-start>.25:#.25 seconds is way too long to wait for a new prompt. We're delaying the prompt, and this can get annoying quickly...
                fansi_print("NOTE: ",'blue','bold',new_line=False)
                fansi_print("pseudo_terminal took "+str(start())[:5]+" seconds to save the UNDO snapshot, which might be because of a large namespace. If your prompts are lagging, this is probably why. You can fix this by using 'UNDO ALL', 'UNDO OFF'. This message will only show once.",'blue','bold')
                gave_undo_warning=True

        def get_ans():
            dupdate(dicts[0],'ans')
            return  dicts[0]['ans']# This should exist

        should_print_ans=True
        # A little python weridness demo: ⮤print(999 is 999)⟶True BUT ⮤a=999⮤print(a is 999)⟶False
        use_ans_history=True
        def set_ans(val,save_history=True,snapshot=True,force_green=False):
            """
            Set the 'ans' variable in pseudo_terminal with history/snapshot support.

            Enhanced Documentation:
            - Core pseudo_terminal function for result storage and history tracking
            - Manages ans variable, history, and automatic snapshots

            Parameters:
                val: Value to set as 'ans'
                save_history (bool): Add to ans_history
                snapshot (bool): Take snapshot before setting

            Tags: pseudo-terminal, ans, history
            """
            try:    
                import rp.rp_ptpython.r_iterm_comm as ric
                ric.ans=val
                save_history&=use_ans_history
                dupdate(dicts[0],'ans')
                if snapshot:# default: save changes in a snapshot BEFORE making modifications to save current state! snapshot_history is independent of ans_history
                    take_snapshot()
                if save_history:
                    ans_history.append(val)
                dicts[0]['ans']=val
            except Exception as e:
                print_verbose_stack_trace(e)
                print("HA HA CAUGHT YOU LA SNEAKY LITTLE BUG! (Idk if this ever errors...but when it might...it's rare")

            if should_print_ans!=False:
                try:
                    #__str__ returned non-string (type NoneType)
                    val_str=str(val)
                except TypeError as error:
                    val_str='(Error when converting ans to string: %s)'%error
                try:
                    if 'numpy' in str(type(get_ans)) and is_numpy_array(get_ans()):
                        set_numpy_print_options(linewidth=max(0,get_terminal_width()-len('ans = ')))#Make for prettier numpy printing, by dynamically adjusting the linewidth each time we enter a command

                    if type(val).__name__ in 'ndarray DataFrame Series Tensor'.split() and len(line_split(val_str))>1:#Recognize pandas dataframes, series, numpy Arrays, pytorch Tensors
                    # if isinstance(val,np.ndarray) and len(line_split(val_str))>1:
                        #It will take more than one line to print this numpy array.
                        #Example:
                        #    ans = [[ 1 -3 -5  0  0  0]
                        #    [ 0  1  0  1  0  0]
                        #    [ 0  0  2  0  1  0]
                        #    [ 0  3  2  0  0  1]]
                        #The above is ugly, because the top row isn't aligned with the others, because it takes up multiple lines.
                        #There's a way to handle it, which prevents a line containing just 'ans=' from existing:
                        val_str=line_split(val_str)
                        val_str=[val_str[0]]+[' '*len('ans = ')+line for line in val_str[1:]]
                        val_str='\n'.join(val_str,)
                        #The result:
                        #    ans = [[ 1 -3 -5  0  0  0]
                        #           [ 0  1  0  1  0  0]
                        #           [ 0  0  2  0  1  0]
                        #           [ 0  3  2  0  0  1]]
                        #Which is much prettier.
                except Exception:pass#print("Failed to set numpy width")# AttributeError: readonly attribute '__module__'
                fansi_print("ans = " + val_str,('green'if save_history or force_green else 'yellow')if use_ans_history else 'gray')

        def eval_for_rinsp(x,*args):
            try:
                return eval(x,*args)
            except SyntaxError:
                fansi_print("Treating message as string...",'italic magenta')
                return str(x)

        def print_history(return_as_string_instead_of_printing=False):
            output=''
            output+=fansi("HISTORY --> Here is a list of all valid python commands you have entered so far (green means it is a single-line command, whilst yellow means it is a multi-lined command):",'blue','underlined')+'\n'
            flipflop=False
            def fansify(string,*args):
                return line_join([fansi(line,*args) for line in line_split(string)])
            for x in  successful_command_history:
                multiline='\n' in x
                if x.strip():#And x.strip() because we don't want to alternate bolding if it's invisible cause then it would look like we have two bold in a row
                    flipflop=not flipflop#Print every other yellow prompt in bold
                output+=fansify(x,'yellow' if multiline else'green','bold' if multiline and flipflop else None)+'\n'# Single line commands are green, and multi-line commands are yellow
            if return_as_string_instead_of_printing:
                return output
            else:
                print(end=output)
                _maybe_display_string_in_pager(output,with_line_numbers=False)

        def show_error(E):
            try:
                error_stack.do_if_new(E)
            except AttributeError:
                # File "/apps/bdi-venv-37-0.1.0-h96.d6e899e~bionic/lib/python3.7/site-packages/pynvml/nvml.py", line 797, in __eq__
                #     return self.value == other.value
                # AttributeError: 'AttributeError' object has no attribute 'value'
                pass
            nonlocal error,display_help_message_on_error,error_message_that_caused_exception
            if display_help_message_on_error:
                display_help_message_on_error=False
                if False: #Nah, don't need this anymore lol
                    fansi_print("""Sorry, but that command caused an error that pseudo_terminal couldn't fix! Command aborted.
            Type 'HELP' for instructions on how to use pseudo_terminal in general.
            To see the full traceback of any error, type either 'MORE' or 'MMORE' (or alt+m as a shortcut).
            NOTE: This will be the last time you see this message, unless you enter 'HELP' without quotes.""",'red','bold')
            error_message_that_caused_exception=user_message# so we can print it in magenta if asked to by 'MORE'
            # print_verbose_stack_trace(E)
            print_stack_trace(E,False,'ERROR: ')
            error=E
        error_message_that_caused_exception=None
        display_help_message_on_error=True# A flag that will turn off the first time it displays "Sorry, but that command caused an error that pseudo_terminal couldn't fix! Command aborted. Type 'HELP' for instructions on pseudo_terminal. To see the full error traceback, type 'MORE'." so that we don't bombard the user with an unnessecary amount of stuff
        pwd_history=[]
        successful_command_history=[]
        all_command_history=[]
        snapshot_history=[]
        ans_redo_history=[]
        snapshots_enabled=False#Turning this on can break flann_dict. I haven't investigated why. Heres's some code that can break with it turned on:
        # (Example code)       f=FlannDict()
        # (Example code)       for _ in range(2000):
        # (Example code)           f[randint(100),randint(100)]=randint(100)
        # (Example code)       ans=f[34,23]
        # (Example code)       ans=f[34,23]
        # (Example code)       ans=f[34,23]
        # (Example code)       ans=f[34,23]
        ans_history=[]
        _tictoc=False
        _profiler=False
        _line_profiler=False
        _use_ipython_exeval=False
        global _user_created_var_names
        _user_created_var_names=set()
        allow_keyboard_interrupt_return=False
        use_modifier=True# Can be toggled with pseudo_terminal keyword commands, enumerated via 'HELP'
        error=None# For MORE
        last_assignable=last_assignable_candidate=None
        assignable_history={}
        warned_about_ans_print_on=False
        do_garbage_collection_before_input=False#I'm going to see if this makes it faster when doing stuff with pytorch
        _reload=False#If this is true, call _reload_modules right before each exeval is called
        global _printed_a_big_annoying_pseudo_terminal_error
        # garbage_collector_timer=tic()

        def pterm_pretty_print(value,*args,**kwargs):
            #If it's a string with valid python code, highlight it
            #Otherwise, pretty_print it
            def _display_pterm_image(value):
                if isinstance(value,str):
                    value=load_image(value)
                if running_in_jupyter_notebook():
                    display_image_in_notebook(value)
                else:
                    display_image_in_terminal_color(value)

            if isinstance(value,str) and is_valid_python_syntax(value):
                highlighted_code=fansi_syntax_highlighting(value)
                print(highlighted_code)
                _maybe_display_string_in_pager(highlighted_code,False)
            elif file_exists(value) and is_image_file(value):
                _display_pterm_image(value)
            elif isinstance(value,str) and is_valid_url(value):
                if get_file_extension(value).lower() in 'jpg png jpeg tiff bmp gif'.split():
                    _display_pterm_image(value)
                else:
                    display_website_in_terminal(value)
            elif is_image(value):
                _display_pterm_image(value)
            else:
                pretty_print(value,*args,**kwargs)
            return

            #from contextlib import redirect_stdout
            #import io


            #f = io.StringIO()
            #with redirect_stdout(f):
            #    pretty_print(value,*args,**kwargs)
            #    help(pow)
            #s = f.getvalue()

            #print(s)
            #_maybe_display_string_in_pager(s)
            #return s
        try:
            #TODO: For some reason psuedo_terminal doesnt capture the scope it was called in. IDK why. Fix that. The next few lines are a patch and should eventually not be nesecay once bugs are fixed.
            _pterm_exeval("None",*dicts,exec=exec,eval=eval)#I don't know why this is necessary (and haven't really tried to debug it) but without running something before importing all from rp nothihng works....
            _,error=_pterm_exeval(rprc,*dicts,exec=exec,eval=eval)#Try to import RP
            if error is not None:
                fansi_print("ERROR in RPRC:",'red','bold')
                print_verbose_stack_trace(error)
        except _PseudoTerminalReturnException:
            raise
        except BaseException as e:
            print("PSEUDO TERMINAL ERROR: FAILED TO IMPORT RP...THIS SHOULD BE IMPOSSIBLE...WAT")
            print_stack_trace(e)
        SHOWN_PERMISSION_ERROR=False
        def add_to_successful_command_history(x):
            try:
                _write_to_pterm_hist(x)
            except PermissionError as e:
                print_stack_trace(e)
                print("PERMISSION ERROR SAVING PTERM HISTORY, FROM r._write_to_pterm_hist(...). COMMAND HISTORY NOT SAVED.")
                print("THIS ERROR WILL ONLY BE SHOWN ONCE PER PSEUDO-TERMINAL SESSION TO AVOID SPAM")
            successful_command_history.append(x)
            import rp.rp_ptpython.r_iterm_comm
            rp.rp_ptpython.r_iterm_comm.successful_commands=successful_command_history.copy()
        help_commands_string="""
        <Input Modifier>
        MOD ON
        MOD OFF
        MOD SET
        SMOD SET

        <Stack Traces>
        MORE
        MMORE
        DMORE
        AMORE
        GMORE
        HMORE
        RMORE
        VIMORE
        PIPMORE
        IMPMORE
        PREVMORE
        NEXTMORE

        <Command History>
        HISTORY    (HIST)
        GHISTORY   (GHIST)
        AHISTORY   (AHIST)
        CHISTORY   (CHIST)
        DHISTORY   (DHIST)
        VHISTORY   (VHIST)
        ALLHISTORY (ALLHIST)

        <Clipboards>
        COPY
        PASTE
        EPASTE
        WCOPY
        WPASTE
        TCOPY
        TPASTE
        LCOPY
        LPASTE
        VCOPY
        VPASTE
        FCOPY
        FPASTE
        MLPASTE

        <'ans' History>
        NEXT
        PREV
        PREV ON
        PREV OFF
        PREV CLEAR
        PREV ALL

        <Namespace History>
        UNDO
        UNDO ON
        UNDO OFF
        UNDO CLEAR
        UNDO ALL

        <Prompt Toolkit>
        PT ON
        PT OFF
        PT

        <RP Settings>
        PT SAVE
        PT RESET
        SET TITLE
        SET STYLE

        <Shell Commands>
        !
        !!
        SRUNA
        SSRUNA

        <Python>
        PY
        PYM
        APY
        APYM
        PU
        PIP
        RUN
        RUNA

        <Simple Timer>
        TICTOC
        TICTOC ON
        TICTOC OFF

        <Profiler>
        PROF
        PROF ON
        PROF OFF
        PROF FLAME
        PROF FLAME OPEN
        PROF FLAME COPY
        PROF FLAME PASTE

        <Toggle Colors>
        FANSI ON
        FANSI OFF

        <Module Reloading>
        RELOAD ON
        RELOAD OFF

        <Documentation>
        HELP
        HHELP
        SHORTCUTS

        <Startup Files>
        RPRC
        VIMRC
        TMUXRC
        XONSHRC
        RYAN RPRC
        RYAN VIMRC
        RYAN TMUXRC
        RYAN XONSHRC
        RYAN RANGERRC

        <Inspection>
        ?
        ??
        ??? ?r
        ?.
        ?v
        ?s ?lj
        ?t ?j
        ?h (?/)
        ?e
        ?p
        ?c ?+c ?c+ ?cp
        ?i
        ?vd

        <Others>
        RETURN  (RET)
        SUSPEND (SUS)
        CLEAR
        WARN
        GPU
        TOP
        TAB
        TABA
        VDA
        MONITOR
        UPDATE
        ANS PRINT ON   (APON)
        ANS PRINT OFF  (APOF)
        ANS PRINT FAST (APFA)
        SHELL (SH)
        LEVEL
        DITTO
        EDIT
        VARS
        RANT
        FORK
        WANS
        WANS+
        ARG
        VIM
        VIMH
        VIMA
        AVIMA
        GC OFF
        GC ON
        GC

        <Unimportant>
        NUM COM
        PROF DEEP
        CDH CLEAN
        ALS
        ALSD
        ALSF

        <File System>
        RM
        RN
        MV
        LS
        LST
        LSD
        LSN
        CD
        CDP
        CDA
        CDB
        CDU
        CDH
        CDH FAST
        CDH GIT
        CDM
        CDZ
        CDQ
        CAT
        NCAT
        CCAT
        ACAT
        CATA
        NCATA
        CCATA
        ACATA
        PWD
        CPWD
        APWD
        TAKE
        MKDIR
        OPEN
        OPENH
        OPENA
        DISK
        DISKH
        TREE    
        TREE ALL   
        TREE DIR
        TREE ALL DIR
        FD
        AFD (FDA)
        FDT
        FDTA
        FD SEL (FDS)
        LS SEL (LSS)
        LS REL (LSR)
        LS FZF (LSZ)
        LS QUE (LSQ)
        RANGER (RNG)
        """
        # """
        # <Broken>
        # RYAN PUDBRC
        # IPYTHON
        # IPYTHON ON
        # IPYTHON OFF
        #
        # <Truly Unimportant>
        # IHISTORY (IHIST)
        # RYAN RPRC YES #Theres a shortcut RRY for this, we don't really need to document it...
        # RYAN VIMRC YES #Theres a shortcut RVY for this, we don't really need to document it...
        # """

        help_commands=[]#All commands, so we can search through them and turn uncapitablized ones into capitalized ones
        for line in help_commands_string.splitlines():
            if '#' in line or  not line.strip() or not line.replace(' ','').replace('(','').replace(')','').isalpha():
                #Skip <Documentation>, ???, blank lines etc
                continue
            line=line.strip()
            if '(' in line:
                #LS SEL (LSS) --->  LS SEL   and    LSS
                first_help_command=line[:line.find('(')].strip()
                second_help_command=line[line.find('('):].strip()[1:-1].strip()
                help_commands.append(first_help_command)
                help_commands.append(second_help_command)
            else:
                help_command=line.strip()
                help_commands.append(help_command)
        help_commands_no_spaces_to_spaces={x.replace(' ',''):x for x in help_commands}
        # print(help_commands)#Should be like ['MOD ON', 'MOD OFF', 'MOD SET', 'SMOD SET.....


        #TODO: Make APOF, APON etc implemented HERE, not elsewhere.
        #TODO: Make these configurable in rprc
        #There are duplicate shortcuts. This is a good thing! They don't interfere with variables.
        #Example: H and HI. Maybe there's a variable called H. You can still use HI.
        rp_import="__import__('rp')."
        command_shortcuts_string='''
        M  MORE
        MM MMORE
        DM DMORE
        GM GMORE
        HM HMORE
        AM AMORE
        VM VIMORE
        PM PIPMORE
        IM IMPMORE
        UM PREVMORE
        NM NEXTMORE
        RIM RMORE

        HI  HIST
        DH  DHIST
        DHI DHIST
        CH  CHIST
        CHI CHIST
        GH  GHIST
        GHI GHIST
        AH  AHIST
        AHI AHIST
        VH  VHIST
        VHI VHIST

        H HELP
        HE HELP
        HH HHELP
        SC SHORTCUTS

        CO  COPY
        WCO WCOPY
        LC  LCOPY
        WC  WCOPY
        LCO LCOPY
        TC  TCOPY
        TCO TCOPY
        VCO VCOPY
        VC  VCOPY

        EPA EPASTE
        EP  EPASTE
        PA  PASTE
        WP  WPASTE
        WPA WPASTE
        VP  VPASTE
        VPA VPASTE
        LP  LPASTE
        LPA LPASTE
        TP  TPASTE
        TPA TPASTE
        FP FPASTE
        FPA FPASTE
        FC FCOPY
        MLP MLPASTE

        PSP  $delist($shlex.split($string_from_clipboard()))
        PAS  $delist($shlex.split($string_from_clipboard()))
        PASH $delist($shlex.split($string_from_clipboard()))

        TPWC $web_copy($printed($tmux_paste()))
        WCTP $web_copy($printed($tmux_paste()))
        TPCO $string_to_clipboard($printed(str($tmux_paste())))
        COTP $string_to_clipboard($printed(str($tmux_paste())))
        TPLC $local_copy($printed($tmux_paste()))
        LCTP $local_copy($printed($tmux_paste()))
        TPVC $vim_copy($printed(str($tmux_paste())))
        VCTP $vim_copy($printed(str($tmux_paste())))
        WPTC $tmux_copy($printed(str($web_paste())))
        TCWP $tmux_copy($printed(str($web_paste())))
        WPCO $string_to_clipboard($printed(str($web_paste())))
        COWP $string_to_clipboard($printed(str($web_paste())))
        WPLC $local_copy($printed($web_paste()))
        LCWP $local_copy($printed($web_paste()))
        WPVC $vim_copy($printed(str($web_paste())))
        VCWP $vim_copy($printed(str($web_paste())))
        PATC $tmux_copy($printed(str($string_from_clipboard())))
        TCPA $tmux_copy($printed(str($string_from_clipboard())))
        PAWC $web_copy($printed($string_from_clipboard()))
        WCPA $web_copy($printed($string_from_clipboard()))
        PALC $local_copy($printed($string_from_clipboard()))
        LCPA $local_copy($printed($string_from_clipboard()))
        PAVC $vim_copy($printed(str($string_from_clipboard())))
        VCPA $vim_copy($printed(str($string_from_clipboard())))
        LPTC $tmux_copy($printed(str($local_paste())))
        TCLP $tmux_copy($printed(str($local_paste())))
        LPWC $web_copy($printed($local_paste()))
        WCLP $web_copy($printed($local_paste()))
        LPCO $string_to_clipboard($printed(str($local_paste())))
        COLP $string_to_clipboard($printed(str($local_paste())))
        LPVC $vim_copy($printed(str($local_paste())))
        VCLP $vim_copy($printed(str($local_paste())))
        VPTC $tmux_copy($printed(str($vim_paste())))
        TCVP $tmux_copy($printed(str($vim_paste())))
        VPWC $web_copy($printed($vim_paste()))
        WCVP $web_copy($printed($vim_paste()))
        VPCO $string_to_clipboard($printed(str($vim_paste())))
        COVP $string_to_clipboard($printed(str($vim_paste())))
        VPLC $local_copy($printed($vim_paste()))
        LCVP $local_copy($printed($vim_paste()))

        U CDU
        UU $r._pterm_cd('../..')
        UUU $r._pterm_cd('../../..')
        UUUU $r._pterm_cd('../../../..')
        UUUUU $r._pterm_cd('../../../../..')
        UUUUUU $r._pterm_cd('../../../../../..')
        UUUUUUU $r._pterm_cd('../../../../../../..')
        UUUUUUUU $r._pterm_cd('../../../../../../../..')
        UUUUUUUUU $r._pterm_cd('../../../../../../../../..')
        UUUUUUUUUU $r._pterm_cd('../../../../../../../../../..')
        UUUUUUUUUUU $r._pterm_cd('../../../../../../../../../../..')
        UUUUUUUUUUUU $r._pterm_cd('../../../../../../../../../../../..')
        UUUUUUUUUUUUU $r._pterm_cd('../../../../../../../../../../../../..')
        UUUUUUUUUUUUUU $r._pterm_cd('../../../../../../../../../../../../../..')
        UUUUUUUUUUUUUUU $r._pterm_cd('../../../../../../../../../../../../../../..')
        UUUUUUUUUUUUUUUU $r._pterm_cd('../../../../../../../../../../../../../../../..')
        UUUUUUUUUUUUUUUUU $r._pterm_cd('../../../../../../../../../../../../../../../../..')
        UUUUUUUUUUUUUUUUUU $r._pterm_cd('../../../../../../../../../../../../../../../../../..')
        UUUUUUUUUUUUUUUUUUU $r._pterm_cd('../../../../../../../../../../../../../../../../../../..')

         1U CDU
         2U $r._pterm_cd('../..')
         3U $r._pterm_cd('../../..')
         4U $r._pterm_cd('../../../..')
         5U $r._pterm_cd('../../../../..')
         6U $r._pterm_cd('../../../../../..')
         7U $r._pterm_cd('../../../../../../..')
         8U $r._pterm_cd('../../../../../../../..')
         9U $r._pterm_cd('../../../../../../../../..')
        10U $r._pterm_cd('../../../../../../../../../..')
        11U $r._pterm_cd('../../../../../../../../../../..')
        12U $r._pterm_cd('../../../../../../../../../../../..')
        13U $r._pterm_cd('../../../../../../../../../../../../..')
        14U $r._pterm_cd('../../../../../../../../../../../../../..')
        15U $r._pterm_cd('../../../../../../../../../../../../../../..')
        16U $r._pterm_cd('../../../../../../../../../../../../../../../..')
        17U $r._pterm_cd('../../../../../../../../../../../../../../../../..')
        18U $r._pterm_cd('../../../../../../../../../../../../../../../../../..')
        19U $r._pterm_cd('../../../../../../../../../../../../../../../../../../..')
        20U $r._pterm_cd('../../../../../../../../../../../../../../../../../../../..')

        B CDB
        BB CDBCDB
        BBB CDBCDBCDB
        BBBB CDBCDBCDBCDB
        BBBBB CDBCDBCDBCDBCDB
        BBBBBB CDBCDBCDBCDBCDBCDB
        BBBBBBB CDBCDBCDBCDBCDBCDBCDB
        BBBBBBBB CDBCDBCDBCDBCDBCDBCDBCDB
        BBBBBBBBB CDBCDBCDBCDBCDBCDBCDBCDBCDB
        BBBBBBBBBB CDBCDBCDBCDBCDBCDBCDBCDBCDBCDB
        BBBBBBBBBBB CDBCDBCDBCDBCDBCDBCDBCDBCDBCDBCDB
        BBBBBBBBBBBB CDBCDBCDBCDBCDBCDBCDBCDBCDBCDBCDBCDB
        BBBBBBBBBBBBB CDBCDBCDBCDBCDBCDBCDBCDBCDBCDBCDBCDBCDB
        BBBBBBBBBBBBBB CDBCDBCDBCDBCDBCDBCDBCDBCDBCDBCDBCDBCDBCDB
        BBBBBBBBBBBBBBB CDBCDBCDBCDBCDBCDBCDBCDBCDBCDBCDBCDBCDBCDBCDB
        BBBBBBBBBBBBBBBB CDBCDBCDBCDBCDBCDBCDBCDBCDBCDBCDBCDBCDBCDBCDBCDB
        BBBBBBBBBBBBBBBBB CDBCDBCDBCDBCDBCDBCDBCDBCDBCDBCDBCDBCDBCDBCDBCDBCDB
        BBBBBBBBBBBBBBBBBB CDBCDBCDBCDBCDBCDBCDBCDBCDBCDBCDBCDBCDBCDBCDBCDBCDBCDB
        BBBBBBBBBBBBBBBBBBB CDBCDBCDBCDBCDBCDBCDBCDBCDBCDBCDBCDBCDBCDBCDBCDBCDBCDBCDB
        BBBBBBBBBBBBBBBBBBBB CDBCDBCDBCDBCDBCDBCDBCDBCDBCDBCDBCDBCDBCDBCDBCDBCDBCDBCDBCDB

        WCIJ1  web_copy(encode_image_to_bytes(ans,'jpeg',quality=10))
        WCIJ2  web_copy(encode_image_to_bytes(ans,'jpeg',quality=20))
        WCIJ3  web_copy(encode_image_to_bytes(ans,'jpeg',quality=30))
        WCIJ4  web_copy(encode_image_to_bytes(ans,'jpeg',quality=40))
        WCIJ5  web_copy(encode_image_to_bytes(ans,'jpeg',quality=50))
        WCIJ6  web_copy(encode_image_to_bytes(ans,'jpeg',quality=60))
        WCIJ7  web_copy(encode_image_to_bytes(ans,'jpeg',quality=70))
        WCIJ8  web_copy(encode_image_to_bytes(ans,'jpeg',quality=80))
        WCIJ9  web_copy(encode_image_to_bytes(ans,'jpeg',quality=90))
        WCIJ95 web_copy(encode_image_to_bytes(ans,'jpeg',quality=95))

        WCIJ   web_copy(encode_image_to_bytes(ans,'jpeg',quality=100))
        WCIP   web_copy(encode_image_to_bytes(ans,'png'))

        WPI    decode_image_from_bytes(web_paste())

        DI $display_image(ans) if $is_image(ans) else $display_video(ans)
        DAI $display_alpha_image(ans) if $is_image(ans) else $display_video(with_alpha_checkerboards(ans,lazy=True))
        DIS $display_image_slideshow(ans)
        DV  $display_video(ans)
        DVL $display_video(ans,loop=True)

        A ACATA
        AA ACATA
        ACA ACATA
        AC ACAT
        CA CAT
        CAA CATA

        WN WARN
        WR WARN

        TT TICTOC

        TA TAKE
        TK TAKE
        MK MKDIR
        MA MKDIR

        ` CD ~
        D` CD ~
        CD` CD ~

        +PA  str(ans)+$string_from_clipboard()
        PPA  str(ans)+$string_from_clipboard()
        +PAL str(ans)+'\\n'+$string_from_clipboard()
        PPAL str(ans)+'\\n'+$string_from_clipboard()
        PPLA str(ans)+'\\n'+$string_from_clipboard()
        PAL  str(ans)+'\\n'+$string_from_clipboard()
        PLA  str(ans)+'\\n'+$string_from_clipboard()

        PF PROF
        PO PROF
        POD PROF DEEP
        POF   PROF FLAME
        FLAME PROF FLAME
        FLA   PROF FLAME
        FLAO  PROF FLAME OPEN
        FLAC  PROF FLAME COPY
        FLAI  PROF FLAME IMAGE COPY
        FLAIC PROF FLAME IMAGE COPY
        FLAP  PROF FLAME PASTE

        LO LINEPROF

        N  NEXT
        P  PREV
        NN NEXT
        PP PREV

        B  CDB
        U  CDU
        DU CDU

        CDC cdhclean
        CCL cdhclean
        
        HC CDH
        HD CDH
        DG  CDH GIT
        HDG CDH GIT
        HDF  CDH FAST
        CDHF CDH FAST
        VCDH $vim($r._cd_history_path);ans=$r._cd_history_path
        CDHV $vim($r._cd_history_path);ans=$r._cd_history_path
        VHD  $vim($r._cd_history_path);ans=$r._cd_history_path
        HDV  $vim($r._cd_history_path);ans=$r._cd_history_path

        GMP $get_module_path(ans) if isinstance(ans,str) or $is_a_module(ans) else $get_module_paths(ans)

        DA CDA

        RU RUN
        SSRA SSRUNA
        SSA  SSRUNA
        SSR  SSRUNA
        SS   SSRUNA
        SRA   SRUNA
        SA    SRUNA
        SR    SRUNA
        RA     RUNA
        EA     RUNA

        CPR !$PY -m rp call check_pip_requirements

        CPPR $r._common_path_prefix_refactoring(ans)

        BAA  $os.system('bash '+str(ans))
        ZSHA $os.system('bash '+str(ans)O

        #PYA and PUA are similar to EA except they run in separate process
        PYA $os.system($sys.executable+' '+$shlex.quote(ans));
        PUA $os.system($sys.executable+' -m pudb '+$shlex.quote(ans));
        PDA $os.system($sys.executable+' -m pudb '+$shlex.quote(ans));

        V VIM
        VI VIM
        AV AVIMA
        AVA AVIMA
        VA VIMA
        VHE VIMH
        VIH VIMH

        CM RMORE

        GPP $get_path_parent(ans) if isinstance(ans,str) else $get_paths_parents(ans)
        GFN $get_file_name(ans) if isinstance(ans,str) else $get_file_names(ans)
        GPN $get_path_name(ans) if isinstance(ans,str) else $get_path_names(ans)
        SFE $strip_file_extension(ans) if isinstance(ans,str) else $strip_file_extensions(ans)
        GFE $get_file_extension(ans) if isinstance(ans,str) else $get_file_extensions(ans)

        64P  ans=$printed($string_from_clipboard()) ; $fansi_print($human_readable_file_size(len( ans )), 'bold cyan') ; ans=$base64_to_object(ans)    #Copy object via Base64 String
        64C _ans64=$printed($object_to_base64(ans)) ; $fansi_print($human_readable_file_size(len(_ans64)), 'bold cyan') ; $string_to_clipboard(_ans64) #Copy object via Base64 String
        64FC  ans=$object_to_base64($r._copy_paths_to_bundle(None)); $string_to_clipboard(ans) #Base-64 File Copy
        64FCA ans=$object_to_base64($r._copy_paths_to_bundle(ans )); $string_to_clipboard(ans) #Base-64 File Copy Ans
        64FCH ans=$object_to_base64($r._copy_paths_to_bundle('.' )); $string_to_clipboard(ans) #Base-64 File Copy Here
        64FP  ans=$r._paste_path_from_bundle($base64_to_object($string_from_clipboard())) #Copy file via Base64 String
        64FPA ans=$r._paste_path_from_bundle($base64_to_object(ans                     )) #Copy file via Base64 String
        IPA   $bytes_to_object($decode_image_to_bytes($load_image_from_clipboard())) #Image Object-Paste
        IMPA  $bytes_to_object($decode_image_to_bytes($load_image_from_clipboard())) #Image Object-Paste
        IMP   $bytes_to_object($decode_image_to_bytes($load_image_from_clipboard())) #Image Object-Paste
        IFP   ans=                                    $r._paste_path_from_bundle($bytes_to_object($decode_image_to_bytes($load_image_from_clipboard()))) #Image File-Paste
        IFPA  ans=                                    $r._paste_path_from_bundle($bytes_to_object($decode_image_to_bytes($load_image_from_clipboard()))) #Image File-Paste
        IFPAO ans=$open_file_with_default_application($r._paste_path_from_bundle($bytes_to_object($decode_image_to_bytes($load_image_from_clipboard())))) #Image File-Paste
        IFPO  ans=$open_file_with_default_application($r._paste_path_from_bundle($bytes_to_object($decode_image_to_bytes($load_image_from_clipboard())))) #Image File-Paste
        ICO   ($display_image if $running_in_jupyter_notebook() else $partial($display_image_in_terminal_imgcat, pixels_per_line=300))($encode_bytes_to_image($object_to_bytes(ans                          ))) #Image Object-Copy (Works best in ITerm2)
        IMCO  ($display_image if $running_in_jupyter_notebook() else $partial($display_image_in_terminal_imgcat, pixels_per_line=300))($encode_bytes_to_image($object_to_bytes(ans                          ))) #Image Object-Copy (Works best in ITerm2)
        IFC   ($display_image if $running_in_jupyter_notebook() else $partial($display_image_in_terminal_imgcat, pixels_per_line=300))($encode_bytes_to_image($object_to_bytes($r._copy_paths_to_bundle(None)))) #Image File-Copy (Works best in ITerm2)
        IFCA  ($display_image if $running_in_jupyter_notebook() else $partial($display_image_in_terminal_imgcat, pixels_per_line=300))($encode_bytes_to_image($object_to_bytes($r._copy_paths_to_bundle(ans )))) #Image File-Copy Ans (Works best in ITerm2)
        IFCH  ($display_image if $running_in_jupyter_notebook() else $partial($display_image_in_terminal_imgcat, pixels_per_line=300))($encode_bytes_to_image($object_to_bytes($r._copy_paths_to_bundle('.' )))) #Image File-Copy Here (Works best in ITerm2)

        # GO GC

        VD VDA

        TRAD treealldir
        TRD treedir
        TR tree
        TRA treeall

        CVD $fansi_print('CUDA_VISIBLE_DEVICES: %s'%get_cuda_visible_devices(), 'bold yellow yellow on dark blue')

        FON fansion
        FOF fansioff
        FOFF fansioff

        UOF UNDO OFF
        UON UNDO ON

        RRC ryanrprc
        RTC ryantmuxrc
        RVC ryanvimrc
        RXC ryanxonshrc
        RR  ryanrprc
        RT  ryantmuxrc
        # RV  ryanvimrc
        RX  ryanxonshrc
        RRY  RYAN RPRC YES
        RVY  RYAN VIMRC YES
        RVN  RYAN VIMRC NO
        RVCP  RYAN VIMRC COPY
        RVHL  RYAN VIMRC HARD LINK
        RVSL  RYAN VIMRC SOFT LINK
        RRNG  RYAN RANGERRC

        BASHRC VIM ~/.bashrc
        ZSHRC VIM ~/.zshrc

        strip ans.strip()
        sp    ans.strip()

        RZG  $r._load_ryan_lazygit_config()

        VIMPROF $r._profile_vim_startup_plugins()

        LSF LSQ
        FDZ LSZ
        FDQ LSQ

        RG RNG
        VS VARS
        OP OPEN

        OPH OPENH
        OH OPENH
        OPA OPENA
        OA OPENA

        LVL LEVEL
        LV LEVEL
        L LEVEL

        SHOGA $pip_install_multiple(ans, shotgun=True) #Shotgun Ans - works well with PIF
        PIMA $pip_install_multiple(ans, shotgun=False) #Pip Install Multiple Ans - works well with PIF
        PIRA $pip_install('-r '+ans)
        PIR PIP install -r requirements.txt
        UVR $r._ensure_uv_installed() ; $r._run_sys_command($sys.executable + ' -m uv pip install -r requirements.txt')
        
        UP UPDATE
        UPWA if $input_yes_no(ans+"\\n\\n"+$fansi("Set r.py to this?",'red','bold')): $string_to_text_file($get_module_path($r), ans)
        UPYE PIP install rp --upgrade --no-cache 
        UPFP $r._upfp()
        UPFPY  $r._upfp(autoyes=True)
        UPFPYE $r._upfp(autoyes=True)
        FCRP $web_copy_path(r.__file__,show_progress=True)


        DK DISK
        DD DITTO
        DT DITTO
        DO DITTO

        PW PWD
        PD PWD
        WD PWD
        APW APWD
        AP  APWD
        AW  APWD

        WA WANS
        WAP WANS+

        VSS $repr_vars(*$r._iterfzf($r._user_created_var_names,multi=True)) #VS Select
        VSM $repr_vars(*$r._iterfzf($r._user_created_var_names,multi=True)) #VS Select
        VSR                        $repr_vars(*$r._user_created_var_names) #VS Repr
        CVSR  $string_to_clipboard($repr_vars(*$r._user_created_var_names)) #Copy VS Repr
        COVSR $string_to_clipboard($repr_vars(*$r._user_created_var_names)) #Copy VS Repr
        CVS   $string_to_clipboard($repr_vars(*$r._user_created_var_names)) #Copy VS Repr


        AFD FDA

        LSA ALS
        LSAD ALSD
        LSAF ALSF

        ATS $tmux_get_scrollback()

        RETK $fansi_print("RETK: Killing this process forcefully!", 'cyan', 'bold'); $kill_process($get_process_id())

        DKH DISKH
         KH DISKH

        DQ CDHQ FAST

        PRP PYM rp
        SURP $os.system('sudo '+sys.executable+' -m rp')

        GOO  $open_google_search_in_web_browser(str(ans))
        GOOP $open_google_search_in_web_browser($string_from_clipboard())

        #SYSTEM MONITORING TOOLS
        MON MONITOR
        SMI $os.system("nvidia-smi");
        NVI $pip_import('nvitop');$pip_import('nvitop.__main__').main()
        NVT $r._ensure_nvtop_installed();$os.system("nvtop");#sudo_apt_install_nvtop
        ZSH $r._ensure_zsh_installed();$os.system("zsh");
        BOP TOP
        BTOP $r._ensure_btop_installed();$r._run_sys_command('btop')

        bashtop $r._run_bashtop() #Good where BOP doesn't work and MON is too basic

        BA   $os.system("bash");
        S   $os.system("sh");
        Z   $os.system("zsh");

        JL PYM jupyter lab
        UNCOMMIT !git reset --soft HEAD^
        REATTACH_MASTER !git branch temp-recovery-branch ; git checkout temp-recovery-branch ; git checkout master ; git merge temp-recovery-branch ; git branch -d temp-recovery-branch #Reattach from the reflog to master
        GRBL $git_revert_blank_lines()
        PULL !git pull
        PUL  !git pull

        EMA $explore_torch_module(ans)

        NB  $extract_code_from_ipynb()
        NBA  $extract_code_from_ipynb(ans)
        NBC  $r.clear_jupyter_notebook_outputs()
        NBCA $r._nbca(ans) # Clear a notebook
        NBCH $r._nbca($get_all_files(file_extension_filter='ipynb')) #Clear all notebooks in the current directory
        NBCHY $r._nbca($get_all_files(file_extension_filter='ipynb',sort_by='size')[::-1], auto_yes=True) #Clear all notebooks in the current directory without confirmation
        NBCHYF $r._nbca($get_all_files(file_extension_filter='ipynb',sort_by='size')[::-1], auto_yes=True,parallel=True) #Clear all notebooks in the current directory without confirmation
        NCA  $r._nbca(ans)

        JL PYM jupyter lab
        IPYK $add_ipython_kernel()
        HOSTLAB !$PY -m rp call pip_import jupyter --auto_yes True ; $PY -m jupyter lab --ip 0.0.0.0 --port 5678 --NotebookApp.password='' --NotebookApp.token='' --allow-root
        HJL !$PY -m rp call pip_import jupyter --auto_yes True ; $PY -m jupyter lab --ip 0.0.0.0 --port 5678 --NotebookApp.password='' --NotebookApp.token='' --allow-root


        INS $input_select("Select:", $line_split(ans) if isinstance(ans,str) else ans)
        ISA $input_select("Select:", $line_split(ans) if isinstance(ans,str) else ans)
        ISM $r._ISM(ans) #Input Select Multi
        IMA $r._ISM(ans) #Input Select Multi
        IMS $r._ISM(ans) #Input Select Multi

        ISENV $r._ISM($os.environ) #Input Select (multiple) Environment (variables)
        ENV   $r._ISM($os.environ) #Input Select (multiple) Environment (variables)
        ENP   $r._ISM({x:y for x,y in $os.environ.items() if ':' in y},preview="""echo {} | cut -d'|' -f2- | cut -c2- | tr ':' '\\n'""") #Input Select (multiple) Environment (variables) with preview for when split by : like on PATH
        ENVP  $r._ISM({x:y for x,y in $os.environ.items() if ':' in y},preview="""echo {} | cut -d'|' -f2- | cut -c2- | tr ':' '\\n'""") #Input Select (multiple) Environment (variables) with preview for when split by : like on PATH
        WHI   $r._ism_whiches() #Investigate 'which x' for every system command x
        WHICH $r._ism_whiches() #Investigate 'which x' for every system command x

        GSC   $r._ISM($get_system_commands())
        SCO   $r._ISM($get_system_commands())

        VCL $delete_file($get_absolute_path('~/.viminfo'))#VimClear_use_when_VCOPY_doesnt_work_properly

        ALSF $get_all_paths($get_current_directory(),include_files=True,include_folders=False,relative=True)
        LSAG  $get_all_paths  (relative=False,sort_by='name') #LSA Global
        LSAFG $get_all_files  (relative=False,sort_by='name') #LSA Files Global
        LSADG $get_all_folders(relative=False,sort_by='name') #LSA Directories Global
        LSM   $r._iterfzf($get_all_paths('.',relative=False,sort_by='name'),multi=True,exact=True)
        LSAI  $get_all_image_files()

        IASM $import_all_submodules(ans,verbose=True);

        SUH $sublime($get_current_directory())
        SUA $sublime(ans)
        COH !code .
        COA $vscode(ans)

        SG $save_gist(ans)
        SGCO ans=$save_gist(ans) ; $string_to_clipboard(ans)
        LG Load gist from URL (can provide URL as argument: 'LG <url>')
        LGA $load_gist(ans)
        OG  $load_gist($input_select(options=$line_split($text_file_to_string($path_join($get_parent_folder($get_module_path($rp)),'old_gists.txt')))))
        OGM $r._input_select_rp_gists(ans=ans)

        # CAH  $copy_path(ans,'.')
        # CPAH $copy_path(ans,'.')
        CAH  $r._cpah(ans)
        CPAH $r._cpah(ans)
        HLAH $r._cpah(ans,method=$partial($make_hardlink,recursive=True))#HardLink Ans Here
        # CPPH $copy_path($string_from_clipboard(),'.')
        # CPH  $copy_path($string_from_clipboard(),'.')
        CPPH $r._cpah($string_from_clipboard())
        CPH  $r._cpah($string_from_clipboard())

        # MAH  $move_path(ans,'.')
        # MVAH $move_path(ans,'.')
        # MVPH $move_path($string_from_clipboard(),'.')
        # MPH  $move_path($string_from_clipboard(),'.')
        MAH  $r._cpah(ans,$move_path)
        MVAH $r._cpah(ans,$move_path)
        MVPH $r._cpah($string_from_clipboard(),$move_path)
        MPH  $r._cpah($string_from_clipboard(),$move_path)

        GCLP  $git_clone($string_from_clipboard())
        GCLPS $git_clone($string_from_clipboard(),depth=1)
        GCLA  $git_clone(ans,show_progress=True)
        GCLAS $git_clone(ans,show_progress=True,depth=1) #Git-Clone ans Shallow
        GURL $get_git_remote_url()
        SURL $shorten_url(ans)
        REPO $get_path_parent($get_git_repo_root($get_absolute_path('.')))
        UG   $r._pterm_cd($get_path_parent($get_git_repo_root($get_absolute_path('.'))))
        GU   $r._pterm_cd($get_path_parent($get_git_repo_root($get_absolute_path('.'))))
        WGA if $os.system('wget\\x20'+ans)==0: ans=$get_file_name(ans)

        LNAH $os.symlink(ans,$get_file_name(ans));ans=$get_file_name(ans)#Created_Symlink
        LN   $os.symlink(ans,$get_file_name(ans));ans=$get_file_name(ans)#Created_Symlink
        HL   $make_hardlink(ans,$get_file_name(ans))

        TMDA $os.system('tmux list-sessions -F "#{session_name}" | xargs -I % tmux detach -s %') #Detach all users from all tmux sessions
    
        RF    $random_element([x for x in $os.scandir() if not x.is_dir(follow_symlinks=False)]).name
        RD    $random_element([x for x in $os.scandir() if x.is_dir(follow_symlinks=False)]).name
        RE    ($random_element(ans.splitlines()) if isinstance(ans,str) else $random_element(ans))

        RDA   $r._pterm_cd($random_element([x for x in $os.scandir() if x.is_dir(follow_symlinks=False)]))   # RD then DA
        CDR   $r._pterm_cd($random_element([x for x in $os.scandir() if x.is_dir(follow_symlinks=False)]))

        LJ LINE JOIN ANS
        AJ  JSON ANS
        JA  JSON ANS
        JEA JSON ANS
        LJEA [$line_join(x) for x in ans] #Line Join Each Ans
        CJ   ans.split(",") if isinstance(ans,str) else ",".join(map(str,ans)) 
        SJ   ans.split(" ") if isinstance(ans,str) else " ".join(map(str,ans)) 
        SPAJ ans.split(" ") if isinstance(ans,str) else " ".join(map(str,ans)) 

        SGC $select_git_commit()
        DUNKA $pip_import('dunk');$os.system(f"git diff {ans} | dunk")
        DUNKP $dunk_string_diff(ans,$string_from_clipboard())
        PDUNK $dunk_string_diff($string_from_clipboard(),ans)

        FN $r._get_function_names(ans)

        SHA $get_sha256_hash(ans,show_progress=True)

        DCI $display_image_in_terminal_color(ans)
        
        FCA $web_copy_path(ans,show_progress=True)
        FCH print("FCH->FileCopyHere");$web_copy_path($get_absolute_path('.'),show_progress=True)
        RMA $r._rma(ans)
        RNA $rename_file(ans,$input_default($fansi('NewPathName:','blue'),$get_file_name(ans)))
        APA $r._absolute_path_ans(ans)
        RPA $r._relative_path_ans(ans)
        UPA $r._user_path_ans(ans)

        UZA $unzip_to_folder(ans,show_progress=True)
        ZIH $make_zip_file_from_folder($get_absolute_path('.'),show_progress=True)
        ZIA $make_zip_file_from_folder(ans,show_progress=True)
        ZIAH $make_zip_file_from_folder(ans,$path_join($get_absolute_path('.'),$get_file_name(ans)+'.zip'),show_progress=True)

        RWC $web_copy($get_source_code($r))

        CCA  $r._run_claude_code(ans).code
        CCH  $r._run_claude_code('.')
        CCHD $r._run_claude_code('.',fullperm=True)
        CHD  $r._run_claude_code('.',fullperm=True)
        GEM  $r._run_gemini_cli('.')
        GEMA $r._run_gemini_cli(ans).code

        RST __import__('os').system('reset')
        RS  __import__('os').system('reset')

        BLA $r._autoformat_python_code_via_black(str(ans))
        FLY $refactor_flynt(str(ans))
        ATC $add_trailing_commas(str(ans))
        23P $python_2_to_3(str(ans))
        PUP $refactor_pyupgrade(str(ans))
        SIM $r.sort_imports_via_isort(ans)
        CIM $r.clean_imports_via_unimport(ans)
        QIM $r._removestar(ans,qualify=True)
        RMS $r._removestar(ans)
        SW $r.strip_trailing_whitespace(ans)
        PWS $propagate_whitespace(ans)
        SPC $r.strip_python_comments(ans)
        SDO $r.strip_python_docstrings(ans)
        D0L $line_join(x for x in str(ans).splitlines() if x.strip())
        UND $unindent(ans)
        IND $indentify(ans,'    ')
        CBP ans=$string_from_clipboard();ans=$r.autoformat_python_via_black_macchiato(ans);$string_to_clipboard(ans) #Autoformat Code on Clipboard
        CSP ans=$string_from_clipboard();ans=$sort_imports_via_isort(ans);$string_to_clipboard(ans) #Sort Imports on Clipboard
        RFS $remove_fstrings(ans)
        IRP $r._inline_rp_code(ans)
        RMFS $remove_fstrings(ans)
        SBL $strip_blank_lines(ans)
        SL  $line_join(sorted(str(ans).splitlines()))
        UL  $line_join(unique(str(ans).splitlines()))
        RL  $line_join(reversed(str(ans).splitlines()))
        RCL $line_join(x[::-1] for x in str(ans).splitlines())

        DAPI __import__('rp.pypi_inspection').pypi_inspection.display_all_pypi_info()

        DISC $display_image_slideshow('.',display=$display_image_in_terminal_color)
        DISI $display_image_slideshow('.',display=lambda image:$display_image_in_terminal_imgcat($with_alpha_checkerboard(image)))

        FZM $r._iterfzf(ans,multi=True)

        NLS $fansi_print(len($os.listdir()),"blue","bold")
        DUSH !du -sh

        PTS ptsave
        ST   settitle
        STIT settitle

        UR   $unshorten_url(ans)
        UUR  $unshorten_url(ans)
        UURL $unshorten_url(ans)

        GP  $print_gpu_summary()
        VGP $r._ensure_viddy_installed() ; $r._run_sys_command('viddy '+sys.executable+' call print_gpu_summary')
        NGP $print_notebook_gpu_summary()
        
        LEA  [eval(str(x)) for x in ans]
        ELA  [eval(str(x)) for x in ans]
        EVLA [eval(str(x)) for x in ans]
        ELJA [eval(str(x)) for x in ans.splitlines()]

        PAF ans=$string_from_clipboard(); ans=ans.splitlines() if '\\n' in ans else ans[1:-1].split("' '") if ans.startswith("'") and ans.endswith("'") else ans #Paste Files (for MacOS when you copy multiple files)

        CLS CLEAR
        VV !vim

        RCLAHF $r._ensure_rclone_installed() ; $r._run_sys_command(f"rclone copy --progress --transfers 128 --metadata {$shlex.quote(ans)} .") #Quickly copy a network drive folder. Copies the contents, not the folder itself! The 'F' stands for fast, which is because this skips checksums - it wont overwrite any files ever!
        RCLAH  $r._ensure_rclone_installed() ; $r._run_sys_command(f"rclone copy --checksum --progress --transfers 128 --metadata {$shlex.quote(ans)} .") #Quickly copy a network drive folder. Copies the contents, not the folder itself!

        DR $r._display_columns(dir(),'dir():')
        DUSHA $fansi_print($human_readable_file_size(sum($get_file_size(x,False)for x in $enlist(ans))),'cyan','bold')

        INM __name__="__main__"

        QPHP $r._input_select_multiple_history_multiline() #Query Prompt-Toolkit History Paragraphs (F3)
        QPH  $r._input_select_multiple_history() #Query Prompt-Toolkit History Lines (F3)
        QVH  $r._input_select_multiple_history($pterm_history_filename) #Query VHISTORY

        GITI      $r._write_default_gitignore()
        GIGN      $r._write_default_gitignore()
        GITIGN    $r._write_default_gitignore()
        GITIGNORE $r._write_default_gitignore()
        IGN       $r._write_default_gitignore()
        IGNORE    $r._write_default_gitignore()
        GIG       $r._write_default_gitignore()
        GPL   !git pull
        GPUL  !git pull
        GPULL !git pull
        GPSH  !git push
        GPUSH !git push
        GADA $r._git_add_ans(ans)
        GADDA $r._git_add_ans(ans()) 
        IGA    $append_line_to_file($printed($line_join(['' +x for x in $get_relative_paths($enlist(ans),root=$get_parent_folder($get_git_repo_root()))])),$r._get_gitignore_path())
        IGNA   $append_line_to_file($printed($line_join(['' +x for x in $get_relative_paths($enlist(ans),root=$get_parent_folder($get_git_repo_root()))])),$r._get_gitignore_path())
        GIGA   $append_line_to_file($printed($line_join(['' +x for x in $get_relative_paths($enlist(ans),root=$get_parent_folder($get_git_repo_root()))])),$r._get_gitignore_path())
        GIGNA  $append_line_to_file($printed($line_join(['' +x for x in $get_relative_paths($enlist(ans),root=$get_parent_folder($get_git_repo_root()))])),$r._get_gitignore_path())
        UNGIGA $append_line_to_file($printed($line_join(['!'+x for x in $get_relative_paths($enlist(ans),root=$get_parent_folder($get_git_repo_root()))])),$r._get_gitignore_path())
        UNGIGA $append_line_to_file($printed($line_join(['!'+x for x in $get_relative_paths($enlist(ans),root=$get_parent_folder($get_git_repo_root()))])),$r._get_gitignore_path())
        NOGIGA $append_line_to_file($printed($line_join(['!'+x for x in $get_relative_paths($enlist(ans),root=$get_parent_folder($get_git_repo_root()))])),$r._get_gitignore_path())
        NOGNA  $append_line_to_file($printed($line_join(['!'+x for x in $get_relative_paths($enlist(ans),root=$get_parent_folder($get_git_repo_root()))])),$r._get_gitignore_path())

        VIG $vim($r._get_gitignore_path())

        PPTA $r._convert_powerpoint_file(ans)
        PPT $r._convert_powerpoint_file($input_select_file(file_extension_filter='pptx'),message='Select a powerpoint file')

        TMD !tmux d
        TMA !tmux a
        TM  !tmux
        TMUX !tmux

        FB $r._run_filebrowser()

        NL $fansi_print('Number of lines in ans: %i'%$number_of_lines(ans), 'yellow')

        ZG $r._install_lazygit();$os.system('lazygit')
        UNCOMMIT !git reset --soft HEAD^

        # ZGA $os.system('cd '+ans'+' && lazygit') #NOT Ready yet - CDA's logic is more complex and can handle funcs and modules, this could only handle strings...

        FART   $r._fart();    #Find and replace text in current directory (recursively). Tip: best to use this with FDT
        AFART  $r._fart()     #Find and replace text in current directory (recursively). Tip: best to use this with FDT
        FARTA  $r._fart(ans); #Find and replace text in paths specified by ans. Tip: best to use this with FDT
        AFARTA $r._fart(ans)  #Find and replace text in paths specified by ans. Tip: best to use this with FDT

        HTTP $os.system($sys.executable+' -m http.server')
        HTP  $os.system($sys.executable+' -m http.server')

        FMA $r._view_markdown_in_terminal(ans) # Displays markdown
        MDA $r._view_markdown_in_terminal(ans) # Displays markdown

        PIF PIP freeze

        '''

        # SA string_to_text_file(input("Filename:"),str(ans))#SaveAnsToFile
        # BB set_current_directory(r._get_cd_history()[-2]);fansi_print('BB-->CDH1-->'+get_current_directory(),'blue','bold')#Use_BB_instead_of_CDH_<enter>_1_<enter>_to_save_time_when_starting_rp
        #Note: \x20 is the space character
        command_shortcuts=line_split(command_shortcuts_string)
        
        import rp.rp_ptpython.r_iterm_comm as ric
        if hasattr(ric,'additional_command_shortcuts'):
            command_shortcuts+=list(ric.additional_command_shortcuts)

        command_shortcuts = [x.replace('$PY',sys.executable) for x in command_shortcuts]
        command_shortcuts = [x.replace('$',rp_import) for x in command_shortcuts]
        
        command_shortcuts=list(map(str.strip,command_shortcuts))
        command_shortcuts=[x for x in command_shortcuts if not x.startswith('#')]
        command_shortcuts=[x for x in command_shortcuts if x]
        # command_shortcuts_pairs=list(map(str.split,command_shortcuts))
        command_shortcuts_pairs=[str.split(x,maxsplit=1)for x in command_shortcuts]
        def join_command(pair):
            #Let us have spaces on the right side
            return [pair[0],' '.join(pair[1:])]
    
        command_shortcuts_pairs=list(map(join_command,command_shortcuts_pairs))
        command_shortcuts={x:y for x,y in command_shortcuts_pairs}
        for key in list(command_shortcuts):
            command_shortcuts[key.lower()]=command_shortcuts[key]#Make it case-insensitive

        try:
            import rp.rp_ptpython.r_iterm_comm
            rp.rp_ptpython.r_iterm_comm.globa=scope()#prime it and get it ready to go (before I had to enter some valid command like '1' etc to get autocomplete working at 100%)
            
            while True:
                rp.rp_ptpython.r_iterm_comm.rp_pt_user_created_var_names[:]=list(_user_created_var_names)
                try:
                    # region Get user_message, xor exit with second keyboard interrupt
                    _update_cd_history()
                    try:
                        did_cdb=False
                        def evaluable_part(cmd:str):
                            # DOesn't take into account the ';' character
                            cmd=cmd.rstrip().split('\n')[-1]
                            # TODO Make everything evaluable like in ipython
                        def try_eval(x,true=False):# If true==True, then we return the actual value, not a formatted string
                            # region Communicate with ptpython via r_iterm_comm
                            if x==rp.rp_ptpython.r_iterm_comm.try_eval_mem_text:
                                return rp.rp_ptpython.r_iterm_comm.rp_evaluator_mem# Text hasn't changed, so don't evaluate it again
                            rp.rp_ptpython.r_iterm_comm.try_eval_mem_text=x
                            temp=sys.stdout.write
                            try:
                                sys.stdout.write=_muted_stdout_write
                                s=scope()
                                # true_value=eval(x,merged_dicts(s,globals(),locals()))
                                if x.count('RETURN')==1:
                                    exec(x.split('RETURN')[0],rp.rp_ptpython.r_iterm_comm.globa)# If we have a RETURN in it,
                                    x=x.split('RETURN')[1].lstrip()# lstrip also removes newlines
                                out="eval("+repr(x)+") = \n"
                                true_value=eval(x,rp.rp_ptpython.r_iterm_comm.globa)
                                if true:
                                    return true_value
                                from pprint import pformat
                                out=out+(str if isinstance(true_value,str) else repr)((true_value))  # + '\nans = '+str(dicts[0]['ans'])
                                rp.rp_ptpython.r_iterm_comm.rp_evaluator_mem=out
                                return str(out)+"\n"
                            except Exception as E:
                                return str(rp.rp_ptpython.r_iterm_comm.rp_evaluator_mem)+"\nERROR: "+str(E)
                            finally:
                                sys.stdout.write=temp
                        rp.rp_ptpython.r_iterm_comm.rp_evaluator=try_eval
                        rp.rp_ptpython.r_iterm_comm.rp_VARS_display=str(' '.join(sorted(list(_user_created_var_names))))
                        # endregion
                        import gc as garbage_collector
                        if do_garbage_collection_before_input:
                            garbage_collector.collect()#Sometimes we run into memory issues, maybe this is what's making it slow when using pytorch and big tensors?
                            # print("GC!")
                            # garbage_collector_timer=tic()

                        if _need_module_refresh:
                            _refresh_autocomplete_module_list()




                        if get_current_directory()=='.':
                            fansi_print("WARNING: Current directory was deleted; moving to a new location",'yellow','bold')
                            set_current_directory('/')
                            fansi_print("PWD: "+_fansi_highlight_path(get_current_directory()),"blue",'bold')

                        user_message=get_user_input(lambda:scope(),header=_get_prompt_style(),enable_ptpython=enable_ptpython)
                        # Check if PT crashed and explicitly turn it off
                        if enable_ptpython and _printed_a_big_annoying_pseudo_terminal_error:
                            enable_ptpython = False
                            fansi_print("PT automatically turned OFF due to crash. Use 'PT ON' to re-enable.", 'cyan', 'bold')
                        try:
                            if 'numpy' in str(type(get_ans)) and is_numpy_array(get_ans()):
                                set_numpy_print_options(linewidth=max(0,get_terminal_width()-len('ans = ')))#Make for prettier numpy printing, by dynamically adjusting the linewidth each time we enter a command
                        except Exception:pass#print("Failed to set numpy width")
                        if not user_message:
                            continue# A bit of optimization for aesthetic value when we hold down the enter key
                        allow_keyboard_interrupt_return=False
                    except (KeyboardInterrupt,EOFError):
                        if allow_keyboard_interrupt_return:
                            fansi_print("Caught repeated KeyboardInterrupt or EOFError --> RETURN",'cyan','bold')
                            while True:
                                try:
                                    if input_yes_no("Are you sure you want to RETURN?"):
                                        user_message="RETURN"
                                        break
                                    else:
                                        break
                                except:
                                    print("<KeyboardInterrupt>\nCaught another KeyboardInterrupt or EOFError...if you'd like to RETURN, please enter 'yes'")
                                    pass
                        else:
                            allow_keyboard_interrupt_return=True
                            raise
                    # endregion
                    _user_created_var_names&=set(scope())# Make sure that the only variables in this list actually exist. For example, if we use 'del' in pseudo_terminal, ∄ code to remove it from this list (apart from this line of course)
                    # region Non-exevaluable Terminal Commands (Ignore user_message)
                    _update_cd_history()

                    import re
                    if not '\n' in user_message and '/' in user_message and not ' ' in user_message:
                        #Avoid the shift key when doing r?v by letting you do r/v (assuming v doesn't exist)
                        #When applicable, let thing/v --> thing?v  and  /v  -->  ?v
                        #Likewise, let /s --> ?s etc
                        #not ' ' in user_message is just a good heuristic
                        split=user_message.split('/')
                        left=''.join(split[:-1])
                        right=split[-1]
                        if right in 'p e s v t h c r i j c+ +c cp lj vd'.split():
                            #/p --> ?p   /e --> ?e   /t --> ?t   /s ---> ?s    /v --> ?v     /h --> ?h     /c --> ?c     /r --> ?r    /i --> ?i     /cp --> ?cp
                            if not right in scope():
                                user_message=left+'?'+right
                                fansi_print("Transformed input to "+repr(user_message)+' because variable '+repr(right)+' doesn\'t exist','magenta','bold')

                    # if 'PWD' in help_commands:
                    #     print("JAJAJA")
                    # if 'vim' in scope():
                    #     print("GLOO GLOO")
                        
                    if user_message in command_shortcuts and user_message not in scope():
                        original_user_message=user_message
                        user_message=command_shortcuts[user_message]
                        if _get_pterm_verbose() or not user_message.isupper(): fansi_print("Transformed input to "+repr(user_message.replace(rp_import,''))+' because variable '+repr(original_user_message)+' doesn\'t exist but is a shortcut in SHORTCUTS','magenta','bold')

                    if user_message.strip().isalpha() and user_message.strip() and user_message.islower() and not user_message.strip() in scope() and user_message.upper().strip() in help_commands_no_spaces_to_spaces:
                        original_user_message=user_message
                        user_message=user_message.upper().strip()
                        user_message=help_commands_no_spaces_to_spaces[user_message]#Allow 'ptoff' --> 'PT OFF'
                        if _get_pterm_verbose(): fansi_print("Transformed input to "+repr(user_message)+' because variable '+repr(original_user_message)+' doesn\'t exist but '+user_message+' is a command','magenta','bold')

                    if user_message == 'RETURN' or user_message =='RET':
                        raise _PseudoTerminalReturnException

                    elif user_message=='SHORTCUTS':
                        lines=[]
                        lines.append(fansi("Showing all pseudo-terminal command shortcuts:\n    * NOTE: Shortcuts are not case sensitive!",'green','bold'))
                        
                        for x,y in command_shortcuts_pairs:
                            if x.isupper():
                                lines.append(fansi(x.ljust(4),'cyan','bold')+'  -->  '+fansi(y.replace(rp_import,''),'blue','bold'))
                        print(line_join(lines))
                        _maybe_display_string_in_pager(line_join(lines),False)


                    elif user_message=='HHELP':
                        fansi_print("HHELP --> Displaying full documentation for rp:",'blue','bold')
                        import rp

                        ans=text_file_to_string(_rp_documentation_file)
                        ans=ans.replace('\t','    ')
                        try:
                            string_pager(ans)
                        except:
                            print(ans)
                        fansi_print("HHELP --> Finished printing documentation.",'blue','bold')

                    elif user_message == 'HELP':
                        def columnify_strings(strings_input):
                            height=55#Total height of output
                            spacing=' '*4#Spacing between columns
                            #strings_input is a string separated by newlines and double-newlines
                            assert isinstance(strings_input,str)
                            strings_input=strings_input.strip()
                            for _ in range(100) :
                                strings_input=strings_input.replace('\n\n\n','\n\n')
                            strings=strings_input.split('\n\n')
                            bl=strings
                            o=[]
                            s=[]
                            for l in bl:
                                l=horizontally_concatenated_strings(l,spacing,rectangularize=True)
                                l=l.strip()
                                #l+='\n'
                                if (line_join(s+[l])).count('\n')<=height:
                                    s+=[l,'']
                                else:
                                    o+=[line_join(s)]
                                    s=[l,'']
                            if s:
                                o+=[line_join(s)]
                            ans=horizontally_concatenated_strings(o,rectangularize=True)
                            return ans
                        strings_input=help_commands_string
                        strings_input=strip_all_lines(strings_input)
                        command_list=columnify_strings(strings_input)

                        display_help_message_on_error=True# Seems appropriate if they're looking for help
                        fansi_print("HELP --> Here are the instructions (type HHELP for more info):",'blue','underlined')
                        fansi_print("""    For those of you unfamiliar, this will basically attempt to exec(input()) repeatedly.",'blue')
        For more documentation, type 'HHELP'
        NOTE: If you're using linux, please use 'sudo apt-get install xclip' to let rp access your system's clipboard
        Note that you must import any modules you want to access; this terminal runs inside a def.
            If the command you enter returns a value other than None, a variable called 'ans' will be assigned that value.
        If the command you enter returns an error, pseudo_terminal will try to fix it, and if it can't it will display a summary of the error.
        To set different prompt styles, use set_prompt_style(' >> ') or set_prompt_style(' ⮤ ') etc. This currently only works with PT ON. This setting will be saved between sessions.
        To launch the debugger, type debug() on the top of your code. HINT: Typing the microcompletion '\\de' will toggleååååå this for you.
        Enter 'HISTORY' without quotes to get a list of all valid python commands you have entered so far, so you can copy and paste them into your code.
        NOTE: 
        Enter 'EPASTE' without quotes to run what is copied to your clipboard, allowing you to run multiple lines at the same time
        Enter 'MORE' without quotes to see the full error traceback of the last error, assuming the last attempted command caused an error.
        Enter 'RETURN' without quotes to end the session, and return ans as the output value of this function.
        Games: Type 'import pychess', 'import snake', 'import py2048', 'import sudoku', 'import mario', 'import tetris', or 'import flappy' (Tetris has to be fixed, its currently a big buggy)
        Enter 'CD directory/path/etc' to cd into a directory, adding it to the system path (so you can use local imports etc with RUN)
        Enter 'RUN pythonfile.py -arg1 --args --blah' to run a python file with the given args
        Enter 'PT OFF' to turn prompt-toolkit off. This saves battery life, and has less features. It's the default when using a non-tty command line
        When PT OFF, use '\\' to delete previous line of input and '/' to enter a multiline input. Yes, you can use multi-line even if PT OFF.
        Enter 'EDIT0' or 'EDIT1' etc to edit the n'th last entry in an external editor (for multiline input when PT OFF)
        Enter 'import shotgun' to attempt to pip-install a bunch of useful optional dependencies
        Note: rinsp is automatically imported into every pseudo_terminal instance; use it to debug your code really easily!
        "rinsp ans 1" is parsed to "rinsp(ans,1)" for convenience (generalized to literals etc)
        "+ 8" is parsed to "ans + 8" and ".shape" is parsed into
        play_sound_from_samples([.1,.2,.3,.4,.5,.5,.6,.6,.6,.6,.6,.6,.6,.6]*238964,3000) ⟵ Play that sound or something like it to debug speed in rp
        Sometimes, you don't have to type a command in all caps. For example, 'pwd' acts like 'PWD' if there's no variable called 'pwd'. This saves you from having to reach to the shift key. Other examples: 'tictocon'-->'TICTOC ON', 'gcon'-->'GC ON'
        Sometimes, you can use "some_variable/v" in place of "some_variable?v" when variable v doesn't exist, to save you from having to reach for the shift key. This also works for "/s"-->"?s", "/p"-->"?e" etc.
        The ?. command has some variations. r?.image will print a list of results. But, just r?. alone will enter FZF. r?.3 will enter FZF with a max search depth of 3.
        ALL COMMANDS:\n"""*0+indentify(command_list,' '*4*0), "blue")
        # Other commands: 'MOD ON', 'MOD OFF', 'SMOD SET', 'MOD SET', 'VARS', 'MORE', 'MMORE', 'RETURN NOW', 'EDIT', 'AHISTORY', GHISTORY', 'COPY', 'PASTE', 'CHISTORY', 'DITTO', 'LEVEL', 'PREV', 'NEXT', 'UNDO', 'PT ON', 'PT OFF', 'RANT', '!', '!!', '?/', '?.', '?', '??', '???', '????', '?????','SHELL', 'IPYTHON', 'UNDO ALL', 'PREV ALL', 'UNDO ON', 'UNDO OFF', 'PREV ON', 'PREV OFF', 'PREV CLEAR', 'UNDO CLEAR', 'GC ON', 'GC OFF', 'SUSPEND', 'TICTOC ON', 'TICTOC OFF', 'TICTOC', 'FANSI ON', 'FANSI OFF', 'RUN', 'CD', 'PROF ON', 'PROF OFF', 'PROF', 'IPYTHON ON', 'IPYTHON OFF', 'PROF DEEP', 'LINEPROF ON', 'LINEPROF OFF', 'LINEPROF', 'SET STYLE', 'PT SAVE', 'PT RESET', 'RELOAD ON', 'RELOAD OFF', 'PWD', 'CPWD', 'LS', 'FORK'

                    elif user_message =='SET TITLE':
                        _set_session_title()

                    elif user_message =='CLEAR':
                        import os
                        os.system('clear')
                        if running_in_jupyter_notebook():
                            from IPython.display import clear_output
                            clear_output()

                    elif user_message =='PT SAVE':
                        try:
                            fansi_print("Saving your Prompt-Toolkit-based GUIs settings, such as the UI and Code color themes, whether to use mouse mode, etc...", 'blue', 'underlined')
                            _save_pyin_settings_file()
                            fansi_print("...done!", 'blue', 'underlined')
                        except Exception as e:
                            fansi_print("...failed to PT SAVE!", 'red', 'underlined')
                            print_stack_trace(e)

                    elif user_message =='PT RESET':
                        try:
                            if input_yes_no("Are you sure you want to delete your settings file? This will reset all your settings to the defaults. This might sometimes be necessary if an invalid settings file prevents you from using PT ON. You can't undo this unless you've made a backup of "+repr(_pyin_settings_file_path)):
                                fansi_print("Deleting your settings file...", 'blue', 'underlined')
                                _delete_pyin_settings_file()
                                fansi_print("...done! When you restart rp, your changes should take effect. If you change your mind before you close this session and want to keep your settings, use PT SAVE before exiting.", 'blue', 'underlined')
                            else:
                                fansi_print("...very well then. We won't reset your PT (PromptToolkit) settings file.", 'blue', 'underlined')
                        except Exception as e:
                            fansi_print("...failed to PT RESET!", 'red', 'underlined')
                            print_stack_trace(e)


                    elif user_message == 'SET STYLE':
                        set_prompt_style()

                    elif user_message=='ANS PRINT FAST' or user_message=='APFA':
                        fansi_print("ANS PRINT FAST --> Will still print the value of 'ans', but it won't check if it's the same value as before (which can make it much faster). It will still print the answer, but it won't always be highlighted yellow if 'ans' is unchanged (normally it's green if there's a new value of 'ans', and yellow if 'ans' hasn't changed)", 'blue', 'bold')
                        # print("TODO: This might be made the default option, in which case ANS PRINT FAST will be removed") #It's not fullproof. [0] twice is green twice, instead of green than yelloq
                        should_print_ans=''
                    elif user_message=='ANS PRINT OFF' or user_message=='APOF':
                        fansi_print("ANS PRINT OFF --> Will no longer automatically print the value of 'ans'. This is often useful when str(ans) is so large that printing 'ans' spams the console too much.", 'blue', 'bold')
                        should_print_ans=False
                    elif user_message=='ANS PRINT ON' or user_message=='APON':
                        fansi_print("ANS PRINT ON --> Will automatically print the value of 'ans'. Will print it in green if it's a new value, and in yellow if it's the same value as it was before.", 'blue', 'bold')
                        should_print_ans=True

                    elif user_message == 'PROF DEEP':
                        global _PROF_DEEP
                        if not _PROF_DEEP:
                            if not _profiler:
                                fansi_print("Turned PROFILER on. This will profile each command you run. To turn if off use PROF OFF.", 'blue', 'underlined')
                                _profiler=True
                            _PROF_DEEP=True
                            fansi_print("Toggled _PROF_DEEP. We just the PROFILER to DEEP mode ON. This means we record all functions, even ones from external libraries. It's more verbose. Use PROF DEEP again to go back to shallow mode.", 'blue', 'underlined')
                        else:
                            fansi_print("Toggled _PROF_DEEP. We just the PROFILER to DEEP mode OFF. Use PROF DEEP again to go back to deep mode.", 'blue', 'underlined')
                            _PROF_DEEP=False

                    elif user_message in ['PROF FLAME']:
                        flamechart_html, flamechart_location = _display_pterm_flamechart(local=False)

                    elif user_message in ['PROF FLAME OPEN']:
                        flamechart_html, flamechart_location = _display_pterm_flamechart(local=True)
                        open_file_with_default_application(flamechart_location)

                    elif user_message in ['PROF FLAME COPY']:
                        flamechart_html, flamechart_location = _display_pterm_flamechart(local=True)
                        string_to_clipboard(flamechart_html)
                        compressed_html = object_to_base64(_truncate_string_floats(flamechart_html))
                        print(compressed_html)
                        fansi_print(
                            "Copied flamechart compressed HTML to clipboard! (or if not, copy the above base64 string). View it with FLAP (PROF FLAME PASTE) on a local rp",
                            "bold cyan",
                        )

                    elif user_message in ['PROF FLAME PASTE']:
                        compressed_html = string_from_clipboard()
                        html = base64_to_object(compressed_html)
                        assert isinstance(html, str), 'Failed to decode pasted flamechart!'
                        path = temporary_file_path('html')
                        save_text_file(html, path)
                        open_file_with_default_application(path)
                        fansi_print("Copied HTML from clipboard to "+path, 'bold cyan')



                    elif user_message == 'WARN':
                        if _warnings_are_off():
                            fansi_print("WARN --> Toggles warnings --> Turning warnings back on", 'blue', 'bold')
                            _warnings_on()
                        else:
                            fansi_print("WARN --> Toggles warnings --> Turning all warnings off", 'blue', 'bold')
                            _warnings_off()
    
                    elif user_message == 'PROF ON':
                        # fansi_print("Turned PROFILER on. This will profile each command you run. To get more detailed profiles, use 'PROF DEEP'. Note: Commands that take under a millisecond to run will not be profiled, to maintain both accuracy and your sanity.", 'blue', 'underlined')
                        fansi_print("Turned PROFILER on. This will profile each command you run. Note: Commands that take under a millisecond to run will not be profiled, to maintain both accuracy and your sanity.", 'blue', 'underlined')
                        _profiler=True

                    elif user_message == 'PROF OFF':
                        fansi_print("Turned PROFILER off.", 'blue', 'underlined')
                        _profiler=False
                        
                    elif user_message == 'PROF':
                        _profiler=not _profiler
                        if _profiler:
                            # fansi_print("Turned PROFILER on. This will profile each command you run. To get more detailed profiles, use 'PROF DEEP'", 'blue', 'underlined')
                            fansi_print("Turned PROFILER on. This will profile each command you run.", 'blue', 'underlined')
                        else:
                            fansi_print("Turned PROFILER off.",'blue','underlined')

                    elif user_message == 'LINEPROF ON':
                        fansi_print("Turned LINE PROFILER on. This will show line-by-line timing for each command you run. Note: Commands that take under a millisecond to run will not be profiled.", 'blue', 'underlined')
                        _line_profiler=True

                    elif user_message == 'LINEPROF OFF':
                        fansi_print("Turned LINE PROFILER off.", 'blue', 'underlined')
                        _line_profiler=False
                        
                    elif user_message == 'LINEPROF':
                        _line_profiler=not _line_profiler
                        if _line_profiler:
                            fansi_print("Turned LINE PROFILER on. This will show line-by-line timing for each command you run.", 'blue', 'underlined')
                        else:
                            fansi_print("Turned LINE PROFILER off.",'blue','underlined')

                    elif user_message=='MONITOR':
                        fansi_print("MONITOR -> Entering a system monitoring tool to show you cpu usage/memory etc of the current computer...", 'blue', 'bold',new_line=False)
                        pip_import('glances').main()
                        fansi_print('...done!','blue','bold')

                    elif user_message=='GPU':
                        try:
                            pip_import('gpustat').main()
                        except Exception as e:
                            print_stack_trace(e)
                            pass

                    elif user_message == 'TICTOC ON':
                        fansi_print("Turned TICTOC on. This will display the running time of each command.", 'blue', 'underlined')
                        _tictoc=True
                    elif user_message == 'TICTOC OFF':
                        fansi_print("Turned TICTOC off.",'blue','underlined')
                        _tictoc=False
                    elif user_message == 'TICTOC':
                        _tictoc=not _tictoc
                        if _tictoc:
                            fansi_print("Turned TICTOC on. This will display the running time of each command.",'blue','underlined')
                        else:
                            fansi_print("Turned TICTOC off.",'blue','underlined')

                    elif user_message == 'RELOAD ON':
                        _reload_modules()
                        fansi_print("Turned RELOAD ON. This will re-import any modules that changed at the beginning of each of your commands.",'blue','underlined')
                        _reload=True
                    elif user_message == 'RELOAD OFF':
                        fansi_print("Turned RELOAD OFF",'blue','underlined')
                        _reload=False

                    elif user_message=='FORK':
                        #TODO: Make this work with PT ON
                        #TODO: Right now this is just a proof of concept, of how to set checkpoints. Might rename this CHECKPOINT, but that's a long name...fork is nicer...\
                        #Used in-case you wanna try something risky that even UNDO can't fix...like mutating tons of variables etc...
                        #But unlike UNDO, it won't use tons and tons of memory (in theory) because of copy-on-write
                        #TODO: Handle Ctrl+C events from being propogaetd to each process at once
                        #TODO: Properly handle stdout so we can support PT ON
                        import os, sys
                        fansi_print("FORK -> Attempting to fork...",'blue','underlined')

                        child_pid = os.fork()
                        if child_pid == 0:
                            if currently_running_mac():
                                #This only seems to be a problem on MacOS, PT ON in FORK runs fine in Ubuntu..
                                fansi_print("Note: PT ON is not currently supported while forking yet on MacOS." ,'blue','underlined')#PT ON gives OSError: [Errno 9] Bad file descriptor
                                enable_ptpython=False
                            else:
                                fansi_print("...spawning child process. Also, please don't use control+c yet, that's not supported either, and if you send a keyboard interrupt during FORK this program will act very glitchy. To exit, use RETURN (or RET, for short).",'blue','underlined')#PT ON gives OSError: [Errno 9] Bad file descriptor
                            # child process
                            # os.system('ping -c 20 www.google.com >/tmp/ping.out')
                            # sys.exit(0)
                        else:
                            pid, status = os.waitpid(child_pid, 0)
                            fansi_print("FORK: resuming parent process...",'blue','underlined')

                    elif user_message=='RANGER' or user_message=='RNG':
                        fansi_print('RANGER --> Launching ranger, a curses-based file manager with vim bindings...','blue',new_line=True)
                        _launch_ranger()
                        fansi_print('...done!','blue',new_line=True)
                    elif user_message=='TOP':
                        fansi_print("TOP --> running 'bpytop'",'blue','bold')
                        if sys.version_info>(3,6):
                            pip_import('bpytop')
                            import subprocess
                            subprocess.run([sys.executable, "-m",'bpytop'])   
                        else:
                            fansi_print("Sorry, bpytop is not supported in python versions < 3.6",'red','bold')

                    elif user_message=='TREE ALL DIR':
                        display_file_tree(all=True,only_directories=True)
                    elif user_message=='TREE DIR':
                        display_file_tree(all=False,only_directories=True)
                    elif user_message=='TREE ALL':
                        display_file_tree(all=True)
                    elif user_message=='TREE':
                        display_file_tree(all=False)

                    elif user_message=='DISKH':
                        _display_filetype_size_histogram()

                    elif user_message=='DISK':
                        print(fansi("Showing disk usage tree for current directory: ",'blue','bold')+fansi(get_current_directory(),'yellow'))
                        pip_import('duviz').main()

                    elif user_message  in {'HISTORY','HIST'}:print_history()
                    elif user_message  in {'IHISTORY','IHIST'}:
                        #Because of the automatic _maybe_display_string_in_pager feature of HIST, this is no longer a nessecary command
                        #It's harmess though, so I'll leave it in anyway (maybe you don't want to spam the console for whatever reason)
                        fansi_print('IHISTORY --> Interactive History --> Displaying HISTORY interactively','blue','bold')
                        string_pager(print_history(True))

                    
                    elif user_message  in {'ALLHISTORY','ALLHIST'}:fansi_print("ALLHISTORY --> Displaying all history, including failures:",'blue','bold');display_list(all_command_history)

                    elif user_message == 'SUSPEND' or user_message=='SUS':
                        try:
                            psutil=pip_import('psutil')
                            fansi_print("Suspending this python session...",'blue','underlined')
                            import psutil,os
                            psutil.Process(os.getpid()).suspend()
                            fansi_print("...restored!",'blue','underlined')
                        except ImportError:
                            fansi_print("ERROR: psutil not installed. Try pip install psutil.",'red')

                    elif user_message  in {'DHISTORY','DHIST'}:
                        fansi_print("DHISTORY --> DEF HISTORY --> Here is a list of all your most recent function definitions in your HISTORY:",'blue','underlined')
                        dhistory=_dhistory_helper('\n'.join(successful_command_history))
                        set_ans('\n'.join(dhistory))
                        #set_ans('\n'+'\n'.join(dhistory))
                        # bold=False
                        # for defcode in :
                        #     fansi_print('\n'+defcode,'yellow','bold' if bold else None)
                    elif user_message in {'GHISTORY','GHIST'}:
                        fansi_print("GHISTORY --> GREEN HISTORY --> Here is a list of all valid single-lined python commands you have entered so far:",'blue','underlined')
                        for x in successful_command_history:
                            fansi_print(x if '\n' not in x else '','green')  # x if '\\n' not in x else '' ≣ '\\n' not in x and x or ''
                    elif user_message in {'CHISTORY','CHIST'}:
                        fansi_print("CHISTORY --> COPY HISTORY --> Copied history to clipboard!",'blue','underlined')
                        string_to_clipboard('\n'.join(successful_command_history))

                    elif user_message == "MORE":
                        if _get_pterm_verbose(): fansi_print("The last command that caused an error is shown below in magenta:",'red','bold')
                        fansi_print(error_message_that_caused_exception,'magenta')
                        if error is None:# full_exception_with_traceback is None --> Last command did not cause an error
                            fansi_print( "(The last command did not cause an error)",'red')
                        else:
                            print_stack_trace(error,True,'')

                    elif user_message == "HMORE":
                        #HMORE is like MORE but with syntax highlighting. It's a tiny difference.
                        if _get_pterm_verbose(): fansi_print("The last command that caused an error is shown below in magenta:",'red','bold')
                        # fansi_print(error_message_that_caused_exception,'magenta')
                        if error is None:# full_exception_with_traceback is None --> Last command did not cause an error
                            fansi_print( "(The last command did not cause an error)",'red')
                        else:
                            try:
                                #By default, try to print a syntax-highlighted stack trace. Fall back to a regular one.
                                print_highlighted_stack_trace(error)   
                            except:
                                print_stack_trace(error,True,'')

                    elif user_message == "MMORE":
                        if _get_pterm_verbose(): fansi_print("The last command that caused an error is shown below in magenta:",'red','bold')

                        fansi_print(error_message_that_caused_exception,'magenta')
                        fansi_print("A detailed stack trace is shown below:",'red','bold')
                        if error is None:# full_exception_with_traceback is None --> Last command did not cause an error
                            fansi_print( "(The last command did not cause an error)",'red')
                        else:
                            print_verbose_stack_trace(error)
                    elif user_message == "RMORE":
                        if error is None:# full_exception_with_traceback is None --> Last command did not cause an error
                            fansi_print( "(The last command did not cause an error)",'red')
                        else:
                            print_rich_stack_trace(error)
                    elif user_message == "AMORE":
                        if _get_pterm_verbose(): fansi_print("AMORE --> 'ans MORE' --> Setting 'ans' to the error",'red','bold')
                        set_ans(error)
                        # if error is None:# full_exception_with_traceback is None --> Last command did not cause an error
                            # fansi_print( "(The last command did not cause an error)",'red')
                        # else:
                            # print_verbose_stack_trace(error)

                    elif user_message == 'DMORE':
                        if _get_pterm_verbose(): fansi_print("DMORE --> Entering a post-mortem debugger","blue")
                        tb=error.__traceback__
                        if currently_in_a_tty() and not currently_running_windows():
                            try:
                                pip_import('pudb').post_mortem(tb)
                            except Exception:
                                import pdb
                                #In jupyter, this will somehow magically become ipdb. Idk how that works but it does.
                                pdb.post_mortem(tb)
                        else:
                            import pdb
                            pdb.post_mortem(tb)

                        # fansi_print("DMORE has not yet been implemented. It will be a post mortem debugger for your error using rp_ptpdb",'red','bold')

                    elif user_message.startswith('MOD SET'):
                        cursor='|'
                        if cursor in user_message:
                            def string_to_modifier(string):
                                #Treat the string as a template.
                                return lambda input:string.replace(cursor,input)
                            template_string=user_message[len('MOD SET'):].lstrip()
                            fansi_print("MOD SET: Setting template with cursor="+repr(cursor)+" string to "+repr(template_string),'blue','bold')
                            fansi_print(repr(cursor)+" will be replaced with user_message",'blue')
                            modifier=string_to_modifier(template_string)
                            if not use_modifier:
                                fansi_print("MOD ON --> use_modifier=True","blue")
                                use_modifier=True
                        else:
                            fansi_print("Failed to set modifier because you didn't use the cursor anywhere. You should use "+repr(cursor)+" somewhere in your modifier string.\nEXAMPLE:\nMOD SET print("+str(cursor)+")",'red','bold')

                    elif user_message.startswith('SMOD SET'):
                        cursor='|'
                        if cursor in user_message:
                            def repr_string_to_modifier(string):
                                #Treat the string as a template.
                                return lambda input:string.replace(cursor,repr(input))
                            template_string=user_message[len('SMOD SET'):].lstrip()
                            fansi_print("SMOD SET: (aka String-modifier set) Setting template with cursor="+repr(cursor)+" string to "+repr(template_string),'blue','bold')
                            fansi_print(repr(cursor)+" will be replaced with repr(user_message)",'blue')
                            modifier=repr_string_to_modifier(template_string)
                            if not use_modifier:
                                fansi_print("MOD ON --> use_modifier=True","blue")
                                use_modifier=True
                        else:
                            fansi_print("Failed to set string-modifier because you didn't use the cursor anywhere. You should use "+repr(cursor)+" somewhere in your modifier string.\nEXAMPLE:\nSMOD SET print("+str(cursor)+")",'red','bold')

                    elif user_message == "MOD OFF":
                        fansi_print("MOD OFF --> use_modifier=False","blue")
                        use_modifier=False
                    elif user_message == "MOD ON":
                        fansi_print("MOD ON --> use_modifier=True","blue")
                        use_modifier=True

                    elif user_message=='FANSI ON':
                        enable_fansi()
                        fansi_print("FANSI ON --> enable_fansi()","blue")
                    elif user_message=='FANSI OFF':
                        disable_fansi()
                        fansi_print("FANSI OFF --> disable_fansi()","blue")

                    elif user_message == "PT ON":
                        fansi_print("PROMPT TOOLKIT ON --> enable_ptpython=True","blue")
                        if _printed_a_big_annoying_pseudo_terminal_error:
                            fansi_print("Warning: PT ON crashed, so PT ON might not be available right now. This could be because PT ON crashed, or you're using a terminal that doesn't support it. Will attempt to PT ON anyway, though.","red")
                        # Always reset event loop on PT ON to ensure clean state after any potential crashes
                        # fansi_print("Resetting event loop for clean startup...","yellow")
                        default_python_input_eventloop = None  # Reset the singleton to force recreation
                        _printed_a_big_annoying_pseudo_terminal_error=False
                        enable_ptpython=True

                    elif user_message == "PT OFF":
                        fansi_print("PROMPT TOOLKIT OFF --> enable_ptpython=False","blue")
                        enable_ptpython=False
                        use_modifier=True

                    elif user_message == "PT":
                        enable_ptpython=not enable_ptpython
                        use_modifier=True

                        if enable_ptpython:
                            fansi_print("PT TOGGLE --> Turning TUI ON (enhanced interface with autocomplete, syntax highlighting) --> enable_ptpython=True","blue",'bold')
                            if _printed_a_big_annoying_pseudo_terminal_error:
                                fansi_print("Warning: PT ON crashed, so PT ON might not be available right now. This could be because PT ON crashed, or you're using a terminal that doesn't support it. Will attempt to PT ON anyway, though.","red")
                            # Always reset event loop when toggling PT on to ensure clean state after any potential crashes
                            # fansi_print("Resetting event loop for clean startup...","yellow")
                            default_python_input_eventloop = None  # Reset the singleton to force recreation
                            _printed_a_big_annoying_pseudo_terminal_error=False
                        else:
                            fansi_print("PT TOGGLE --> Turning TUI OFF (basic command line interface) --> enable_ptpython=False","blue",'bold')

                    elif user_message == "LEVEL":
                        #TODO: add more info:
                        #       - If we're in VM
                        #       - rp version
                        #       - If we're in TMUX
                        #       - If we're in docker
                        #       - Current memory
                        #       - Available GPU's (and if they have CUDA)
                        name=get_computer_name()
                        name=fansi(name,'green','bold')
                        if running_in_ssh():
                            name=fansi('(SSH) ','green',)+name
                            #     def _get_ssh_client_ip_address():
                            #         return shell_command('echo $SSH_CLIENT').split()[0]
                            #     ip=_get_ssh_client_ip_address()
                            #     name=fansi('(SSH from %s) '%ip,'green',)+name
                        if running_in_docker():
                            name=fansi('(Docker) ','green',)+name
                        if running_in_tmux():
                            name=fansi('(tmux) ','green',)+name
                        if running_in_google_colab():
                            fansi_print("Google Colab",'yellow','bold')
                        elif running_in_jupyter_notebook():
                            fansi_print("Jupyter",'yellow','bold')
                        if running_in_conda():
                            try:fansi_print("Conda"+(" (Mamba) " if running_in_mamba() else "")+": "+str(get_conda_name()),'yellow','bold')
                            except:print_stack_trace()
                        if running_in_venv():
                            try:fansi_print("VENV: "+str(get_venv_name()),'yellow','bold')
                            except:print_stack_trace()
                        if currently_in_a_tty():
                            print("(Running in a terminal)")
                        else:
                            print("(NOT Running in a terminal)")
                        def cyan(text):return fansi(text,'cyan')
                        import platform,sys,getpass
                        version=platform.python_implementation()+' '+str(sys.version_info.major)+'.'+str(sys.version_info.minor)+'.'+str(sys.version_info.micro)
                        version=fansi(version,'magenta','bold')

                        platform_type=""
                        if currently_running_unix():platform_type='Unix'
                        if currently_running_linux():platform_type='Linux'
                        if currently_running_mac():platform_type='Mac'
                        if currently_running_windows():platform_type='Windows'

                        bullet='    - '

                        env_info = _get_env_info() #Like SystemEnv(cuda_runtime_version='10.1.243', nvidia_gpu_models='GPU 0: NVIDIA GeForce RTX 3090', nvidia_driver_version='470.103.01', os='Ubuntu 20.04.2 LTS (x86_64)')

                        os_name = env_info.os or platform_type

                        disk_stat_path='.' #VS /
                        print('Python version: '+version+' at '+fansi(sys.executable,'magenta'))
                        print('Current time: '+_format_datetime(get_current_date()))
                        print(
                            "Disk space: "
                            + "%s / %s used   :  %s free"
                            % (
                                human_readable_file_size(get_used_disk_space(disk_stat_path)),
                                human_readable_file_size(get_total_disk_space(disk_stat_path)),
                                human_readable_file_size(get_free_disk_space(disk_stat_path)),
                            )
                        )
                        print(
                            "RAM: "
                            + "%s / %s used   :  %s free"
                            % (
                                human_readable_file_size(get_used_ram()),
                                human_readable_file_size(get_total_ram()),
                                human_readable_file_size(get_free_ram()),
                            )
                        )
                        print('Computer details:')
                        print(
                            bullet
                            + "Operating system: "
                            + fansi("(" + os_name + ") ", "red", "bold")
                            + fansi(platform.platform(), "red")
                        )
                        print(bullet+'Computer name: '+name)
                        print(bullet+'User Name: '+cyan(getpass.getuser()))
                        print(bullet+'CPU: '+cyan(_get_processor_name()))

                        if env_info.nvidia_gpu_models or env_info.cuda_runtime_version or env_info.cuda_runtime_version:
                            print('GPU Details:')
                        if env_info.nvidia_gpu_models:
                            gpu_header=(bullet+'NVIDIA GPU Models: ')
                            gpu_lines=env_info.nvidia_gpu_models
                            gpu_lines=line_split(gpu_lines)
                            if len(gpu_lines)>1:
                                gpu_lines[1:]=[' '*len(gpu_header)+x for x in gpu_lines[1:]]
                            gpu_lines=line_join(gpu_lines)
                            print(gpu_header+cyan(gpu_lines))

                        cuda_info = ''
                        if env_info.cuda_runtime_version:
                            cuda_info+='Cuda version: '+cyan(env_info.cuda_runtime_version)+'    '
                        if env_info.cuda_runtime_version:
                            cuda_info+='Driver version: '+cyan(env_info.nvidia_driver_version)
                        if cuda_info:
                            print(bullet+cuda_info)

                        print("Network:")
                        print(bullet+"MAC Address:",cyan(get_my_mac_address()))
                        if connected_to_internet():
                            print(bullet+"Local IP:",cyan(get_my_local_ip_address()))
                            #TODO: Cache the public IP, it can be slow...
                            print(bullet+"Public IP:",cyan(get_my_public_ip_address()))
                        else:
                            print(bullet+'(NOT Connected to internet)')

                        # def getCurrentMemoryUsage():
                        #THIS FUNCTION DOESNT WORK
                        #     # https://stackoverflow.com/questions/938733/total-memory-used-by-python-process
                        #     ''' Memory usage in kB '''
                        #     with open('/proc/self/status') as f:
                        #         memusage = f.read().split('VmRSS:')[1].split('\n')[0][:-3]
                        #     return int(memusage.strip())

                        

                        print('Process:',fansi(get_process_id(),'yellow','bold'),fansi(get_process_title(),'yellow'))
                        # print(bullet+'Memory used: '+human_readable_file_size(getCurrentMemoryUsage()))
                        fansi_print("LEVEL --> "+level_label(-1),"blue")
                        use_modifier=True

                    elif user_message == "COPY":
                        from rp import string_to_clipboard as copy
                        if is_image(get_ans()):
                            #Can copy images to clipboard
                            fansi_print("COPY --> r.copy_image_to_clipboard(ans)","blue")
                            copy_image_to_clipboard(get_ans())
                        else:
                            
                            do_string_copy=True

                            #Attempt to copy file paths
                            if currently_running_desktop() and not running_in_ssh() and (
                                isinstance(get_ans(), str) and get_ans()
                                or has_len(get_ans()) and len(get_ans())
                                and len(get_ans()) <= 100
                                and all(isinstance(x, str) for x in get_ans())
                            ):
                                print(repr(get_ans()))
                                try:
                                    copy_paths_to_clipboard(get_ans())
                                    #If we succesfully copied, it should also have been copied as a string too
                                    do_string_copy=False
                                    rp.fansi_print("COPY: Copied the files/paths to system clipboard!",'blue italic')
                                except NotImplementedError:
                                    #Unsupported OS right now
                                    pass
                                except (FileNotFoundError, OSError):
                                    #Not a valid path, silently fall back to string copy
                                    pass
                                except Exception as e:
                                    #For now, hide errors...
                                    print_stack_trace()


                            if do_string_copy:
                                #Can copy text to clipboard
                                fansi_print("COPY --> r.string_to_clipboard(str(ans))","blue")
                                copy(str(get_ans()))

                    elif user_message == "PASTE":
                        #Attempt to paste file paths from clipboard
                        if currently_running_desktop() and not running_in_ssh():
                            try:
                                paths = paste_paths_from_clipboard()
                                if paths:
                                    new_ans=(paths if len(paths) > 1 else paths[0])
                                    fansi_print("PASTE --> r.paste_paths_from_clipboard()","blue")
                                    fansi_print("PASTE: Pasted %s file path(s) from clipboard!" % len(paths),'blue italic')
                                else:
                                    #Fall back to string paste
                                    new_ans=(string_from_clipboard())
                            except NotImplementedError:
                                #Unsupported OS, fall back to string paste
                                new_ans=(string_from_clipboard())
                            except Exception as e:
                                #On error, fall back to string paste
                                print_stack_trace()
                                new_ans=(string_from_clipboard())

                            if not new_ans:
                                try:
                                    import PIL
                                    new_ans = load_image_from_clipboard()
                                    fansi_print("PASTE --> r.load_image_from_clipboard()","blue")
                                except ImportError:
                                    pass #Dont pip install this crap on pasting empty strings
                                except RuntimeError:
                                    pass #Leave it an empty string

                            if isinstance(new_ans, str):
                                fansi_print("PASTE --> r.string_from_clipboard()","blue")

                            set_ans(new_ans)
                        else:
                            #SSH or not desktop, just do string paste
                            set_ans(string_from_clipboard())
                            fansi_print("PASTE --> r.string_from_clipboard()","blue")

                    elif user_message == "VARS":
                        if _get_pterm_verbose(): fansi_print("VARS --> ans = user_created_variables (AKA all the names you created in this pseudo_terminal session):","blue")
                        if _get_pterm_verbose(): fansi_print("  • NOTE: ∃ delete_vars(ans) and globalize_vars(ans)","blue")
                        set_ans(_user_created_var_names,save_history=True)

                    elif user_message == "WCOPY":
                        from rp import string_to_clipboard as copy
                        fansi_print("WCOPY --> Web Copy --> rp.web_copy(ans) --> Copying ans to the internet","blue")
                        from time import time
                        start_time=time()
                        if callable(get_ans()):
                            fansi_print("        *Note: I noticed that ans is callable. If you're trying to copy a function, make sure you paste it in the same python version!",'blue')

                        fansi_print("    ...please wait, communicating with "+repr(_web_clipboard_url)+"...","blue",new_line=False)
                        web_copy(get_ans(), show_progress=True)
                        fansi_print("done in "+str(time()-start_time)[:6]+' seconds!',"blue",new_line=True)
                        successful_command_history.append("#WCOPY rp.web_copy(ans)")

                    elif user_message=='TCOPY':
                        fansi_print("TCOPY --> tmux Copy --> Copying str(ans) to tmux's clipboard","blue",'bold')
                        tmux_copy(str(get_ans()))

                    elif user_message=='VCOPY':
                        fansi_print("VCOPY --> Vim Copy","blue",'bold')
                        vim_copy(str(get_ans()))


                    elif user_message == "WPASTE":
                        from rp import string_to_clipboard as copy
                        fansi_print("WPASTE --> Web Paste --> rp.web_paste(ans) --> Pasting ans from the internet","blue",'bold')
                        fansi_print("    ...please wait, communicating with "+repr(_web_clipboard_url)+"...","blue",new_line=False)
                        from time import time
                        start_time=time()
                        new_ans=web_paste(show_progress=True)
                        fansi_print("done in "+str(time()-start_time)[:6]+' seconds!',"blue",new_line=True)

                        if isinstance(new_ans,str):
                            successful_command_history.append("ans=%s#WPASTE"%repr(new_ans))
                        else:
                            successful_command_history.append("#WPASTE ans=rp.web_paste()")

                        set_ans(new_ans)

                    elif user_message == "LCOPY":
                        from rp import string_to_clipboard as copy
                        fansi_print("LCOPY --> Local Copy --> rp.local_copy(ans) --> Copying ans to a clipboard file on your computer (faster than WCOPY)","blue",'bold')
                        from time import time
                        start_time=time()
                        if callable(get_ans()):
                            fansi_print("        *Note: I noticed that ans is callable. If you're trying to copy a function, make sure you paste it in the same python version!",'blue')

                        local_copy(get_ans())
                        fansi_print("Done in "+str(time()-start_time)[:6]+' seconds!',"blue",new_line=True)
                        successful_command_history.append("#LCOPY rp.local_copy(ans)")

                    elif user_message == "LPASTE":
                        from rp import string_to_clipboard as copy
                        fansi_print("LPASTE --> Local Paste --> rp.local_copy(ans) --> Pasting ans from a clipboard file on your computer (faster than WPASTE)","blue",'bold')
                        from time import time
                        start_time=time()
                        new_ans=local_paste()
                        fansi_print("Done in "+str(time()-start_time)[:6]+' seconds!',"blue",new_line=True)

                        if isinstance(new_ans,str):
                            successful_command_history.append("ans=%s#LPASTE"%repr(new_ans))
                        else:
                            successful_command_history.append("#LPASTE ans=rp.local_paste()")

                        set_ans(new_ans)

                    elif user_message in {"#PREV","PREV"}:
                        fansi_print("PREV -->  ans = ‹the previous value of ans›:","blue",'bold')
                        if len(ans_history) <= 1:
                            # If history has 0 or 1 items, we can't go back
                            fansi_print("    [Cannot get PREV ans because ans_history is empty]",'red')
                        else:
                            # Pop from history and push current to redo
                            ans_redo_history.append(ans_history.pop())
                            set_ans(ans_history[-1],save_history=False,force_green=True)#Because ans_history isn't updated when we know that we have a duplicate ans value, we can logically conclude that it should be green (and not yellow)
                            successful_command_history.append("#PREV")# We put this here in case the user wants to analyze the history when brought back into normal python code
                    elif user_message in {"#NEXT","NEXT"}:
                        if not ans_redo_history:
                            fansi_print("    [Cannot get NEXT ans because ans_redo_history is empty. NEXT is to PREV as REDO is to UNDO. Try using PREV before using NEXT.]",'red')
                        else:
                            fansi_print("NEXT -->  ans = ‹the next value of ans› (PREV is to UNDO as NEXT is to REDO):","blue",'bold')
                            set_ans(ans_redo_history.pop(),save_history=True)
                            successful_command_history.append("#NEXT")# We put this here in case the user wants to analyze the history when brought back into normal python code

                    elif user_message in {"UNDO","#UNDO"}:
                        fansi_print("UNDO --> UNDO:","blue")
                        if not snapshot_history:
                            fansi_print("    [Cannot UNDO anything right now because snapshot_history is empty"+(' becuase UNDO is OFF (enable it with UNDO ON)' if not snapshots_enabled else '')+"]",'red')
                        else:
                            while snapshot_history and not set_snapshot(snapshot_history.pop()):# Keep undoing until something changes
                                successful_command_history.append("#UNDO")# We put this here in case the user wants to analyze the history when brought back into normal python code
                            successful_command_history.append("#UNDO")# We put this here in case the user wants to analyze the history when brought back into normal python code
                            # set_snapshot([{},{},{}])
                    elif user_message in {"UNDO ALL","#UNDO ALL"}:
                        fansi_print("UNDO ALL --> snapshot_history=[] (Doing UNDO over and over again):\n\tCleared %i entries"%len(snapshot_history),"blue")
                        if snapshot_history:set_snapshot(snapshot_history[0])
                        else:fansi_print("\t(snapshot_history is allready empty, so no changes were made)",'blue')
                        snapshot_history=[]
                        successful_command_history.append("#UNDO ALL")
                    elif user_message in {"PREV ALL","#PREV ALL"}:
                        fansi_print("PREV ALL --> ans_history=[] (Doing PREV over and over again):\n\tCleared %i entries"%len(ans_history),"blue")
                        fansi
                        if ans_history:set_ans(ans_history[0])
                        else:fansi_print("\t(ans_history is allready empty, so no changes were made)",'blue')
                        ans_history=[]
                        successful_command_history.append("#PREV ALL")# We put this here in case the user wants to analyze the history when brought back into normal python code

                    elif user_message in {"UNDO CLEAR","#UNDO CLEAR"}:
                        fansi_print("UNDO ALL --> snapshot_history=[] (Clearing the UNDO history):\n\tCleared %i entries"%len(snapshot_history),"blue")
                        snapshot_history=[]
                        successful_command_history.append("#UNDO CLEAR")
                    elif user_message in {"PREV CLEAR","#PREV CLEAR"}:
                        fansi_print("PREV CLEAR --> ans_history=[] (Clearing the PREV history):\n\tCleared %i entries"%len(ans_history),"blue")
                        ans_history=[]
                        successful_command_history.append("#PREV CLEAR")# We put this here in case the user wants to analyze the history when brought back into normal python code

                    elif user_message in {"UNDO ON","#UNDO ON"}:
                        fansi_print("UNDO ON --> snapshots_enabled=True (Enables future UNDO history recording)","blue")
                        snapshots_enabled=True
                        successful_command_history.append("#UNDO ON")# We put this here in case the user wants to analyze the history when brought back into normal python code
                    elif user_message in {"UNDO OFF","#UNDO OFF"}:
                        fansi_print("UNDO OFF --> snapshots_enabled=False (Disables future UNDO history recording)","blue")
                        snapshots_enabled=False
                        successful_command_history.append("#UNDO OFF")# We put this here in case the user wants to analyze the history when brought back into normal python code
                    elif user_message in {"PREV OFF","#PREV OFF"}:
                        fansi_print("PREV OFF --> use_ans_history=False (Disables future PREV history recording)","blue")
                        use_ans_history=False
                        successful_command_history.append("#PREV OFF")# We put this here in case the user wants to analyze the history when brought back into normal python code
                    elif user_message in {"PREV ON","#PREV ON"}:
                        fansi_print("PREV ON --> use_ans_history=True (Enables future PREV history recording)","blue")
                        use_ans_history=True
                        successful_command_history.append("#PREV ON")# We put this here in case the user wants to analyze the history when brought back into normal python code

                    elif user_message in {"GC ON","#GC ON"}:
                        fansi_print("GC ON --> do_garbage_collection_before_input=True ('GC ON' Forcibly invokes the garbage collector upon each user prompt). This is is especially useful, for example, when python forgets to deallocate pytorch CUDA tensors in a timely fashion, which fills up vram and makes it unusable.","blue")
                        #This feature was added to avoid errors like """CUDA out of memory. Tried to allocate 76.00 MiB (GPU 0; 3.95 GiB total capacity; 1.72 GiB already allocated; 43.69 MiB free; 1.73 GiB reserved in total by PyTorch) """
                        do_garbage_collection_before_input=True
                        successful_command_history.append("#GC ON")# We put this here in case the user wants to analyze the history when brought back into normal python code
                    elif user_message in {"GC OFF","#GC OFF"}:
                        fansi_print("GC OFF --> do_garbage_collection_before_input=False ('GC ON' Forcibly invokes tgarbage collector upon each user prompt)","blue")
                        do_garbage_collection_before_input=False
                        successful_command_history.append("#GC OFF")# We put this here in case the user wants to analyze the history when brought back into normal python code
                    elif user_message in {"GC","#GC"}:
                        fansi_print("GC --> toggles forced garbage collection between prompts --> toggles between GC ON and GC OFF.","blue",'bold')
                        do_garbage_collection_before_input=not do_garbage_collection_before_input
                        fansi_print('\tSet GC %s'%('ON' if do_garbage_collection_before_input else 'OFF'),'blue','bold')
                        successful_command_history.append("#GC")# We put this here in case the user wants to analyze the history when brought back into normal python code

                    # endregion
                    # region  Short-hand rinsp
                    elif user_message=='?v' or user_message=='VIMA':
                        if not is_image(get_ans()) and not is_image_file(get_ans()):
                            if user_message=='VIMA':
                                if _get_pterm_verbose(): fansi_print("VIMA (VIM ans) is an alias for ?v","blue",'bold',)
                            if _get_pterm_verbose(): fansi_print("?v --> Running rp.vim(ans)...","blue",'bold',new_line=False)
                            vim(get_ans())
                            if _get_pterm_verbose(): fansi_print("done!","blue",'bold')
                        else:
                            fansi_print("?v --> Viewing image in terminal interactively","blue",'bold',new_line=False)
                            _view_image_via_textual_imageview(get_ans())
                    elif user_message.endswith('?v') and not '\n' in user_message:
                        user_message=user_message[:-2]
                        value=eval_for_rinsp(user_message,scope())
                        if not is_image(value):
                            if _get_pterm_verbose(): fansi_print("?v --> Running rp.vim(%s)..."%user_message,"blue",'bold',new_line=False)
                            if _get_pterm_verbose(): fansi_print("done!","blue",'bold')
                            vim(value)
                        else:
                            if _get_pterm_verbose(): fansi_print("?v --> Viewing image in terminal interactively","blue",'bold',new_line=False)
                            _view_image_via_textual_imageview(value)
                    elif user_message=='?s':
                        if _get_pterm_verbose(): fansi_print("?s --> string viewer --> shows str(ans)","blue",'bold')
                        string=str(get_ans())
                        print(string)
                        _maybe_display_string_in_pager(string)
                    elif user_message=='?lj':
                        if _get_pterm_verbose(): fansi_print("?s --> line-joined string viewer --> shows line_join(map(str,ans))","blue",'bold')
                        string=line_join(map(str,get_ans()))
                        print(string)
                        _maybe_display_string_in_pager(string)
                    elif user_message.endswith('?s') and not '\n' in user_message:
                        if _get_pterm_verbose(): fansi_print("?s --> string viewer --> shows str(ans)","blue",'bold')
                        user_message=user_message[:-2]
                        value=eval(user_message,scope())
                        string=str(value)
                        print(string)
                        _maybe_display_string_in_pager(string)
                    elif user_message.endswith('?lj') and not '\n' in user_message:
                        if _get_pterm_verbose(): fansi_print("?lj --> string viewer --> shows line_join(map(str,ans))","blue",'bold')
                        user_message=user_message[:-3]
                        value=eval_for_rinsp(user_message,scope())
                        string=line_join(map(str,value))
                        print(string)
                        _maybe_display_string_in_pager(string)

                    elif user_message=='?t' or user_message=='TABA':
                        if user_message=='TABA':
                            if _get_pterm_verbose(): fansi_print("TABA (TAB ans) is an alias for ?t","blue",'bold',)
                        if _get_pterm_verbose(): fansi_print("?t --> Table Viewer --> Running view_table(ans):","blue",'bold')
                        view_table(get_ans())
                    elif user_message.endswith('?t') and not '\n' in user_message:
                        user_message=user_message[:-2]
                        if _get_pterm_verbose(): fansi_print("t --> Table Viewer --> Running view_table(%s):"%user_message,"blue",'bold')
                        value=eval_for_rinsp(user_message,scope())
                        view_table(value)

                    elif user_message=='?vd' or user_message=='VDA':
                        if user_message=='VDA':
                            if _get_pterm_verbose(): fansi_print("VDA aka launch_visidata(ans) is an alias for ?vd","blue",'bold',)
                        if _get_pterm_verbose(): fansi_print("?vd --> Visidata --> Running launch_visidata(ans):","blue",'bold')
                        new_value=launch_visidata(get_ans())
                        set_ans(new_value)
                    elif user_message.endswith('?vd') and not '\n' in user_message:
                        user_message=user_message[:-2]
                        if _get_pterm_verbose(): fansi_print("?vd --> Visidata --> Running launch_visidata(%s):"%user_message,"blue",'bold')
                        value=eval_for_rinsp(user_message,scope())
                        new_value=launch_visidata(value)
                        set_ans(new_value)

                    elif user_message=='?p':
                        if _get_pterm_verbose(): fansi_print("?p --> Pretty Print --> Running pretty_print(ans,with_lines=False):","blue",'bold')
                        pterm_pretty_print(get_ans(),with_lines=False)
                    elif user_message.endswith('?p') and not '\n' in user_message:
                        user_message=user_message[:-2]
                        if _get_pterm_verbose(): fansi_print("?p --> Pretty Print --> Running pretty_print(%s,with_lines=False):"%user_message,"blue",'bold')
                        value=eval_for_rinsp(user_message,scope())
                        pterm_pretty_print(value,with_lines=False)
                        #pip_import('rich').print(value)
                    elif user_message=='?j':
                        if _get_pterm_verbose(): fansi_print("?j --> JSON Viewer --> Interactively displaying ans with collapsible menus with r._view_interactive_json(ans)","blue",'bold')
                        _view_interactive_json(get_ans())
                    elif user_message.endswith('?j') and not '\n' in user_message:
                        user_message=user_message[:-2]
                        if _get_pterm_verbose(): fansi_print("?j --> JSON Viewer --> Interactively displaying ans with collapsible menus with r._view_interactive_json","blue",'bold')
                        value=eval_for_rinsp(user_message,scope())
                        _view_interactive_json(value)
                    elif user_message=='?i':
                        fansi_print("?i --> PyPI Package Inspection:","blue",'bold')
                        import rp.libs.pypi_inspection as pi
                        pi.display_module_pypi_info(get_ans())
                    elif user_message.endswith('?i') and not '\n' in user_message:
                        user_message=user_message[:-2]
                        fansi_print("?i --> PyPI Package Inspection:","blue",'bold')
                        try:
                            value=eval(user_message,scope())
                        except Exception as e:
                            try:
                                value = get_module_path_from_name(user_message)
                            except Exception:
                                raise e

                        import rp.libs.pypi_inspection as pi
                        pi.display_module_pypi_info(value)

                    elif user_message=='?e':
                        fansi_print("Running peepdis.peep(ans):","blue",'bold')
                        from rp.libs.peepdis import peep
                        peep(get_ans())
                    elif user_message.endswith('?e') and not '\n' in user_message:
                        user_message=user_message[:-2]
                        fansi_print("running peepdis.peep(%s):"%user_message,"blue",'bold')
                        from rp.libs.peepdis import peep
                        value=eval(user_message,scope())
                        peep(value)
                    elif user_message == "?":
                        if _get_pterm_verbose(): fansi_print("? --> rinsp(ans)","blue")
                        rinsp(get_ans())
                    elif user_message == "??":
                        if _get_pterm_verbose(): fansi_print("?? --> rinsp(ans,1,1)","blue")
                        rinsp(get_ans(),1,1)
                        # fansi_print("?? --> rinsp(ans,1)","blue")
                        # rinsp(get_ans(),1)
                    elif user_message == "???":
                        if _get_pterm_verbose(): fansi_print("??? --> rinsp(ans,1,0,1)","blue")
                        rinsp(get_ans(),1,0,1)
                        # fansi_print("??? --> rinsp(ans,1,1)","blue")
                        # rinsp(get_ans(),1,1)
                
                    ##### I decided to deprecate the old ??, and ????? because I found I never used them. But naturally, this means getting rid of ????? and ???? instead.

                    # elif user_message == "????":
                    #     fansi_print("???? --> rinsp(ans,1,0,1)","blue")
                    #     rinsp(get_ans(),1,0,1)
                    # elif user_message == "?????":
                    #     fansi_print("????? --> rinsp(ans,1,1,1)","blue")
                    #     rinsp(get_ans(),1,1,1)
                    elif user_message == "?/" or user_message=='?h':
                        if _get_pterm_verbose(): fansi_print("?h --> help(ans)","blue")
                        # fansi_print("?/ --> help(ans)","blue")
                        help(get_ans())
                    elif user_message.endswith("?/") or user_message.endswith('?h'):
                        if _get_pterm_verbose(): fansi_print("◊?h --> help(◊)","blue")
                        help(eval(user_message[:-2],scope()))
                    # elif user_message.endswith("?????"):
                    #     fansi_print("◊????? --> rinsp(◊,1,1,1)","blue")
                    #     rinsp(eval(user_message[:-5],scope()))
                    # elif user_message.endswith("????"):
                    #     fansi_print("◊???? --> rinsp(◊,1,0,1)","blue")
                    #     rinsp(eval(user_message[:-4],scope()),1,0,1)
                    elif user_message.endswith("???"):
                        if _get_pterm_verbose(): fansi_print("◊??? --> rinsp(◊,1,0,1)","blue")
                        rinsp(eval(user_message[:-3],scope()),1,0,1)
                        # fansi_print("◊??? --> rinsp(◊,1,1)","blue")
                        # rinsp(eval(user_message[:-3],scope()),1,1)
                    elif user_message.endswith("??"):
                        if _get_pterm_verbose(): fansi_print("◊?? --> rinsp(◊,1,1)","blue")
                        rinsp(eval(user_message[:-2],scope()),1,1)
                        # fansi_print("◊?? --> rinsp(◊,1)","blue")
                        # rinsp(eval(user_message[:-2],scope()),1)
                    elif user_message.endswith("?"):
                        if _get_pterm_verbose(): fansi_print("◊? --> rinsp(◊)","blue")
                        rinsp(eval_for_rinsp(user_message[:-1],scope()))

                    elif user_message=='PWD':
                        fansi_print("PWD: "+_fansi_highlight_path(get_current_directory()),"blue",'bold')
                    elif user_message=='CPWD':
                        fansi_print("CPWD: Copied current directory to clipboard: "+_fansi_highlight_path(get_current_directory()),"blue",'bold')
                        string_to_clipboard(get_current_directory())
                    elif user_message.startswith('CAT ') or user_message.startswith('NCAT ') or user_message in ['CAT','NCAT','CATA','NCATA']:

                        if user_message.startswith('CAT ' ):user_message = 'CAT '  + _autocomplete_lss_name(user_message,command_name='CAT' )
                        if user_message.startswith('NCAT '):user_message = 'NCAT ' + _autocomplete_lss_name(user_message,command_name='NCAT')

                        if user_message in ['CAT','NCAT']:
                            print("Please select the file you would like to display")
                            file_name=input_select_file()
                        elif user_message in ['CATA','NCATA']:
                            file_name=str(get_ans())
                        else:
                            file_name=user_message[user_message.find(' '):].strip()

                        

                        
                        line_numbers=user_message.startswith('N')#Should we print with line numbers
                        highlight   =get_file_extension(file_name)=='py'#Should we do syntax highlighting

                        if line_numbers:
                            if highlight:
                                if _get_pterm_verbose(): fansi_print("NCAT: Printing (with line numbers and python syntax highlighting) the contents of "+repr(file_name),"blue")
                            else:
                                if _get_pterm_verbose(): fansi_print("NCAT: Printing (with line numbers) the contents of "+repr(file_name),"blue")
                        else:
                            if highlight:
                                if _get_pterm_verbose(): fansi_print("CAT: Printing (with python syntax highlighting) the contents of "+repr(file_name),"blue")
                            else:
                                if _get_pterm_verbose(): fansi_print("CAT: Printing the contents of "+repr(file_name),"blue")

                        contents=_load_text_from_file_or_url(file_name)

                        def print_code(code,highlight=False,line_numbers=False):
                            printed_lines=[]
                            def print_line(line):
                                print(line)
                                printed_lines.append(line)

                            s=code
                            l=s.splitlines()#code lines
                            if not l:return#Nothing to print, don't cause errors...
                            n=list(map(str,range(len(l))))#numbers
                            c=max(map(len,n))#max number of chars in any number
                            for line,num in zip(s.splitlines(),n):
                                if line_numbers:
                                    num=num.rjust(c)
                                    num=fansi(num,'black','bold','blue')
                                else:
                                    num=''
                                if highlight:
                                    line=fansi_syntax_highlighting(line)
                                print_line(num+line)

                            _maybe_display_string_in_pager(line_join(printed_lines),with_line_numbers=False)

                        print_code(contents,highlight,line_numbers)

                    elif user_message.startswith('CCAT ') or user_message=='CCAT' or user_message=='CCATA':

                        if user_message.startswith('CCAT '):
                            user_message = 'ACAT '+_autocomplete_lss_name(user_message,command_name='CCAT')

                        if user_message=='CCATA':
                            if _get_pterm_verbose(): fansi_print('CCAT -->text_file_to_string Copy CAT ans --> Copies the contents of the file or url at \'ans\' to your clipboard','blue','bold')
                            user_message='CCAT '+str(get_ans())
                        elif user_message=='CCAT':
                            if _get_pterm_verbose(): fansi_print('CCAT --> Copy CAT --> Copies a files contents to your clipboard --> Please select a file!','blue','bold')
                            user_message='CCAT '+input_select_file()

                        file_name=user_message[user_message.find(' '):].strip()
                        if _get_pterm_verbose(): fansi_print("CCAT: Copying to your clipboard the contents of "+repr(file_name),"blue")
                        string_to_clipboard(_load_text_from_file_or_url(file_name))

                    elif user_message=='LS' or user_message=='LST' or user_message=='LSN' or user_message=='LSD':
                        import os

                        printed_lines=[]
                        def print_line(line):
                            printed_lines.append(line)

                        paths=sorted(sorted(os.listdir()),key=is_a_directory)

                        if user_message=='LSN':
                            fansi_print("LSN -> Printing all paths from LS sorted by Number (number)",'blue','bold')
                            paths=[path for path in paths if path_exists(path)]
                            paths=sorted(paths,key=lambda x:(len(x),x))
                            paths=sorted(paths,key=is_a_directory)

                        if user_message=='LSD':
                            def file_size_key(x):
                                if is_a_file(x):
                                    return get_file_size(x, human_readable=False)
                                else:
                                    return 0
                            fansi_print("LSD -> Printing all paths from LS sorted by Disk Size (size)",'blue','bold')
                            paths=[path for path in paths if path_exists(path)]
                            paths=sorted(paths,key=file_size_key)
                            paths=sorted(paths,key=is_a_directory)

                        if user_message=='LST':
                            fansi_print("LST -> Printing all paths from LS sorted by Time (date_modified)",'blue','bold')
                            paths=[path for path in paths if path_exists(path)]
                            paths=sorted(paths,key=date_modified)
                            paths=sorted(paths,key=is_a_directory)

                        for item in paths:

                            extra_style = " "
                            folder_style=' bold '
                            if get_file_name(item).startswith('.'):
                                #Hidden files: Faded
                                extra_style += ' faded italic '
                                folder_style=''

                            if is_symbolic_link(item):
                                if is_a_directory(item):
                                    print_line(fansi(item,extra_style+'green'+folder_style))
                                else:
                                    print_line(fansi(item,extra_style+'green'))
                            elif is_a_directory(item):
                                print_line(fansi(item,extra_style+'cyan'+folder_style))
                            elif is_a_file(item):
                                print_line(fansi(item,extra_style+'gray'))
                            else:
                                print_line(fansi(item,extra_style+'red'))

                        text=line_join(printed_lines)

                        if user_message=='LST' or user_message=='LSD':
                            dates=[_format_datetime(date_modified(path)) for path in paths]
                            dates=[fansi(date,'blue',None) for date in dates]
                            dates=line_join(dates)

                            sizes=[get_file_size(path,human_readable=True) if is_a_file(path) else '' for path in paths]
                            sizes=[fansi(size.rjust(8),'green',None) for size in sizes]
                            sizes=line_join(sizes)

                            text=horizontally_concatenated_strings(text,'    ',dates,'  ',sizes,rectangularize=True)

                        try:
                            # ERROR: UnicodeEncodeError: 'utf-8' codec can't encode character '\udcd9' in position 10: surrogates not allowed
                            print(text)
                        except (UnicodeDecodeError,UnicodeEncodeError):
                            for line in line_split(text):
                                print(''.join(x for x in line if ord(x)<5000))

                        _maybe_display_string_in_pager(text)


                    elif user_message=='WANS' or user_message.startswith("WANS ") and user_message.strip()!='WANS':
                        fansi_print("WANS -> Write ans to a file (can be text, bytes, or an image)","blue",'bold')
                        if user_message.startswith('WANS '):
                            path = user_message[len('WANS '):]
                        else:
                            path=input(fansi("(Enter blank path to select and overwrite an existing file)\nPath: ",'blue','bold'))
                        if not path:
                            path=input_select_file(message='WANS: Select a file to overwrite')
                        if path_exists(path):
                            if not input_yes_no("Are you sure you want to overwrite "+path+"?"):
                                path=None
                        if path is None:
                            fansi_print("WANS cancelled",'red')
                        else:
                            # if is_image(get_ans()):
                            #     path=save_image(get_ans(),path)
                            #     fansi_print("WANS: Wrote image file to "+path,'blue','bold')
                            # elif get_file_extension(path)=='json' and not isinstance(get_ans(),str):
                            #     save_json(get_ans(),path)
                            # elif isinstance(get_ans(),bytes):
                            #     bytes_to_file(str(get_ans()),path)
                            #     fansi_print("WANS: Wrote binary file to "+path,'blue','bold')
                            # else:
                            #     string_to_text_file(path,str(get_ans()))
                            path = _omni_save(get_ans(), path, auto_extension=True)
                            fansi_print("WANS: Wrote "+path,'blue','bold')
                            set_ans(path)
                    
                    elif user_message=='WANS+':
                        fansi_print("WANS+ -> Append ans as a line to a file","blue",'bold')
                        path=input(fansi("(Enter blank path to select and overwrite an existing file)\nPath: ",'blue','bold'))
                        if not path:
                            path=input_select_file(message='WANS: Select a file to overwrite')
                        if path is None:
                            fansi_print("WANS cancelled",'red')
                        else:
                            sans=str(get_ans())
                            append_line_to_file(sans,path)
                            set_ans(path)

                    elif user_message=='LG' or user_message.startswith("LG ") and user_message.strip()!='LG':
                        fansi_print("LG -> Load gist from URL","blue",'bold')
                        if user_message.startswith('LG '):
                            gist_url = user_message[len('LG '):]
                        else:
                            gist_url = input(fansi('URL: ','blue','bold'))
                        if gist_url:
                            result = load_gist(gist_url)
                            set_ans(result)
                            fansi_print("LG: Loaded gist content into ans",'blue','bold')
                        else:
                            fansi_print("LG cancelled",'red')


                    elif user_message=='UPDATE':
                        fansi_print("UPDATE -> Attempting to update this program...","blue",'bold')
                        update_rp()
                    elif user_message=='IPYTHON ON':
                        fansi_print("IPYTHON ON --> running all commands with an ipython interpereter. Run '%magic' to see help for all available iPython magics commands. (pro-tip: for line magics, you don't even need to use %, so just 'magic' works too)","blue",'underlined')
                        global _ipython_exeval
                        try:
                            if _ipython_exeval is None:
                                _ipython_exeval=_ipython_exeval_maker(dicts[0])#Right now this is global. This is seriously messy. But since pseudoterminal's namespace is allready F'd up right now, who cares...I'll fix this when I rewrite pseudoterminal
                            _use_ipython_exeval=True
                        except ImportError:
                            fansi_print("IPYTHON ON failed due to an import error",'red','bold')
                            _use_ipython_exeval=False
                    elif user_message=='IPYTHON OFF':
                        fansi_print("IPYTHON OFF --> running your inputs as regular ol' python again","blue")
                        _use_ipython_exeval=False
                    # endregion
                    else:

                        if user_message=='MLPASTE':
                            fansi_print("MLPASTE --> Multi-Line Paste","blue",'bold')
                            user_message=repr(input_multiline())

                        if user_message == 'NUM COM':
                            fansi_print("NUM COM --> listing all %i commands"%len(help_commands),"blue",'bold')
                            user_message=repr(help_commands)

                        if user_message == "SHELL" or user_message=='SH':
                            fansi_print("SHELL --> entering Xonsh shell","blue",'bold')
                            xonsh=pip_import('xonsh')
                            # xonsh.execer.Execer.__del__=lambda *x:None# This prevents it from unimportant error messages after we leave the shell
                            # xonsh.execer.print_exception=lambda *x:None# This prevents it from unimportant error messages after we leave the shell
                            
                            #The following line hasn't been a problem in a while, and the message is kinda a nuisance
                            # fansi_print('Will try to run Xonsh (a python-based alternative to bash). Note that its the same runtime as. If it fails to launch properly, try "pip3 install prompt-toolkit pygments --upgrade". If it\'s fine, ignore this message.','blue')
                            user_message='__import__("rp").launch_xonsh()'  # Import xonsh, run the shell, then update the directory

                        elif user_message == 'LINE JOIN ANS':
                            #This isn't in the help because this is really meant to be used by shortcuts so the message can be multiplexed between list-> string and string->list
                            ans=get_ans()
                            if isinstance(ans,str):
                                user_message="ans.splitlines()"
                            else:
                                if all(isinstance(x,str) for x in ans):
                                    user_message="'\\n'.join(ans)"
                                else:
                                    user_message="'\\n'.join(map(str,ans))"

                            fansi_print("Transformed input to "+repr(user_message),'magenta','bold')

                        elif user_message == 'JSON ANS':
                            #This isn't in the help because this is really meant to be used by shortcuts so the message can be multiplexed between list-> string and string->list
                            ans=get_ans()
                            if isinstance(ans,str):
                                if not '\n' in ans and ans.endswith('.json') and file_exists(ans):
                                    user_message="load_json(ans)"
                                else:
                                    user_message="import json\nans=json.loads(ans)"
                            else:
                                user_message="import json\nans=json.dumps(ans)"

                            fansi_print("Transformed input to "+repr(user_message),'magenta','bold')

                        elif user_message=='AVIMA':
                            fansi_print("AVIMA --> Letting you edit ans in vim as a string","blue",'bold')
                            temp=temporary_file_path()
                            text=str(get_ans())
                            if is_valid_python_syntax(text):
                                #If we're editing a python-code string, let vim use syntax highlighting by indicating the correct file extension
                                temp+='.py'
                            try:
                                string_to_text_file(temp,str(get_ans()))
                                vim(temp)
                                user_message=repr(text_file_to_string(temp))
                            finally:
                                delete_file(temp)

                        elif user_message=='FCOPY' or user_message.startswith('FCOPY '):
                            fansi_print("FCOPY --> Web File Copy --> rp.web_copy_path() --> Copying a file or folder to the internet","blue",'bold')

                            if user_message.startswith('FCOPY '):
                                path = _autocomplete_lss_name(user_message,command_name='FCOPY')
                            else:
                                path=input_select_path()

                            from time import time
                            start_time=time()
                            fansi_print("    ...please wait, communicating with "+repr(_web_clipboard_url)+"...","blue",new_line=False)
                            web_copy_path(path, show_progress=True)
                            fansi_print("done in "+str(time()-start_time)[:6]+' seconds!',"blue",new_line=True)
                            user_message=repr(path)

                        elif user_message=='?c':
                            fansi_print("?c --> Getting source code --> ans = rp.get_source_code(ans)...","blue",'bold')
                            try:
                                user_message=repr(get_source_code(get_ans()))
                            except TypeError:
                                user_message=repr(type(get_source_code(get_ans())))

                        elif user_message=='?cp':
                            fansi_print("?cp --> Printing source code","blue",'bold')
                            try:              _maybe_display_string_in_pager(printed(fansi_syntax_highlighting(     get_source_code(get_ans()) ,show_line_numbers=True,line_wrap_width=get_terminal_width())),with_line_numbers=False)
                            except TypeError: _maybe_display_string_in_pager(printed(fansi_syntax_highlighting(type(get_source_code(get_ans())),show_line_numbers=True,line_wrap_width=get_terminal_width())),with_line_numbers=False)
                            user_message=""

                        elif ends_with_any(user_message,'?c','?+c','?c+','?cp') and not '\n' in user_message:
                            #You can do 
                            #     load_image?c+   ---   adds the source code of load_image to the top of the current str(ans)
                            #     load_image?+c   ---   adds the source code of load_image to the bottom of the current str(ans)
                            #     load_image,save_image?c   ---   loads the source code of both load_image and save_image, one on top of the other

                            just_print=False

                            if user_message.endswith('?c'):
                                user_message=user_message[:-2]
                                start=[]
                                end=[]
                            if user_message.endswith('?c+'):
                                user_message=user_message[:-3]
                                start=[]
                                end=[str(get_ans())]
                            if user_message.endswith('?+c'):
                                user_message=user_message[:-3]
                                start=[str(get_ans())]
                                end=[]
                            if user_message.endswith('?cp'):
                                user_message=user_message[:-3]
                                start=[]
                                end=[]
                                just_print=True

                            values=eval(user_message,scope())

                            if isinstance(values,(tuple,list)):
                                fansi_print("?c --> Getting source code --> ans = rp.get_source_code(%s)..."%user_message,"blue",'bold')
                            else:
                                fansi_print("?c --> Getting source code --> ans = line_join(map(rp.get_source_code,%s))..."%user_message,"blue",'bold')
                                values=[values]

                            parts=[]
                            for value in values:
                                try:
                                    parts.append(get_source_code(value))
                                except Exception:
                                    print_stack_trace()

                            user_message=repr(line_join(start+parts+end))

                            if just_print:
                                _maybe_display_string_in_pager(printed(fansi_syntax_highlighting(eval(user_message),show_line_numbers=True,line_wrap_width=get_terminal_width())),with_line_numbers=False)
                                user_message=""
                                

                        elif user_message=='?r':
                            fansi_print("?r --> rich.inspect(ans)","blue",'bold')

                            rp.r._rich_inspect(get_ans())

                            user_message=""

                        elif user_message.endswith('?r') and not '\n' in user_message:
                            user_message=user_message[:-2]
                            fansi_print("?r --> rich.inspect(%s)"%user_message,"blue",'bold')
                            value=eval(user_message,scope())
                            rp.r._rich_inspect(value)
                            user_message=""


                        elif user_message=='FPASTE':
                            fansi_print("FPASTE --> Web File Paste --> rp.web_paste_path() --> Pasting a file or folder from the internet","blue",'bold')
                            fansi_print("    ...please wait, communicating with "+repr(_web_clipboard_url)+"...","blue",new_line=False)
                            from time import time
                            start_time=time()
                            path=web_paste_path(show_progress=True)
                            fansi_print("done in "+str(time()-start_time)[:6]+' seconds!',"blue",new_line=True)
                            user_message=repr(path)

                        elif '\n' not in user_message and re.fullmatch(r'[a-zA-Z0-9_]*\.\?.*',user_message[::-1]) or (not '\n' in user_message and user_message.endswith('/.')):
                            def breakify(entry):
                                #Make '.asoij.woi.avoaap.thing' only contain 'thing' when using iterfzf to search for it
                                l=entry.split('.')
                                l[:-1]=['\u2060'.join(x) for x in l[:-1]]
                                return '.'.join(l)
                            if user_message.endswith('/.'):
                                #Turn 'thing/.' into 'thing?.' and '/.' into '?.'
                                user_message=user_message[:-2]+'?.'
                            if user_message.startswith('i.'):
                                user_message='ans'+user_message
                            qans=user_message.endswith('?.') or user_message in {'/.','?.'}
                            qans=user_message in 'ans?. ?. /. ans/.'.split()
                            if qans:
                                user_message='ans'+user_message



                            # if user_message and not user_message.isnumeric():
                            #     fansi_print("Recursively rinsp_search searching for "+repr(user_message)+" in ans:","blue",'bold')
                            #     rinsp_search(get_ans(),user_message)
                            #     user_message=''
                            # else:
                                depth = 5 
                                try:
                                    #Allow ?.5 to set depth of 5
                                    depth=int(user_message)
                                    assert depth>0
                                except Exception:pass
                                #If given no arguments, use FZF to select something as your new answer
                                results=('ans.'+'.'.join(result) for result in _rinsp_search_helper(get_ans(),'',depth=depth))
                                results=map(breakify,results)
                                result=_iterfzf(results,exact=True) #Exact to prevent fuzzy matching
                                result=result.replace('\u2060','')#Remove all no-space spaces
                                if result is not None:
                                    user_message=result
                                    fansi_print("Transformed command into: " + fansi_syntax_highlighting(user_message),'magenta')
                                    # set_ans(result)
                                    # successful_command_history.append()
                                else:
                                    user_message=''
                            # else:
                                    # fansi_print("You didn't give ?. a query. You must follow ?. by a query. For example, '?.print' when ans is rp","red")
                                #if user_message like 'some_value[0](x,y,z)?.query'
                            split=[x[::-1] for x in user_message[::-1].split('.?',1)][::-1]#Split on the last ?.
                            if not qans:
                                assert len(split)==2
                                try:    
                                    value=eval(split[0],scope())
                                except _PseudoTerminalReturnException:
                                    raise
                                except BaseException as e:
                                    print_stack_trace(e)
                                query=split[1]

                                if query and not query.isnumeric():
                                    fansi_print("Recursively rinsp_search searching for "+repr(user_message)+" in "+split[0]+":","blue",'bold')
                                    rinsp_search(value,query)
                                    user_message=''
                                else:
                                    # fansi_print("You didn't give some_value?. a query. You must follow some_value?. by a query. For example, 'thing?.print' is ok while 'thing?.' is not","red")
                                    depth = 5 #default depth of the rinsp search
                                    try:
                                        #Allow ?.5 to set depth of 5
                                        depth=int(query)
                                        query=''
                                        assert depth>0
                                    except Exception:pass

                                    #If given no arguments, use FZF to select something as your new answer
                                    results=('.'+'.'.join(result) for result in _rinsp_search_helper(value,'',depth=depth))

                                    
                                    results=map(breakify,results)
                                    result=_iterfzf(results,exact=True,multi=True) #Exact to prevent fuzzy matching
                                    if result is None:
                                        raise KeyboardInterrupt #This is how that happens:wq
                                    result=[x.replace('\u2060','') for x in result]#Remove all no-space spaces
                                    result = [split[0]+x for x in result]
                                    if len(result)==1:
                                        result=result[0]
                                    else:
                                        result=', '.join(result)
                                    user_message=result
                                    fansi_print("Transformed command into: " + fansi_syntax_highlighting(user_message),'magenta')
                                    # set_ans(result)
                                    # successful_command_history.append()



                        # elif user_message.startswith('?.') or user_message in {'/.','?.'}:
                        #     user_message=user_message[2:]
                        #     if user_message and not user_message.isnumeric():
                        #         fansi_print("Recursively rinsp_search searching for "+repr(user_message)+" in ans:","blue",'bold')
                        #         rinsp_search(get_ans(),user_message)
                        #         user_message=''
                        #     else:
                        #         depth = 5 
                        #         try:
                        #             #Allow ?.5 to set depth of 5
                        #             depth=int(user_message)
                        #             assert depth>0
                        #         except Exception:pass
                        #         #If given no arguments, use FZF to select something as your new answer
                        #         results=('ans.'+'.'.join(result) for result in _rinsp_search_helper(get_ans(),'',depth=depth))
                        #         result=_iterfzf(results,exact=True) #Exact to prevent fuzzy matching
                        #         if result is not None:
                        #             user_message=result
                        #             fansi_print("Transformed command into: " + fansi_syntax_highlighting(user_message),'magenta')
                        #             # set_ans(result)
                        #             # successful_command_history.append()
                        #         else:
                        #             user_message=''

                        #         # fansi_print("You didn't give ?. a query. You must follow ?. by a query. For example, '?.print' when ans is rp","red")
                        # elif '\n' not in user_message and re.fullmatch(r'[a-zA-Z0-9_]*\.\?.*',user_message[::-1]):
                        #     #if user_message like 'some_value[0](x,y,z)?.query'
                        #     split=[x[::-1] for x in user_message[::-1].split('.?',1)][::-1]#Split on the last ?.
                        #     assert len(split)==2
                        #     value=eval(split[0],scope())
                        #     query=split[1]
                        #     if query:
                        #         fansi_print("Recursively rinsp_search searching for "+repr(user_message)+" in "+split[0]+":","blue",'bold')
                        #         rinsp_search(value,query)
                        #     else:
                        #         fansi_print("You didn't give some_value?. a query. You must follow some_value?. by a query. For example, 'thing?.print' is ok while 'thing?.' is not","red")
                        #     user_message=''


                        elif user_message in {'AHISTORY','AHIST'}:
                            fansi_print("AHISTORY --> ans HISTORY --> Set ans to HISTORY",'blue','underlined')
                            user_message=repr('\n'.join(successful_command_history))

                        elif user_message in ['VHIST','VHISTORY']:
                            fansi_print("VHISTORY --> VIM HISTORY --> Letting you browse all HISTORY's from previous rp.pseudo_terminal() sessions. Try yanking some entries from it, then pasting them into your buffer using \\vi mode",'blue','underlined')
                            vim(pterm_history_filename)
                            user_message=repr(pterm_history_filename)
                            # user_message=repr(pterm_history_filename)

                        elif user_message.startswith('ACAT ') or user_message=='ACAT' or user_message=='ACATA':
                            if user_message=='ACATA':
                                fansi_print('ACATA --> ans CAT ans --> Copies the contents of the file or url at \'ans\' to ans','blue','bold')
                                user_message='ACAT '+str(get_ans())

                                
                            if user_message=='ACAT':
                                fansi_print('ACAT --> ans CAT --> Copies a file\'s contents to ans --> Please select a file!','blue','bold')
                                user_message='CCAT '+input_select_file()

                            if user_message.startswith('ACAT '):
                                user_message = 'ACAT '+_autocomplete_lss_name(user_message,command_name='ACAT')

                            file_name=user_message[user_message.find(' '):].strip()

                            if file_name.startswith('~'):file_name=get_absolute_path(file_name)
                            try:
                                fansi_print("ACAT: Copying to your ans the contents of "+repr(file_name),"blue",'bold')
                                if is_valid_url(file_name) and get_file_extension(file_name).lower() in 'jpg png gif tiff tga jpeg bmp exr'.split():
                                    user_message='ans=__import__("rp").load_image(%s)'%repr(file_name)
                                elif file_name.endswith('.ipynb'):
                                    #Unlike json or other formats that could be loaded, ipynb's are almost always generated automatically
                                    #They're best used as code!
                                    user_message="""ans=__import__("rp").extract_code_from_ipynb(%s)"""%repr(file_name)
                                # elif file_name.endswith('.json'):
                                #     #This works fine! But currently disabled in favor of "aa aj"
                                #     user_message="""ans=__import__("rp").load_json(%s)"""%repr(file_name)
                                # elif file_name.endswith('.yaml'):
                                #     user_message="""ans=__import__("rp").load_yaml(%s)"""%repr(file_name)
                                # elif file_name.endswith('.tsv'):
                                #     user_message="""ans=__import__("rp").load_tsv(%s,show_progress=True)"""%repr(file_name)
                                # elif is_image_file(file_name):
                                #     user_message='ans=__import__("rp").load_image(%s)'%repr(file_name)
                                # elif is_video_file(file_name):
                                #     user_message='ans=__import__("rp").load_video(%s)'%repr(file_name)
                                # elif (file_name.endswith('.pt') or file_name.endswith('.pth')) and module_exists('torch'):
                                #     user_message="""ans=__import__("torch").load(%s,map_location='cpu')"""%repr(file_name)
                                # elif is_sound_file(file_name):
                                #     user_message='ans=__import__("rp").load_sound_file(%s)'%repr(file_name)
                                # elif file_name.endswith('.npy') and module_exists('numpy'):
                                #     user_message="""ans=__import__("numpy").load(%s)"""%repr(file_name)
                                # elif file_name.endswith('.pkl'):
                                #     user_message="""ans=__import__("rp").load_pickled_value(%s)"""%repr(file_name)
                                # elif file_name.endswith('.parquet'):
                                #     user_message="""ans=__import__("rp").pip_import("pandas").read_parquet(%s)"""%repr(file_name)
                                # else(: 
                                #     text_len_threshold = 10000  # If it's longer than this we just put the load text command to make the history cleaner...
                                #     text_set_command = repr(_load_text_from_file_or_url(file_name))
                                #     if len(text_set_command) > text_len_threshold:
                                #         text_set_command = '__import__("rp").text_file_to_string(%s)'%repr(file_name)
                                #     user_message='ans='+text_set_command
                                else:   
                                    user_message = "ans=__import__('rp').r._omni_load(%s)"%repr(file_name)

                            except UnicodeDecodeError:
                                user_message='ans=__import__("rp").file_to_bytes(%s)'%repr(file_name)
                                # assert False,'Failed to read file '+repr(file_name)

                        elif user_message == "IPYTHON":
                            fansi_print("WARNING: Use 'IPYTHON ON' or 'IPYTHON OFF' for now on, 'IPYTHON' is broken until further notice. Will try to do it anyway, though.",'red','bold')
                            fansi_print("IPYTHON --> embedding iPython","blue")
                            # user_message='import IPython;IPython.embed()'
                            user_message='import rp.rp_ptpython.ipython;rp.rp_ptpython.ipython.embed()'

                        # region Alternate methods of user_input (PASTE/EDIT/DITTO etc)
                        elif user_message == 'RPRC':
                            print("Your .rprc is run each time you start rp. You can edit it (tip: use 'vim(ans)'). Your .rprc file is in the following path:")
                            print(rprc_file_path)
                            try:
                                vim(rprc_file_path)
                            except Exception:pass
                            user_message='ans = '+repr(rprc_file_path)

                        elif user_message == 'RYAN XONSHRC':
                            if input_yes_no('Would you like to use Ryan Burgert\'s settings in your ~/.xonshrc? (This is the settings file for the SHELL command, which uses the Xonsh shell)'):
                                _set_ryan_xonshrc()
                                user_message='ans = '+repr(xonshrc_path)

                        elif user_message=='APWD':
                            fansi_print("APWD: Set ans to current directory: "+_fansi_highlight_path(get_current_directory()),"blue",'bold')
                            user_message=repr(get_current_directory())


                        elif user_message=='RYAN PUDBRC':
                            print("TODO: Make sure that the pudb pseudo-terminal is able to see the debugger's scope! This is currently broken.")
                            fansi_print("RYAN PUDBRC: Setting PUDB's shell to pseudo_terminal",'blue','bold')

                            pudb_config_file_path=get_absolute_path('~/.config/pudb/pudb.cfg')
                            make_parent_directory(pudb_config_file_path)
                            if not file_exists(pudb_config_file_path):
                                pudb_config=line_join([
                                  '[pudb]',
                                  'custom_shell = /home/ryan/anaconda3/lib/python3.7/site-packages/rp/pudb_shell.py',
                                  'shell = /home/ryan/anaconda3/lib/python3.7/site-packages/rp/pudb_shell.py',
                                ])
                            else:
                                pudb_shell_path=get_module_path_from_name('rp.pudb_shell')#should be pudb_shell.py
                                assert file_exists(pudb_shell_path)
                                assert get_file_name(pudb_shell_path)=='pudb_shell.py'
                                pudb_config=text_file_to_string(pudb_config_file_path)

                                fansi_print("OLD PUDB CONFIG",'blue','underlined')
                                fansi_print(pudb_config,'yellow')

                                pudb_config=[line for line in line_split(pudb_config) if not line.startswith('shell = ') and not line.startswith('custom_shell = ')]
                                pudb_config.append('shell = '+pudb_shell_path)
                                pudb_config.append('custom_shell = '+pudb_shell_path)
                                pudb_config=line_join(pudb_config)

                                fansi_print("NEW PUDB CONFIG",'blue','underlined')
                                fansi_print(pudb_config,'yellow')

                            string_to_text_file(pudb_config_file_path,pudb_config)

                            user_message='ans = '+repr(pudb_config_file_path)

                        elif user_message=='RYAN TMUXRC':
                            _set_ryan_tmux_conf()
                            user_message='ans = '+repr(get_absolute_path('~/.tmux.conf'))


                        elif user_message in ['RYAN VIMRC', 'RYAN VIMRC YES', 'RYAN VIMRC NO', 'RYAN VIMRC COPY', 'RYAN VIMRC HARD LINK', 'RYAN VIMRC SOFT LINK']:

                            confirm = not 'YES' in user_message
                            if 'NO' in user_message:
                                confirm = 'NO'
                            elif 'COPY' in user_message:
                                confirm = 'COPY'  # COPY means copy vimrc without packages
                            elif 'HARD LINK' in user_message:
                                confirm = 'HARD LINK'  # Hard link vimrc without packages
                            elif 'SOFT LINK' in user_message:
                                confirm = 'SOFT LINK'  # Soft link vimrc without packages
                            _set_ryan_vimrc(confirm = confirm)
                            user_message='ans = '+repr(get_absolute_path('~/.vimrc'))

                        elif user_message=='RYAN RANGERRC':
                            ranger_config_path=_set_ryan_ranger_config()
                            user_message='ans = '+repr(ranger_config_path)
                            
                        elif user_message == 'XONSHRC':
                            fansi_print("XONSHRC --> editing your ~/.xonshrc file","blue",'bold')
                            vim(get_absolute_path('~/.xonshrc'))
                            user_message='ans = '+repr(get_absolute_path('~/.xonshrc'))

                        elif user_message == 'TMUXRC':
                            fansi_print("TMUXRC --> editing your ~/.tmux.conf file","blue",'bold')
                            vim(get_absolute_path('~/.tmux.conf'))
                            user_message='ans = '+repr(get_absolute_path('~/.tmux.conf'))

                        elif user_message == 'VIMRC':
                            fansi_print("VIMRC --> editing your ~/.vimrc file","blue",'bold')
                            vim(get_absolute_path('~/.vimrc'))
                            user_message='ans = '+repr(get_absolute_path('~/.vimrc'))

                        elif user_message == 'RYAN RPRC' or user_message=='RYAN RPRC YES':
                            #This isn't in the help documentation, because it's something I made for myself. You can use it too though!
                            if user_message=='RYAN RPRC YES' or input_yes_no('Would you like to add Ryan Burgert\'s default settings to your rprc?'):
                                # _get_ryan_rprc_path() #This already exists!
                                rprc_lines = unindent(
                                    '''

                                    # < Ryan RPRC Start >
                                    from rp import *

                                    #__import__('rp').r._set_default_session_title() # now handled in _load_pyin_settings_file
                                    #__import__('rp').r._default_timezone=rp.get_current_timezone() #Or 'PST', 'EST', etc
                                    __import__('rp').r._pip_import_autoyes=True
                                    __import__('rp').r._pip_install_needs_sudo=False

                                    ## Custom pterm commands
                                    #__import__('rp').r._add_pterm_command_shortcuts("""
                                    #     CLC $r._pterm_cd("~/CleanCode")
                                    #     RZG $os.system(f"cd {$get_path_parent($get_module_path(rp)} ; lazygit")
                                    #""")

                                    ##Custom command prefix shortcuts
                                    #__import__('rp').r._add_pterm_prefix_shortcut("fu","!!fileutil")
                                    #__import__('rp').r._add_pterm_prefix_shortcut("rcl",["!rclone copy --progress --transfers 128 --metadata --checksum "," ."])

                                    # < Ryan RPRC End >

                                    '''
                                )
                                append_line_to_file(rprc_lines,rprc_file_path)
                                user_message=rprc_lines+'\nans='+repr(rprc_file_path)

                        elif user_message == 'GMORE':
                            fansi_print("GMORE --> 'google-search MORE' --> Searching the web for your error...","red",'bold')
                            if error is None:
                                fansi_print('    (Can\'t use GMORE because there haven\'t been any errors yet)','red')
                                user_message=''
                            else:
                                error_string=strip_ansi_escapes(print_stack_trace(error,full_traceback=False,header='',print_it=False))
                                print(fansi("    Searching for: ",'red','bold')+fansi(error_string,'yellow'))
                                url=google_search_url(error_string)
                                open_url_in_web_browser(url)
                                user_message=repr(url)

                        elif user_message == 'VIMORE':
                             fansi_print("VIMORE --> 'vim MORE' --> Edit some file in the last error's traceback",'red','bold')
                             if error is None:
                                fansi_print('    (Can\'t use VIMORE because there haven\'t been any errors yet)','red')
                                user_message=''
                             else:
                                 try:
                                      user_message=repr(_vimore(error))
                                 except KeyboardInterrupt:
                                      print('(Cancelled)')
                                      user_message=''
                                 except:
                                      user_message=''
                                      pass
                        elif user_message=='IMPMORE':
                            def get_name_from_name_error(error:NameError):
                                #EXAMPLE:
                                #    INPUT:
                                #        NameError: name 'thing' is not defined
                                #    OUTPUT:
                                #        'thing'
                                assert isinstance(error,NameError)
                                ans=error
                                ans=ans.args
                                ans=ans[0]
                                ans=ans.split()
                                ans=ans[1]
                                import ast
                                ans=ast.literal_eval(ans)
                                return ans
                            fansi_print("IMPMORE --> attempts to import a module resulting from a NameError",'red','bold')
                            if 'ModuleNotFoundError' not in vars():
                                ModuleNotFoundError=ImportError#Older versions of python, like python3.5
                            if not isinstance(error,NameError) and not isinstance(error,ModuleNotFoundError) and not isinstance(error,ImportError):
                                fansi_print("     (Current error is not a NameError, ImportError or ModuleNotFoundError but is instead a %s, so IMPMORE won't do anything)"%str(type(error)),'red','bold')
                                user_message=''
                            else:
                                if isinstance(error,NameError):
                                    user_message='import '+get_name_from_name_error(error)
                                elif isinstance(error,ModuleNotFoundError) or isinstance(error,ImportError):
                                    user_message='import '+str(error)[16:][1:-1]
                                print(fansi("Transformed command into: ",'magenta')+fansi_syntax_highlighting(user_message))

                        elif user_message=='NEXTMORE':
                            fansi_print("NEXTMORE --> sets the error to the next error in history",'red','bold')
                            if error_stack.can_redo():
                                error=error_stack.redo()
                                print(fansi('ERROR: ','red','bold')+fansi(error,'red'))
                            else:
                                fansi_print("     (Cannot go to the NEXT error: allready at the latest)",'red','bold')
                            user_message=''


                                
                        elif user_message=='PREVMORE':
                            fansi_print("PREVMORE --> sets the error to the previous error in history",'red','bold')
                            if error_stack.can_undo():
                                error=error_stack.undo()
                                print(fansi('ERROR: ','red','bold')+fansi(error,'red'))
                            else:
                                fansi_print("     (Cannot go to the PREV error: allready at the earliest error)",'red','bold')
                            user_message=''
                            


                        elif user_message=='PIPMORE':
                            fansi_print("PIPMORE --> 'pip_install MORE' --> Will try to install missing modules with pip",'red','bold')
                            #Used when you have something like ERROR: ModuleNotFoundError: No module named 'tensorflow'
                            #Will automatically install tensorflow  
                            if 'ModuleNotFoundError' not in vars():
                                ModuleNotFoundError=ImportError #Python3.5 doesn't have ModuleNotFoundError
                            if not isinstance(error,ModuleNotFoundError):
                                if error is None:
                                    fansi_print('    (Warning: PIPMORE cannot be used yet because you havent made any errors)','red','bold')
                                else:
                                    fansi_print('    (Warning: PIPMORE cannot be used on this error because its not a ModuleNotFoundError)','red','bold')
                            else:
                                missing_module_name=error
                                missing_module_name=str(missing_module_name)
                                missing_module_name=missing_module_name[len('No module named '):]
                                missing_module_name=eval(missing_module_name)
                                try:
                                    pip_import(missing_module_name)#pip_import instad of pip_install because it takes into account known_pypi_module_package_names
                                except Exception as e:
                                    raise
                            user_message=''


                        elif user_message == 'EPASTE':
                            fansi_print("EPASTE --> Exec/Eval PASTE --> Running code from your clipboard (printed below):",'blue','underlined')
                            user_message=string_from_clipboard()
                            # fansi_print(user_message,"yellow")
                            print(fansi_syntax_highlighting(user_message))
                        elif user_message.startswith("RANT ") or user_message.startswith("RANT\n"):
                            user_message="__import__('rp').run_as_new_thread(exec,"+repr(user_message[5:].strip())+",globals(),locals())"
                        elif user_message=='RANT':
                            user_message="__import__('rp').run_as_new_thread(exec,"+repr(get_ans())+",globals(),locals());"
                        elif user_message.startswith("RANP "):
                            user_message="__import__('rp').run_as_new_process(exec,"+repr(user_message[5:].strip())+",globals(),locals())"
                        elif user_message=='VPASTE':
                            fansi_print("VPASTE --> Vim Paste","blue",'bold')
                            tmux_clipboard=vim_paste()
                            user_message=repr(tmux_clipboard)
                        elif user_message=='TPASTE':
                            fansi_print("TPASTE --> tmux paste --> Setting ans to tmux's clipboard","blue",'bold')
                            tmux_clipboard=tmux_paste()
                            user_message=repr(tmux_clipboard)
                        elif user_message == 'PASTE':
                            new_ans = _paste_from_clipboard()
                            if isinstance(new_ans, str) and len(new_ans)<100000:
                                fansi_print("PASTE --> ans=str(string_from_clipboard()):",'blue','underlined')
                                user_message = repr(new_ans)

                            else:
                                if is_image(new_ans):
                                    fansi_print("PASTE --> ans=load_image_from_clipboard():",'blue','underlined')
                                else:
                                    fansi_print("PASTE --> ans=str(string_from_clipboard()):",'blue','underlined')

                                user_message='#PASTE '+type(new_ans).__name__

                                set_ans(new_ans)

                        elif user_message in 'ALS ALSF ALSD'.split():
                            if user_message in 'ALS' :
                                fansi_print("ALS --> ans LS --> Sets ans to the list of paths in the current directory",'blue','bold')
                                user_message = repr(get_all_paths(get_current_directory(),include_files=True,include_folders=True,relative=True,sort_by='name'))
                            if user_message in 'ALSD':
                                fansi_print("ALSD --> ans LS directories --> Sets ans to the list of directories in the current directory",'blue','bold')
                                user_message = repr(get_all_paths(get_current_directory(),include_files=False,include_folders=True,relative=True,sort_by='name'))
                            if user_message in 'LSAF ALSF':
                                fansi_print("ALSF --> ans LS files --> Sets ans to the list of files in the current directory",'blue','bold')
                                user_message = repr(get_all_paths(get_current_directory(),include_files=True,include_folders=False,relative=True,sort_by='name'))
                                
                        elif user_message.startswith('DITTO'):
                            ditto_arg=user_message[len('DITTO'):]
                            try: ditto_number=int(ditto_arg.strip())
                            except: ditto_number=1
                            if not successful_command_history:
                                fansi_print("DITTO --> Cannot use DITTO, the successful_command_history is empty!",'red')
                                user_message=""# Ignore it
                            else:
                                fansi_print("DITTO --> re-running last successful command "+str(ditto_number)+" times, shown below in yellow:",'blue','underlined')
                                user_message='\n'.join([successful_command_history[-1]]*ditto_number)
                                fansi_print(user_message,"yellow")
                        elif user_message=='LS SEL' or user_message=='LSS' or user_message in ['LS REL','LSR'] or starts_with_any(user_message, "LSS ", 'LSR ') and not '\n' in user_message:
                            if starts_with_any(user_message, "LSS ", "LSR "):
                                command_name = user_message[:len("LSS")]

                                lss_name =_autocomplete_lss_name(user_message,command_name=command_name)

                                if command_name=='LSR':
                                    user_message = repr(lss_name)
                                elif command_name=='LSS':
                                    user_message=repr(path_join(get_current_directory(),lss_name))


                                # user_message=repr(get_absolute_path(user_message[len("LSS "):]))
                            else:
                                rel = user_message in ['LS REL','LSR']
                                if rel:
                                    fansi_print("LS REL aka LSR--> LS Select (Relative Path) --> Same as LSS, except uses relative path instead of global path--> Please select a file or folder",'blue','underlined')
                                else:
                                    fansi_print("LS SEL aka LSS--> LS Select --> Please select a file or folder",'blue','underlined')
                                try:
                                    path=input_select_path()
                                    if rel:
                                        path=get_relative_path(path)
                                    user_message='ans = '+repr(path)
                                except KeyboardInterrupt:
                                    fansi_print("\t(LS SEL cancelled)",'blue')
                                    user_message=''
                        
                        elif user_message=='FDT':
                            fansi_print("FDT aka FinD Text --> Grep with FZF",'blue','bold')
                            fansi_print("    (Reminder) PWD: "+_fansi_highlight_path(get_current_directory()),"blue",'bold')
                            result=rp.r._fzf_multi_grep()
                            result=repr(result)
                            user_message=result
                        
                        elif user_message.startswith('FDT '):
                            fansi_print("FDT <filetypes>  aka FinD Text --> Grep with FZF (example: 'FDT py txt yaml' searches only those filetypes)",'blue','bold')
                            fansi_print("    (Reminder) PWD: "+_fansi_highlight_path(get_current_directory()),"blue",'bold')
                            result=rp.r._fzf_multi_grep(user_message[len('FDT '):])
                            result=repr(result)
                            user_message=result

                        elif user_message=='FDTA':
                            fansi_print("FDT aka FinD Text ans --> Grep with FZF on all files listed in ans",'blue','bold')

                            ans_val = get_ans()
                            if isinstance(ans_val, str):
                                ans_val = ans_val.splitlines()

                            result=rp.r._fzf_multi_grep(text_files=ans_val)
                            result=repr(result)
                            user_message=result

                        elif user_message=='LS FZF' or user_message=='LSZ' or user_message=='LSQ' or user_message=='LS QUE':
                            #TODO: LSQ could be made obsolete if there was some way to sort the results of LSZ
                            #However, I don't know how to do this
                            #For this reason, I don't know if I'll made a CDQ soon, as you could just to LSQ then CDA. 
                            #Hopefully there's some way to sort the FZF results...
                            #Also, it would be nice if we didn't show irrelevant search results. For example, when searchig for a folder 'Thing', having 'Thing/Stuff' show up doesn't make sense when 'Thing' was already a result
                            if user_message in ['LSQ','LS QUE']:    
                                fansi_print("LS QUE aka LSQ --> LS Query --> Please select a file(s) or folder(s) (this is basically LS FZF, but requires an exact match)",'blue','underlined')
                                exact=True
                            else:
                                fansi_print("LS FZF aka LSZ --> LS Fuzzy-Select --> Please select a file(s) or folder(s)",'blue','underlined')
                                exact=False

                            try:
                                result=_iterfzf((line.replace('\n',' ').replace('\r',' ') for line in breadth_first_path_iterator('.')),exact=exact,multi=True)
                            except:
                                result=None

                            if not result:
                                raise KeyboardInterrupt #This is the only way this could have happened; and it seems pretty natural. More so than seeing an error message.
                                # fansi_print("LS FZF (aka LSZ) cancelled: you didn't select a path",'red','underlined')
                                # user_message=''
                            else:
                                # result=get_absolute_path(result)
                                if len(result)==1:
                                    result=result[0]
                                user_message=repr(result)


                        elif (user_message.startswith('FDS ') or user_message.startswith('FD ') or user_message.startswith('FDA')) and not '\n' in user_message and ' ' in user_message.strip() or user_message=='FDS' or user_message=='FDA' or user_message=='FD':
                            if user_message=='FDA' or user_message=='FD':
                                user_message+=' '+input(fansi('Please enter a search query: ','blue','bold'))
                            return_list=False
                            if user_message.startswith('FDA '):
                                return_list=True
                                user_message='FD '+user_message[len('FDA '):]
                            if user_message=='FDS':
                                user_message='FDS '+input(fansi("Please enter a search query: ",'blue','bold'))
                            if user_message.startswith('FDS '):
                                user_message='FD SEL '+user_message[len('FDS '):]
                            query=user_message[len('FD '):]
                            select=False
                            if query.startswith("SEL "):
                                select=True
                                query=query[len('SEL '):]

                            print(fansi("FD --> Searching recursively for a path name containing:","blue"), fansi(query,'yellow'))
                            results=_fd(query)
                            
                            if not results:
                                fansi_print("\t(There were no results matching your query)",'blue')
                            if select and results:
                                print()
                                print()
                                selected_result=input_select(fansi('Please select a path, or press control+c to cancel:','yellow','bold'),results,stringify=str)
                                selected_result=strip_ansi_escapes(selected_result)#get rid of highlighting...
                                user_message='ans = '+repr(selected_result)
                            else:
                                user_message=''

                            if return_list:
                                user_message=repr(list(map(strip_ansi_escapes,results)))

                                    
                        elif user_message in {'RUNA','SRUNA','SSRUNA'}:
                            cmd=user_message
                            user_message=str(get_ans())
                            if cmd=='SSRUNA':
                                fansi_print("SRUNA --> Shell-Run !!ans --> Run ans as a shell command and return result as string",'blue','bold')
                                user_message='ans=__import__("rp").shell_command('+repr(user_message)+')#SSRUNA'
                            if cmd=='SRUNA':
                                fansi_print("SRUNA --> Shell-Run !ans --> Run ans as a shell command",'blue','bold')
                                user_message='import os;os.system('+repr(user_message)+')#SRUNA'

                            fansi_print("RUNA --> Running the contents of 'ans' as a command",'blue','bold')
                            if (
                                (
                                    file_exists(user_message)
                                    or is_valid_url(user_message)
                                )
                                and not "\n" in user_message
                                and user_message == user_message.lstrip()
                                and (
                                    user_message.endswith(".py")
                                    or user_message.endswith(".rpy")
                                )
                            ):
                                fansi_print("Loading code from "+user_message+"...",'blue','bold')
                                user_message=_load_text_from_file_or_url(user_message)

                            print(fansi("Transformed command into:",'magenta')+'\n' +fansi_syntax_highlighting(user_message))
                        elif user_message.startswith('RUN ') or user_message=='RUN':
                            import shlex
                            command=shlex.split(user_message[4:])#shlex handles quoted strings even if there are spaces in them. https://stackoverflow.com/questions/899276/python-how-to-parse-strings-to-look-like-sys-argv
                            if not command:
                                command=[input_select_file(message=fansi("RUN (without arguments) --> Please select a python file","blue",'bold'))]
                            script_path=command[0]
                            script_path=script_path.strip()
                            if not script_path:
                                fansi_print("RUN --> Error: Please specify a python script. Example: 'RUN test.py --args",'red')
                            else:
                                fansi_print("RUN --> Executing python script at file with args: "+script_path,'blue')
                                lines=line_split(text_file_to_string(script_path))
                                for i,line in enumerate(lines):
                                    if line.strip() and not line.startswith('from __future__'):#These must come first
                                        lines.insert(i,'import sys;_old_sys_argv=sys.argv;sys.argv='+repr(command)+" #RUN: Set the appropriate arguments")
                                        break
                                lines.append('sys.argv=_old_sys_argv #RUN: Restore the original arguments')
                                user_message=line_join(lines)
                                fansi_print("Printing script below: "+script_path,'blue')
                                print(fansi("Transformed command into:",'magenta')+'\n'+ fansi_syntax_highlighting(user_message))
                        # elif not is_valid_python_syntax(user_message) and re.fullmatch(,user_message):
                        elif user_message and 'print'.startswith(user_message) and not any(user_message in dict for dict in dicts):
                            fansi_print("Variable "+repr(user_message)+" does not exist, so parsed command into print(ans)",'magenta')
                            user_message='print(ans)'

                        elif user_message == 'VIM' or user_message.count('\n')==0 and user_message.startswith('VIM ') or user_message=='VIMH':
                            
                            if user_message=='VIMH':
                                fansi_print("VIMH --> VIM Here --> VIM .",'blue','bold')
                                user_message='VIM .'
                            fansi_print("VIM --> Launching the vim text editor",'blue','bold')
                            if user_message=='VIM':
                                path=input_select_path() 
                            else:
                                path=user_message[len('VIM '):]

                                path=_autocomplete_lss_name(user_message,command_name='VIM')

                            vim(path.split())
                            user_message='ans = '+repr(path)+" # VIM"
                    
                        elif user_message == 'TAB' or user_message.count('\n')==0 and user_message.startswith('TAB '):
                            fansi_print("TAB --> Launching tabview (a tabular data viewer)",'blue','bold')
                            if user_message=='TAB':
                                path=input_select_path() 
                            else:
                                path=_autocomplete_lss_name(user_message,command_name='TAB')
                            pip_import('tabview')
                            import tabview
                            # tabview.tabview.view(path)
                            view_table(path)
                            # import sys
                            # command=sys.executable+' -m tabview '+path
                            # shell_command(command)
                            user_message='ans='+repr(path)+" # TAB"

                        elif user_message.startswith('RN ') or user_message=='RN':
                            fansi_print("RN --> Renames a file or folder",'blue','bold')
                            if user_message=='RN': path=input_select_path(message='Please select the file or folder to be renamed')
                            else:                  path=_autocomplete_lss_name(user_message,command_name='RN')

                            print('Renaming %s'%fansi(get_file_name(path),'green','bold'))
                            print('Please input the new name of the %s'%('file' if is_a_file(path) else 'folder'))
                            new_name=input_default(fansi(' > ','blue','bold'),get_file_name(path))
                                
                            user_message='__import__("rp").rename_path('+repr(path)+','+repr(new_name)+')# '+path

                            fansi_print("Renaming %s %s to %s: "%(('folder' if is_a_folder(path) else 'file'),path,new_name),'blue','bold')

                        elif user_message.startswith('RM ') or user_message=='RM':
                            fansi_print("RM --> Deletes a file or folder (actually, tries to move it to the trash bin if possible)",'blue','bold')
                            path=input_select_path()
                            # if user_message=='RM':
                            #     path=input_select_file()
                            # else:
                            #     path=user_message[len('RM '):]
                            user_message='__import__("rp").delete_path('+repr(path)+')# '+path
                            if is_a_folder(path) and not is_empty_folder(path):
                                if not input_yes_no(fansi("Warning: You selected a non-empty folder. Are you sure you want to delete it?",'red','underlined')):
                                    user_message=""#Cancelled

                            if user_message:
                                fansi_print("Deleting %s: "%('folder' if is_a_folder(path) else 'file')+repr(get_absolute_path(path)),'blue','bold')
                                
                        elif user_message.startswith('MV'):
                            fansi_print("MV --> Moves a file or folder to a folder",'blue','bold')
                            user_message= repr(_mv())
                                
                        elif user_message.startswith("PIP "):
                            fansi_print("PIP --> Equivalent to %pip in IPython ",'blue','bold')
                            command = sys.executable + ' -m pip '+user_message[len('PIP '):]
                            if user_message=='PIP freeze':
                                user_message = "__import__('rp').shell_command(" + repr(command) + ")"
                                _need_module_refresh=True
                            else:
                                user_message = "__import__('os').system(" + repr(command) + ");"
                                _need_module_refresh=True
                            fansi_print("Transformed command into: " + user_message,'magenta')

                        elif starts_with_any(user_message, 'PY ', 'APY ', 'PYM ', 'APYM ', 'PU ') or user_message in 'PY APY PYM APYM'.split():
                            if user_message.startswith('PU '):
                                fansi_print("PU --> Pudb debugger",'blue','bold')
                                user_message='PYM pudb '+user_message[len('PU '):]

                            #Extract the arguments
                            if user_message.startswith('PYM'):
                                command=user_message[len('PYM'):]
                                fansi_print("PYM --> Runs python -m command using sys.executable",'blue','bold')
                            elif user_message.startswith('APYM'):
                                command=user_message[len('APYM'):]
                                fansi_print("APYM --> Runs python -m command using sys.executable and returns resulting stdout as ans",'blue','bold')
                            elif user_message.startswith('PY'):
                                command=user_message[len('PY'):]
                                fansi_print("PY --> Runs python command using sys.executable",'blue','bold')
                            elif user_message.startswith('APY'):
                                command=user_message[len('APY'):]
                                fansi_print("APY --> Runs python command using sys.executable and returns resulting stdout as ans",'blue','bold')
                            else:
                                assert False, 'impossible'

                            command=command.strip()

                            #If args left blank start dialog
                            if not command:
                                command = input("Args: ")

                            #Maybe add -m
                            if starts_with_any(user_message, 'PYM', 'APYM'):
                                command = sys.executable +' -m '+command 
                            elif starts_with_any(user_message, 'PY', 'APY'):
                                command = sys.executable +' '+command 
                            else:
                                assert False, 'impossible'

                            #Use shell_command or os.system
                            if starts_with_any(user_message, 'APY', 'APYM'):
                                user_message = '__import__("rp").shell_command(' + repr(command) + ')'
                            elif starts_with_any(user_message, 'PY', 'PYM'):
                                user_message = '__import__("os").system(' + repr(command) + ');'
                            else:
                                assert False, 'impossible'

                            fansi_print("Transformed command into: " + user_message,'magenta')

                        elif user_message.startswith("TAKE ") or user_message =='TAKE' or user_message=='MKDIR' or user_message.startswith('MKDIR '):
                            make=user_message.startswith('MKDIR')
                            take=not make
                            if make:
                                fansi_print("MKDIR --> Makes a directory",'blue','bold')
                            elif take:
                                fansi_print("TAKE --> MKDIR then CD --> Makes a directory then cd's into it, inspired from zsh:",'blue','bold')

                            if user_message=='TAKE' or user_message=='MKDIR':
                                path=input(fansi("Enter the name of the new directory: ",'blue','bold'))
                                if path=='':
                                    fansi_print("(Given a blank input --> cancelled)",'blue','bold')
                                    user_message=''
                                    continue
                                else:
                                    user_message+=' '+path


                            if take:
                                new_dir=user_message[len('TAKE '):]
                            elif make:
                                new_dir=user_message[len('MKDIR '):]

                            make_directory(new_dir)

                            if take:
                                if directory_exists(new_dir):
                                    user_message='import sys,os;os.chdir('+repr(new_dir)+');sys.path.append(os.getcwd())# '+user_message
                                else:
                                    user_message='import sys,os;os.mkdir('+repr(new_dir)+');os.chdir('+repr(new_dir)+');sys.path.append(os.getcwd())# '+user_message
                                fansi_print("TAKE --> Current directory = "+_fansi_highlight_path(get_absolute_path(new_dir)),'blue')
                            elif make:
                                fansi_print("MKDIR --> Created new directory: "+new_dir,'blue')
                                # user_message="__import__('os').mkdir(%s)"%repr(new_dir)
                                user_message=''

                        elif user_message in {'CDH', 'CDH FAST', "CDHQ FAST", "CDH GIT"} :
                            if user_message=='CDH GIT':
                                fansi_print("CDH GIT --> CD History Git Repos --> Please select a git repo to cd into!",'blue','bold')
                            else:
                                fansi_print("CDH --> CD History --> Please select an entry to cd into!",'blue','bold')
                            hist=_get_cd_history()
                            fast=user_message=='CDH FAST' or fansi_is_disabled()
                            if not hist:
                                fansi_print("    CDH: There are no history entries. Try going somwhere else; for example with 'CD ..'",'red')
                                user_message=''
                            else:
                                slow_stringify = lambda x: fansi(
                                    x,
                                    "blue"
                                    if _cdh_folder_is_protected(x)
                                    else "yellow"
                                    if folder_exists(x)
                                    else "red",
                                    "bold" if x in sys.path else None,
                                )
                                fast_stringify = lambda x: fansi(
                                    x,
                                    "blue"
                                    if _cdh_folder_is_protected(x)
                                    # else "yellow"
                                    # if folder_exists(x)
                                    else "gray",
                                    "bold" if x in sys.path else None,
                                )
                                if fast:
                                    def precache_all():
                                        #Make it so we can use CDH later...maybe make this funciton disableable in the future just in case...
                                        par_map(folder_exists,hist)
                                        # for x in hist:
                                        #     folder_exists(x) #The harddrive will usually cache this somehow idk...it makes it faster the second time around
                                    run_as_new_thread(precache_all)

                                        
                                # stringify=identity if fast else slow_stringify
                                stringify=fast_stringify if fast else slow_stringify
                                import sys
                                hist_options=hist[::-1]
                                if user_message=='CDH GIT':
                                    hist_options=[is_a_git_repo(x,use_cache=True) for x in hist_options]
                                    hist_options=[x for x in hist_options if x]
                                    hist_options=get_paths_parents(hist_options)
                                    hist_options=list(unique(hist_options))
                                if user_message=='CDHQ FAST':
                                    new_dir = _iterfzf_with_ls_preview(hist_options, exact=True)
                                else:
                                    new_dir = input_select(
                                        "Please choose a directory",
                                        hist_options,
                                        stringify=stringify,
                                        reverse=True,
                                    )
                                user_message='import sys,os;os.chdir('+repr(new_dir)+');sys.path.append(os.getcwd())# '+user_message

                                #The next two lines are duplicated code from the below 'CD' section!
                                if get_absolute_path(new_dir)!=get_absolute_path(get_current_directory()):
                                    from rp.rp_ptpython.completer import get_all_importable_module_names
                                    _cd_history.append(get_current_directory())

                                print(fansi("CDH: You chose:",'blue','bold'),fansi(new_dir,'yellow','bold'))
                        elif user_message=='CDH CLEAN':
                            if input_yes_no("Are you sure you want to clean CDH? This will permanently remove all red options (paths in your CD History that no longer exist)"):
                                _clean_cd_history()
                            user_message=''

                        elif (
                            not "\n" in user_message
                            and (
                                user_message == "CD"
                                or user_message.startswith("CD ")
                                or user_message.startswith("CDU ")
                                or user_message.startswith("CDH ")
                                or user_message.startswith("CDM ")
                                or user_message == "CDP"
                                or not is_valid_python_syntax(user_message) and folder_exists(rp.os.path.expanduser(user_message))
                            )
                            or user_message == "CDB"
                            or user_message == "CDU"
                            or user_message == "CDA"
                            or user_message in "CDZ"
                            or user_message == "CDQ"
                            or user_message.replace('CDB','')=='' and user_message
                        ):
                            if not is_valid_python_syntax(user_message) and folder_exists(rp.os.path.expanduser(user_message)):
                                #Pasting a folder path and entering CD's to it
                                user_message = "CD "+user_message

                            if user_message.startswith('CDM '):
                                module_name = user_message[len("CDM "):].strip()
                                try:
                                    module_path = get_module_path_from_name(module_name)

                                except Exception:
                                    fuzzy_module_name = _ric_current_candidate_fuzzy_matches(module_name)
                                    if fuzzy_module_name is not None:
                                        fansi_print('CDM: Completed module to '+repr(fuzzy_module_name),'blue')
                                        module_name = fuzzy_module_name

                                    module_path = get_module_path_from_name(module_name)
                                
                                if is_a_file(module_path):
                                    module_path = get_parent_folder(module_path)
                                user_message = 'CD '+module_path

                            if user_message.startswith('CD ') and not user_message in ['CD .','CD ..']:
                                #Do fuzzy searching
                                old_cd_path = user_message[len('CD '):]
                                new_cd_path = _pterm_fuzzy_cd(old_cd_path)
                                user_message = 'CD '+new_cd_path

                            if user_message.startswith('CDH '):
                                #Do fuzzy searching
                                old_cd_path = user_message[len('CDH '):]
                                new_cd_path = _cdh_back_query(old_cd_path)
                                user_message = 'CD '+new_cd_path
                                
                            if user_message=='CDU':
                                fansi_print("CDU (aka CD Up) is an alias for 'CD ..'",'blue')
                                user_message='CD ..'
                            if user_message.startswith('CDU '):
                                up_folder_name=user_message[len('CDU '):]
                                pwd=get_current_directory()
                                split_pwd=path_split(pwd)

                                if up_folder_name not in split_pwd:
                                    #Fuzzy search it, give it a best shot
                                    for up_candidate in split_pwd[::-1][1:]:
                                        if fuzzy_string_match(up_folder_name,up_candidate,case_sensitive=False):
                                            up_folder_name=up_candidate
                                            break

                                if not up_folder_name in split_pwd:
                                    fansi_print("CDU: Please choose a valid parent folder name (i.e. CDU %s)"%random_element(split_pwd),'blue','bold')
                                else:
                                    for _ in range(split_pwd[::-1].index(up_folder_name)):
                                        pwd=get_parent_directory(pwd)
                                    _pterm_cd(pwd)
                                continue 
                                    
                                    
                            if user_message=='CDA':
                                new_dir=str(get_ans())
                                new_dir=get_absolute_path(new_dir)
                                if is_a_file(new_dir):
                                    new_dir=get_parent_directory(new_dir)
                                if is_a_module(get_ans()):
                                    new_dir=get_module_path(get_ans())
                                elif not isinstance(get_ans(),str):
                                    try:
                                        new_dir=get_source_file(get_ans())
                                    except Exception:
                                        pass
                                if is_a_file(new_dir):
                                    new_dir=get_parent_directory(new_dir)
                                fansi_print("CDA (aka CD ans) is basically 'CD str(ans)' (or CD get_module_path(ans))",'blue')
                                if not directory_exists(new_dir):
                                    fansi_print("CDA (aka CD ans) aborted: ans is not a valid directory!",'red','underlined')
                                    cancel=True
                                    continue
                                else:
                                    user_message='CD '+new_dir
                            if user_message=='CDZ' or user_message=='CDQ':
                                if user_message=='CDQ':
                                    fansi_print("CDQ (aka CD Query) --> Letting you -search for a directory",'blue')
                                else:
                                    fansi_print("CDZ (aka CD FZF) --> Letting you fuzzy-search for a directory",'blue')
                                try:
                                    if not get_subfolders('.'):
                                        fansi_print('Cannot use CDQ or CDZ because there are no folders in this directory to CD into','blue','bold')
                                        assert False
                                    # Use fast scandir-based iterator for O(1) directory detection via inodes
                                    result=_iterfzf_with_ls_preview((line.replace('\n',' ').replace('\r',' ') for line in _recursively_iter_subdirs('.')),exact=user_message=='CDQ')
                                except:
                                    result=None
                                if not result:
                                    fansi_print("CDZ (aka CD FZF) aborted: you didn't select a folder",'red','underlined')
                                    cancel=True
                                    continue
                                else:
                                    user_message='CD '+result
                            cancel=False
                            if user_message=='CDP':
                                new_dir=string_from_clipboard()
                                fansi_print("CDP --> CD Paste (CD to the string in your clipboard, aka "+repr(new_dir)+')','blue')
                                if not path_exists(new_dir):
                                    fansi_print("CDP (aka CD PASTE) aborted: Path Directory %s doesn't exist"%repr(new_dir),'red','underlined')
                                    continue
                            #This was disabled because I was too lazy to finish it properly. But it would be nice to implement this in the future
                            elif user_message.startswith('CDB'):
                                while user_message.startswith('CDB'):
                                    user_message=user_message[len('CDB'):]

                                    #Means CD Back
                                    if _get_pterm_verbose(): fansi_print("CDB --> CD Back (CD to the previous directory in your history)",'blue',)
                                    #fansi_print("    Old Directory: "+get_current_directory(),'blue')
                                    # if _cd_history:
                                    #     _cd_history.pop()
                                    cdh=_get_cd_history()
                                    if _cd_history:
                                        new_dir=_cd_history[-1]
                                        _cd_history.pop()
                                    elif len(cdh)>=2:
                                        if _get_pterm_verbose():     fansi_print("    (Empty CD history for this session; going to previous CDH directory)",'blue')
                                        new_dir=cdh[-2]
                                    else:
                                        fansi_print("    (Cannot CDB because the CD history is empty)",'red')
                                        cancel=True
                                    did_cdb=True
                            else:
                                new_dir=user_message[2:].strip()
                            if cancel:
                                user_message=''
                            else:
                                if not new_dir:
                                    try:
                                        fansi_print("CD --> No directory was specified, please choose one!",'blue')
                                        new_dir=input_select_folder()
                                    except:
                                        fansi_print("CD --> Error: Please specify a directory. Example: 'CD /Users/Ryan'"+new_dir,'red')
                                if new_dir:
                                    _pwd=get_current_directory()
                                    # import posix
                                    # _pwd=posix.getcwd()
                                    if new_dir.startswith('~'):
                                        new_dir=get_absolute_path(new_dir)
                                    set_current_directory(new_dir)
                                    fansi_print("CD --> Current directory = "+(_fansi_highlight_path(get_current_directory())),'blue','bold')
                                    set_current_directory(_pwd)
                                    if user_message!='CDB':
                                        if get_absolute_path(new_dir)!=get_absolute_path(get_current_directory()):
                                            _cd_history.append(get_current_directory())

                                new_dir=get_absolute_path(new_dir)
                                user_message='import sys,os;os.chdir('+repr(new_dir)+');sys.path.append(os.getcwd())# '+user_message
                            # fansi_print("Transformed command into:\n" + fansi_syntax_highlighting(user_message),'magenta')
                        elif user_message == 'EDIT' or re.fullmatch(r'EDIT[0-9]+',user_message):
                            # pip install python-editor
                            start=''
                            give_up=False
                            if re.fullmatch(r'EDIT[0-9]+',user_message):
                                n=int(user_message[4:])
                                fansi_print("EDIT"+str(n)+" --> Editing your "+str(n)+'th last entry:','blue','underlined')
                                try:
                                    start=all_command_history[-(n+1)]
                                except IndexError:
                                    if not all_command_history:
                                        fansi_print("EDIT"+str(n)+" --> Error: Can't go back into ALLHISTORY, you haven't entered any commands yet.",'red','underlined')
                                    else:
                                        fansi_print("EDIT"+str(n)+" --> Error: Can't go back that far into ALLHISTORY; try a lower value of n",'red','underlined')
                                    give_up=True
                            if not give_up:
                                fansi_print("EDIT --> Replacing EDIT with your custom text, shown below in yellow:",'blue','underlined')
                                try:
                                    editor=pip_import('editor')
                                    temp_file=temporary_file_path()
                                    string_to_text_file(temp_file,start)
                                    vim(temp_file)
                                    user_message=text_file_to_string(temp_file)
                                    delete_file(temp_file)
                                    # user_message=editor.edit(contents=start,use_tty=True,suffix='.py').decode()
                                except ImportError:
                                    user_message=mini_editor(start,list(scope()))
                                fansi_print(user_message,'yellow')
                            else:
                                user_message=''

                        elif user_message.startswith('await ') and not '\n' in user_message:
                            user_message='__import__("rp").run_until_complete('+user_message[len('await '):]+')'
                            # user_message='from asyncio import get_event_loop\nans=get_event_loop().run_until_complete('+user_message[len('await '):]+')'
                            fansi_print("Transformed command into:\n" + fansi_syntax_highlighting(user_message),'magenta')

                        elif user_message.startswith('ARG '):
                            import sys,shlex
                            args_string=user_message[len('ARG '):]
                            fansi_print('ARG: Old sys.argv: '+repr(sys.argv),'blue','bold')
                            sys.argv=command=[sys.argv[0]]+shlex.split(args_string)
                            fansi_print('ARG: New sys.argv: '+repr(sys.argv),'blue','bold')
                            user_message='sys.argv='+repr(sys.argv)

                        elif user_message=='ARG':
                            
                            fansi_print('ARG (dislpaying current ARG value):','blue','bold')
                            import sys
                            user_message='ans = '+repr(' '.join(sys.argv[1:]))


                        elif user_message.startswith('OPEN ') or user_message=='OPEN' or user_message=='OPENH' or user_message=='OPENA':
                            if user_message=='OPENH':
                                fansi_print("OPENH --> OPEN Here --> OPEN .",'blue','bold')
                                user_message='OPEN .'
                            if user_message=='OPENA':
                                fansi_print("OPENA --> OPEN ans --> OPENs the path or URL specified by ans",'blue','bold')
                                if isinstance(get_ans(),str):
                                    if '\n' in get_ans():
                                        set_ans(get_ans().splitlines())
                                if isinstance(get_ans(),str):
                                    path=str(get_ans())
                                    if not path_exists(path) and not is_valid_url(path):
                                        fansi_print("    (Error: path %s does not exist)"%repr(path[:1000]),'red','bold')
                                        continue
                                    user_message='OPEN '+path
                                else:
                                    user_message = "for file in ans: squelch_call(open_file_with_default_application, file)"

                            if user_message == 'OPEN':
                                print("Please select the file or folder you would like to open")
                                file_path=input_select_path()
                            else:
                                file_path=user_message[len('OPEN '):]

                            if path_exists(file_path):
                                user_message='open_file_with_default_application('+repr(file_path)+')'
                                user_message='__import__("rp").'+user_message
                            elif is_valid_url(file_path):
                                user_message='__import__("rp").open_url_in_web_browser('+repr(file_path)+')'
                                
                            fansi_print("Transformed command into:\n" + fansi_syntax_highlighting(user_message),'magenta')
                

                        # endregion
                        # region Modifier
                        if use_modifier and modifier is not None:
                            try:
                                new_message=modifier(user_message)
                                original_user_message=user_message
                                user_message=new_message
                            except Exception as E:
                                original_user_message=None
                                fansi_print("ERROR: Failed to modify your command. Attempting to execute it without modifying it.","red","bold")
                        # endregion
                        # region Lazy-Parsers:Try to parse things like 'rinsp ans' into 'rinsp(ans)' and '+7' into 'ans+7'
                        # from r import space_split
                        current_var=rp.rp_ptpython.r_iterm_comm.last_assignable_comm
                        # if user_message in '/ // /// //// /////'.split():
                        #     user_message=user_message.replace('/','?')
                        #     fansi_print("Transformed command into " + repr(user_message),'magenta')
                        if not '\n' in user_message and user_message.startswith('..'):
                            user_message='ans[' + repr(user_message[len('..'):])+']'
                            fansi_print("Transformed command into " + repr(user_message),'magenta')
                        elif not '\n' in user_message and user_message.startswith('.') and len(user_message)>1 and user_message[1].isalpha():
                            user_message='ans'+user_message
                            fansi_print("Transformed command into " + repr(user_message),'magenta')
                        elif not '\n' in user_message and user_message.startswith('[') and not user_message.strip().endswith(']') and hasattr(get_ans(),'__getitem__') and not is_valid_python_syntax(user_message):
                            # Input '[0' would be a syntax error. turn it into 'ans[0]'. Only do this if ans is indexable though.
                            user_message='ans'+user_message+']'
                            fansi_print("Transformed command into " + repr(user_message),'magenta')
                        elif not '\n' in user_message and user_message.startswith('(') and not user_message.strip().endswith(')') and callable(get_ans()):
                            # Input '(0' would be a syntax error. turn it into 'ans(0)'. Only do this if ans is callable though.
                            user_message='ans'+user_message+')'
                            fansi_print("Transformed command into " + repr(user_message),'magenta')
                        elif current_var is not None and user_message in ['+','-','*','/','%','//','**','&','|','^','>>','<<']+['and','or','not','==','!=','>=','<=']+['>','<','~']:
                            user_message='ans ' + user_message +' ' + current_var
                            fansi_print("Transformed command into " + repr(user_message),'magenta')
                        else:
                            def maybe_inject_ans_into_environ():
                                if rp.contains_any(user_message, '$ans', '${ans}'):
                                    fansi_print("Injecting str(ans) --> $ans", 'blue italic')
                                    import os
                                    os.environ['ans']=str(get_ans())
                            if user_message.startswith("!!"):# For shell commands
                                maybe_inject_ans_into_environ()
                                user_message="ans=__import__('rp').shell_command("+repr(user_message[2:])+")"
                                # fansi_print("Transformed command into " + repr(user_message),'magenta')
                            elif user_message.startswith("!"):# For shell commands
                                maybe_inject_ans_into_environ()
                                # user_message="from rp import shell_command;ans=shell_command("+repr(user_message[1:])+",True)"#Disabled because we no longer guarentee that rp is imported
                                user_message="import os;os.system("+repr(user_message[1:])+")"
                                # fansi_print("Transformed command into " + repr(user_message) ,'magenta')
                            if True and len(user_message.split("\n")) == 1 and not enable_ptpython:  # If we only have 1 line: no pasting BUT ONLY USE THIS IF WE DONT HAVE ptpython because sometimes this code is a bit glitchy and its unnessecary if we have ptpython
                                _thing=space_split(user_message)
                                if len(_thing) > 1:
                                    # from r import is_literal
                                    bracketeers=None
                                    try:
                                        if hasattr(eval(_thing[0]),'__getitem__'):
                                            bracketeers="[]"
                                    except:
                                        pass
                                    try:
                                        if callable(eval(_thing[0])):
                                            bracketeers="()"
                                    except Exception:pass
                                            
                                    if bracketeers is not None: 
                                        flaggy=False
                                        if all(map(is_literal,_thing)):  # If there are no ';' or ',' in the arguments; just 'rinsp' or 'ans' etc
                                            user_message=_thing[0] + bracketeers[0] + ','.join(_thing[1:]) + bracketeers[1]
                                            flaggy=True
                                        elif is_literal(_thing[0]):
                                            user_message=_thing[0] + bracketeers[0] + " " + repr(user_message[len(_thing[0]):]) + bracketeers[1]
                                            flaggy=True
                                        if flaggy:
                                            fansi_print("Transformed command into " + repr(user_message),'magenta')
                            if user_message.lstrip():
                                try:
                                    float(user_message)  # could be a negative number; we dont want Transformed command into 'ans -1324789'
                                except:
                                    arg_0=user_message.lstrip()
                                    if arg_0=='=' or last_assignable and (arg_0[0] == '=' and arg_0[1] != "=" or arg_0[0:2] in ['+=','-=','*=','/=','&=','|=','^=','%='] or arg_0[:3] in ['//=','**=','<<=','>>=']):
                                        if not last_assignable in assignable_history:
                                            assignable_history[last_assignable]=[]
                                        else:
                                            assignable_history[last_assignable].append(eval(last_assignable,scope()))
                                        user_message=last_assignable + user_message
                                        fansi_print("Transformed command into " + repr(user_message),'magenta')
                                    elif arg_0[0] in '.+/*^=><&|' or space_split(user_message.lstrip().rstrip())[0] in ['and','or','is']:
                                        if not user_message.startswith('.'):#This is a fix for: We don't want '.01+1' --> 'ans .01+1'
                                            #intentionally excluding '-' from this, as we want to be able to say -value 
                                            user_message='ans ' + user_message
                                            fansi_print("Transformed command into " + repr(user_message),'magenta')
                            if user_message.rstrip().endswith("="):
                                user_message=user_message + ' ans'
                                fansi_print("Transformed command into " + repr(user_message),'magenta')
                            # from r import is_namespaceable
                            if True and (user_message.replace("\n","").lstrip().rstrip() and not '\n' in user_message and (("=" in user_message.replace("==","") and not any(x in user_message for x in ["def ",'+=','-=','*=','/=','&=','|=','^=','%='] + ['//=','**=','<<=','>>='])) or is_namespaceable(''.join(set(user_message) - set(",.:[] \\t1234567890"))))):  # Doesn't support tuple unpacking because it might confuse it with function calls. I.E. f(x,y)=z looks like (f,x)=y to it
                                last_assignable_candidate=user_message.split("=")[0].lstrip().rstrip()
                                if last_assignable_candidate.startswith("import "):
                                    last_assignable_candidate=last_assignable_candidate[7:]
                            else:
                                pass
                        # endregion
                        while user_message:  # Try to correct any errors we might find in their code that may be caused mistakes made in the pseudo_terminal environment
                            # region Try to evaluate/execute user_message
                            if last_assignable_candidate not in ['',None,'ans']:
                                last_assignable=last_assignable_candidate
                                import rp.rp_ptpython.r_iterm_comm
                                last_assignable=regex_replace(last_assignable,'from .* import ','')#remove the 'from x import y' from that name, it's gibberish
                                last_assignable=itc(lambda x:regex_replace(x,r'\w+\s+as\s+(\w+)',r'\1'),last_assignable)#a as b --> b; and "a as b,c as d"-->b,d
                                rp.rp_ptpython.r_iterm_comm.last_assignable_comm=last_assignable
                            try:
                                scope_before=set(scope())
                                take_snapshot()# Taken BEFORE modifications to save current state!
                                _temp_old_ans=get_ans()
                                all_command_history.append(user_message)
                                if _reload:
                                    try:
                                        _reload_modules()
                                    except _PseudoTerminalReturnException:
                                        raise
                                    except BaseException as E:
                                        fansi_print('RELOAD Error: Failed to reload modules because: '+str(E),'blue')
                                result,__error=_pterm_exeval(user_message,
                                                      *dicts,
                                                      exec=exec,
                                                      eval=eval,
                                                      tictoc=_tictoc,
                                                      profile=_profiler,
                                                      line_profile=_line_profiler,
                                                      ipython=_use_ipython_exeval)
                                if __error is not None:
                                    if isinstance(__error,IndentationError):
                                        raise __error#Catch this by the indendation fixer
                                    show_error(__error)
                                if get_ans() is not _temp_old_ans:#This is here so we can say ' >>> ans=234' and still rely on PREV
                                    set_ans(get_ans())
                                del _temp_old_ans#
                                # raise KeyboardInterrupt()
                                if __error is None and result is None:
                                    add_to_successful_command_history(user_message)
                                elif __error is None:
                                    dupdate(dicts[0],'ans')
                                    dupdate(dicts[0],'ans')
                                    if should_print_ans and fansi_is_enabled():
                                        from time import time
                                        pip_import('dill')#Don't count the import time
                                        start_time=time()
                                        save_history=not equal(result,dicts[0]['ans'])
                                        end_time=time()
                                        delta_time=end_time-start_time
                                        if delta_time>.25:
                                            if not warned_about_ans_print_on:
                                                fansi_print("pseudo_terminal took "+str(delta_time)[:5]+" seconds to display 'ans', which can happen when ans is a very large object in memory (and thus takes a long time to compare to the previous value of ans). If your prompts are lagging, this is probably why. You can fix this by using 'ANS PRINT FAST' (aka 'APFA'), 'ANS PRINT OFF' (aka 'APOF'), or 'FANSI OFF'. This message will only show once.",'blue','bold')
                                                warned_about_ans_print_on=True
                                    else:
                                        #Generally, the reason we turn should_print_ans off with ANS PRINT OFF, is because printing 'ans' is slow. When this is the case, we probably also don't want to wait to check if the current ans is the same as the previous ans: str(x) is generally slow iff object_to_bytes(x) is also slow, which is what handy_hash falls on. In other words, the equal() function used above is slow when we have a list of big images, for example; which is also when we would want to turn ANS PRINT OFF.
                                        save_history=result is not dicts[0]['ans']
                                    set_ans(result,save_history=save_history,snapshot=False)# snapshot=False beacause we've already taken a snapshot! Only saves history if ans changed, though. If it didn't, you'll see yellow text instead of green text
                                    if user_message.lstrip().rstrip()!='ans':# Don't record 'ans=ans'; that's useless. Thus, we can view 'ans' without clogging up successful_command_history
                                        add_to_successful_command_history("ans="+user_message)# ans_history is only changed if there is a change to ans, but command history is always updated UNLESS user_message=='ans' (having "ans=ans" isn't useful to have in history)
                                _user_created_var_names=_user_created_var_names|(set(scope())-scope_before)
                                break
                            # endregion
                            # region  Try to fix user_input, or not use modifier etc
                            except IndentationError as E:
                                if _get_prompt_style() in user_message:  # They probably just copied and pasted one of their previous commands from the console. If they did that it would contain the header which would cause an error. So, we delete the header.
                                    print(type(E))
                                    fansi_print("That command caused an error, but it contained '" + _get_prompt_style() + "' without quotes. Running your command without any '" + _get_prompt_style() + "'_s, shown below in magenta:","red","bold")
                                    user_message=user_message.replace(_get_prompt_style(),"")  # If we get an error here, try getting rid of the headers and then try again via continue...
                                    fansi_print(user_message,"magenta")
                                elif user_message.lstrip() != user_message:  # If our string is only one line long, try removing the beginning whitespaces...
                                    fansi_print("That command caused an error, but it contained whitespace in the beginning. Running your command without whitespace in the beginning, shown below in magenta:","red","bold")
                                    def number_of_leading_spaces(string):#strip spaces from every line...
                                        i=0
                                        for x in string:
                                            if not x.strip():
                                                i+=1
                                            else:
                                                break
                                        return i
                                    old_user_message=user_message
                                    user_messsage=strip_blank_lines(user_message)
                                    _nls=number_of_leading_spaces(user_message)
                                    user_message=user_message.split('\n')
                                    for i,user_message_line in enumerate(user_message):
                                        user_message[i]=user_message_line[min(_nls,number_of_leading_spaces(user_message_line)):]
                                    user_message='\n'.join(user_message) # If we get an error here, try getting rid of the headers and then try again via continue...
                                    user_message=user_message.lstrip()
                                    if user_message==old_user_message:
                                        assert "Failed to fix indentation error...aborting your command..."
                                    fansi_print(user_message,"magenta")
                                else:
                                    raise  # We failed to fix the indentation error. We can't fix anything, so return the error and effectively break the while loop.
                            except:
                                if use_modifier and modifier is not None and original_user_message is not None:# If we're using the modifier and we get a syntax error, perhaps it'_s because the user tried to input a regular command! Let them do that, meaning they have to use the 'MOD ON' and 'MOD OFF' keywords less than they did before.
                                    fansi_print("That command caused an error, but it might have been because of the modifier. Trying to run the original command (without the modifier) shown below in magenta:","red","bold")
                                    # noinspection PyUnboundLocalVariable
                                    fansi_print(user_message,"magenta")
                                    user_message=original_user_message # ⟵ We needn't original_user_message=None. This will literally never happen when use_modifier==True
                                    original_user_message=None# We turn original_user_message to None so that we don't get an infinite loop if we get a syntax error with use_modifier==True.
                                else:
                                    raise
                            # endregion
                    rp.rp_ptpython.r_iterm_comm.globa=scope()
                    
                    #Add the current directory to the cd history if its changed
                    try:
                        current_pwd=get_current_directory()
                        if not pwd_history or pwd_history[-1]!=current_pwd:
                            pwd_history.append(current_pwd)
                    except FileNotFoundError:
                        #When the folder we're working in is deleted, get_current_directory throws an error
                        #This is ok, just ignore it.
                        pass

                    if did_cdb and len(_cd_history):
                        _cd_history.pop()
                        
                except Exception as E:
                    show_error(E)
                except KeyboardInterrupt:
                    print(fansi('Caught keyboard interrupt','cyan','bold'),end='')
                    if allow_keyboard_interrupt_return:
                        print(fansi(': Interrupt again to RETURN','cyan','bold'),end='')
                    print()
        except _PseudoTerminalReturnException:
            raise
        except BaseException as E:
            print(fansi('FATAL ERROR: Something went very, very wrong. Printing HISTORY so you can recover!','red','bold'))
            print_stack_trace(E)
            print_history()
    except _PseudoTerminalReturnException:
        try:
            if get_ans() is None:
                fansi_print("rp.pseudo_terminal(): Exiting session. No value returned.",'blue','bold')
            else:
                # fansi_print("rp.pseudo_terminal(): Exiting session. Returning ans = " + str(get_ans()),'blue','bold')
                fansi_print("rp.pseudo_terminal(): Exiting session. Returning ans",'blue','bold')
            return on_return(get_ans())
        except Exception as e:
            print_verbose_stack_trace(e)
            fansi_print("rp.pseudo_terminal(): Exiting session. Failed to call get_ans() (this is a strange, rare error). Returning ans = None",'blue','bold')
            return on_return(None)#Sometimes, calling get_ans() fails


    finally:
        rp.rp_ptpython.r_iterm_comm.pseudo_terminal_level-=1
        if level_label():
            fansi_print("    - Exiting pseudo-terminal at "+level_label(),'blue' ,'bold')

# @formatter:off
def set_process_title(title):
    return pip_import('setproctitle').setproctitle(title)

def get_process_title():
    try:
        import setproctitle
        return pip_import('setproctitle').getproctitle()
    except Exception:
        return pip_import('psutil').Process().name()
#@formatter:on


def parenthesizer_automator(string:str):

    if string!=strip_ansi_escapes(string):
        #String is a colorized terminal string. Handle it properly.
        #EXAMPLE:
        #   print(parenthesizer_automator(  '[[[' + fansi("Hello","red") + "]]]" ))
        plain_string=strip_ansi_escapes(string)
        output_lines=parenthesizer_automator(plain_string).splitlines()
        output_lines[len(output_lines)//2]=string
        return line_join(output_lines)


    def _parenthesizer_automator(x:str):
        # Parenthesis automator for python
        #For best results, x should be one line.
        l=lambda q:''.join('(' if x in '([{' else ')' if x in ')]}' else ' ' for x in q)
        def p(x,r=True):
            y=list(l(x))
            if not r and ('(' not in y or ')' not in y):
                return [x]
            n=None
            for i,e in enumerate(y):
                if e == '(':
                    n=i
                elif e == ')':
                    if n is not None:
                        y[i]='>'
                        y[n]='<'
                        n=None
                else:
                    y[i]=' '
            y=''.join(y)
            if r:
                y=p(y,False)
                assert isinstance(y,list)
                y=[x.replace('(','│').replace(')','│').replace('<','┌').replace('>','┐') for x in y]
                z=[x.replace('┌','└').replace('┐','┘') for x in y]
                return '\n'.join(y[::-1] + [x] + z)
            if x==y:
             return [x]#Prevent possible infinite recursion errors
            return [x] + p(y,False)
        return delete_empty_lines(strip_trailing_whitespace(p(x)))
    return '\n'.join(_parenthesizer_automator(line) for line in string.splitlines())
    #I tried and failed to do this without recursion. I wonder what the time complexity of this function is? My failure is below
    # assert not '\n' in line,'Input must be a single line, not multiple lines'
    
    # from rp import string_transpose
    
    # inc='([{'
    # dec=')]}'
    
    # levels=[]
    # i=0
    # for char in line:
    #     if   char in inc:
    #         levels.append(i)
    #         i+=1
    #     elif char in dec:
    #         i-=1
    #         levels.append(i)
    #     else:
    #         levels.append(i)


    # if not levels:
    #     #If there are no parenthesis in the original input, don't change it
    #     return line
    
    # #Make sure that there are no negatives if we have unbalanced parenthesis like '(()))))'        
    # min_level=min(levels)
    # levels=[level-min_level for level in levels]
    
    # #Right now, the inner parenthesis' levels are higher than the outer ones. Flip that around, and make the middle levels small and the outer levels large (so that outer parenthesis are taller than inner parnethesis)
    # max_level=max(levels)
    # levels=[max_level-level for level in levels]
    
    # def render(char:str,level:str)->str:
    #     out=char
    #     if char in inc+dec:
    #         if char in inc:
    #             top='┌'
    #             mid='│'
    #             bot='└'
    #         else:
    #             assert char in dec
    #             top='┐'
    #             mid='│'
    #             bot='┘'
    #         out=top+(level-1)*mid+out+(level-1)*mid+bot
    #     out=out.center(max_level+1+max_level)
    #     return out
    
    # return string_transpose('\n'.join(render(char,level) for char,level in zip(line,levels)))

    # # ORIGINAL, LESS EFFICIENT CODE THAT HAD A FEW PROBLEMS (such as recursion that was too deep)


def timeout(f,t):
    import signal

    class TimeoutException(BaseException):   # Custom exception class
        pass

    def timeout_handler(signum, frame):   # Custom signal handler
        raise TimeoutException

    # Change the behavior of SIGALRM
    signal.signal(signal.SIGALRM, timeout_handler)
    # https://stackoverflow.com/questions/25027122/break-the-function-after-certain-time
    # Start the timer. Once 5 seconds are over, a SIGALRM signal is sent.
    signal.alarm(t)
    # This try/except loop ensures that
    #   you'll catch TimeoutException when it's sent.
    try:
        return f()
    except TimeoutException:
        return "[Timed out]"# continue the for loop if function A takes more than 5 second

def save_animated_png(frames, path=None,*,framerate=None):
    """
    Save a sequence of images as an animated PNG (APNG) file.
    
    Enhanced Documentation:
    Creates animated PNG files from image sequences. APNG provides better quality
    than GIF with full color depth and alpha channel support. Auto-installs
    numpngw dependency and handles frame rate conversion.
    
    Args:
        frames: Sequence of images (list of numpy arrays) 
        path (str, optional): Output file path. Auto-generated if None.
        framerate (float, optional): Frames per second. If None, no delay between frames.
        
    Returns:
        None (saves file to disk)
        
    Examples:
        >>> import numpy as np
        >>> from rp.r import save_animated_png
        >>> # Create simple animated sequence
        >>> frames = []
        >>> for i in range(10):
        ...     frame = np.ones((50, 50, 3), dtype=np.uint8) * (i * 25)
        ...     frames.append(frame)
        >>> save_animated_png(frames, "/tmp/animation.png", framerate=5)
        
    Features:
        - Automatic path generation with unique names if path=None
        - Converts frame rate to millisecond delays for APNG format
        - Auto-converts images to byte format (0-255 range)
        - Creates parent directories automatically
        - Uses numpngw library (pip_import pattern)
        
    File Format:
        - APNG (Animated PNG) - superior to GIF for quality
        - Supports full RGB color depth and alpha transparency
        - Compatible with modern browsers and image viewers
        
    Related Functions:
        - save_video_gif(): Alternative GIF format
        - as_byte_images(): Frame format conversion
        - display_video(): View animated sequences
        
    Aliases:
        - save_video_png(): Same function, different name
        
    Tags: animation, apng, video-export, image-sequence, png
    """
    if path is None:
        path = get_unique_copy_path("video.png")
    path = with_file_extension(path ,'png')
        
    if not framerate:
        delay=None
    else:
        #Delay in millis between frames
        delay=int(1000*(1/framerate))

    pip_import('numpngw') 
    from numpngw import write_apng as save_animated_png#Takes numpy ndarray as input
    #Another option: https://github.com/eight04/pyAPNG

    frames = as_byte_images(frames)

    make_parent_directory(path)
    save_animated_png(path, frames, delay=delay)

    path = get_absolute_path(path)

    return path

save_video_png = save_animated_png

#region Wrappers for psutil
def battery_percentage()->float:
    try:
        import psutil
        return psutil.sensors_battery().percent
    except Exception:
        return 100#Don't crash pseudoterminal just because we don't have psutil installed....it's unnessecary. It's just nice, that's all. Perhaps we're not even on a laptop...so default to 100%.
def battery_plugged_in()->bool:
    try:
        import psutil
        return psutil.sensors_battery().power_plugged
    except Exception:
        return True
def battery_seconds_remaining():
    try:
        import psutil
        return psutil.sensors_battery().secsleft
    except Exception:
        return float('inf')
#endregion


def total_disc_bytes(path):
    """
    path can be either a folder or a file; it will detect that for you. Implemented recursively (checks subfolders)
    returns total size in bytes
    
    Enhanced Documentation:
    
    Parameters:
        path: Path to file or directory to measure (string path)
    
    Returns:
        int: Total size in bytes. For files, returns file size. For directories, 
             returns recursive sum of all files and subdirectories.
    
    Algorithm:
        - For files: Uses os.path.getsize() directly
        - For directories: Recursively traverses all subdirectories and sums file sizes
        - Handles nested directory structures automatically
    
    Usage Patterns:
        - Measure single file: total_disc_bytes('file.txt')
        - Measure directory tree: total_disc_bytes('/path/to/directory')
        - Often used internally by get_file_size() function
    
    Related Functions:
        - get_file_size(): Higher-level function that uses this internally
        - os.path.getsize(): Core OS function for individual file sizes
        - get_folder_size(): Local function used for directory size calculation
    
    Examples:
        >>> total_disc_bytes('document.txt')
        1024
        >>> total_disc_bytes('/home/user/documents')
        15728640
    """

    def get_file_size(path):
        return os.path.getsize(path)
    def get_folder_size(folder):
        # Get the total disc space of a given directory
        # Source: stackoverflow.com/questions/1392413/calculating-a-directory-size-using-python/1392549
        total_size = get_file_size(folder)
        for item in os.listdir(folder):
            itempath = os.path.join(folder, item)
            if os.path.isfile(itempath):
                total_size += get_file_size(itempath)
            elif os.path.isdir(itempath):
                total_size += get_folder_size(itempath)
        return total_size

    if os.path.isfile(path):
        return get_file_size(path)
    elif os.path.isdir(path):
        return get_folder_size(path)
    else:
        assert False,'r.get_disc_space ERROR: '+path+' is neither a folder nor a file!'

def human_readable_file_size(file_size:int,):
    """
    Given a file size in bytes, return a string that represents how large it is in megabytes, gigabytes etc - whatever's easiest to interperet
    EXAMPLES:
         >>> human_readable_file_size(0)
        ans = 0B
         >>> human_readable_file_size(100)
        ans = 100B
         >>> human_readable_file_size(1023)
        ans = 1023B
         >>> human_readable_file_size(1024)
        ans = 1KB
         >>> human_readable_file_size(1025)
        ans = 1.0KB
         >>> human_readable_file_size(1000000)
        ans = 976.6KB
         >>> human_readable_file_size(10000000)
        ans = 9.5MB
         >>> human_readable_file_size(1000000000)
        ans = 953.7MB
         >>> human_readable_file_size(10000000000)
        ans = 9.3GB
    """
    
    for count in 'B KB MB GB TB PB EB ZB YB BB GB'.split():
        #Bytes Kilobytes Megabytes Gigabytes Terrabytes Petabytes Exobytes Zettabytes Yottabytes Brontobytes Geopbytes
        if file_size > -1024.0 and file_size < 1024.0:
            if int(file_size)==file_size:   
                return "%i%s" % (file_size, count)
            else:
                return "%3.1f%s" % (file_size, count)
        file_size /= 1024.0

def string_to_file_size(size_str: str) -> int:
    """
    Converts a human-readable file size string back to the number of bytes,
    handling various units and their common abbreviations (case-insensitive).
    This function also handles numeric words like 'one', 'two', etc.
    
    Inverse of rp.human_readable_file_size

    Parameters:
        size_str (str): The human-readable file size string (e.g., "9.3GB", "one megabyte").

    Returns:
        int: The equivalent file size in bytes as an integer.

    Raises:
        ValueError: If the format of the input string is invalid or if the unit is unknown.

    Examples:
        >>> string_to_file_size("123")                -->   123
        >>> string_to_file_size("byte")               -->   1
        >>> string_to_file_size("1 byte")             -->   1
        >>> string_to_file_size("1b")                 -->   1
        >>> string_to_file_size("1024 bytes")         -->   1024
        >>> string_to_file_size("1 kilobyte")         -->   1024
        >>> string_to_file_size("1 KB")               -->   1024
        >>> string_to_file_size("1kb")                -->   1024
        >>> string_to_file_size("1kib")               -->   1024
        >>> string_to_file_size("1 KiB")              -->   1024
        >>> string_to_file_size("one kilobyte")       -->   1024
        >>> string_to_file_size("ten kilobytes")      -->   10240
        >>> string_to_file_size("1 MB")               -->   1048576
        >>> string_to_file_size("1mb")                -->   1048576
        >>> string_to_file_size("1 mib")              -->   1048576
        >>> string_to_file_size("1.5 MB")             -->   1572864
        >>> string_to_file_size("one megabyte")       -->   1048576
        >>> string_to_file_size("5 gigabytes")        -->   5368709120
        >>> string_to_file_size("2.5 GB")             -->   2684354560
        >>> string_to_file_size("1 terabyte")         -->   1099511627776
        >>> string_to_file_size("1 TB")               -->   1099511627776
        >>> string_to_file_size("1000 tb")            -->   1099511627776000
        >>> string_to_file_size("500 petabytes")      -->   562949953421312000
        >>> string_to_file_size("1 exabyte")          -->   1152921504606846976
        >>> string_to_file_size("1.2 yottabytes")     -->   1407374883553280000
        >>> string_to_file_size("three zettabytes")   -->   3298534883328000000
        >>> string_to_file_size("1kb 500b")           -->   ValueError: Invalid size format
        >>> string_to_file_size("1024 mbsss")         -->   ValueError: Unknown or invalid unit: 'mbsss'

    https://chat.openai.com/share/4b7dc44a-26eb-4520-9ed6-3a7f6f9aaece
    """

    assert isinstance(size_str,str)
    assert len(size_str)>0

    try:
        #If given no units, just take it as bytes...
        return int(size_str)
    except Exception:
        pass

    import re

    _condensed_filesize_units = {
        'b                      byte': 1024**0,
        'k kb kib kilobyte  kibibyte': 1024**1,
        'm mb mib megabyte  mebibyte': 1024**2,
        'g gb gib gigabyte  gibibyte': 1024**3,
        't tb tib terabyte  tebibyte': 1024**4,
        'p pb pib petabyte  pebibyte': 1024**5,
        'e eb eib exabyte   exbibyte': 1024**6,
        'z zb zib zettabyte zebibyte': 1024**7,
        'y yb yib yottabyte yobibyte': 1024**8,
    }

    def postprocess(units_dict):
        """ Expands the condensed dictionary of file size units. """
        expanded_units = {}
        for key, value in units_dict.items():
            units = key.split()
            for unit in units:
                expanded_units[unit] = value
        return expanded_units

    _filesize_units = postprocess(_condensed_filesize_units)
    _filesize_units[''] = 1

    def normalize_unit(unit: str) -> str:
        """
        Normalize the unit string to ensure consistency in dictionary lookup.
        Converts to lowercase and checks for valid units, accepting only valid singular or simple plural forms.
        """
        normalized_unit = unit.lower().rstrip('s')
        if normalized_unit not in _filesize_units:
            raise ValueError("Unknown or invalid unit: "+repr(unit))
        return normalized_unit

    if ' ' in size_str:
        size_str=size_str.split()
        unit=size_str[-1]
        number=' '.join(size_str[:-1])
        number=words_to_number(number)
    else:
        def split_numbers_and_letters(input_str: str):
            """
            Splits a string into a number and letter part.

            Parameters:
                input_str (str): Input string containing numbers followed by letters.

            Returns:
                tuple: (float, str) of the number and letter parts.
                
            Examples:
                >>> split_numbers_and_letters("234.234aa")
                (234.234, 'aa')
                >>> split_numbers_and_letters("1000xyz")
                (1000.0, 'xyz')
            """
            if not input_str[0].isnumeric():
                number_part=1
                letter_part=input_str
            else:
                match = re.match(r'^(\d+(\.\d+)?)([a-zA-Z]+)$', input_str)
                if not match:
                    raise ValueError("Input must start with numbers followed by letters.")
                number_part = float(match.group(1))
                letter_part = match.group(3)
            
            return (number_part, letter_part)
        number,unit=split_numbers_and_letters(size_str)
        
    unit=normalize_unit(unit)
    
    if unit in _filesize_units:
        return int(number * _filesize_units[unit])
    else:
        raise ValueError("Unknown unit: " + unit)



def get_file_size(path:str, human_readable:bool=False):
    """
    Gets the filesize of the given path
    Can also get the size of folders
    If human_readable is True, it will return a string.
    If human_readable is False, it will return an int specifying the number of bytes.
    
    Enhanced Documentation:
    
    Parameters:
        path (str): Path to file or directory to measure
        human_readable (bool, default=False): If True, returns formatted string (e.g., "1.2 MB");
                                            if False, returns size in bytes as integer
    
    Returns:
        int or str: Size in bytes (if human_readable=False) or formatted string (if human_readable=True)
    
    Raises:
        AssertionError: If path does not exist
    
    Usage Patterns:
        - Get exact byte count: get_file_size(path)
        - Get readable format: get_file_size(path, human_readable=True)
        - Works with both files and directories (recursive for directories)
    
    Related Functions:
        - total_disc_bytes(): Core function used internally for size calculation
        - human_readable_file_size(): Converts bytes to human-readable format
        - path_exists(): Checks if path exists
        - get_folder_size: Alias for this same function
        - get_directory_size: Alias for this same function
        - get_path_size: Alias for this same function
    
    Examples:
        >>> get_file_size('document.txt')
        1024
        >>> get_file_size('document.txt', human_readable=True)
        '1.0 KB'
        >>> get_file_size('/path/to/directory', human_readable=True)
        '15.3 MB'
    """
    
    assert path_exists(path),'The path you gave doesnt exist: '+repr(path)

    size=total_disc_bytes(path)

    if not human_readable:
        return size

    return human_readable_file_size(size)

get_path_size=get_folder_size=get_directory_size=get_file_size
    
#def inherit_def(parent,child=None):
#    # Needs examples for documentation.
#    if child is None:
#        return lambda z:inherit_def(parent,z)
#    # Author: Ryan Burgert
#    # This decorator modifies the child in-place without copying it!
#    # Made because I don't like creating classes when it can be avoided; I'd much rather crate new functions.
#    # This is a decorator used to override default method inputs.
#    assert callable(child )
#    assert callable(parent)
#    #
#    import inspect as inspect
#    child_spec =inspect.getfullargspec(child )
#    parent_spec=inspect.getfullargspec(parent)
#    #region  Example of getfullargspec results:
#    #      ⎧                                    ⎫
#    # def f(a, b:str, c=0, d:int=1, *e, f=2, **g):pass
#    #      ⎩                                    ⎭
#    #             ⎧                                                                                                                                                                   ⎫
#    #             ⎪     ⎧                  ⎫                                   ⎧    ⎫             ⎧   ⎫                 ⎧      ⎫              ⎧                                      ⎫⎪
#    #  FullArgSpec(args=['a', 'b', 'c', 'd'], varargs='e', varkw='g', defaults=(0, 1), kwonlyargs=['f'], kwonlydefaults={'f': 2}, annotations={'b': <class 'str'>, 'd': <class 'int'>})
#    #             ⎪     ⎩                  ⎭                                   ⎩    ⎭             ⎩   ⎭                 ⎩      ⎭              ⎩                                      ⎭⎪
#    #             ⎩                                                                                                                                                                   ⎭
#    #endregion
#    child_args            =child_spec .args
#    parent_defaults=parent_spec.defaults or []
#    child_defaults=child_spec.defaults or []
#    undefaulted_child_args=child_args [:len(child_defaults)]
#    parent_args           =parent_spec.args or []
#    defaulted_parent_args =parent_args[-len(parent_defaults):]
#    #
#    parent_defaults=dict(zip(parent_args,parent_defaults))# parent_spec.defaults ≣ parent.__defaults__
#    parent_defaults.update(parent.__kwdefaults__ or {})# Should be no overrides
#    #
#    flag=True
#    for arg,default in reversed(undefaulted_child_args):# All un-defaulted child args
#        if arg in parent_defaults:
#            assert flag,'Error: Please rearrange arguments, this is a vague error message (that could easily be improved) but you cant extend functions like that'
#            # Assertion Explanation:
#            # NOTE: All default args MUST come before undefaulted args, by rules of python syntax.
#            #     def parent(a=5):pass
#            #     def child(z,a,x):pass
#            #   First possiblity for handling this: x is None, and no longer required (which modifies the child)
#            #     def child(z,a=5,x=None):pass
#            #   Second possiblity for handling this: (reordering arguments; could be REALLY hard to debug if misused, which might be easy to do)
#            #     def child(z,x,a=5):pass
#            # This method should use neither method for handling it and instead just throw an error.
#            child.__defaults__=(parent_defaults[arg],)+child.__defaults__
#        else:
#            flag=False
#    from rp import merged_dicts
#    for key in set(parent.__kwdefaults__ or {})-set(child.__defaults__ or {})-set(parent_args)-set(child_args):
#        child.__kwdefaults__=[key]=parent.__kwdefaults__[key]
#    #
#    return child

def num_args(f):# https://stackoverflow.com/questions/847936/how-can-i-find-the-number-of-arguments-of-a-python-function
    from inspect import isfunction, getargspec
    if isfunction(f):
        return len(getargspec(f).args)
    else:
        spec = f.__doc__.split('\n')[0]
        args = spec[spec.find('(')+1:spec.find(')')]
        return args.count(',')+1 if args else 0


def _rich_inspect(x):
    pip_import('rich')
    from rich.console import Console
    import rich

    #CAPTURE doesn't work for rich inspect...string==''
    # console = Console()
    # with console.capture() as capture:
    #     rich.inspect(x,all=True,help=True,methods=True,private=True,dunder=True)
    # string=capture.get()
    # print('LENNNGTHH',len(string))
    # _maybe_display_string_in_pager(string,with_line_numbers=False)
    # print(string)

    rich.inspect(x,all=True,help=True,methods=True,private=True,dunder=True)

def _rich_print(x):
    """
    Enhanced Documentation:
    Internal helper for pretty-printing objects using the Rich library with paging support.
    
    This function uses the Rich Console library to format and colorize complex data structures,
    then displays the output through a pager if it's long enough. It provides enhanced
    formatting for nested dictionaries, lists, and other Python objects.
    
    Parameters:
        x: The object to print (any type - dict, list, object, etc.)
    
    Returns:
        None: Displays formatted output to console and potentially in pager
        
    Example:
        >>> data = {"name": "test", "numbers": [1, 2, 3], "nested": {"key": "value"}}
        >>> _rich_print(data)
        {'name': 'test', 'numbers': [1, 2, 3], 'nested': {'key': 'value'}}
        
    Tags: internal, printing, formatting, rich, pager
    """
    pip_import('rich')
    from rich.console import Console
    console = Console()
    with console.capture() as capture:
        console.print(x)
    string=capture.get()
    _maybe_display_string_in_pager(string,with_line_numbers=True)
    print(string)

def pretty_print(x,with_lines=False):
    """
    Used to print out highly-nested dicts and lists etc, which are hard to read when it's all in one line.
    Particularly useful for JSON objets from web requests.
    """
    if not with_lines and sys.version_info>(3,6):
        try:
            _rich_print(x)
            return
        except Exception as e:
            print_stack_trace(e)
            pass    

    from pprint import pformat
    string=pformat(x)
    def pretty_lines(s):
        s=string_transpose(string_transpose(s))  # Ensure all have same length
        l=s.split('\n')
        h=len(l)
        w=len(l[0])
        for i in range(1,h):
            t=l[i - 1]  # top
            b=l[i]  # bottom
            for j in range(w):
                c=''
                if t[j] in '({[' + '│├└':
                    if not b[:j + 1].lstrip():
                        if b[j + 1].lstrip():
                            c='├'
                        else:
                            c='│'
                if c:
                    l[i]=l[i][:j] + c + l[i][j + 1:]
        l[h-1]=l[h-1].replace('│',' ').replace('├','└')
        for i in reversed(range(1,h)):
            t=l[i - 1]  # top
            b=l[i]  # bottom
            for j in range(w):
                c=''
                if b[j] not in '├│└':
                    if t[j] in '├':
                        c='└'
                    if t[j] in '│':
                        c=' '
                if c:
                    l[i - 1]=l[i - 1][:j] + c + l[i - 1][j + 1:]
        l=[x.rstrip() for x in l]
        l='\n'.join(l)
        for c in '├│└':
            l=l.replace(c,fansi(c,'gray'))
        return l
    if with_lines:
        string=pretty_lines(string)
    print(fansi_syntax_highlighting(string,style_overrides={'operator':('\033[0;34m','\033[0m'),'string':('\033[0;35m','\033[0m')}))

def repr_kwargs_dict(x, *, align_equals=True):
    """
    EXAMPLE:
        >>> x = {
        ...     "instance_data_root": "/root/CleanCode/Github/CogVideo/finetune/datasets/Disney-VideoGeneration-Dataset",
        ...     "dataset_name": None,
        ...     "dataset_config_name": None,
        ...     "caption_column": "prompts.txt",
        ...     "video_column": "videos.txt",
        ...     "height": 480,
        ...     "width": 720,
        ...     "fps": 8,
        ...     "max_num_frames": 49,
        ...     "skip_frames_start": 0,
        ...     "skip_frames_end": 0,
        ...     "cache_dir": "~/.cache",
        ...     "id_token": None,
        ... }
        ... 
        ... print(dict_repr(x),align_equals=True)
        ... 
        ... #RESULTS:
        ... #    dict(
        ... #         instance_data_root  = '/root/CleanCode/Github/CogVideo/finetune/datasets/Disney-VideoGeneration-Dataset',
        ... #         dataset_name        = None,
        ... #         dataset_config_name = None,
        ... #         caption_column      = 'prompts.txt',
        ... #         video_column        = 'videos.txt',
        ... #         height              = 480,
        ... #         width               = 720,
        ... #         fps                 = 8,
        ... #         max_num_frames      = 49,
        ... #         skip_frames_start   = 0,
        ... #         skip_frames_end     = 0,
        ... #         cache_dir           = '~/.cache',
        ... #         id_token            = None,
        ... #    )
    """
    lines = ["dict("]
    keys = list(x.keys())
    values = list(x.values())

    assert all(isinstance(key, str) for key in keys)

    max_key_length = max(map(len, keys))

    for key, value in x.items():
        assert isinstance(key, str)
        indent = "     "
        equals = " = "
        if align_equals:
            equals = equals.rjust(max_key_length - len(key) + len(equals))
        new_line = indent + key + equals + repr(value) + ","
        lines.append(new_line)
    lines.append(")")

    return line_join(lines)

def repr_multiline(string):
    """Like repr for strings - except it uses multiline strings with triple quotes"""
    #TODO: Make sure it works for escaped invisible characters like \u0000 etc...right now it doesnt do that....

    string=str(string)
    
    if '"""' not in string:
        string = '"""'+string+'"""'
    elif "'''" not in string:
        string = "'''"+string+"'''"
    else:
        string = string.replace('"""',r'\"\"\"')
        string = repr_multiline(string)

    if '\\' in string:
        string = 'r'+string

    return string

def repr_vars(*vars, sort=True, frames_back=0):
    """
    Returns the string representation of specified variables.
    Imperfectly implemented right now. Still useful though.

    Args:
        *vars: Variable names as strings, or a single string with space-separated names.
        sort (bool, optional): Whether to sort the output. Defaults to True.

    Returns:
        str: A string containing the variable names, values, and import statements.

    Example:
        >>> repr_vars('names folder load_image r rp badvar numpyarray')
        ans =
        import rp
        import rp.r
        from __main__ import repr_vars
        from rp.r import load_image
        folder = '/Users/ryan/Downloads/InitFrame/AnyV2V'
        names = ['Ours', 'Motion Clone', 'AnyV2V']
        #EVAL ERROR: numpyarray
        #NOT FOUND: badvar
    """
    import inspect 
    vars = detuple(vars)
    if isinstance(vars, str):
        names = vars.split()
    else:
        names = vars
    assert all(isinstance(x, str) for x in vars), "vars is not iterable of str"
    vals = gather_vars(names, frames_back=2+frames_back, skip_missing=True)
    output = []
    for v in names:

        if v in vals:
            val = vals[v]
            if inspect.isfunction(val) or inspect.isclass(val) or inspect.ismodule(val):
                if val.__name__ == "builtins":
                    output += ["import builtins"]
                elif val.__name__ == "inspect":
                    output += ["import inspect"]
                else:
                    module_name = getattr(val, "__module__", None)
                    obj_name = val.__name__
                    if module_name == 'rp.r' and not hasattr(rp.r,v):
                        #It was created in pseudo terminal
                        source_code = get_source_code(val)
                        if starts_with_any(source_code, 'def ','class ') and is_valid_python_syntax(source_code):
                            output+=[source_code]
                            continue
                    if module_name is not None:
                        output += ["from "+module_name+" import "+obj_name]
                    else:
                        output += ["import "+obj_name]
            else:
                r = repr(val)
                try:
                    if eval(r) == val:
                        output += [v + " = " + r]
                    else:
                        output += ["#USELESS REPR: " + v]
                except Exception:
                    output += ["#EVAL ERROR: " + v]
                    pass
        else:
            output += ["#NOT FOUND: " + v]

    if sort:
        output = set(output)

        defs         = {line for line in output if line.startswith("def ")   }; output -= defs
        classes      = {line for line in output if line.startswith("class ") }; output -= classes
        imports      = {line for line in output if line.startswith("import ")}; output -= imports
        from_imports = {line for line in output if line.startswith("from ")  }; output -= from_imports
        assignments  = {line for line in output if " = " in line             }; output -= assignments
        comments     = {line for line in output if line.startswith("#")      }; output -= comments

        output = sorted(imports) + sorted(from_imports) + sorted(classes) + sorted(defs) + sorted(assignments) + sorted(comments)

    return "\n" + line_join(output)

def has_len(x):
    """ Returns True if you can safely call len(x) """
    return hasattr(x,'__len__')

def as_example_comment(code,*,indent=' '*8):
    '''
    Takes code and makes it suitable for using in docstrings
    EXAMPLE:
        >>> print(as_example_comment("""def as_example_comment(code,*,indent=' '*8):
        ...     code=code.strip()
        ...     code=code.splitlines()
        ...     code[1:]=[indent+'... '+line for line in code[1:]]
        ...     code[0] = indent+'>>> '+code[0]
        ...     return line_join(code)
        ... """))
        ... #CONSOLE OUTPUT:
        ... #        >>> as_example_comment("""def as_example_comment(code,*,indent=' '*8):
        ... #        ...     code=code.strip()
        ... #        ...     code=code.splitlines()
        ... #        ...     code[1:]=[indent+'... '+line for line in code[1:]]
        ... #        ...     code[0] = indent+'>>> '+code[0]
        ... #        ...     return line_join(code)
        ... #        ... """)
    '''
    code=str(code)
    code=code.strip()
    code=code.splitlines()
    code[1:]=[indent+'... '+line for line in code[1:]]
    code[0] = indent+'>>> '+code[0]
    return line_join(code)

def string_transpose(x,fill=' '):
    ''' >>> string_transpose("Hello\nWorld")
    ans =
    HW
    eo
    lr
    ll
    od
    '''
    assert len(fill) == 1
    assert isinstance(fill,str)
    l=x.split('\n')
    out=''
    m=0
    for s in l:
        m=max(m,len(s))
    for i,s in enumerate(l):
        l[i]=s + fill * (m - len(s))
    return '\n'.join(''.join(i) for i in zip(*l))

def print_to_string(f,*args,**kwargs):
    """
    args and kwargs are passed to f
    Example: assert print_to_string(lambda:print("Hello World"))=="Hello World"
    """
    assert callable(f)
    out=''
    def patch(x):
        nonlocal out
        out+=x
    import sys
    temp=sys.stdout.write
    sys.stdout.write=sys.stdout.write,patch
    f(*args,**kwargs)
    sys.stdout.write=temp
    return out

def print_lines(*args, flush=False):
    """
    Shorthand for print(line_join(args)) 

    EXAMPLE:
        >>> print_lines(1,2,3,4,5)
        1
        2
        3
        4
        5
    """
    args=detuple(args)
    if isinstance(args, str):
        args=[args]
    print(line_join(map(str,args)),flush=flush)

def fansi_print_lines(
    *args,
    text_color=None,
    style=None,
    background_color=None,
    underline_color=None,
    reset=True,
    link=None,
    truecolor=None
):
    """
    Shorthand for print(fansi(line_join(args), style=...))

    EXAMPLE:
        >>> fansi_print_lines(1,2,3,4,5, style='green')
        1
        2
        3
        4
        5

        >>> fansi_print_lines('Hello','World',style='green bold')
        Hello
        World
    """
    args=detuple(args)
    if isinstance(args, str):
        args=[args]
    print(
        fansi(
            line_join(map(str, args)),
            text_color=text_color,
            background_color=background_color,
            style=style,
            reset=reset,
            truecolor=truecolor,
            link=link,
            underline_color=underline_color,
        )
    )

def reduced_row_echelon_form(M):
    pip_import('sympy')
    import sympy
    return sympy.matrix2numpy(sympy.Matrix(M).rref()[0])



def all_rolls(vector,axis=None):
    """
    TODO: See if this is the same thing as a toeplitz matrix
    TODO: There might be a faster way of doing this, but until then this implementation works. It can be swapped out later.
    Return all circshifts of a vector
    EXAMPLE:
        CODE: print(all_rolls([1,2,3,4,5]))
        Output:
           [[1 2 3 4 5]
            [5 1 2 3 4]
            [4 5 1 2 3]
            [3 4 5 1 2]
            [2 3 4 5 1]]
    EXAMPLE:
        THIS
            [[7 8 9]
             [1 2 3]
             [4 5 6]]
        BECOMES
           [[[7 8 9]
             [1 2 3]
             [4 5 6]]
    
            [[4 5 6]
             [7 8 9]
             [1 2 3]]
    
            [[1 2 3]
             [4 5 6]
             [7 8 9]]]
    """
    vector=np.asarray(vector)#If this breaks, it's the fault of the user of this function
    out=[]
    for _ in range(len(vector)):
        out.append(vector)
        vector=np.roll(vector,1,axis=axis)
    return np.asarray(out)

def circular_diff(array,axis=0):
    """
    Returns the diff of an array along the axis, taking into account looping unlike numpy's implementation (aka np.diff)
    Example: circular_diff([1,2,3,4,5])  ->  [ 1  1  1  1 -4]   VS   np.diff([1,2,3,4,5])  ->  [1 1 1 1]
    """
    return np.roll(array,shift=-1,axis=axis)-array
circ_diff=circular_diff#For convenience's sake
def circular_quotient(array,axis=0):
    """
    >>> circular_quotient([1,2,4,8])
    ans = [2.    2.    2.    0.125]
    """
    return np.roll(array,shift=-1,axis=axis)/array
circ_quot=circular_quotient#For convenience's sake
def circular_convolve(a,b):
    """
    Convolve vector a with vector b with wrapping on the boundaries
    Works with any numpy dtype, and returns the same kind of dtype inputted
    Examples: circular_convolve([1,0,0,0,0],[1,2,3,4,5]) --> [1 2 3 4 5]
    Examples: circular_convolve([0,1,0,0,0],[1,2,3,4,5]) --> [5 1 2 3 4] #Notice how it wrapped around
    """
    a=np.asarray(a)
    b=np.asarray(b)
    assert len(a.shape)==len(b.shape)==1,'Right now, circ_conv requires that both inputs are vectors. This may be generalized in the future to n-d convolutions.'
    assert a.shape==b.shape,'Right now, circ_conv requires that both vectors are the same length. This may change in the future.'
    if len(a)==len(b)==0:
        return np.asarray([],np.find_common_type([a.dtype,b.dtype],[]))#If the length of a and b are 0, return an empty array with the maximal dtype. Otherwise this function would throw an error that you can't take the FFT of a vector with 0 elements.
    l=len(a)#Should be the same as len(b)
    f=np.fft.fft
    i=np.fft.ifft
    with warnings.catch_warnings():
        output_type=(a+b).dtype
        warnings.filterwarnings('ignore',message='Casting complex values to real discards the imaginary part',category=np.ComplexWarning)#We're going to output the same type of number that we inputted
        return i(f(a)*f(b)).astype(output_type)
circ_conv=circular_convolve#For convenience's sake
def circular_cross_correlate(a,b):
    """
    TODO let varargs input (because circular_cross_correlate is associative)
    Let a★b = circular_cross_correlate(a,b)
    Cross correltation is convolution where the kernel is flipped.
    Cross correlation contains the element dot(a,b) whereas convolution does NOT (cross correlation can compare similarities)
    Properties:
    FOR ALL INT 'n':   (a★b)[n] = np.dot(a,np.roll(b,n))
    AND THEREFORE...   (a★b)[0] = np.dot(a,b)
    UNLIKE Convolution, a★b ≠ b★a
    UNLIKE Convolution, a★b ≠ ifft(fft(a)*fft(b))
    UNLIKE Convolution, a★b = ifft(fft(a)*fft(b).conj)
    """
    def reverse(x):
        #NOT the same as x[::-1], due to the way FFT works...
        #Same as ifft(fft(x).conj()), but faster...
        #Example: reverse([1,2,3,4])  ->  [1 4 3 2]
        return np.roll(np.flip(x,axis=0),1)
    return circular_convolve(reverse(a).conj(),b)#This is according to what I think the wikipedia defintion is...
circ_cross_corr=circular_cross_correlate
def circular_auto_correlate(a):
    """
    TODO extend to multiple dimenations etc.
    According to wikipedia, auto-correlation is defined as a vector's cross-correlation with itself.
    The first element of the output is dot(a,a), AKA
      circular_auto_correlate(a)[0]  ====  np.dot(a,a)
    This function returns a shift-invariant descriptor of vector 'a', with half the degrees of freedom of a
    """
    return circular_cross_correlate(a,a)
circ_auto_corr=circular_auto_correlate
def circular_gaussian_blur(vector,sigma=1):
    """
     >>> circ_gauss_blur([1,0,0,0,0,0])
    ans = [0.4   0.095 0.005 0.005 0.095 0.4  ]
     >>> circ_gauss_blur([1,0,0,0,0])
    ans = [0.403 0.244 0.054 0.054 0.244]
    """
    vector=np.asarray(vector)
    assert len(vector.shape)==1,'Right now input must be a vector. This may change in the future.'
    if sigma==0:return np.copy(vector)
    kernel=gaussian_kernel(size=len(vector),sigma=sigma,dim=1)
    kernel=np.roll(kernel,int(np.ceil(len(kernel)/2)))#Shift it over so that the blur doesn't shift the original vector
    assert len(kernel)==len(vector),'Internal logic assertion to circular_gaussian_blur'
    return circular_convolve(vector,kernel)
circ_gauss_blur=circular_gaussian_blur
def circular_extrema_indices(x):
    """
    Return the indices of all local extrema, treating the input as cyclic (TODO: perhaps add a non-cyclic version later)
    If there is a continuous area where the derivative is 0, return the indices of the whole area (example: circular_extrema_indices([1,2,2,2,3]))
    The order of the extremas returned is the relative order they originally appear in the input (as opposed to being sorted by value etc)
    EXAMPLE: circular_extrema_indices([1,2,3,4])      -> [0 3]     #In this and the next three examples, notice how the shift affects the indices
    EXAMPLE: circular_extrema_indices([4,1,2,3])      -> [0 1]     #Note how because this function treats the input as cyclic, and therefore the 3 at the end is not an extrema
    EXAMPLE: circular_extrema_indices([3,4,1,2])      -> [1 2]
    EXAMPLE: circular_extrema_indices([2,3,4,1])      -> [2 3]
    EXAMPLE: circular_extrema_indices([0,1,2,3,2,1])  -> [0 3]     #Captures both local minima AND local maxima, aka the 0 and the 3
    EXAMPLE: circular_extrema_indices([0,0,0,1,2,3])  -> [0 1 2 5] #Notice how areas with a derivative of 0 are all treated as extrema (aka the first three 0's)
    EXAMPLE: circular_extrema_indices([])             -> []        #No elements in the input means no elements in the output
    EXAMPLE: circular_extrema_indices([])             -> []        #No elements in the input means no elements in the output
    EXAMPLE: circular_extrema_indices([A])            -> [A]       #True for all numeric values A. If we only have one point, it is technically an extrema.
    EXAMPLE: circular_extrema_indices([A,B])          -> [A B]     #True for all numeric values A and B. If we only have two points, both are technically extremas.
    """
    x=np.asarray(x)
    assert len(x.shape)==1,'Currently, only vectors are supported. This may change in the future.'
    r=np.roll(x, 1)#Right
    l=np.roll(x,-1)#Left
    return np.argwhere((x>r) & (x>l) | (x<r) & (x<l) | (x==r) | (x==l)).squeeze()
circ_extrema=circular_extrema_indices

def circ_diff_inverse(x):
    #Note that we lose a constant of integration
    #circ_diff(circ_diff_inverse(circ_diff(x))) == circ_diff(x)
    return np.cumsum(np.concatenate(([0],x)))[:-1]

def gcd(*i):#Unlike the default gcd, this can accept varargs
    from math import gcd as _gcd#_gcd because it took me a while to read this after coming back to it: there is no recursion involved
    from functools import reduce
    return reduce(_gcd,i,0)
gcf=gcd#Greatest common denominator and Greatest Common Factor are synonyms
def lcm(*i):#lcm doesn't exist in the math module
    from functools import reduce
    return reduce(lambda x,y:x*y//gcd(x,y),i,1)
def product(*i):#redefined from earlier in r.py, but it does the same thing. It's just written differntly (in a way that makes it less dependent on r.py; the last one used the 'scoop' function)
    from functools import reduce
    return reduce(lambda x,y:x*y,i,1)

def ncr(n, r):
    """ choose r objects from n """
    from functools import reduce
    import operator as op
    r = min(r, n-r)
    numer = reduce(op.mul, xrange(n, n-r, -1), 1)
    denom = reduce(op.mul, xrange(1, r+1), 1)
    return numer//denom

def get_process_memory(pid:int=None)->int:
    "Returns the process memory usage in bytes."
    if pid is None: pid=get_process_id()

    pip_import('psutil')
    import psutil

    process = psutil.Process(pid)
    return process.memory_info().rss#Get this process's total memory in bytes

def get_process_username(pid: int=None) -> str:
    """
    Returns the username associated with the given process ID (pid).
    Made by ChatGPT: https://sharegpt.com/c/Tvzy0Rt
    OLD NAME: get_username_from_process_id
    """
    if pid is None: return get_username() #Much faster, see get_username's code comment

    pip_import('psutil')
    import psutil

    try:
        process = psutil.Process(pid)
        username = process.username()
        return username
    except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
        raise Exception("The process with PID {} does not exist or could not be accessed.".format(pid))
get_username_from_process_id = get_process_username

def get_username():
    """
    Get the username of the current python process
    """
    #Implementation was chosen based on speed:
    #     >>> for _ in range(100000): getpass.getuser()
    #    TICTOC: 0.06527    seconds
    #     >>> for _ in range(100000): get_process_username(get_process_id())
    #    TICTOC: 1.53992    seconds

    import getpass
    return getpass.getuser()
    return get_process_username() # Much slower but equivalent


def get_process_id():
    """ Get the current process id, aka pid """
    import os
    return os.getpid()

def get_process_exists(pid: int) -> bool:
    "Returns True if the pid exists, False otherwise"

    if pid==os.getpid(): 
        #Obviously, the current process is running. Speed things up a lot.
        #     >>> for _ in range(100000):get_process_exists(0)
        #    TICTOC: 7.85240    seconds
        #     >>> for _ in range(100000):get_process_exists(get_process_id())
        #    TICTOC: 0.03110    seconds
        #
        #This check is fast enough that it's worth it
        #     >>> pid = os.getpid()
        #     >>> for _ in range(100000): get_process_exists(pid)
        #    TICTOC: 0.14078    seconds
        #     >>> for _ in range(100000): os.getpid()
        #    TICTOC: 0.00949    seconds
        return True

    pip_import('psutil')
    import psutil
    try:
        return psutil.pid_exists(pid)
    except OverflowError:
        # It errors if PID is too large:
        #     OverflowError: signed integer is greater than maximum
        # ...in this case we know it doesn't exist.
        return False
process_exists=get_process_exists

def get_process_start_date(pid=None):
    """Given a process ID, returns a datetime object of when it started"""
    if pid is None: pid=get_process_id()

    try:
        pip_import('psutil')
        import psutil
        import datetime

        # Get the process using psutil
        process = psutil.Process(pid)
        
        # Get the start time of the process
        start_timestamp = process.create_time()
        
        return datetime.datetime.fromtimestamp(start_timestamp)

    except psutil.NoSuchProcess:
        return "No process found with PID: {}".format(pid)
    except Exception as e:
        return "An error occurred: {}".format(e)

def kill_process(pid, signall="SIGKILL"):
    """
    Send a signal to a process identified by its PID.

    Args:
        pid (int): Process ID to which the signal is sent.
        signall (str or int): The signal to send. Default is 'SIGKILL'.

    Common signals:
        SIGKILL - Immediately terminate the process (cannot be caught or ignored).
        SIGTERM - Request the process to terminate gracefully.
        SIGHUP  - Sent to a process when its controlling terminal is closed.
        SIGINT  - Sent to a process by its controlling terminal when a user interrupts the process with a keyboard input, typically Ctrl+C.

    Examples of usage:
        kill_process(1234)  # Defaults to sending SIGKILL
        kill_process(1234, 'SIGTERM')  # Send SIGTERM to request graceful termination
        kill_process(1234, 'SIGHUP')  # Send SIGHUP typically used to trigger a reload of configuration
        kill_process(1234, 'SIGINT')  # Simulate a keyboard interrupt

    Exceptions:
        ValueError: If the signal name is not valid or no such process exists.
        Exception: For other unexpected errors.
    """
    import os
    import signal

    try:
        # Convert the signall to its corresponding integer value
        if isinstance(signall, str):
            signall = getattr(signal, signall)

        # Send the signal to the process
        os.kill(pid, signall)
        print("Signal {} successfully sent to process {}.".format(signall, pid))
    except AttributeError:
        valid_signals = ", ".join([s for s in dir(signal) if s.startswith("SIG") and not s.startswith("SIG_")])
        error_message = "Error: {} is not a valid signal. Choose from: {}".format(signall, valid_signals)
        raise ValueError(error_message)
    except ProcessLookupError:
        error_message = "No process found with PID {}.".format(pid)
        raise ValueError(error_message)


def kill_processes(*pids, signall="SIGKILL", strict=True):
    """Plural of rp.kill_process"""
    pids = detuple(pids)
    for pid in pids:
        try:
            kill_process(pid)
        except Exception:
            if strict:
                raise


def search_processes(pattern, *, show_progress=False, whole_arg=False):
    """
    Search for processes containing pattern in their command.

    Args:
        pattern: String to search for in process commands
        show_progress: Whether to show progress information
        whole_arg: If True, only match complete command arguments

            Examples:
            For a process with command: "/usr/bin/python3 script.py --config file.txt"

            With whole_arg=False (default):
                - "python" would match (substring of an argument)
                - "config" would match (substring of an argument)

            With whole_arg=True:
                - "python" would NOT match (not a complete argument)
                - "python3" would NOT match (part of "/usr/bin/python3")
                - "--config" would match (complete argument)
                - "script.py" would match (complete argument)

            Tips:
                - To match whole args, you can add spaces to the beginning and ends of the pattern
                >>> search_processes(' ollama serve ')
                ans = {18374: {'pid': 18374, 'command': ['ollama', 'serve']}}

    Returns:
        dict[int, dict]: Dictionary mapping matching process PIDs to their information
    """
    import psutil
    import sys
    from typing import List

    matches = {}
    pattern = pattern.lower()

    # Get process count for progress reporting
    total = 0
    if show_progress:
        try:
            total = len(list(psutil.process_iter()))
            sys.stdout.write("\rSearching through " + str(total) + " processes...")
            sys.stdout.flush()
        except:
            show_progress = False

    # Search processes
    for i, proc in enumerate(psutil.process_iter(["pid", "cmdline"])):
        if show_progress and i % 100 == 0:
            sys.stdout.write("\rSearching: " + str(i) + "/" + str(total))
            sys.stdout.flush()

        try:
            command = proc.info["cmdline"]
            if not command:
                continue

            if whole_arg:
                # Match whole arguments only
                match_found = any(arg.lower() == pattern for arg in command)
            else:
                # Match anywhere in the command
                match_found = pattern in " " + " ".join(command).lower() + " "

            if match_found:
                pid = proc.info["pid"]
                matches[pid] = gather_vars("pid command")
        except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
            pass

    if show_progress:
        sys.stdout.write("\rFound " + str(len(matches)) + " processes matching '" + pattern + "'")
        sys.stdout.flush()
        print()

    return matches

def regex_match(string,regex)->bool:
    """ returns true if the regex describes the whole string """
    import re
    return bool(re.fullmatch(regex,string))
def regex_replace(string,regex,replacement):
    """ Regex replacement. Example: regex_replace('from abc import def','from .* import (.*)',r'\1') == 'def' """
    import re
    return re.sub(regex,replacement,string)

def ring_terminal_bell():
    """ Lets the terminal make a little noise. You've probably heard this sound at least once before on your OS...  """
    print(end=chr(7),flush=True)#This character should ring the TTY's bell, if that's possible.



def _pterm():
    """ This is what gets run when we run rp from the command line """
    try:
        pseudo_terminal(locals(),globals(),rprc=_get_ryan_rprc_path())
    finally:
        if _pterm_hist_file is not None:
            _pterm_hist_file.close()

def clear_terminal_screen():
    """ Will clear the screen of a tty """
    print(end="\033[0;0H\033[2J")#https://www.csie.ntu.edu.tw/~r92094/c++/VT100.html

def set_cursor_to_bar(blinking=False):
    """
    Modify the shape of the cursor in a vt100 terminal emulator
    I'm not sure what the escape codes are for different terminals; tmux for example is a mystery.
    To see what these do, I reccomend just running them in a unix terminal.
    """
    if blinking:
        print(end="\033[5 q")
    else:
        print(end="\033[6 q")

def set_cursor_to_box(blinking=True):
    """
    Modify the shape of the cursor in a vt100 terminal emulator
    I'm not sure what the escape codes are for different terminals; tmux for example is a mystery.
    To see what these do, I reccomend just running them in a unix terminal.
    """
    if blinking:
        print(end="\033[1 q")
    else:
        print(end="\033[2 q")

def set_cursor_to_underscore(blinking=True):
    """
    Modify the shape of the cursor in a vt100 terminal emulator
    I'm not sure what the escape codes are for different terminals; tmux for example is a mystery.
    To see what these do, I reccomend just running them in a unix terminal.
    """
    if blinking:
        print(end="\033[3 q")
    else:
        print(end="\033[4 q")

def line_number():
    """
    Return the line number of the caller
    """
    import inspect
    return inspect.currentframe().f_back.f_lineno

def is_number(x):
    """Returns True if x is a numeric value or numeric type.
    
    Checks if the input is a number using Python's numbers.Number abstract base class.
    Works with Python built-in numeric types and NumPy numeric types, but does NOT
    work with torch tensors (returns False for tensors, even scalar ones).
    
    Enhanced Documentation:
    
    This function is widely used throughout RP for type checking before applying
    numeric operations. It's particularly useful for:
    - Input validation in mathematical functions
    - Type-safe parameter clamping (like in float_clamp)
    - Distinguishing numeric from non-numeric data in processing pipelines
    - Filtering numeric values from mixed-type collections
    
    Supported numeric types:
    - Python built-ins: int, float, complex, bool
    - NumPy scalars: np.int32, np.float64, np.uint8, etc.
    - NumPy type objects: np.float32, np.int64, etc.
    - Decimal and Fraction types from standard library
    
    NOT supported:
    - Torch tensors (returns False, even for scalar tensors)
    - Numeric strings like "123" (use float() to convert first)
    - NumPy arrays (use is_numpy_array for arrays)
    - Lists or tuples of numbers
    
    Args:
        x: Any value to test for numeric nature.
    
    Returns:
        bool: True if x is a number or numeric type, False otherwise.
    
    Examples:
        >>> # Python built-in numeric types
        >>> is_number(123)
        True
        >>> is_number(5.6)
        True
        >>> is_number(complex(1, 2))
        True
        >>> is_number(True)  # bool is subclass of int
        True
        
        >>> # NumPy numeric types and values
        >>> import numpy as np
        >>> is_number(np.int32(123))
        True
        >>> is_number(np.float64(3.14))
        True
        >>> is_number(np.uint8)  # Type object itself
        True
        
        >>> # Non-numeric values
        >>> is_number("Hello")
        False
        >>> is_number("123")  # String, even if numeric
        False
        >>> is_number([1, 2, 3])
        False
        >>> is_number(np.array([1, 2, 3]))
        False
        
        >>> # Torch tensors return False
        >>> import torch
        >>> is_number(torch.tensor(5))  # Even scalar tensors
        False
        >>> is_number(torch.tensor([1, 2, 3]))
        False
        
        >>> # Type checking pattern
        >>> def safe_multiply(x, factor):
        ...     if not is_number(x):
        ...         raise TypeError("x must be numeric")
        ...     return x * factor
    
    Related Functions:
        - is_numpy_array: Check if value is a NumPy array
        - is_torch_tensor: Check if value is a PyTorch tensor
        - float_clamp: Uses this function for input validation
        - is_color_vector: Check if value represents a color
    
    Technical Note:
        The function intentionally excludes torch tensors to maintain clear separation
        between scalar numbers and tensor objects. For torch tensors, use is_torch_tensor()
        or check tensor.numel() == 1 for scalar tensors.
    
    Tags: type-checking, numeric, validation, numbers, numpy-compatible
    """
    from numbers import Number
    if isinstance(x,Number) or isinstance(x,type) and issubclass(x,Number):
        return True
    return False

    #Does NOT work with torch tensors. I don't know if I should include that or not, so for now it returns false on torch tensors but the code is commented out and be uncommented.
    #try:
    #    #The above check fails for torch tensors. Here's a modification:
    #    if x.__class__.__name__=='Tensor' or x.__name__=='Tensor':#Try to avoid importing torch, as that takes a while...
    #        import torch#...we might not even have torch, which is why this is in a try-catch block
    #        if isinstance(x,torch.Tensor) or isinstance(x,type) and issubclass(x,torch.Tensor):
    #            return True
    #except Exception:pass#Maybe we don't have torch.

def _refresh_autocomplete_module_list():
    from rp.rp_ptpython.completer import get_all_importable_module_names
    get_all_importable_module_names()

def line_join(iterable,separator='\n'):
    """ Joins a set of strings with the newline character. See also: rp.line_split """
    return separator.join(map(str,iterable))



def powerset(iterable,reverse=False):
    "powerset([1,2,3]) --> () (1,) (2,) (3,) (1,2) (1,3) (2,3) (1,2,3)"
    #From https://stackoverflow.com/questions/18035595/powersets-in-python-using-itertools
    from itertools import chain, combinations
    s = list(iterable)
    order=range(len(s)+1)
    if reverse:
        order=reversed(order)
    return chain.from_iterable(combinations(s, r) for r in order)

def print_fix(ans):
    r"""
    Meant to use this command in the pseudoterminal: `print_fix\py
    Turn all python2 print statements (without the parenthesis) into python3-style statements
    Example: print_fix('if True:\n\tprint 5')    ====    'if True:\n\tprint(5)'
    """
    ans=ans.splitlines()
    for i,e in enumerate(ans):
        if e.lstrip().startswith('print '):
            j=len(e)-len(e.lstrip())+len('print')
            e=e[:j]+'('+e[j+1:]+')'
            ans[i]=e
    ans=line_join(ans)
    return ans

def remove_all_whitespace(string):
    return ''.join(string.split())#Remove all whitespace

# def delete_empty_lines(string):
#     return '\n'.join(line for line in string.splitlines() if line.strip())

#region OpenCV Helpers
def cv_bgr_rgb_swap(image_or_video,*,copy=True):
    """
    Works for both images AND video. Mutates the input.
    Works with RGBA and RGB. If grayscale image, no changes.
    Opencv has an annoying feature: it uses BGR instead of RGB. Heckin' hipsters. This swaps RGB to BGR, vice-versa.
    If the input array is contiguous, the output array will be too
    If copy=True, doesn't mutate the input and instead returns a new array
    """
    out = np.asarray(image_or_video)

    assert is_image(out)

    if out.ndim==2: return out #Grayscale Image

    if copy:
        out = _copy_tensor(out)

    num_channels = out.shape[-1]

    #Works for RGBA and RGB images/videos in HWC or THWC form
    out[..., [0, 2]] = out[..., [2, 0]]

    return out

cv_rgb_bgr_swap=cv_bgr_rgb_swap#In-case you forgot what to type. It's all the same thing though.

_first_time_using_cv_imshow=True
def cv_imshow(img,label="cvImshow",*,
        img_is_rgb=True,#As opposed to BGR. If this is true, then the R and B channels are swapped before the image is displayed.
        wait=1,#Set to None to skip waiting all-together (will have to wait at some point or else the images won't display)
        on_mouse_down =None, #Either set to None or some function like lambda x,y:print(x,y)
        on_mouse_move =None, #Either set to None or some function like lambda x,y:print(x,y)
        on_mouse_up   =None, #Either set to None or some function like lambda x,y:print(x,y)
        on_key_press  =None  #Either set to None or some function like lambda key:print(key).
        # on_key_press will either be sent a character representing the key (such as pressing 'a' makes key='a') or else a multi-character string describing it. Examples: 'left','right','backspace','delete'
        ):
    """
    Display image using OpenCV with interactive capabilities.
    
    Enhanced cross-platform image display function that works in various environments 
    (desktop, Jupyter, Google Colab). Provides mouse and keyboard interaction callbacks
    for interactive computer vision applications.
    
    Args:
        img: Image to display. Accepts numpy arrays, PIL images, various formats
        label (str): Window title/label (default "cvImshow")
        img_is_rgb (bool): Whether image is RGB (True) or BGR (False). RGB images have
                          R and B channels swapped before display (default True)
        wait (int): Milliseconds to wait for key press. None skips waiting (default 1)  
        on_mouse_down (callable): Callback function for mouse clicks, e.g. lambda x,y: print(x,y)
        on_mouse_move (callable): Callback for mouse movement, e.g. lambda x,y: print(x,y)
        on_mouse_up (callable): Callback for mouse button release, e.g. lambda x,y: print(x,y) 
        on_key_press (callable): Callback for key presses, e.g. lambda key: print(key)
                                Keys can be single chars ('a') or strings ('left', 'backspace')
        
    Examples:
        >>> import numpy as np
        >>> # Simple image display
        >>> img = np.random.randint(0, 255, (200, 200, 3), dtype=np.uint8)
        >>> cv_imshow(img, "Test Image")
        
        >>> # Interactive display with mouse callback
        >>> def mouse_handler(x, y):
        ...     print(f"Clicked at ({x}, {y})")
        >>> cv_imshow(img, "Interactive", on_mouse_down=mouse_handler)
        
        >>> # Key press handling
        >>> def key_handler(key):
        ...     print(f"Key pressed: {key}")
        >>> cv_imshow(img, "Keys", on_key_press=key_handler)
        
    Note: Used by display_image, cv_manually_selected_contours, and interactive CV functions.
    Automatically handles environment detection (Jupyter, Colab) and format conversion.
    
    Related: display_image, cv_manually_selected_contour, as_byte_image
    
    Tags: display, opencv, interactive, mouse, keyboard, computer-vision, gui
    """

    img=as_numpy_image(img,copy=False)

    if running_in_google_colab() or running_in_ipython():
        #Quick hack to make sure the notebook doesn't crash
        #It will crash if you try to use cv2.imshow in it
        display_image(img)
        return

    #A non-blocking image display, using OpenCV
    tensor_shape=img.shape
    ndim=len(tensor_shape)
    assert ndim in {2,3},'Cannot display img, because img.shape == '+str(img.shape)
    if not np.prod(tensor_shape):
        return#If the dimensions are like (0,0,3) it means we have no height and width, and opencv will cause an error if we try to display that image. This is not useful, so we just return and don't display the image.
    img_is_grayscale=ndim==2#If there are only two dimensions
    assert(len(img))
    cv2=pip_import('cv2')
    if img_is_rgb and not img_is_grayscale:
        img=cv_bgr_rgb_swap(img)
    if is_binary_image(img):
        img=as_byte_image(img)#Avoid ERROR: TypeError: mat data type = 0 is not supported thrown by opencv's imshow
    assert isinstance(label,str),"cvImshow: Inputted label is not a string: repr(label) == "+repr(label)
    def mouse_callback(event,x,y,flags,param):
        if event==cv2.EVENT_LBUTTONDOWN and on_mouse_down:on_mouse_down(x=x,y=y)
        if event==cv2.EVENT_MOUSEMOVE   and on_mouse_move:on_mouse_move(x=x,y=y)
        if event==cv2.EVENT_LBUTTONUP   and on_mouse_up  :on_mouse_up  (x=x,y=y)
    cv2.namedWindow(label,cv2.WINDOW_KEEPRATIO)#cv2.WINDOW_KEEPRATIO lets us resize the window in the window's gui. By the way, for future reference, this function (cv2.namedWindow) has no effect if the window allready exists.
    if on_mouse_down or on_mouse_move or on_mouse_up:#If any of these are set (and not None), then we overwrite the mousecallback for this namedWindow
        cv2.setMouseCallback(label,mouse_callback)#This makes it interactive. It will also overwrite any

    if running_in_google_colab():
        pass
        print("rp.cv_imshow: Warning: Cannot use cv_imshow in google colab. Sorry. Maybe this will change in the future.")
        # from google.colab.patches cv2=pip_import('cv2')_imshow#[From google colab documentation] The cv2.imshow() and cv.imshow() functions from the opencv-python package are incompatible with Jupyter notebook
        # cv2_imshow(label,img)#https://github.com/jupyter/notebook/issues/3935
    else:
        global _first_time_using_cv_imshow
        if _first_time_using_cv_imshow:
            #We try to display it twice the first time we display an image
            #This is because, for some reason, it usually just comes up blank the first time I display an image using cv2.imshow, and end up having to call cv_imshow twice. I don't want to have to do this, so I let this function handle that automatically
            cv2.imshow(label,img)
            sleep(.1)
            _first_time_using_cv_imshow=False
        cv2.imshow(label,img)#Wait is in millis

    if wait is not None:
        key=cv2.waitKey(max(1,wait//2))
        if key==-1:
            key=None#Opencv returns -1 when key was pressed. I'll call it None.
        if on_key_press and key is not None:
            # https://stackoverflow.com/questions/14494101/using-other-keys-for-the-waitkey-function-of-opencv
            try:
                key=chr(key)
            except ValueError:#Something like "ValueError: chr() arg not in range(0x110000)", which means we pressed a non-character key like delete or the left arrow key etc
                pass#This section doesn't work well right now
                # recognized_keys={
                #     2490368:'up',
                #     2621440:'down',
                #     2424832:'left',
                #     2555904:'right',
                #     3014656:'delete',
                #     }
                # if key in recognized_keys:
                #     key=recognized_keys[key]
                # else:
                    #Unrecognized key
                    # key=None
            if key=='\r':
                key='\n'#The enter key returns '\r' using opencv, but we want it to return the more familiar '\n'
            if key!=None:
                on_key_press(key)

def _cv_helper(*,image,copy,antialias):
    cv2=pip_import('cv2')
    #This function exists to remove redundancy from other OpenCV helper functions in rp
    kwargs={}
    if antialias:kwargs['lineType']=cv2.LINE_AA#Whether to antialias the things we draw
    if copy     :image=image.copy();#as_byte_image(as_rgb_image(image))#Decide whether we should mutate an image or create a new one (which is less efficient but easier to write in my opinion)
    return image,kwargs

Contour = None
def _init_contour_class():
    global Contour
    class Contour(np.ndarray):
        """ Used in the output of cv_find_contours - gives extra info, more than a simply numpy array """
        # __slots__ = ['parent','children','_descendants_cache','_is_inner_cache']#Prevent adding new attriutes. This makes it faster.
        @property
        def is_inner(self):
            #https://stackoverflow.com/questions/45323590/do-contours-returned-by-cvfindcontours-have-a-consistent-orientation
            if hasattr(self,'_is_inner_cache'):
                return self._is_inner_cache
            self._is_inner_cache=is_counter_clockwise(self)#Edge case I don't know what to do with: what should we return if  len(self)<=2?
            return self._is_inner_cache

        @property
        def is_outer(self):
            return not self.is_inner#Edge case I don't know what to do with: what should we return if  len(self)<=2? Same problem as in is_inner

        @property
        def is_solid_white(self):
            return not self.children and self.is_outer

        @property
        def is_solid_black(self):
            return not self.children and self.is_inner

        @property
        def descendants(self):
            #Return not just the immediate children, but their children's children etc recursively
            if hasattr(self,'_descendants_cache'):
                return self._descendants_cache
            def helper():
                for child in self.children:
                    yield child.descendants()
                yield self
            self._descendants_cache = list(helper())
            return self._descendants_cache

def cv_find_contours(image,*,include_every_pixel=False):
    """
    Contours are represented in the form [[x,y],[x,y],[x,y]].
    If you want to get rid of the extra, useless dimension, don't forget to use .squeeze()
    NOTE: This doesn't return normal numpy arrays: The output arrays subclass numpy.ndarray and have these attributes:
       parent, children, descendants, is_inner, is_outer, is_solid_white, is_solid_black
    This is really useful, because it's like hierarchy but much easier to use. The parent of a contour is the contour immediately and completely surrounding it (None if no such contour exists.) This is calculated from the hierarchy.
     'contour.is_inner' is always the same as 'not contour.is_outer'. It returns whether it is an inner or an outer contour
    If include_every_pixel is true, we include every single coordinate in our contour, using CHAIN_APPROX_NONE. Otherwise, opencv will simplify vertical and horizontal segments of pixels into a single edge (which is almost lossless)
    """
    cv2=pip_import('cv2')
    image=as_grayscale_image(image)
    image=as_byte_image(image)
    raw_contours, hierarchy = cv2.findContours(image,cv2.RETR_CCOMP,cv2.CHAIN_APPROX_NONE if include_every_pixel else cv2.CHAIN_APPROX_SIMPLE)[-2:]


    _init_contour_class()
    contours=[as_points_array(raw_contour).view(Contour) for raw_contour in raw_contours]#This is how we subclass numpy arrays (by using (some ndarray).view(wrapper class))

    for contour in contours:
        contour.parent=None
        contour.children=[]

    try:
        hierarchy=hierarchy[0]
        for info,contour in zip(hierarchy,contours):
            parent_index=info[3]
            if parent_index != -1:#Opencv tells us that the contour has no parents by telling us it is its own parent, and therefore is its own child. I think it makes more sense to say its parent is None (which is the default value)
                parent=contours[parent_index]#How to read the opencv contour hierarchy: https://stackoverflow.com/questions/22240746/recognize-open-and-closed-shapes-opencv
                contour.parent=parent
                parent.children.append(contour)
    except TypeError:pass#ERROR: TypeError: 'NoneType' object is not iterable (due to hierarchy being None before hierarchy=hierarchy[0])

    return contours

def dilate_contour(contour,shift=1,*,loop=False):
    """

    EXAMPLE:

        >>> #Single Image Demo
        ... image = load_image(
        ...     "https://www.shutterstock.com/image-vector/vector-illustration-black-silhouette-elk-600nw-736283863.jpg",
        ...     use_cache=True,
        ... )
        ... contour = max(cv_find_contours(image), key=len)
        ... contour = as_complex_vector(contour)
        ... contour = circ_gauss_blur(contour, 10)
        ... contour = evenly_split_path(contour, 1000)
        ... radii = [-10, 10, 20, 30, 40, 50]
        ... display_image(
        ...     contours_to_image(
        ...         [contour] + [dilate_contour(contour, shift) for shift in radii]
        ...     )
        ... )

    EXAMPLE:

        >>> #Wobbles Animation - Jiggly Lines
        ... 
        ... image = load_image(
        ...     "https://www.shutterstock.com/image-vector/vector-illustration-black-silhouette-elk-600nw-736283863.jpg",
        ...     use_cache=True,
        ... )
        ... contour = max(cv_find_contours(image), key=len)
        ... contour = as_complex_vector(contour)
        ... contour = circ_gauss_blur(contour, 10)
        ... contour = evenly_split_path(contour, 1000)
        ... 
        ... @globalize_locals
        ... def animation():
        ...     for phase in np.linspace(0,tau,num=30):
        ...         num_wobbles = 100
        ...         wobble_amplitude = 10
        ...         wobbles = wobble_amplitude * np.sin(
        ...             phase + np.linspace(
        ...                 0,
        ...                 tau * num_wobbles,
        ...                 len(contour),
        ...             )
        ...         )
        ...         
        ...         frame = contours_to_image(
        ...             [contour] + [dilate_contour(contour, wobbles)],
        ...             crop=False,
        ...         )
        ...         yield frame
        ... 
        ... display_video(animation(), loop=True)

    """
    contour = as_complex_vector(contour)

    if not is_number(shift):
        #Broadcasting
        assert len(shift) == len(contour), [len(shift), len(contour)]
    else:
        shift = [shift+0j] * len(contour)
        
    
    if loop:
        contour = [contour[-1]] + list(contour) + [contour[0]]

    num_points=len(contour)
    f_deltas =  np.diff(contour)             #Forward  Deltas
    b_deltas = -np.diff(contour[::-1])[::-1] #Backward Deltas
    
    nf_deltas = f_deltas / np.abs(f_deltas)
    nb_deltas = b_deltas / np.abs(b_deltas)
        
    μn_deltas = np.zeros(num_points,dtype=f_deltas.dtype) #Avg Normalized Deltas
    μn_deltas[ :-1]+=f_deltas/2
    μn_deltas[1:  ]+=b_deltas/2
    μn_deltas[[0,-1]]*=2
    
    nμn_deltas = μn_deltas / np.abs(μn_deltas)
    
    output = contour + shift * nμn_deltas * -1j
    
    if loop:
        output = output[1:-1]
    
    return output

def cv_simplify_contour(contour, epsilon=1, closed=True):
    """
    Simplifies a closed contour using the Ramer-Douglas-Peucker algorithm.

    Parameters:
        contour (numpy.ndarray):
            The input contour, a 2D NumPy array of shape (n, 1, 2) or (n, 2), where n is the number of points, OR a complex-numbered vector of shape (n,)
        epsilon (float, optional):
            The approximation accuracy, a non-negative value. This is the maximum distance between the original contour and its approximation.
            A smaller value results in a more accurate approximation and more vertices.
            For size equivariance, make it proportional to the contour length such as epsilon=.05*cv_contour_length(contour)
            Default is
        closed (bool, optional): If True, treats the contour as a closed loop, meaning the endpoints might be simplified too.
            Defaults to True.

    Returns:
        numpy.ndarray: A simplified contour, a 2D NumPy array of shape (m, 2), where m is the number of points in the simplified contour.
        
    EXAMPLE:
        
        >>> @globalize_locals
        ... def animation():
        ...     image = load_image(
        ...         "https://www.shutterstock.com/image-vector/vector-illustration-black-silhouette-elk-600nw-736283863.jpg",
        ...         use_cache=True,
        ...     )
        ... 
        ...     contour = max(cv_find_contours(image), key=len)
        ...     contour = as_complex_vector(contour)
        ... 
        ...     epsilons = np.linspace(0, 15, num=100)
        ...     for i, epsilon in enumerate(eta(epsilons)):
        ...         simplified_contour = cv_simplify_contour(contour, epsilon)
        ...         simplified_contour = as_complex_vector(simplified_contour)
        ...         shift = 20 + 20j
        ... 
        ...         frame = contour_to_image(
        ...             simplified_contour + shift,
        ...             crop=False,
        ...             antialias=True,
        ...         )
        ... 
        ...         frame = image_with_progress_bar(frame, i / (len(epsilons) - 1))
        ...         frame = labeled_image(frame, f"epsilon={epsilon:.3}", font="Futura")
        ... 
        ...         yield frame
        ... 
        ... 
        ... display_video(boomerang_video(animation()), loop=True)
        
    """
    pip_import("cv2")
    import cv2

    big_number = 100000
    epsilon *= big_number
    contour = as_cv_contour(contour * big_number)  # It turns into ints
    approx_contour = cv2.approxPolyDP(contour, epsilon, closed=True)
    approx_contour = as_points_array(approx_contour)
    approx_contour = approx_contour / big_number
    return approx_contour





def cv_distance_to_contour(contour,x,y):
    """
    Return the distance from x,y to the point on contour closest to x,y
    """
    cv2=pip_import('cv2')
    contour=as_cv_contour(contour)
    return abs(cv2.pointPolygonTest(contour,(x,y),True))

def cv_closest_contour_point(contour,x,y):
    """
    Return the point on contour closest to x,y
    EXAMPLE:
       cv_closest_contour_point([[1,1],[2,2],[3,3],[4,4],[5,5]],4.4,4.4)  -->  (4,4)
    """
    cv2=pip_import('cv2')
    points=contour
    points=as_points_array(points)
    diffs=points-[x,y]
    squared_diffs=diffs**2
    squared_dists=np.sum(squared_diffs**2,1)
    index=np.argmin(squared_dists)
    return tuple(points[index])

def cv_closest_contour(contours,x,y):
    """
    Return the contour with a point closest to x,y
    """
    cv2=pip_import('cv2')
    assert len(contours)!=0,'cv_closest_contour: error: There are no contours to pick from because len(contours)==0'
    def distance(contour):
        return cv_distance_to_contour(contour,x,y)
    return min(contours,key=distance)

def cv_draw_contours(image,contours,color='white',width=1,*,fill=False,antialias=True,copy=True):
    """
    Draw multiple contours on an image using OpenCV.
    
    Args:
        image: Input image (numpy array, PIL image, or torch tensor)
        contours: List of contours to draw. Each contour can be:
            - numpy array of shape (N, 2) or (N, 1, 2) for (x,y) coordinates
            - List of (x,y) tuples
        color (str|tuple, default='white'): Color for drawing contours.
            Accepts color names ('red', 'blue', etc.) or RGB tuples
        width (int, default=1): Line thickness for contour outlines
        fill (bool, default=False): Whether to fill contours with solid color
        antialias (bool, default=True): Enable antialiasing for smoother lines
        copy (bool, default=True): Return a copy of the image vs modify in place
        
    Returns:
        numpy array: Image with drawn contours
        
    Note:
        TODO: This function should preserve whether each contour is closed or not.
        Currently treats all contours as closed polygons.
        
    Examples:
        >>> image = load_image('path/to/image.jpg')
        >>> contours = [[(10,10), (50,10), (50,50), (10,50)]]
        >>> result = cv_draw_contours(image, contours, color='red', width=2)
        >>> result = cv_draw_contours(image, contours, fill=True, color='blue')
    """
    color=as_rgb_float_color(color)
    color=float_color_to_byte_color(color)
    
    cv2=pip_import('cv2')
    contours=list(map(as_cv_contour,contours))
    image,kwargs=_cv_helper(image=image,copy=copy,antialias=antialias)
    cv2.drawContours(image,contours,-1,color,width,**kwargs)
    if fill:cv2.fillPoly(image,contours,color)
    return image

def cv_draw_contour(image,contour,color='white',width=1,*,fill=False,antialias=True,copy=True):
    """
    Draw a single contour on an image using OpenCV.
    
    This is a convenience function that wraps cv_draw_contours for drawing a single contour.
    
    Args:
        image: Input image (numpy array, PIL image, or torch tensor)
        contour: Single contour to draw. Can be:
            - numpy array of shape (N, 2) or (N, 1, 2) for (x,y) coordinates  
            - List of (x,y) tuples
        color (str|tuple, default='white'): Color for drawing the contour.
            Accepts color names ('red', 'blue', etc.) or RGB tuples
        width (int, default=1): Line thickness for contour outline
        fill (bool, default=False): Whether to fill the contour with solid color
        antialias (bool, default=True): Enable antialiasing for smoother lines
        copy (bool, default=True): Return a copy of the image vs modify in place
        
    Returns:
        numpy array: Image with drawn contour
        
    Examples:
        >>> image = load_image('path/to/image.jpg')
        >>> contour = [(10,10), (50,10), (50,50), (10,50)]
        >>> result = cv_draw_contour(image, contour, color='red', width=3)
        >>> result = cv_draw_contour(image, contour, fill=True, color='green')
    """
    return gather_args_call(cv_draw_contours, contours=[contour])

def cv_draw_rectangle(image,
                      *,
                      start_point:tuple,
                      end_point:tuple,
                      color='white',
                      thickness=1,
                      copy=True,
                      antialias=True):
    """
    Right now rectangles are defined by two (x,y) points (start_point, end_point). They're required keyword arguments for now,
    becuase I might add more ways to specify rectangles in the future such as top_left, and height/width.

    EXAMPLE:
       image=load_image('https://upload.wikimedia.org/wikipedia/en/7/7d/Lenna_%28test_image%29.png')
       for _ in range(100):
           def random_coords():
               return ( random_int(get_image_height(image)),random_int(get_image_width(image)))
           image=cv_draw_rectangle(image,start_point=random_coords(),end_point=random_coords(),color=random_rgb_byte_color())
           display_image(image)
    """

    color=as_rgb_float_color(color)
    color=float_color_to_byte_color(color)
    
    #Input assertions:
    assert isinstance(start_point,tuple) and len(start_point)==2
    assert isinstance(end_point  ,tuple) and len(end_point  )==2
    start_point=tuple(int(x) for x in start_point)
    end_point  =tuple(int(x) for x in end_point  )
    
    pip_import('cv2')
    import cv2

    image = as_byte_image(image)
    image = as_rgb_image(image)

    image, kwargs = _cv_helper(image=image, copy=copy, antialias=True)

    image = cv2.rectangle(image, start_point, end_point, color, thickness)
    return image

def cv_contour_length(contour,closed=False):
    """ Get the length or circumference of a list of 2d points """
    cv2=pip_import('cv2')
    contour=as_cv_contour(contour)
    return cv2.arcLength(contour,closed=closed)

def cv_contour_area(contour):#,closed=False):
    """ Get the area of a list of 2d points """
    cv2=pip_import('cv2')
    contour=as_cv_contour(contour)
    return cv2.contourArea(contour)

def contour_distance_to_index(contour, distance, *, loop=False):
    """
    Convert arc-length distances along a contour to fractional vertex indices.
    
    Given a polyline (contour) and one or more distances measured along its edges,
    this function returns the corresponding index positions as floats. Supports both
    scalar and vectorized inputs for `distance`.
    
    Parameters:
        - contour (array-like): Path data in any format supported by `as_points_array`
                                (e.g., list of points, cv2 contour, complex vector).
                                Represents a 2D polyline of shape (N, 2).
        - distance (float or array-like): Distance(s) along the path to convert into
                                          fractional indices. Vectorized: can be scalar
                                          or iterable of scalars.
        - loop (bool, optional): If True, treat the path as closed. Distances may be
                                 negative or exceed the total perimeter and will wrap
                                 modulo the path length. Default False clamps distances
                                 to [0, total length].
    
    Returns:
        - float or numpy.ndarray: Fractional index or array of indices.  
                                  Non-looped: range is [0, N-1].  
                                  Looped: values are in [0, N).
    
    Raises:
        - ValueError: If the input contour has zero length and indices cannot be determined.
    
    EXAMPLES:

        >>> contour_distance_to_index([[0,0],[1,1]],1) # 0.7071067811865475
        >>> contour_distance_to_index([[0,0],[0,3]],1) # 0.3333333333333333
        >>> contour_distance_to_index([[0,0],[0,3]],2) # 0.6666666666666666
        >>> contour_distance_to_index([[0,0],[0,3]],3) # 1.0
        >>> contour_distance_to_index([[0,0],[0,3]],[0,1,2,3]) # [0.  0.333 0.666 1. ]
    
    """
    if not len(contour): raise ValueError(get_current_function_name()+": contour has 0 length, cannot retrieve any indices")

    contour = as_points_array(contour)
    distance = as_numpy_array(distance)
    
    cumdist = cumulative_euclidean_distances(contour, include_zero=True, loop=loop)
    total = cumdist[-1]

    if loop:
        distance = distance % total
    else:
        distance = np.clip(distance, 0, total)

    # handle zero-length segments (duplicate distances)                     
    xp, idx = np.unique(cumdist, return_index=True)

    out = np.interp(distance, xp, idx)

    return out if distance.ndim else float(out)

def trim_contour(contour, start: float, end: float, *, loop=False):
    """
    
    EXAMPLE:
        
        >>> image = load_image(
        ...     "https://www.shutterstock.com/image-vector/vector-illustration-black-silhouette-elk-600nw-736283863.jpg",
        ...     use_cache=True,
        ... )
        ... contour = max(cv_find_contours(image), key=len)
        ... contour = as_complex_vector(contour)
        ... contour = cv_simplify_contour(contour,10)
        ... #contour = circ_gauss_blur(contour, 10)
        ... #contour = evenly_split_path(contour, 1000)
        ... 
        ... @globalize_locals
        ... def animation():
        ...     circumference=cv_contour_length(contour)
        ...     for distance in np.linspace(0,circumference,num=1000):
        ...         frame=uniform_byte_color_image(*get_image_dimensions(image),'dark blue')
        ... 
        ...         end=contour_distance_to_index(contour,distance)
        ... 
        ...         trimmed_contour = trim_contour(contour,0,end)
        ...         
        ...         frame = cv_draw_contour(frame, trimmed_contour, copy=False,fill=True)
        ...         frame=image_with_progress_bar(frame,distance/circumference)
        ...         
        ...             
        ...         yield frame
        ... 
        ... display_video(animation(), loop=True)


    ------------------ TODO: TEST THIS MORE -----------------------
    ------------------ WRITTEN WITH GPT5 --------------------------

    Trim a contour between two (possibly fractional) vertex indices.

    Returns a new contour that starts at `start` and ends at `end`,
    interpolating linearly between vertices. If `start > end`, the
    output traverses backward. If `start == end`, it still returns
    two points (start and end are identical), so the output length
    is guaranteed to be at least 2.

    Parameters:
        - contour (array-like): Path data accepted by `as_points_array`
                                (e.g., [[x,y],...], cv2 contour, complex vector).
                                Represents a 2D polyline of shape (N, 2).
        - start (float): Starting fractional index along the contour.
        - end (float): Ending fractional index along the contour.
        - loop (bool, optional): If True, treat the contour as cyclic and allow
                                 `start`/`end` to wind around multiple times
                                 (values outside [0, N) are permitted and respected).
                                 If False, `start` is clipped to ≥ 0 and `end`
                                 is clipped to ≤ N. Default False.

    Returns:
        - numpy.ndarray: New contour with shape (M, 2), consisting of:
                         [point_at(start), (all crossed integer vertices), point_at(end)].
                         M >= 2 always (even when start == end).

    Notes:
        - Indices are fractional over vertex positions: 1.5 means halfway between
          vertex 1 and 2.
        - For `loop=True`, seams interpolate across the last→first edge naturally.
        - Duplicated input vertices are fine; interpolation uses vertex indices.

    EXAMPLES:

        # open square, forward
        >>> trim_contour([[0,0],[1,0],[1,1],[0,1]], 0.5, 2.5, loop=False)
        # [[0.5 0. ]
        #  [1.  0. ]
        #  [1.  1. ]
        #  [0.5 1. ]]

        # open square, reversed
        >>> trim_contour([[0,0],[1,0],[1,1],[0,1]], 2.5, 0.5, loop=False)
        # [[0.5 1. ]
        #  [1.  1. ]
        #  [1.  0. ]
        #  [0.5 0. ]]

        # looped square, winds across seam
        >>> trim_contour([[0,0],[1,0],[1,1],[0,1]], 3.5, 5.25, loop=True)
        # [[0.   0.5 ]
        #  [0.   0.  ]
        #  [1.   0.  ]
        #  [1.   0.25]]

        # start == end -> two identical points
        >>> trim_contour([[0,0],[1,0],[1,1],[0,1]], 1.0, 1.0, loop=False)
        # [[1. 0.]
        #  [1. 0.]]
    """
    if not len(contour):
        raise ValueError(get_current_function_name()+": contour has 0 length, cannot trim")

    pts = as_points_array(contour).astype(float)
    N = len(pts)

    start = float(start)
    end   = float(end)

    # For non-loop, clamp into [0, N]; for loop, allow any real (multi-wrap).
    if not loop:
        if start<0: start = N-start
        if end  <0: end   = N-end
        start = np.clip(start, 0.0, float(N))
        end   = np.clip(end,   0.0, float(N))

    # Per-dimension linear interpolation over vertex index with optional period
    xp = np.arange(N, dtype=float)
    period = float(N) if loop else None

    def pos_at(xq):
        xq = np.asarray(xq, dtype=float)
        x0 = np.interp(xq, xp, pts[:, 0], period=period)
        x1 = np.interp(xq, xp, pts[:, 1], period=period)
        return np.stack([x0, x1], axis=-1)

    # Collect all crossed integer boundaries (inclusive) in traversal order.
    # Always include both endpoints; ensures output length >= 2.
    if end > start:
        ks = np.arange(np.ceil(start), np.floor(end) + 1, 1.0)
        xq = np.concatenate(([start], ks, [end]))
    else:
        ks = np.arange(np.floor(start), np.ceil(end) - 1, -1.0)
        xq = np.concatenate(([start], ks, [end]))

    return pos_at(xq)

    

def cv_draw_circle(
    image,
    x,
    y,
    radius=5,
    color='white',

    rim = 0,
    rim_color='black',
    *,
    antialias=True,
    copy=True
):
    """
    Draws a filled circle with center x,y on a given image. If copy=False, it *might* mutate the original image if the given image is an RGB or RGBA byte image.

    Rim draws an outline around the circle. If it's positive, it draws on the outside of the circle. If negative, draws on the inside.

    EXAMPLE:
        >>> N = 300
        ... image = load_image(
        ...     "https://github.com/RyannDaGreat/Diffusion-Illusions/blob/gh-pages/images/emma.png?raw=true",
        ...     use_cache=True,
        ... )
        ... 
        ... colors = as_rgba_float_colors("random blue" for _ in range(N))
        ... rim_colors = as_rgba_float_colors("white randomgray" for _ in range(N))
        ... y = random_ints(N, get_image_height(image) - 1)
        ... x = random_ints(N, get_image_width(image) - 1)
        ... radii = random_floats(N, 1, 30)
        ... rims = random_floats(N, -10, 10)
        ... 
        ... display_image(cv_draw_circles(image, x, y, radii, colors, rims, rim_colors))

    """

    cv2 = pip_import("cv2")

    radius = max(0, radius)
    
    image, kwargs = _cv_helper(image=image, copy=copy, antialias=antialias)

    if not radius and not rim:
        #Fast shortcut
        return image

    #Handle rim - a ring aronud the circle
    if rim > 0:
        image = cv_draw_circle(image, x, y, radius = radius + rim, color = rim_color, rim=0, copy=False, antialias=antialias)
        image = cv_draw_circle(image, x, y, radius = radius      , color =     color, rim=0, copy=False, antialias=antialias)
        return image
    elif rim < 0:
        image = cv_draw_circle(
            image,
            x,
            y,
            radius=radius + rim,
            color=color,
            rim=-rim,
            rim_color=rim_color,
            copy=False,
            antialias=antialias,
        )
        return image
    
    if is_binary_image(image):
        image = rp.as_rgb_image(image, copy=copy)
    image = as_byte_image(image, copy=copy)

    if is_rgba_image(image):
        color=as_rgba_float_color(color)
    else:
        assert is_rgb_image(image), 'Internal assertion'
    color=float_color_to_byte_color(color)

    radius = int(radius)        
    x      = int(x)
    y      = int(y)
    
    cv2.circle(image, (x, y), radius, color, -1, **kwargs)
    return image

def cv_draw_circles(
    image,
    x,
    y,
    radius=5,
    color='white',

    rim = 0,
    rim_color='black',
    *,
    antialias=True,
    show_progress=False,
    copy=True
):
    """
    Plural of cv_draw_circle
    x, y, color, antialias are all broadcastable

    EXAMPLE:
        >>> N = 300
        ... image = load_image(
        ...     "https://github.com/RyannDaGreat/Diffusion-Illusions/blob/gh-pages/images/emma.png?raw=true",
        ...     use_cache=True,
        ... )
        ... 
        ... colors = as_rgba_float_colors("random blue" for _ in range(N))
        ... rim_colors = as_rgba_float_colors("white randomgray" for _ in range(N))
        ... y = random_ints(N, get_image_height(image) - 1)
        ... x = random_ints(N, get_image_width(image) - 1)
        ... radii = random_floats(N, 1, 30)
        ... rims = random_floats(N, -10, 10)
        ... 
        ... display_image(cv_draw_circles(image, x, y, radii, colors, rims, rim_colors))
    """

    #broadcast_lists only broadcasts lists - so make any iterables lists
    try:              color = [as_rgba_float_color(color)]
    except Exception: color = list(color)
    try:              rim_color = [as_rgba_float_color(rim_color)]
    except Exception: rim_color = list(rim_color)
    if is_iterable(x)                : x         = list(x)
    if is_iterable(y)                : y         = list(y)
    if is_iterable(radius)           : radius    = list(radius)
    if is_iterable(antialias)        : antialias = list(antialias)
    if is_iterable(rim)              : rim       = list(rim)

    xs, ys, radiuss, colors, antialiass, rims, rim_colors = broadcast_lists(x, y, radius, color, antialias, rim, rim_color)
    assert len(xs)==len(ys)==len(radiuss)==len(colors)==len(antialiass)==len(rims)==len(rim_colors)
    
    bundles = zip(xs, ys, radiuss, colors, antialiass, rims, rim_colors)

    if show_progress:
        length = len(xs)
        bundles = eta(bundles, "cv_draw_circles", length=length)

    image, kwargs = _cv_helper(image=image, copy=copy, antialias=antialias)

    for x, y, radius, color, antialias, rim, rim_color in bundles:
        image = cv_draw_circle(image, x, y, radius, color, rim=rim, rim_color=rim_color, antialias=antialias, copy=False)

    return image

def cv_draw_arrow(
    image,
    start_x,
    start_y,
    end_x,
    end_y,
    thickness=2,
    color='white',
    
    rim = 0,
    rim_color='black',
    tip_length=0.3,
    *,
    antialias=True,
    copy=True
):
    """
    Draws an arrow from (start_x, start_y) to (end_x, end_y) on a given image. If copy=False, it *might* mutate the original image if the given image is an RGB or RGBA byte image.

    Rim draws an outline around the arrow. If it's positive, it draws on the outside of the arrow. If negative, draws on the inside.
    
    tip_length controls the ratio of arrow head length to total arrow length. Specifically, it's the proportion of the arrow's length 
    taken up by the diagonal lines that form the arrowhead. For example:
    - tip_length = 0.5 creates a diamond shape (head takes up half the arrow)
    - tip_length = sqrt(2) ≈ 1.414 creates a right isosceles triangle arrowhead
    - tip_length = 0.3 (default) creates a typical arrow appearance

    EXAMPLE:
        >>> N = 50
        ... image = load_image(
        ...     "https://github.com/RyannDaGreat/Diffusion-Illusions/blob/gh-pages/images/emma.png?raw=true",
        ...     use_cache=True,
        ... )
        ... 
        ... colors = as_rgba_float_colors("random red" for _ in range(N))
        ... rim_colors = as_rgba_float_colors("white randomgray" for _ in range(N))
        ... start_y = random_ints(N, get_image_height(image) - 1)
        ... start_x = random_ints(N, get_image_width(image) - 1)
        ... end_y = random_ints(N, get_image_height(image) - 1)
        ... end_x = random_ints(N, get_image_width(image) - 1)
        ... thicknesses = random_floats(N, 1, 5)
        ... rims = random_floats(N, -3, 3)
        ... tip_lengths = random_floats(N, 0.1, 0.5)
        ... 
        ... display_image(cv_draw_arrows(image, start_x, start_y, end_x, end_y, thicknesses, colors, rims, rim_colors, tip_lengths))

    EXAMPLE:
        >>> # Demonstrating tip_length effect with arrows going down
        ... N = 10
        ... image = np.ones((400, 600, 3), dtype=np.uint8) * 255  # White background
        ... 
        ... # Create arrows in a row, all pointing down with varying tip_length
        ... start_x = np.linspace(50, 550, N)
        ... start_y = np.full(N, 50)
        ... end_x = start_x  # Same x coordinates (straight down)
        ... end_y = np.full(N, 350)
        ... 
        ... # Vary tip_length from 0.0 to 1.0
        ... tip_lengths = np.linspace(0.0, 1.0, N)
        ... colors = ["black"] * N
        ... thickness = 3
        ... 
        ... # Add labels for tip_length values
        ... for i, (x, tip) in enumerate(zip(start_x, tip_lengths)):
        ...     cv2.putText(image, f"{tip:.1f}", (int(x)-15, 30), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0,0,0), 1)
        ... 
        ... display_image(cv_draw_arrows(image, start_x, start_y, end_x, end_y, thickness, colors, tip_length=tip_lengths))

    """

    cv2 = pip_import("cv2")

    thickness = max(1, thickness)
    tip_length = max(0.0, min(1.0, tip_length))
    
    image, kwargs = _cv_helper(image=image, copy=copy, antialias=antialias)

    if not thickness and not rim:
        #Fast shortcut
        return image

    #Handle rim - a ring around the arrow
    if rim > 0:
        image = cv_draw_arrow(image, start_x, start_y, end_x, end_y, thickness = thickness + rim, color = rim_color, rim=0, tip_length=tip_length, copy=False, antialias=antialias)
        image = cv_draw_arrow(image, start_x, start_y, end_x, end_y, thickness = thickness      , color =     color, rim=0, tip_length=tip_length, copy=False, antialias=antialias)
        return image
    elif rim < 0:
        image = cv_draw_arrow(
            image,
            start_x,
            start_y,
            end_x,
            end_y,
            thickness=thickness + rim,
            color=color,
            rim=-rim,
            rim_color=rim_color,
            tip_length=tip_length,
            copy=False,
            antialias=antialias,
        )
        return image

    #OpenCV handles arrow alpha not by blending over the original image, but by simply replacing the alpha values...
    #There is no fast way to alpha-blend the arrows onto the image afaik...
    color=as_rgba_float_color(color)
    alpha = color[-1]
    color=float_color_to_byte_color(color)
    if is_binary_image(image):
        image = rp.as_rgb_image(image, copy=copy)
    if alpha==1 and is_rgb_image(image):
        color = color[:3] #Make it RGB
    if len(color)==4:
        image = as_rgba_image(image, copy=copy)

    image = as_byte_image(image, copy=copy)

    thickness = int(thickness)        
    start_x = int(start_x)
    start_y = int(start_y)
    end_x = int(end_x)
    end_y = int(end_y)
    
    # cv2.arrowedLine doesn't accept lineType in kwargs, so we need to filter it
    line_type = kwargs.pop('lineType', cv2.LINE_8)
    cv2.arrowedLine(image, (start_x, start_y), (end_x, end_y), color, thickness, line_type, tipLength=tip_length)
    return image


def cv_draw_arrows(
    image,
    start_x,
    start_y,
    end_x,
    end_y,
    thickness=2,
    color='white',
    
    rim = 0,
    rim_color='black',
    tip_length=0.3,
    *,
    visible=True,
    antialias=True,
    show_progress=False,
    copy=True
):
    """
    Plural of cv_draw_arrow
    start_x, start_y, end_x, end_y, thickness, color, rim, rim_color, tip_length, antialias are all broadcastable

    EXAMPLE:
        >>> N = 50
        ... image = load_image(
        ...     "https://github.com/RyannDaGreat/Diffusion-Illusions/blob/gh-pages/images/emma.png?raw=true",
        ...     use_cache=True,
        ... )
        ... 
        ... colors = as_rgba_float_colors("random red" for _ in range(N))
        ... start_y = random_ints(N, get_image_height(image) - 1)
        ... start_x = random_ints(N, get_image_width(image) - 1)
        ... end_y = random_ints(N, get_image_height(image) - 1)
        ... end_x = random_ints(N, get_image_width(image) - 1)
        ... thicknesses = random_floats(N, 1, 5)
        ... tip_lengths = random_floats(N, 0.1, 0.5)
        ... 
        ... display_image(cv_draw_arrows(image, start_x, start_y, end_x, end_y, thicknesses, colors, rims, rim_colors, tip_lengths))
    """

    #broadcast_lists only broadcasts lists - so make any iterables lists
    try:              color = [as_rgba_float_color(color)]
    except Exception: color = list(color)
    try:              rim_color = [as_rgba_float_color(rim_color)]
    except Exception: rim_color = list(rim_color)
    if is_iterable(start_x)          : start_x     = list(start_x)
    if is_iterable(start_y)          : start_y     = list(start_y)
    if is_iterable(end_x)            : end_x       = list(end_x)
    if is_iterable(end_y)            : end_y       = list(end_y)
    if is_iterable(thickness)        : thickness   = list(thickness)
    if is_iterable(tip_length)       : tip_length  = list(tip_length)
    if is_iterable(antialias)        : antialias   = list(antialias)
    if is_iterable(rim)              : rim         = list(rim)
    if is_iterable(visible)          : visible     = list(visible)

    start_xs, start_ys, end_xs, end_ys, thicknesses, colors, tip_lengths, antialiass, rims, rim_colors, visible = broadcast_lists(
        start_x, start_y, end_x, end_y, thickness, color, tip_length, antialias, rim, rim_color, visible
    )
    assert len(start_xs)==len(start_ys)==len(end_xs)==len(end_ys)==len(thicknesses)==len(colors)==len(tip_lengths)==len(antialiass)==len(rims)==len(rim_colors)
    
    bundles = zip(start_xs, start_ys, end_xs, end_ys, thicknesses, colors, tip_lengths, antialiass, rims, rim_colors, visible)

    if show_progress:
        length = len(start_xs)
        bundles = eta(bundles, "cv_draw_arrows", length=length)

    image, kwargs = _cv_helper(image=image, copy=copy, antialias=antialias)

    for start_x, start_y, end_x, end_y, thickness, color, tip_length, antialias, rim, rim_color, visible in bundles:
        if visible:
            image = cv_draw_arrow(image, start_x, start_y, end_x, end_y, thickness, color, rim=rim, rim_color=rim_color, tip_length=tip_length, antialias=antialias, copy=False)

    return image


def cv_line_graph(
    y_values,
    x_values=None,
    *,
    height=None,
    width=None,
    y_min=None,
    y_max=None,
    x_min=None,
    x_max=None,
    background_color=(1, 1, 1, 0),
    line_color=(0, 0, 0, 1),
    vertical_bar_x=None,
    vertical_bar_color=(1, 0, 0, 1),
    antialias=True,
    thickness=1,
    vertical_bar_thickness=None
):
    """
    Draws a line graph using OpenCV with the given values and colors.

    Args:
        y_values (list or numpy.ndarray): The y-values of the data points. This is NOT in image-coordinates, it's in graph coordinates.
        x_values (list or numpy.ndarray, optional): The x-values of the data points. If not provided, the indices of y_values will be used.
        height (int, optional): The height of the output image. If not provided, it will be determined based on the range of y-values.
        width (int, optional): The width of the output image. If not provided, it will be determined based on the number of data points.
        y_min (float, optional): The minimum value of the y-axis. If not provided, it will be determined based on the minimum y-value.
        y_max (float, optional): The maximum value of the y-axis. If not provided, it will be determined based on the maximum y-value.
        x_min (float, optional): The minimum value of the x-axis. If not provided, it will be determined based on the minimum x-value.
        x_max (float, optional): The maximum value of the x-axis. If not provided, it will be determined based on the maximum x-value.
        background_color (tuple, optional): The RGBA color of the background. Default is (255, 255, 255, 255) (white).
        line_color (tuple, optional): The RGBA color of the line. Default is (0, 0, 0, 255) (black).
        vertical_bar_x (float, optional): The x-coordinate of the vertical bar. If provided, a vertical bar will be drawn at this x-coordinate.
        vertical_bar_color (tuple, optional): The RGBA color of the vertical bar. Default is (255, 0, 0, 255) (red).
        antialias (bool, optional): Whether to apply antialiasing to the line. Default is True.
        thickness (int, optional): The thickness of the line. Default is 1.
        vertical_bar_thickness (int, optional): The thickness of the vertical bar. If not provided, it defaults to the thickness option.

    Returns:
        numpy.ndarray: The resulting line graph image as a uint8 HW4 image.

    Notes:
        To have multiple lines, I reccomend using rp.overlay_images and running this function multiple times with a transparent background

    Example:
        >>> # Example usage: Moving sine waves with moving vertical x bars and changing colors, widths, and alpha values
        ... def animate_sine_waves(num_frames=1000):
        ...     for frame in range(num_frames):
        ...         t = frame * 0.1
        ...         y_values = [math.sin(x + t) for x in np.linspace(0, 2 * math.pi, 100)]
        ...         x_values = np.linspace(0, 2 * math.pi, 100)
        ...
        ...         height = int(200 + 50 * math.sin(t))
        ...         width = int(400 + 50 * math.cos(t))
        ...         y_min, y_max = -1.5, 1.5
        ...         x_min, x_max = 0, 2 * math.pi
        ...
        ...         background_color = (int(math.sin(t) * 127 + 128), int(math.cos(t) * 127 + 128), 0, int(abs(math.sin(t)) * 255))
        ...         line_color = (int(math.cos(t) * 127 + 128), int(math.sin(t) * 127 + 128), 0, int(abs(math.cos(t)) * 255))
        ...         vertical_bar_x = 2 * math.pi * (frame % num_frames) / num_frames
        ...         vertical_bar_color = (255, 0, 0, int(abs(math.sin(t)) * 255))
        ...
        ...         thickness = int(2 + math.sin(t))
        ...         vertical_bar_thickness = int(4 + math.cos(t))
        ...
        ...         #rp.gather_args_call passes current variables as kwargs
        ...         graph = rp.gather_args_call(cv_line_graph)  
        ...
        ...         rp.display_alpha_image(graph)
        ... animate_sine_waves()

    Example:
        >>> def mouse_graph_demo():
        ...     #This demo is pure eye candy!
        ...     mouse_xs = []
        ...     mouse_ys = []
        ...     while True:
        ...         #Add mouse positions to the graph
        ...         mouse_x, mouse_y = get_mouse_position()
        ...         mouse_xs.append(mouse_x)
        ...         mouse_ys.append(mouse_y)
        ...  
        ...         #Only record so much
        ...         history=300
        ...         mouse_xs=mouse_xs[-history:]
        ...         mouse_ys=mouse_ys[-history:]
        ...  
        ...         #Make sure the y values of the graph are the same for the same values
        ...         y_min = min(mouse_xs + mouse_ys)
        ...         y_max = max(mouse_xs + mouse_ys)
        ...         thickness=2
        ...  
        ...         #Create the graph image
        ...         graph = overlay_images(
        ...             (0,0,0,0),
        ...             gather_args_call(cv_line_graph, mouse_xs, height=100, width=500, line_color=(255, 64, 32, 255)),
        ...             gather_args_call(cv_line_graph, mouse_ys, height=100, width=500, line_color=(64, 255, 32, 255)),
        ...             mode='add'
        ...         )
        ...         
        ...         #Raaainboww! Hue gradient from left to right
        ...         gradient = (1-xy_float_images(*get_image_dimensions(graph))[0])/3
        ...         graph=shift_image_hue(graph,gradient)
        ...  
        ...         #Add some pretty glow effects! Adding the alphas is improper...but looks really cool
        ...         graph+=cv_alpha_weighted_gauss_blur(graph, sigma=30)*1.5
        ...         
        ...         graph=labeled_image(graph,'Min=%i Max=%i Entries=%i'%(y_min,y_max,len(mouse_xs)))
        ...         graph=labeled_image(graph,'MouseX=%i MouseY=%i'%(mouse_x,mouse_y),position='bottom')
        ...         
        ...         display_alpha_image(graph,tile_size=70,first_color=.1,second_color=.2)
        ... mouse_graph_demo()

    """

    background_color   = float_color_to_byte_color(as_rgba_float_color(background_color))
    line_color         = float_color_to_byte_color(as_rgba_float_color(line_color))
    vertical_bar_color = float_color_to_byte_color(as_rgba_float_color(vertical_bar_color))

    #Ensure things are installed
    rp.pip_import('cv2')
    rp.pip_import('numpy')

    #Keep imports inside the function
    import numpy as np
    import cv2
    import math

    if len(y_values)==1 and x_values is None:
        #If given a single value make it a line
        y_values=[y_values[0]]*2
    
    # Convert input values to numpy arrays
    y_values = np.array(y_values, dtype=np.float32)
    x_values = np.array(x_values, dtype=np.float32) if x_values is not None else np.arange(len(y_values), dtype=np.float32)

    # Determine the dimensions of the output image if not provided
    height = height or int(np.ptp(y_values)+1)
    width  = width  or int(np.ptp(x_values)+1)

    # Create the output image with the specified background color
    graph = np.full((height, width, 4), background_color, dtype=np.uint8)

    assert len(y_values)==len(x_values), (len(x_values), len(y_values))
    if not len(y_values):
        #No plotting!
        return graph

    # Determine the range of the axes if not provided
    y_min = y_min if y_min is not None else np.min(y_values)
    y_max = y_max if y_max is not None else np.max(y_values)
    x_min = x_min if x_min is not None else np.min(x_values)
    x_max = x_max if x_max is not None else np.max(x_values)

    # Map the data points to pixel coordinates
    x = ((x_values - x_min) / (x_max - x_min + 1e-10) * width ).astype(int)
    y = ((y_max - y_values) / (y_max - y_min + 1e-10) * height).astype(int)

    # Draw the line graph
    line_type = cv2.LINE_AA if antialias else cv2.LINE_8
    cv2.polylines(graph, [np.column_stack((x, y))], isClosed=False, color=line_color, thickness=thickness, lineType=line_type)

    # Draw the vertical bar if provided
    if vertical_bar_x is not None:
        bar_x = int((vertical_bar_x - x_min) / (x_max - x_min) * width)
        bar_thickness = thickness if vertical_bar_thickness is None else vertical_bar_thickness
        cv2.line(graph, (bar_x, 0), (bar_x, height), color=vertical_bar_color, thickness=bar_thickness)

    return graph

def rgb_histogram_image(histograms, *, width=256, height=128, yscale=1, smoothing=0):
    """
    Takes an image, or its color histograms
    Returns an image with RGB graphs of the colors in that image on a black background

    EXAMPLE:

        >>> while True:
        ...     wc = load_image_from_webcam()
        ...     wc = resize_image_to_fit(wc, height=256)
        ...     width = get_image_width(wc)
        ...     display_image(
        ...         vertically_concatenated_images(
        ...             wc, rgb_histogram_image(wc, width=width),
        ...         )
        ...     )

    """

    if is_image(histograms):
        histograms = as_rgb_image(histograms)
        histograms = byte_image_histogram(histograms)
    
    assert histograms.shape==(3,256)

    histograms = histograms / histograms.sum()

    histograms *= height * yscale

    rh,gh,bh=histograms    

    rh = circ_gauss_blur(rh, smoothing)
    gh = circ_gauss_blur(gh, smoothing)
    bh = circ_gauss_blur(bh, smoothing)
 
    red_graph = cv_line_graph(
        rh,
        width=width,
        height=height,
        background_color=(0, 0, 0, 0),
        line_color=float_color_to_byte_color(as_rgba_float_color("red orange")),
        y_max=1,
        y_min=0,
    )
    green_graph = cv_line_graph(
        gh,
        width=width,
        height=height,
        background_color=(0, 0, 0, 0),
        line_color=float_color_to_byte_color(as_rgba_float_color("green")),
        y_max=1,
        y_min=0,
    )
    blue_graph = cv_line_graph(
        bh,
        width=width,
        height=height,
        background_color=(0, 0, 0, 0),
        line_color=float_color_to_byte_color(as_rgba_float_color("blue cyan")),
        y_max=1,
        y_min=0,
    )

    red_graph  =as_float_image(red_graph)
    green_graph=as_float_image(green_graph)
    blue_graph =as_float_image(blue_graph)
    
    output = red_graph+green_graph+blue_graph
    output = as_rgb_image(output)
    return output

def byte_image_histogram(image):
    """
    Given an image, returns a matrix with shape (num_channels, 256)
    """
    import numpy as np
    
    image=as_byte_image(image)

    if is_grayscale_image(image):
        image=image[:,:,None]

    num_channels=image.shape[-1]
    
    histogram = np.zeros((num_channels, 256), dtype=int)

    for i in range(num_channels):  # 0: Red, 1: Green, 2: Blue
        histogram[i], _ = np.histogram(image[:, :, i], bins=256, range=(0, 255))

    return histogram



def cv_apply_affine_to_image(image,affine,output_resolution=None):
    """
    Warps an image to the affine matrix provided (of shape 2,3)
    output_resolution is to speed things up when we don't want the full resolution of the original image. It can be specified as None to get the default (original) image resolution, or some tuple with 2 ints to represent resolution
    EXAMPLE: display_image(cv_apply_affine_to_image(pup,rotation_affine_2d(60/360*tau)))
    """
    cv2=pip_import('cv2')
    image=np.asarray(image)
    affine=np.asarray(affine)
    if output_resolution is None:
        output_resolution=image.shape[:2][::-1]
    if is_number(output_resolution):
        output_resolution=tuple(x*output_resolution for x in image.shape[:2][::-1])#If output resolution is just a number, we use it as a scaling factor to the original image (so .5 means 1/2 the dimensions of the original image etc)
    assert isinstance(output_resolution,tuple) and len(output_resolution)==2
    return cv2.warpAffine(image,np.asarray(affine).astype(np.float32),output_resolution)



def cv_manually_selected_contours(contours,image=None):
    """
    Let the user manually pick out a set of contours by clicking them, then hitting the enter key to confirm their selection
    It shows you an image with contours. Click to toggle the contours on and off.
    Optionally, you can specify a background image to be shown during this selection. This is particularly useful if the contours originally came from that image. I personally like to divide that image by 2 to make it darker, letting the contours pop out more apparently.
    Special keys: press "b" to toggle a black background with your image, to help see the contours better
    Special keys: press "a" to select all contours
    Special keys: press "d" to deselect all contours
    Special keys: press "\n" confirm your selection
    """

    assert not running_in_google_colab(),'Sorry, cv_manually_selected_contours uses OpenCVs gui and cannot be used inside a Jupyter notebook'
    assert len(contours)!=0,'manually_selected_contours: error: There are no contours to pick from because len(contours)==0'

    #Set up the background image
    if image is None:
        #Specifying 'image' is optional
        image=contours_to_image(contours,crop=False)
    image=as_byte_image(image)
    image= as_rgb_image(image)
    alternative_image=image*0#Swap between these by pressing "b"

    display_needs_update=True

    #Record where the mouse moves and which contour it's selecting
    mouse_x=mouse_y=mouse_contour=None
    def on_mouse_move(x,y):
        nonlocal mouse_x,mouse_y,mouse_contour,display_needs_update
        mouse_x=x
        mouse_y=y
        new_mouse_contour=cv_closest_contour(contours,mouse_x,mouse_y)
        if id(mouse_contour)!=id(new_mouse_contour):
            display_needs_update=True
            mouse_contour=new_mouse_contour

    selected_contours=dict()#dict mapping id(contour) to contour
    def on_mouse_down(x,y):
        nonlocal display_needs_update
        if mouse_contour is None:
            return
        key=id(mouse_contour)#We keep track of the countours by memory address because numpy arrays are not hashable and we never create new contours in this function

        #Toggle the existence of mouse_contour in selected_contours
        if key in selected_contours:
            del selected_contours[key]
        else:
            selected_contours[key]=mouse_contour
        display_needs_update=True

    done=False
    def on_key_press(key):
        nonlocal done,image,alternative_image,display_needs_update,contours,selected_contours
        if key=='b':
            #Swap the background image between black and the original.
            #Has no meaningful effect if we didn't specify a background image in the first place, because the default is black
            image,alternative_image=alternative_image,image
            display_needs_update=True
        elif key=='a':
            #Select all contours
            selected_contours.update({id(contour):contour for contour in contours})
            display_needs_update=True
        elif key=='d':
            #Deselect all contours
            selected_contours.clear()
            display_needs_update=True
        elif key=='\n':
            #Pressing the enter key makes it return the result
            done=True

    while True:
        if display_needs_update:
            #Don't re-render unless necessary
            display_needs_update=False

            #Draw the contours
            display    =image.copy()
            if mouse_contour is not None:
                display=cv_draw_contour (display,mouse_contour             ,color=(255,255,255),width=5,antialias=False)
            display    =cv_draw_contours(display,contours                  ,color=(255,0  ,0  ),width=2,antialias=False)
            display    =cv_draw_contours(display,selected_contours.values(),color=(0  ,255,255),width=2,antialias=False)

            #Display the result
        cv_imshow(display                    ,
                  on_mouse_move=on_mouse_move,
                  on_mouse_down=on_mouse_down,
                  on_key_press =on_key_press)

        #Check to see if we're done
        if done:
            return list(selected_contours.values())



def cv_manually_selected_contour(contours,image=None):
    """
    TODO Merge cv_manually_selected_contours with cv_manually_selected_contour to eliminate redundancy
    Let the user manually pick out a contour by clicking it, then hitting the enter key to confirm your selection
    It shows you an image with contours. Click to toggle the contours on and off.
    Optionally, you can specify a background image to be shown during this selection. This is particularly useful if the contours originally came from that image. I personally like to divide that image by 2 to make it darker, letting the contours pop out more apparently.
    Special keys: press "b" to toggle a black background with your image, to help see the contours better
    Special keys: press "\n" confirm your selection
    """

    assert not running_in_google_colab(),'Sorry, cv_manually_selected_contours uses OpenCVs gui and cannot be used inside a Jupyter notebook'
    assert len(contours)!=0,'manually_selected_contours: error: There are no contours to pick from because len(contours)==0'

    #Set up the background image
    if image is None:
        #Specifying 'image' is optional
        image=contours_to_image(contours,crop=False)
    image=as_byte_image(image)
    image= as_rgb_image(image)
    alternative_image=image*0#Swap between these by pressing "b"

    display_needs_update=True

    #Record where the mouse moves and which contour it's selecting
    mouse_x=mouse_y=mouse_contour=None
    def on_mouse_move(x,y):
        nonlocal mouse_x,mouse_y,mouse_contour,display_needs_update
        mouse_x=x
        mouse_y=y
        new_mouse_contour=cv_closest_contour(contours,mouse_x,mouse_y)
        if id(mouse_contour)!=id(new_mouse_contour):
            display_needs_update=True
            mouse_contour=new_mouse_contour

    selected_contour=None
    def on_mouse_down(x,y):
        nonlocal display_needs_update,mouse_contour,selected_contour
        if mouse_contour is None:
            return
        key=id(mouse_contour)#We keep track of the countours by memory address because numpy arrays are not hashable and we never create new contours in this function

        selected_contour=mouse_contour#Select the contour we clicked
        display_needs_update=True

    done=False
    def on_key_press(key):
        nonlocal done,image,alternative_image,display_needs_update,contours,selected_contour
        if key=='b':
            #Swap the background image between black and the original.
            #Has no meaningful effect if we didn't specify a background image in the first place, because the default is black
            image,alternative_image=alternative_image,image
            display_needs_update=True
        elif key=='\n':
            #Pressing the enter key makes it return the result
            if selected_contour is not None:#To return the result we must have selected a contour
                done=True

    while True:
        if display_needs_update:
            #Don't re-render unless necessary
            display_needs_update=False

            #Draw the contours
            display    =image.copy()
            if mouse_contour    is not None:
                display=cv_draw_contour (display,mouse_contour   ,color=(255,255,255),width=5,antialias=False)
            display    =cv_draw_contours(display,contours        ,color=(255,0  ,0  ),width=2,antialias=False)
            if selected_contour is not None:
                display=cv_draw_contour (display,selected_contour,color=(0  ,255,255),width=2,antialias=False)

            #Display the result
        cv_imshow(display                    ,
                  on_mouse_move=on_mouse_move,
                  on_mouse_down=on_mouse_down,
                  on_key_press =on_key_press)

        #Check to see if we're done
        if done:
            return selected_contour

def cv_distance_transform(mask, distance_to='white', metric='l2', algorithm='precise', ):
    """
    Compute distance transform using OpenCV.
    
    Args:
        mask: 2D boolean array
        distance_to: What are we returning distances to? Options:
            - 'white': Regions where mask==True
            - 'black': Regions where mask==False
        metric: Distance metric. Options:
            - 'l1': Manhattan distance (cv2.DIST_L1)
            - 'l2': Euclidean distance (cv2.DIST_L2) 
            - 'c': Chessboard distance (cv2.DIST_C)
        algorithm: Algorithm precision. Options:
            - 'precise': Exact Euclidean (Felzenszwalb algorithm, slowest)
            - '3': Fast 3x3 mask (Borgefors algorithm)
            - '5': Better 5x5 mask with knight's moves
    """
    pip_import('cv2')

    import cv2
    
    mask=as_grayscale_image(mask)
    mask=as_byte_image(as_binary_image(mask))

    #Are we retuning the distance to white areas or black areas?
    if distance_to=='white':
        mask = inverted_image(mask)
    elif distance_to=='black':
        pass
    else:
        raise ValueError('cv_distance_transform: distance_to should be either "white" or "black" but got %s'%distance_to)

    metric_map = {
        'l1': cv2.DIST_L1,
        'l2': cv2.DIST_L2, 
        'c': cv2.DIST_C
    }
    
    mask_map = {
        '3': cv2.DIST_MASK_3,
        '5': cv2.DIST_MASK_5,
        'precise': cv2.DIST_MASK_PRECISE
    }
    
    distances = cv2.distanceTransform(mask, metric_map[metric], mask_map[algorithm])
    
    assert distances.shape==mask.shape
    return distances


def cosine_similarity(x,y):
    return np.sum(normalized(x)*normalized(y).conj())

def fourier_descriptor(contour,*,order=10,normalize=True):
    # import pyefd
    # contour=np.asarray(contour).squeeze()
    # descriptor=pyefd.elliptic_fourier_descriptors(contour, order=order, normalize=normalize).flatten()[3 if normalize else 0:]
    # # descriptor/=np.arange(len(descriptor))+1#Make higher harmonics worth less
    # return descriptor
    def complex_descriptors(points,approach='mean'):
        assert approach in 'mean','delta'#Try both of these and see which is better
        #TODO: How do we ensure all of these points are clockwise?
        #TODO: Right now we just assume all of these points are clockwise...this function shouldn't need that assumption, though.
        #EXAMPLE: complex_descriptors([[1,1],[1,2],[2,2],[2,1]])  -> [[ 0.-1.j -0.-1.j -0.-1.j  0.-1.j]...]  #four points of a square  (... means there are just 4 duplicate elements with this value in the array)
        #EXAMPLE: complex_descriptors([[0,0],[0,1],[1,1],[1,0]])  -> [[ 0.-1.j -0.-1.j -0.-1.j  0.-1.j]...]  #translated down by 1
        #EXAMPLE: complex_descriptors([[0,1],[1,1],[1,0],[0,0]])  -> [[ 0.-1.j -0.-1.j -0.-1.j  0.-1.j]...]  #rotated 90 degrees (shifted order of points)
        #EXAMPLE: complex_descriptors([[0,2],[2,2],[2,0],[0,0]])  -> [[ 0.-1.j -0.-1.j -0.-1.j  0.-1.j]...]  #scaled up by 2
        #EXAMPLE: complex_descriptors([[1,2],[2,2],[2,0],[0,0]])  -> [[-0.5-1.j   0.2-0.4j  0. -2.j  -0. -1.j ]
        #                                                             [ 0. -2.j  -0. -1.j  -0.5-1.j   0.2-0.4j]
        #                                                             [-0. -1.j  -0.5-1.j   0.2-0.4j  0. -2.j ]
        #                                                             [ 0.2-0.4j  0. -2.j  -0. -1.j  -0.5-1.j ]]#Made it asymmetrical; so now we have four different possible shifts
        points=np.asarray(points,np.complex128)
        assert points.shape[1]==2

        #Make turn all the 2d points (x,y) into complex scalars x+yi (where i is the imaginary constant)
        points=points[:,0]+points[:,1]*1j

        #Right now we're still dealing with complex numbers...

        if approach=='delta':points=np.roll(points,-1)-points#Get raw difference points (position invariance)
        if approach=='mean' :points=points-np.mean(points,0) #Altenrative approach: Subtract the mean (position invariance)
        points=np.roll(points,-1)/points#Get rotation vectors required (both scale and rotation invariance)
        #Note that we do NOT have to explicitly normalize any vectors to obtain scale invariance. Division does that implicitly.
        return points
        #Return every possible shift for these points
        #return np.abs(np.fft.fft(points))
        #return all_rolls(points)

    #If normalize, invariant to scale, rotation, and position
    #Notes: this seems to be invariant to subdivision (taking one edge and breaking it into two while keeping the same shape)
    #   fourier_descriptor([[0,0],[0,1],[1,1],[1,0]])  ====  fourier_descriptor([[0,0],[0,1],[1,1],[1,.5],[1,0]])  (They're exactly equal on almost all of the elements of the result, barring two of the descriptors' floating point errors)
    #   Therefore it is probably safe to decimate a contour first if speed is important.
    return np.abs(np.fft.fft(complex_descriptors(evenly_split_path(np.squeeze(contour),250))))[:20]

def fourier_descriptor_distance(contour_1,contour_2,**fourier_descriptor_kwargs):
    """
    For guidance on how to use fourier_descriptor_kwargs, see the kwargs of fourier_descriptor
    """
    return euclidean_distance(fourier_descriptor(contour_1,**fourier_descriptor_kwargs),
                              fourier_descriptor(contour_2,**fourier_descriptor_kwargs))

def fourier_descriptor_similarity(contour_1,contour_2,**fourier_descriptor_kwargs):
    """
    For guidance on how to use fourier_descriptor_kwargs, see the kwargs of fourier_descriptor
    """
    normalized_dot_product(fourier_descriptor(contour_1,**fourier_descriptor_kwargs),
                                  fourier_descriptor(contour_2,**fourier_descriptor_kwargs))
    return normalized_dot_product(fourier_descriptor(contour_1,**fourier_descriptor_kwargs),
                                  fourier_descriptor(contour_2,**fourier_descriptor_kwargs))

def cv_contour_match(a,b,scale_invariant=False):

    def conv_circ( signal, kernel ):
        '''
            signal: real 1D array
            kernel: real 1D array
            signal and kernel must have same shape/length
        '''
        return np.fft.ifft(np.fft.fft(signal)*np.fft.fft(kernel))

    def complex_descriptor(points,approach='mean'):
        assert approach in 'mean','delta'#Try both of these and see which is better
        #TODO: How do we ensure all of these points are clockwise?
        #TODO: Right now we just assume all of these points are clockwise...this function shouldn't need that assumption, though.
        #EXAMPLE: complex_descriptors([[1,1],[1,2],[2,2],[2,1]])  -> [[ 0.-1.j -0.-1.j -0.-1.j  0.-1.j]...]  #four points of a square  (... means there are just 4 duplicate elements with this value in the array)
        #EXAMPLE: complex_descriptors([[0,0],[0,1],[1,1],[1,0]])  -> [[ 0.-1.j -0.-1.j -0.-1.j  0.-1.j]...]  #translated down by 1
        #EXAMPLE: complex_descriptors([[0,1],[1,1],[1,0],[0,0]])  -> [[ 0.-1.j -0.-1.j -0.-1.j  0.-1.j]...]  #rotated 90 degrees (shifted order of points)
        #EXAMPLE: complex_descriptors([[0,2],[2,2],[2,0],[0,0]])  -> [[ 0.-1.j -0.-1.j -0.-1.j  0.-1.j]...]  #scaled up by 2
        #EXAMPLE: complex_descriptors([[1,2],[2,2],[2,0],[0,0]])  -> [[-0.5-1.j   0.2-0.4j  0. -2.j  -0. -1.j ]
        #                                                             [ 0. -2.j  -0. -1.j  -0.5-1.j   0.2-0.4j]
        #                                                             [-0. -1.j  -0.5-1.j   0.2-0.4j  0. -2.j ]
        #                                                             [ 0.2-0.4j  0. -2.j  -0. -1.j  -0.5-1.j ]]#Made it asymmetrical; so now we have four different possible shifts
        points=np.asarray(points,np.complex128)
        assert points.shape[1]==2

        #Make turn all the 2d points (x,y) into complex scalars x+yi (where i is the imaginary constant)
        points=points[:,0]+points[:,1]*1j

        #Right now we're still dealing with complex numbers...

        if approach=='delta':points=np.roll(points,-1)-points#Get raw difference points (position invariance)
        if approach=='mean' :points=points-np.mean(points,0) #Altenrative approach: Subtract the mean (position invariance)
        points=np.roll(points,-1)/points#Get rotation vectors required (both scale and rotation invariance)
        #Note that we do NOT have to explicitly normalize any vectors to obtain scale invariance. Division does that implicitly.

        return points

    def ryan_match(from_points,to_points):
        df=complex_descriptor(from_points)#Descriptor from
        dt=complex_descriptor(  to_points)#Descriptor to
        df=df/np.linalg.norm(df)
        dt=dt/np.linalg.norm(dt)
        c=conv_circ(df,np.conjugate(dt[::-1]))#This is just a hunch...I dont completely understand what Im doing yet
        c=c.real
        return 1-max(c)


    return ryan_match(a.squeeze(),b.squeeze())

    #QUICK HACK GET RID OF THIS
    #Compare two contours: a and b. Returns a float.
    #The closer the output is to 0, the better the match between a and b.
    #This is invariant to rotation, scale, and translation (it uses hu moments to compare contours)
    #https://docs.opencv.org/3.1.0/d5/d45/tutorial_py_contours_more_functions.html
    cv2=pip_import('cv2')
    # out=cv2.matchShapes(a,b,1,0.0)
    n=lambda x:x/np.linalg.norm(x)
    hu=lambda contour:cv2.HuMoments(cv2.moments(contour))
    da=[*n(fourier_descriptor(a))*1]#,*n(hu(a))]#descriptor a
    db=[*n(fourier_descriptor(b))*1]#,*n(hu(b))]#descriptor b
    return 1-np.dot(da,db)
    if not scale_invariant:
        #There should be some way to make the hu moments simply not be invariant to scale, but I don't know how to do this
        #TODO: Do that ^
        #This doesn't seem to have much effect...and I think that's OK for now...
        #TODO: Clean this function up...in particular, right here:
        #For now, we'll just add the contour length to the output
        out*=np.exp((np.log(cv_contour_length(a)+1)-np.log(cv_contour_length(b)+1))**2)
        out*=np.exp((np.log(cv_contour_area  (a)+1)-np.log(cv_contour_area  (b)+1))**2)
        pass
    # out+=cv2.createHausdorffDistanceExtractor().computeDistance(a,b)
    return out
def cv_best_match_contour(contour,contours,**kwargs):
    """
    Find the best matching contour from a collection of contours.
    
    Given a target contour and a list of candidate contours, returns the contour
    that most closely matches the target using OpenCV's contour matching algorithm.
    Useful for template matching and object detection workflows.
    
    Args:
        contour: Target contour to match against
        contours: Iterable of candidate contours to search through
        **kwargs: Additional arguments passed to cv_contour_match()
        
    Returns:
        The contour from contours that best matches the target contour
        
    Examples:
        >>> # Find shapes similar to a reference
        >>> import cv2
        >>> # reference_contour = ... (some reference shape)
        >>> # detected_contours = cv_find_contours(image)
        >>> # best_match = cv_best_match_contour(reference_contour, detected_contours)
        
    Note: Uses cv_contour_match() internally for comparison. Lower scores indicate 
    better matches. Intended for contour-based shape matching in computer vision.
    
    Related: cv_best_match_contours, cv_contour_match, cv_find_contours
    
    Tags: computer-vision, contours, matching, opencv, shape-detection
    """
    #Given a target contour and a list of contours, return the closest match to contour among contours
    #(Intended to be used to search for a contour in an image)
    assert is_iterable(contours)
    return min(contours,key=lambda candidate:cv_contour_match(contour,candidate,**kwargs))
def cv_best_match_contours(contour,contours,n=None,**kwargs):
    """
    Find the n best matching contours from a collection of contours.
    
    Given a target contour and a list of candidate contours, returns the n contours 
    that most closely match the target, sorted by match quality (best first).
    Useful for finding multiple similar shapes or getting ranked results.
    
    Args:
        contour: Target contour to match against  
        contours: Iterable of candidate contours to search through
        n (int, optional): Number of best matches to return. If None, returns all sorted
        **kwargs: Additional arguments passed to cv_contour_match()
        
    Returns:
        list: List of contours sorted by match quality (best matches first)
        
    Examples:
        >>> # Find top 5 similar shapes
        >>> import cv2
        >>> # reference_contour = ... (some reference shape)
        >>> # detected_contours = cv_find_contours(image) 
        >>> # top_5 = cv_best_match_contours(reference_contour, detected_contours, n=5)
        >>> 
        >>> # Get all matches ranked by quality
        >>> # all_ranked = cv_best_match_contours(reference_contour, detected_contours)
        
    Note: Uses cv_contour_match() internally for comparison. Lower scores indicate 
    better matches. Returns results sorted from best to worst match.
    
    Related: cv_best_match_contour, cv_contour_match, cv_find_contours
    
    Tags: computer-vision, contours, matching, opencv, shape-detection, ranking
    """
    #Return the n best matches to contour in contours
    assert is_iterable(contours)
    return sorted(contours,key=lambda candidate:cv_contour_match(contour,candidate,**kwargs))[:n or len(contours)]

def _cv_morphological_helper(image,diameter,cv_method,*,copy,circular,iterations):
    """
    Used for erosion, dilation, and other functions.
    Please see the documentation if you'd like to know what a morpholocical filter is:
    https://docs.opencv.org/3.0-beta/doc/py_tutorials/py_imgproc/py_morphological_ops/py_morphological_ops.html
    """
    image=as_byte_image(image)
    original_dtype=image.dtype
    # if image.dtype==bool:image=image.astype(np.uint8)
    if copy:image=image.copy()
    if diameter==0:return image
    if circular:
        kernel=flat_circle_kernel(diameter)
        kernel=kernel.astype(image.dtype)
        image  = cv_method(image,kernel,iterations=iterations)
    else:
        #Uses a box kernel. Runs very quickly because it takes two orthoganal 1-d passes.
        for kernel in (diameter,1),(1,diameter):
            kernel = np.ones(kernel,image.dtype)
            image  = cv_method(image,kernel,iterations=iterations)
    if original_dtype==bool:image=as_binary_image(image)
    return image

def cv_erode (image,diameter=2,*,copy=True,circular=False,iterations=1):
    #TODO min_filter is now kinda redundant, and slower if you dont have opencv. What to do about that?
    cv2=pip_import('cv2')
    return _cv_morphological_helper(image,diameter,cv_method=cv2.erode ,copy=copy,circular=circular,iterations=iterations)

def cv_dilate(image,diameter=2,*,copy=True,circular=False,iterations=1):
    """
    Dilates image with a box kernel. Runs very quickly because it takes two orthoganal 1-d passes.
    TODO max_filter is now kinda redundant, and slower if you dont have opencv. What to do about that?
    """
    cv2=pip_import('cv2')
    return _cv_morphological_helper(image,diameter,cv_method=cv2.dilate,copy=copy,circular=circular,iterations=iterations)

def cv_gauss_blur(image, sigma=1, *, alpha_weighted=False):
    """
    Gauss blur an image with the given radius using opencv

    The alpha_weighted option:
        cv_gauss_blur blurs all channels independently: R,G,B and A
        But this can mix the background with the foreground, making it ugly around mask edges
        This function fixes that problem by weighting the blur on the alpha values
        This function has the same IO signature as cv_gauss_blur

        Example:
            >>> #This example shows the difference between regular gauss blur and this function
            >>> def create_test_image():
            >>>     import cv2
            >>>     height = width = 300
            >>>     image = np.zeros((height, width, 4), dtype=np.uint8)
            >>>     cv2.line(image, (50, 100), (250, 100), (0, 255, 0, 255), 10)
            >>>     cv2.line(image, (50, 150), (250, 150), (255, 255, 0, 255), 10)
            >>>     cv2.line(image, (50, 200), (250, 200), (255, 0, 0, 255), 10)
            >>>     return image
            >>> sigma=50
            >>> test_image=create_test_image()
            >>> alpha_blurred  =cv_gauss_blur(test_image,sigma,alpha_weighted=True)
            >>> regular_blurred=cv_gauss_blur(test_image,sigma,alpha_weighted=False)
            >>> display_alpha_image(
            >>>     grid_concatenated_images(
            >>>         [
            >>>             [test_image, alpha_blurred, regular_blurred],
            >>>             as_rgb_images([test_image, alpha_blurred, regular_blurred]),
            >>>         ]
            >>>     ),
            >>> )
    """
    cv2=pip_import('cv2')

    if alpha_weighted: return _alpha_weighted_rgba_image_func(cv_gauss_blur, image, sigma)

    if is_binary_image(image):
        image=as_float_image(image) #cv2.GaussianBlur can't handle binary images, just float_images and byte_images

    sigma=int(sigma)
    if not sigma%2:
        sigma+=1#Make sigma odd
    return cv2.GaussianBlur(image,(sigma,sigma),0)

def is_opaque_image(image):
    """
    If there is a single transparent pixel in the image, return false
    Equivalent to the slower:
        return (as_rgba_image(image)==as_rgba_image(as_rgb_image(image))).all()
    """

    return (
        is_grayscale_image(image)
        or is_rgb_image(image)
        or is_rgba_image(image)
        and (
            is_byte_image(image)
            and (get_alpha_channel(image) == 255).all()
            or (get_alpha_channel(image) >= 1).all()
        )
    )

def is_transparent_image(image):
    """
    If there is a single transparent pixel in the image, return True
    Equivalent to the slower:
        return (as_rgba_image(image)!=as_rgba_image(as_rgb_image(image))).all()
    """
    return is_image(image) and not is_opaque_image(image)

def _alpha_weighted_rgba_image_func(func, image, *args, **kwargs):
    """
    Func is a function that looks like func(image, *args, **kwargs) and operates on RGBA images as defined by rp.is_rgba_image]
    See usages throughout rp.r - such as rp.cv_gauss_blur with alpha_weighted = True! Please see its docstring for examples!!
    """
    if is_opaque_image(image):
        #If the given image doesn't have any alpha, just return 
        return func(image, *args, **kwargs)

    image=as_float_image(image,copy=False)
    image=as_rgba_image (image,copy=False)
    
    epsilon=1e-4
    alpha=get_image_alpha(image)
    alpha=np.maximum(epsilon,alpha)
    alpha=as_rgb_image(alpha)
    rgb=as_rgb_image(image)
    
    weight=func(alpha    ,*args,**kwargs)
    color =func(alpha*rgb,*args,**kwargs)
    
    output=color/weight
    output=with_alpha_channel(output,weight,copy=False)
    
    return output

#endregion

def rotation_matrix(angle,out_of=tau):
    """ Set out_of to 360 to use degrees instead of radians """
    theta = angle/out_of*tau#Convert to radians
    c, s = np.cos(theta), np.sin(theta)
    R = np.array(((c,-s), (s, c)))
    return R

def loop_direction_2d(loop):
    """
    loop is like [(x,y),(x,y)...]
    Given a list of 2d points, return a negative number if they're clockwise else a positive number if theyre conuter-clockwise
    https://stackoverflow.com/questions/1165647/how-to-determine-if-a-list-of-polygon-points-are-in-clockwise-order
    Of course, if a loop has 0, or 1 points, then it's neither counter clockwise nor clockwise so it returns 0
    """
    loop=as_points_array(loop)
    if len(loop)<=2:return 0#If we have 2 or less points in this loop, it doesn't make sense to say that it's clockwise or counter-clockwise
    assert loop.shape[1]==2,'loop_direction_2d is for 2d loops only'
    next=np.roll(loop,-1,axis=0)
    next[:,0]*=-1
    return np.sum(np.prod(loop+next,1))
def is_clockwise(loop):
    """ loop is like [(x,y),(x,y)...] (two dimensions) """
    return loop_direction_2d(loop)<0
def is_counter_clockwise(loop):
    """ loop is like [(x,y),(x,y)...] (two dimensions) """
    return loop_direction_2d(loop)>0
def cv_make_clockwise(contour):
    return contour if is_clockwise(contour) else contour[::-1]



def scatter_plot(x,y=None,*,block=False,clear=True,color=None,dot_size=1,ylabel=None,xlabel=None,title=None):
    """
    Parameters:
       x and y:
           There are three ways to give this function points:
              - One is by specifying x and y as lists of numbers, where x and y are the same length, like x==[x0,x1,x2...] and y==[y0,y1,y2...]
              - Another is by leaving y None, and x is a list of points like [(x0,y0),(x1,y1),(x2,y2)...] or the numpy equivalent
              - Another is to specify x as a complex vector and leave y blank
       clear: if this is true, wipe the plot clean before drawing (if it's false, this plot will be drawn over whatever happens to exist there allready)
       block: whether to pause the python program and make the plot interactive until closed (blocks the main thread I think)
       dot_size: how big/thick should the points on the plot be?
    EXAMPLE: scatter_plot(randints_complex(100))
    EXAMPLE: x=np.linspace(0,tau);scatter_plot(np.cos(x),np.sin(x))
    EXAMPLE: scatter_plot((.9+.2j)**np.linspace(0,10*tau))#A spiral
    """
    color = as_rgba_float_color(color)
    if is_complex_vector(x):
        assert y is None,'scatter_plot: x is a complex vector but y is not None. This is an invalid input combination as the imaginary part of x ARE the y-values'
        x=as_points_array(x)
    if y is None:
        #x was given as a point list where x==[(x0,y0),(x1,y1),(x2,y2)...] and y==None
        if len(x):
            x,y=zip(*x)#Convert to x==[x0,x1,x2...] and y==[y0,y1,y2...]
        else:
            x=y=[]
    global plt
    pip_import('matplotlib')
    plt=get_plt()
    if clear and plt:
        #Clear the plot (wipe it clean of any(previous drawings)
        plt.clf()
    plt.scatter(x,y,
        s=dot_size,#The size of the dots. Smaller value --> smaller dots. The default is too big for my taste.
        color=color)

    if ylabel:
        plt.ylabel(ylabel)
    if xlabel:
        plt.xlabel(xlabel)
    if title:
        plt.title(title)

    display_update(block=block)

def line_split(string):
    """
    I find myself often wishing this function exists for a few seconds before remembering String.splitlines exists
    EXAMPLE: line_split('hello\nworld')==['hello','world']
    
    Enhanced Documentation:
    Splits a string into lines using newline characters as delimiters.
    This is essentially a convenient alias for Python's built-in str.splitlines()
    method, providing a more functional programming style interface.
    
    Args:
        string (str): Input string to split into lines
        
    Returns:
        list[str]: List of lines (without newline characters)
        
    Examples:
        >>> line_split('hello\nworld')
        ['hello', 'world']
        >>> line_split('line1\nline2\nline3')
        ['line1', 'line2', 'line3']
        >>> line_split('single_line')
        ['single_line']
        >>> line_split('')
        ['']
        
    Related Functions:
        - line_join(): Join lines back together with newlines
        - number_of_lines(): Count lines in a string
        - string_width(): Get maximum line width
        - load_file_lines(): Load lines from a file
        
    Tags: string, text, lines, split, utility
    """
    return string.splitlines()
def line_join(*lines):
    """ EXAMPLE: line_join(['hello','world'])=='hello\nworld' 
    
    Enhanced Documentation:
    Joins strings/objects with newlines. Accepts either a single iterable or varargs.
    All items are converted to strings before joining.
    
    Usage patterns:
    - Creating multi-line strings from lists
    - Formatting error messages and output
    - Used by fansi functions for colorized output
    
    Parameters:
        *lines: Either a single iterable of items, or multiple individual items
        
    Returns:
        str: Items joined with '\n' separator, empty string if no items
        
    Examples:
        >>> line_join(['hello', 'world'])
        'hello\nworld'
        >>> line_join('a', 'b', 'c')
        'a\nb\nc'
        >>> line_join([1, 2, 3, 4, 5])
        '1\n2\n3\n4\n5'
        >>> line_join(['hello', 42, 'world'])
        'hello\n42\nworld'
        >>> line_join([])
        ''
        
    Related functions: detuple, fansi_print_lines, print_lines
    Tags: string, formatting, join, text
    """
    lines=detuple(lines)
    lines=[str(line) for line in lines]#Make sure all lines are strings. This lets line_join([1,2,3,4,5]) not crash
    return '\n'.join(lines)

#region numpy utilities
def append_uniform_row(matrix,scalar=0):
    """
    Adds a row to the bottom of a matrix with a constant value equal to scalar
    Example: append_uniform_row([[1,2,3],[4,5,6],[7,8,9]],0)   ====   [[1,2,3,0],[4,5,6,0],[7,8,9,0]]
    Meant for use with numpy, and returns a numpy array.
    Does NOT mutate matrix. It makes a copy.
    """
    matrix=np.asarray(matrix)#If this line cause errors, then it's up to the user of this function to figure out why.
    return np.row_stack((matrix,scalar*np.ones((1,matrix.shape[1]))))
def append_zeros_row(matrix):
    """
    Adds a row of zeros to the bottom of a matrix
    Example: append_zeros_row([[1,2,3],[4,5,6],[7,8,9]])   ====   [[1,2,3,0],[4,5,6,0],[7,8,9,0]]
    Meant for use with numpy, and returns a numpy array.
    Does NOT mutate matrix. It makes a copy.
    """
    return append_uniform_row(matrix,0)
def append_ones_row(matrix):
    """
    Adds a row of ones to the bottom of a matrix
    Example: append_zeros_row([[1,2,3],[4,5,6],[7,8,9]])   ====   [[1,2,3,1],[4,5,6,1],[7,8,9,1]]
    Meant for use with numpy, and returns a numpy array.
    Does NOT mutate matrix. It makes a copy.
    """
    return append_uniform_row(matrix,1)

def append_uniform_column(matrix,scalar=0):
    """
    Adds a column to the bottom of a matrix with a constant value equal to scalar
    Example: append_uniform_column([[1,2,3],[4,5,6],[7,8,9]],0)   ====   [[1,2,3],[4,5,6],[7,8,9],[0,0,0]]
    Meant for use with numpy, and returns a numpy array.
    Does NOT mutate matrix. It makes a copy.
    """
    matrix=np.asarray(matrix)#If this line cause errors, then it's up to the user of this function to figure out why.
    return np.column_stack((matrix,scalar*np.ones((matrix.shape[0],1))))
def append_zeros_column(matrix):
    """
    Adds a column of zeros to the bottom of a matrix
    Example: append_zeros_column([[1,2,3],[4,5,6],[7,8,9]])   ====   [[1,2,3],[4,5,6],[7,8,9],[0,0,0]]
    Meant for use with numpy, and returns a numpy array.
    Does NOT mutate matrix. It makes a copy.
    """
    return append_uniform_column(matrix,0)
def append_ones_column(matrix):
    """
    #Adds a column of ones to the bottom of a matrix
    #Example: append_zeros_column([[1,2,3],[4,5,6],[7,8,9]])   ====   [[1,2,3],[4,5,6],[7,8,9],[1,1,1]]
    #Meant for use with numpy, and returns a numpy array.
    #Does NOT mutate matrix. It makes a copy.
    """
    return append_uniform_column(matrix,1)
#endregion

#region some more math stuff
def squared_euclidean_distance(from_point,to_point):
    """
    This function exists so you don't have to use euclidean_distance then square it (which is both inefficient and can lead to floating point errors)
    from_point and to_point are like (x0,y0,...) or [x0,y0,z0,...], or some numpy equivalent
    Example:   euclidean_distance([0,0,0],[1,1,0]) ==== 2
    ##NOTE we use float64 because float128 can cause problems with some libraries and is annoying to deal with...
    ##Note: We convert to np.complex256 for maximum accuracy across all datatypes
    """
    return float(np.sum(np.abs(as_numpy_array(to_point)-as_numpy_array(from_point))**2))
    return float(np.sum(np.abs((np.asarray(to_point,dtype=np.complex256)-np.asarray(from_point,dtype=np.complex256)))**2)) #This breaks on my M1 mac - cant handle that datatype

def euclidean_distance(from_point,to_point):
    """
    from_point and to_point are like (x0,y0,...) or [x0,y0,z0,...], or some numpy equivalent
    Example:   euclidean_distance([0,0,0],[1,1,0]) ==== sqrt(2)
    """
    return squared_euclidean_distance(from_point,to_point)**.5

def differential_euclidean_distances(points,*,include_zero=False,loop=False):
    """
    Sequential distances between points, like np.diff except returns a single vector and has options like include_zero and loop. 
    TODO: Make better doc by looking at cumulative_euclidean_distances
    """
    points=np.asarray(points)
    if loop:points=np.asarray([*points,points[0]])
    deltas=np.diff(points,axis=0)
    dists =np.sum(deltas**2,axis=tuple(range(1,deltas.ndim)))**.5
    return np.asarray([0,*dists]) if include_zero else dists

def cumulative_euclidean_distances(points,*,include_zero=False,loop=False):
    """
    If loop is true, as also add the distance from the last point to the first point at the end (one extra element in the output)
    'points' represents a list of points
    Returns an array of the cumulative distances from each point to each next point
    Examples:
        cumulative_euclidean_distances([[0,1],[0,0],[1,0]],include_zero=False)  ->     [1. 2.]
        cumulative_euclidean_distances([[0,1],[0,0],[1,0]],include_zero= True)  ->  [0. 1. 2.]
    """
    return np.cumsum(differential_euclidean_distances(points, include_zero=include_zero,loop=loop))

def evenly_split_path(path,number_of_pieces=100,*,loop=False):
    """
    Path is a list of points. Can be any number of dimensions.
    The euclidean distance from each point to the next point in the output of this function is NOT guarenteed to be even by iteself; however it is guarenteed to be equidistant ALONG the path given to this function
    Evenly splits the path into number_of_pieces pieces
    PRO TIP: This function works with points of any dimension! (Not just 2d, as shown in the examples below)
    Example:
        CODE: evenly_split_path([[0,0],[0,1],[1,1],[1,0]],7,loop=False)
        OUTPUT: [[0.  0. ]
                [0.  0.5]
                [0.  1. ]
                [0.5 1. ]
                [1.  1. ]
                [1.  0.5]
                [1.  0. ]]
    Example:
        CODE: evenly_split_path([[0,0],[0,1],[1,1],[1,0]],8,loop=True)
        OUTPUT: [[0.  0. ]
                [0.  0.5]
                [0.  1. ]
                [0.5 1. ]
                [1.  1. ]
                [1.  0.5]
                [1.  0. ]
                [0.5 0. ]]
    Tip: Also, try graphing these examples with scatter_plot(ans)
    """
    path=np.asarray(path)
    path=as_points_array(path)
    cum_dists=cumulative_euclidean_distances(path,include_zero=True,loop=loop)
    total_dist=cum_dists[-1]
    out_dists=np.linspace(0,total_dist,num=number_of_pieces,endpoint=not loop)#The distances along the path where we ouput a point. They're evenly spaced along the path.
    path=path.T#Turns [(x,y),(x,y)...] into ([x,x,...],[y,y,...])
    out=[]
    for dimension in path:
        out.append(np.interp(x=out_dists,xp=cum_dists[:-1] if loop else cum_dists,fp=dimension,period=total_dist if loop else None))
    return np.transpose(out)

#region Conversions between path types
def is_complex_vector(x):
    """ 
    Return True iff x is like [1+2j,3+4j,5+6j,...] 
    
    Enhanced Documentation:
    
    Parameters:
        x: Input to check (any type)
    
    Returns:
        bool: True if x is a 1D array of complex numbers, False otherwise
    
    Validation Rules:
        - Must be 1-dimensional when converted to numpy array
        - Must contain complex numbers (np.iscomplexobj)
        - Empty arrays return True (vacuous truth)
    
    Usage Patterns:
        - Format validation: if is_complex_vector(data): process_complex(data)
        - Used by conversion functions like as_points_array()
        - Part of RP's path format detection system
    
    Related Functions:
        - is_points_array(): Check for [[x,y],...] format
        - is_cv_contour(): Check for OpenCV contour format
        - as_complex_vector(): Convert to complex vector format
        - as_points_array(): Uses this for format detection
    
    Examples:
        >>> is_complex_vector([1+2j, 3+4j])
        True
        >>> is_complex_vector([[1,2], [3,4]])
        False
        >>> is_complex_vector([1, 2, 3])
        False
    """
    x=np.asarray(x)
    if not len(x):return True#Vaccuous truth
    return len(x.shape)==1 and np.iscomplexobj(x)
def is_points_array(x):
    """ 
    Return True iff x is like [[1,2],[3,4],[5,6],...] 
    
    Enhanced Documentation:
    
    Parameters:
        x: Input to check (any type)
    
    Returns:
        bool: True if x is a 2D array with shape (N, 2), False otherwise
    
    Validation Rules:
        - Must be 2-dimensional when converted to numpy array
        - Second dimension must have size 2 (for x,y coordinates)
        - Empty arrays return True (vacuous truth)
    
    Usage Patterns:
        - Format validation: if is_points_array(data): use_directly(data)
        - Used by conversion functions for format detection
        - Standard format for 2D point data in RP
    
    Related Functions:
        - is_complex_vector(): Check for complex number format
        - is_cv_contour(): Check for OpenCV contour format
        - as_points_array(): Convert to points array format
        - scatter_plot(): Expects this format
    
    Examples:
        >>> is_points_array([[1,2], [3,4], [5,6]])
        True
        >>> is_points_array([1+2j, 3+4j])
        False
        >>> is_points_array([[[1,2]], [[3,4]]])
        False
    """
    x=np.asarray(x)
    if not len(x):return True#Vaccuous truth
    return len(x.shape)==2 and x.shape[1]==2
def is_cv_contour(x):
    """ 
    Return True iff x is like [[[1,2]],[[3,4]],[[5,6]],...] and dtype=np.int32 
    
    Enhanced Documentation:
    
    Parameters:
        x: Input to check (any type)
    
    Returns:
        bool: True if x matches OpenCV contour format, False otherwise
    
    OpenCV Contour Format:
        - 3-dimensional array with shape (N, 1, 2)  
        - Each point is wrapped in extra dimension: [[[x,y]]]
        - Typically has dtype np.int32 (integer coordinates)
        - Used by OpenCV functions like cv2.findContours()
    
    Usage Patterns:
        - Detect OpenCV contours: if is_cv_contour(data): handle_contour(data)
        - Format validation before OpenCV operations
        - Used by conversion functions for format detection
    
    Related Functions:
        - is_points_array(): Check for standard [[x,y],...] format
        - is_complex_vector(): Check for complex number format
        - as_cv_contour(): Convert to OpenCV contour format
        - cv_find_contours(): Returns data in this format
    
    Examples:
        >>> is_cv_contour([[[1,2]], [[3,4]], [[5,6]]])
        True
        >>> is_cv_contour([[1,2], [3,4], [5,6]])
        False
    """
    x=np.asarray(x)#TODO this might cast it to a type other than np.int32 if given a list...though it wouldn't be WRONG to say it's not a cv contour in this case...idk should I change this or leave it be?
    if not len(x):return True#Vaccuous truth
    return len(x.shape)==3 and x.shape[1]==1 and x.shape[2]==2 and x.dtype==np.int32

#All the manual conversions (might be hidden later after we have automatic conversions) (the number of functions grows at (number of types)^2 )
def _points_array_to_complex_vector(points_array):
    #_points_array_to_complex_vector([[1,2],[3,4]])  ->  [1.+3.j 2.+4.j]
    points_array=np.asarray(points_array)
    assert is_points_array(points_array)
    return points_array[:,0]+1j*points_array[:,1]
def _points_array_to_cv_contour(points_array):
    points_array=np.asarray(points_array)
    assert is_points_array(points_array)
    return np.expand_dims(points_array,1).astype(np.int32)

def _complex_vector_to_points_array(complex_vector):
    #_complex_vector_to_points_array([1.+3.j ,2.+4.j])  ->  [[1. 3.],[2. 4.]]
    complex_vector=np.asarray(complex_vector)
    assert is_complex_vector(complex_vector)
    return np.transpose([complex_vector.real,complex_vector.imag])
def _complex_vector_to_cv_contour(complex_vector):
    complex_vector=np.asarray(complex_vector)
    assert is_complex_vector(complex_vector)
    return _points_array_to_cv_contour(_complex_vector_to_points_array(complex_vector))

def _cv_contour_to_points_array(cv_contour):
    assert is_cv_contour(cv_contour)
    return cv_contour.squeeze(1)
def _cv_contour_to_complex_vector(cv_contour):
    assert is_cv_contour(cv_contour)
    return _points_array_to_complex_vector(_cv_contour_to_points_array(cv_contour))

#Automatic path conversions (tries to detect the type of path then convert appropriately)
def as_complex_vector(path):
    """
    Convert path data to complex vector format.
    
    Automatically detects input format and converts to 1D array of complex numbers
    where each complex number represents a 2D point (x + yj).
    
    Parameters:
        path: Path data in various formats (complex vector, points array, cv_contour, set, dict, or list)
    
    Returns:
        numpy.ndarray: 1D array of complex numbers [x₁+y₁j, x₂+y₂j, ...]
    
    Supported Input Formats:
        - Complex vector: [1+2j, 3+4j, ...] → unchanged (copied)
        - Points array: [[1,2], [3,4], ...] → [1+2j, 3+4j, ...]
        - OpenCV contour: [[[1,2]], [[3,4]], ...] → [1+2j, 3+4j, ...]
        - Set/dict: Converted to list first, then processed
    
    Raises:
        AssertionError: If input format is not recognized or convertible
    
    Usage:
        >>> as_complex_vector([[1,2], [3,4]])
        array([1.+2.j, 3.+4.j])
    """
    if isinstance(path,set) or isinstance(path,dict):path=list(path)
    if   is_complex_vector(path):return                        np.asarray(path.copy())
    elif is_points_array  (path):return   _points_array_to_complex_vector(path)
    elif is_cv_contour    (path):return     _cv_contour_to_complex_vector(path)
    else:assert False,'Cannot convert 2d path: path='+repr(path)
def as_points_array(path):
    """ 
    Automatically convert path data 
    
    Enhanced Documentation:
    
    Parameters:
        path: Path data in various formats (complex vector, points array, cv_contour, set, dict, or list)
    
    Returns:
        numpy.ndarray: 2D points array with shape (N, 2) where each row is [x, y]
    
    Supported Input Formats:
        - Complex vector: [1+2j, 3+4j, ...] → [[1,2], [3,4], ...]
        - Points array: [[1,2], [3,4], ...] → unchanged (copied)
        - OpenCV contour: [[[1,2]], [[3,4]], ...] → [[1,2], [3,4], ...]
        - Set/dict: Converted to list first, then processed
    
    Multiplexing Pattern:
        This is a key RP multiplexing function that dispatches to format-specific converters:
        - _complex_vector_to_points_array() for complex vectors
        - _cv_contour_to_points_array() for OpenCV contours
        - Direct numpy conversion for points arrays
    
    Usage Patterns:
        - Normalize path formats: as_points_array(any_path_format)
        - Prepare data for drawing functions: scatter_plot(as_points_array(path))
        - Convert before geometric operations: distance_matrix(as_points_array(path))
    
    Related Functions:
        - as_complex_vector(): Convert to complex number format
        - as_cv_contour(): Convert to OpenCV contour format  
        - is_points_array(): Check if already in points array format
        - is_complex_vector(): Check if in complex vector format
        - is_cv_contour(): Check if in OpenCV contour format
    
    Examples:
        >>> as_points_array([[1,2],[3,4],[5,6]])  # Points array
        array([[1., 2.], [3., 4.], [5., 6.]])
        >>> as_points_array([1+2j, 3+4j, 5+6j])  # Complex vector  
        array([[1., 2.], [3., 4.], [5., 6.]])
    """
    if isinstance(path,set) or isinstance(path,dict):path=list(path)
    if   is_complex_vector(path):return _complex_vector_to_points_array(path)
    elif is_points_array  (path):return                      np.asarray(path.copy())
    elif is_cv_contour    (path):return     _cv_contour_to_points_array(path)
    else:assert False,'Cannot convert 2d path: path='+repr(path)
def as_cv_contour(path):
    """
    Convert path data to OpenCV contour format.
    
    Automatically detects input format and converts to OpenCV-compatible 3D array
    with shape (N, 1, 2) where each point is wrapped in an extra dimension.
    
    Parameters:
        path: Path data in various formats (complex vector, points array, cv_contour, set, dict, or list)
    
    Returns:
        numpy.ndarray: 3D array with shape (N, 1, 2) in OpenCV contour format [[[x₁,y₁]], [[x₂,y₂]], ...]
    
    Supported Input Formats:
        - Complex vector: [1+2j, 3+4j, ...] → [[[1,2]], [[3,4]], ...]
        - Points array: [[1,2], [3,4], ...] → [[[1,2]], [[3,4]], ...]
        - OpenCV contour: [[[1,2]], [[3,4]], ...] → unchanged (copied)
        - Set/dict: Converted to list first, then processed
    
    OpenCV Compatibility:
        Output format is compatible with OpenCV functions like cv2.drawContours(),
        cv2.contourArea(), cv2.arcLength(), etc.
    
    Raises:
        AssertionError: If input format is not recognized or convertible
    
    Usage:
        >>> as_cv_contour([[1,2], [3,4]])
        array([[[1, 2]], [[3, 4]]], dtype=int32)
    """
    if isinstance(path,set) or isinstance(path,dict):path=list(path)
    if   is_complex_vector(path):return _complex_vector_to_cv_contour(path)
    elif is_points_array  (path):return   _points_array_to_cv_contour(path)
    elif is_cv_contour    (path):return                    np.asarray(path.copy())
    else:assert False,'Cannot convert 2d path: path='+repr(path)
# EXAMPLES:
#       >>> as_complex_vector([[1,2],[3,4],[5,6]])
#      ans = [1.+2.j 3.+4.j 5.+6.j]
#       >>> as_cv_contour(ans)
#      ans = [[[1 2]]
#       [[3 4]]
#       [[5 6]]]
#       >>> as_complex_vector(ans)
#      ans = [1.+2.j 3.+4.j 5.+6.j]
#       >>> as_points_array(ans)
#      ans = [[1. 2.]
#       [3. 4.]
#       [5. 6.]]
#       >>> as_cv_contour(ans)
#      ans = [[[1 2]]
#       [[3 4]]
#       [[5 6]]]
#       >>> as_complex_vector(ans)
#      ans = [1.+2.j 3.+4.j 5.+6.j]
#       >>> as_points_array(ans)
#      ans = [[1. 2.]
#       [3. 4.]
#       [5. 6.]]



def contours_to_image(contours,*,scale=1,crop=True,**kwargs):
    """
    Returns a grayscale binary image of dtype bool
    This function draws the given path onto a blank, black image scaled to fit the contour
    By increasing 'scale' from 1 to some larger number, you increase the resolution of the output
    TODO add flags for whether these contours are loops, for padding/margin etc, color/thickness of contours
    Give this function contours and it will turn it into a black and white image
    Hint: kwarg fill=True
    You don't need to specify the size; that will be auto-calculated for you (which is why this function is so convenient)
    EXAMPLE:
      tris=[randints_complex(randint(3,10))for _ in range(3)]#Three Triangles
      img=contours_to_image(tris)
      display_image(img)
    """
    contours=[contour for contour in contours if len(contour)>1]#Gives errors otherwise
    if not contours:
        return np.asarray([[]],bool)#Return an empty image if we have no contours. This is to avoid errors later on.
    contours=list(map(as_points_array,contours))
    corner_point=lambda func:func([func(contour,0) for contour in contours],0)
    if crop:
        min_point=corner_point(np.min)
        contours=[contour-min_point for contour in contours]#If we use crop, we lose the original coordinates of the values of each contour point
    contours=[contour*scale     for contour in contours]
    max_point=corner_point(np.max)
    dims=np.floor(max_point+1).astype(int)
    dims=dims[::-1]#I'm not sure why but opencv seems to need this otherwise it gets the dimensions backwards
    contours=list(map(as_cv_contour,contours))
    image=np.zeros(dims)
    return cv_draw_contours(image,contours,**kwargs)>0

def contour_to_image(contour,**kwargs):
    """ The singular form of contours_to_image (just give it one contour instead of a list of contours) """
    return contours_to_image([contour],**kwargs)

#endregion
def squared_distance_matrix(from_points,to_points=None):
    """
    if to_points is None, it defaults to from_points (returning a symmetric matrix)
    This function exists so you don't have to use distance_matrix then square it (which is both inefficient and can lead to floating point errors)
    from_points and to_points are like [(x0,y0,...), (x1,y1,...), ...] or [(x0,y0,z0,...), (x1,y1,z1,...), ...], or some numpy equivalent
    Returns a matrix M such that M[i,j] ==== euclidean_distance(from_points[i],to_points[j])**2
    Example: squared_distance_matrix([[0,0],[10,10],[-10,-10]], [[1,0],[0,1],[5,6],[4,5],[-1,-1],[-5,-6]]).shape   ====   (3,6)
    Example: squared_distance_matrix([[1,0],[0,1],[5,6],[4,5],[-1,-1],[-5,-6]], [[0,0],[10,10],[-10,-10]]).shape   ====   (6,3)
    """
    if to_points is None:to_points=from_points
    if is_complex_vector(from_points) or is_cv_contour(from_points):from_points=as_points_array(from_points)
    if is_complex_vector(to_points  ) or is_cv_contour(to_points  ):to_points  =as_points_array(to_points  )
    from_points=np.expand_dims(np.asarray(from_points),1)
    to_points  =np.expand_dims(np.asarray(to_points  ),0)
    return np.sum((to_points-from_points)**2,2)#Use numpy's broadcasting rules to make this function fast and concise

def distance_matrix(from_points,to_points=None):
    """
    if to_points is None, it defaults to from_points (returning a symmetric matrix)
    from_points and to_points are like [(x0,y0,...), (x1,y1,...), ...] or [(x0,y0,z0,...), (x1,y1,z1,...), ...], or some numpy equivalent
    Returns a matrix M such that M[i,j] ==== euclidean_distance(from_points[i],to_points[j])
    Example: distance_matrix([[0,0],[10,10],[-10,-10]], [[1,0],[0,1],[5,6],[4,5],[-1,-1],[-5,-6]]).shape   ====   (3,6)
    Example: distance_matrix([[1,0],[0,1],[5,6],[4,5],[-1,-1],[-5,-6]], [[0,0],[10,10],[-10,-10]]).shape   ====   (6,3)
    """
    return squared_distance_matrix(from_points,to_points)**.5

def closest_points(from_points,to_points=None,*,return_values=False):
    """
    if to_points is None, it defaults to from_points (returning a symmetric matrix)
    This function was originally created to help implement the ICP algorithm (Iterative Closest Point algorithm), but has other uses as well
    from_points and to_points are like [(x0,y0,...), (x1,y1,...), ...] or [(x0,y0,z0,...), (x1,y1,z1,...), ...], or some numpy equivalent
    In the edge-case where two to_points are equidistant from some point in from_points, a single index will be selected arbitrarily by numpy.argmin
    Outputs a list of indices referring to elements in to_points, with the same length as from_points.
    NOTE there is an exception: if return_values is True, we return the actual points themselves instead of their indices in to_points
    Takes a set of points from_points, and a set of to_points, and returns [index of closest point in to_points to P for P in from_points]
    Example: closest_points([[0,0],[10,10],[-10,-10]], [[1,0],[0,1],[5,6],[4,5],[-1,-1],[-5,-6]])   ====   [0 2 5]
    Example: closest_points([[1,0],[0,1],[5,6],[4,5],[-1,-1],[-5,-6]], [[0,0],[10,10],[-10,-10]])   ====   [0 0 1 0 0 2]
    return_values is False by defualt because there could be duplicate values, but there can never be duplicate indices. Therefore, returning indices gives more information.
    """
    to_points  =np.asarray(to_points  )#If this or the next line cause errors, then it's up to the user of this function to figure out why.
    from_points=np.asarray(from_points)
    indices=np.argmin(distance_matrix(from_points,to_points),1)
    return to_points[indices] if return_values else indices

def least_squares_euclidean_affine(from_points,to_points,*,include_correlation=False):
    """
    TODO: Inspect this function! Is it right?!?!? It seems to follow
    This function is strictly limited to two dimensions.
    This function is like least_squares_affine, except skew is skipped. Only translation, rotation and scale are considered here.
    This function is meant as an alternative to OpenCV's estimateRigidTransform function (with fullAffine=False), which I find frustrating to use (it sometimes returns None, and can only take certain numerical data types). Unlike OpenCV, this does NOT use ransac.
    Returns an affine matrix with shape (2,3) that attempts to transform points in from_points to points to their respective point in to_points
    from_points and to_points are like [(x0,y0), (x1,y1), ...] or [[x0,y0], [x1,y1],...], or some numpy equivalent
    If include_extra is False, this function will just return the affine matrix. No fuss.
    However, if include_extra is True, this function will return a tuple in the form (affine,correlation)
    This function was written with the help of https://nghiaho.com/?p=2208 (or https://archive.is/UVROT or https://web.archive.org/web/20190611175717/https://nghiaho.com/?p=2208 if the link is broken)
    Test Example:
     # CODE:
     #  from_points=np.array([[0,0],[1,0],[0 ,1],[-1,0] ,[0,-1]])      #A plus-shape
     #  to_points  =np.array([[0,0],[1.1,0.9],[-1.2,.9],[-.8,-1.1],[1,-1]])+[0,1]#An x-shape shifted up by 1 with a bit of noise
     #  affine=least_squares_euclidean_affine(from_points,to_points)
     #  ans=apply_affine(from_points,affine)
     #  print('affine=\n',affine)
     #  print('ans=\n',ans)
     #  print('to_points=\n',to_points)
     # OUTPUT:
     #  affine=
     #   [[ 0.95 -1.05  0.02]
     #   [ 1.05   0.95  0.94]]
     #  ans=
     #   [[ 0.02  0.94]
     #   [ 0.97   1.99]
     #   [-1.03   1.89]
     #   [-0.93  -0.11]
     #   [ 1.07  -0.01]]
     #  to_points=
     #   [[ 0.   1. ]
     #   [ 1.1   1.9]
     #   [-1.2   1.9]
     #   [-0.8  -0.1]
     #   [ 1.    0. ]]
     # ANALYSIS:
     #   You can see that to_points is close to ans, which means it worked pretty well.
    """

    #TODO Clean this up. Here's the newer implementation which runs faster than the old one:
    from_points=as_complex_vector(from_points)
    to_points=as_complex_vector(to_points)
    m,b,r=least_squares_regression_line_coeffs(as_complex_vector(from_points),as_complex_vector(to_points),include_correlation=True)
    affine=complex_linear_coeffs_to_euclidean_affine(m,b)
    if include_correlation:
        return affine,r
    return affine
        #Comparison of the new vs old methods:
    #   >>> y=randints_complex(10000)
    #   2 x=randints_complex(10000)
    #   3 m=6-4j
    #   4 b=3+7j
    #   5 y=m*x+b
    #   6 tic();[least_squares_regression_line_coeffs(x,y)for _ in range(10000)];ptoc()
    #   7 xp=as_points_array(x)
    #   8 yp=as_points_array(y)
    #   9 tic();[least_squares_euclidean_affine(xp,yp)for _ in range(10000)];ptoc()
    #  2.905686140060425 seconds
    #  19.899923086166382 seconds  <--- Using the old implementation, it's about 2 times slower

    if False:#The old method whose code works but is complicated by comparison
        to_points  =np.asarray(to_points  )#If this or the next line cause errors, then it's up to the user of this function to figure out why.
        from_points=np.asarray(from_points)
        assert from_points.shape[1]==to_points.shape[1]==2,'All points must be two dimensional. from_points and to_points should both have shapes like (N,2), where N is any integer >=2. from_points.shape=='+str(from_points.shape)+' and to_points.shape=='+str(to_points.shape)
        assert len(from_points>=2) and len(to_points>=2),'To fit a euclidean 2d transform (including only translation, rotation and scale), we must have at least two points. However, len(from_points)='+str(len(from_points))+' and len(to_points)='+str(len(to_points))
        assert len(from_points   ) ==  len(to_points   ),'You must have the same number of points in both from_points and to_points, or else it doesnt make sense to say theres a 1-to-1 correspondence between the to_points and from_points. len(from_points)='+str(len(from_points))+' and len(to_points)='+str(len(to_points))
        A=np.insert(from_points,slice(None),from_points,0)#[x1  y1;x1 y1;x2  y2;x2 y2;...]  (Note: A is commonly seen in AX=B when describing least-squares fit using matrices)
        A[1::2]=A[1::2,::-1]                              #[x1  y1;y1 x1;x2  y2;y2 x2;...]
        A[::2]*=[1,-1]                                    #[x1 -y1;y1 x1;x2 -y2;y2 x2;...]
        Z=np.zeros(A.shape)                               #[ 0   0; 0  0; 0   0; 0  0;...]  (Note: Z stands for Zeros)
        Z[::2,0]=Z[1::2,1]=1                              #[ 1   0; 0  1; 1   0; 0  1;...]
        A=np.column_stack((A,Z))                          #[x1 -y1  1  0;y1  x1  0  1;x2 -y2 1 0;y2 x2 0 1;...]
        B=np.reshape(to_points,-1)                        #[x0  y0 x1 y1...] (Where x0 and y0 etc refer to to_points as opposed to from_points, like x0,y0 etc do above this line)
        # exec(mini_terminal)
        X,(error,),_,residuals=np.linalg.lstsq(A,B,rcond=None)#Solving least-squares for X given AX=B where A is a square matrix, and X and B are vectors. The variable named '_' is useless; I don't understand why numpy included it. It just returns the length of the result, which we allready know. rcond=None exists to make numpy shut up (it gives future warnings blah blah....all completely harmless but annoying)
        a,b,c,d=X                                         #Individual numbers that make up the affine matrix. Same variables used on the website's tutorial (URL posted above)
        affine=np.asarray([[a,-b,c],[b,a,d]])
        if not include_extra:
            return affine
        class result:pass
        result.affine   =affine
        result.error    =error
        result.residuals=residuals
        return result

def least_squares_affine(from_points,to_points,*,include_extra=False):
    """
    TODO Clean this function up and make it more like least_squares_euclidean_affine
    from_points and to_points are like [(x0,y0), (x1,y1), ...] or [[x0,y0], [x1,y1],...], or some numpy equivalent
    If include_extra is False, this function will just return the affine matrix. No fuss.
    However, if include_extra is True, this function will return a class (with static values) in this form: {'affine':‹the affine matrix (a 2x3 matrix)›, 'error':‹total error (a number)›, 'residuals':‹individual errors for every point (a list of numbers)›) (Access it with result.affine, result.error, result.residuals, etc.)
    """
    to_points  =np.asarray(to_points  )#If this or the next line cause errors, then it's up to the user of this function to figure out why.
    from_points=np.asarray(from_points)
    assert from_points.shape[1]==to_points.shape[1]==2,'All points must be two dimensional. from_points and to_points should both have shapes like (N,2), where N is any integer >=2. from_points.shape=='+str(from_points.shape)+' and to_points.shape=='+str(to_points.shape)
    assert len(from_points>=2) and len(to_points>=2),'To fit a euclidean 2d transform (including only translation, rotation and scale), we must have at least two points. However, len(from_points)='+str(len(from_points))+' and len(to_points)='+str(len(to_points))
    assert len(from_points   ) ==  len(to_points   ),'You must have the same number of points in both from_points and to_points, or else it doesnt make sense to say theres a 1-to-1 correspondence between the to_points and from_points. len(from_points)='+str(len(from_points))+' and len(to_points)='+str(len(to_points))
    from_points_1=append_ones_column(from_points)
    for_to_x=np.insert(np.zeros_like(from_points_1),slice(None),from_points_1,0)
    for_to_y=np.insert(from_points_1,slice(None),np.zeros_like(from_points_1),0)
    A=np.column_stack((for_to_x,for_to_y))
    piA=np.linalg.pinv(A)#Pseudo-inverse of A
    b=np.reshape(to_points,-1)
    out_a,out_b,out_c,out_d,out_e,out_f=np.matmul(piA,b)
    affine=[[out_a,out_b,out_c],[out_d,out_e,out_f]]
    if not include_extra:
        return affine
    class result:pass
    result.affine   =affine
    result.error    ='TODO'
    result.residuals='TODO'
    return result

def translation_affine(vector):
    """
    EXAMPLE:
      CODE:
        translation_affine([20,30])
      RESULT:
        [[ 1.  0. 20.]
         [ 0.  1. 30.]]
    """
    return np.column_stack((np.eye(len(vector)),vector))

def rotation_affine_2d(angle,pivot=[0,0],*,out_of=tau):
    """
    EXAMPLE:
     CODE:
       rotation_affine_2d(90,out_of=360)
     RESULT:
       [[ 0. -1.  0.]
        [ 1.  0.  0.]]
    EXAMPLE:
     CODE:
       print(apply_affine([[0 ,0]],rotation_affine_2d(180,pivot=[1,1],out_of=360)))
       print(apply_affine([[-1,1]],rotation_affine_2d(180,pivot=[1,1],out_of=360)))
       print(apply_affine([[-1,1]],rotation_affine_2d(180,pivot=[0,0],out_of=360)))
     RESULT:
       [[ 2.  2.]]
       [[ 3.  1.]]
       [[ 1. -1.]]
     ANALYSIS:
       Note how (in the second two lines) the change from pivot [1,1] to [0,0] changed the result
    """
    pivot   =np.asarray(pivot)
    shift   =translation_affine(-pivot)                                   #Shift pivot to origin
    rotation=np.column_stack((rotation_matrix(angle,out_of=out_of),[0,0]))#Rotate about the origin
    unshift =translation_affine(pivot)                                    #Put the pivot back again
    return combined_affine(shift,rotation,unshift)

def inverse_affine(affine):
    """
    QUICK AND DIRTY EXAMPLE:
         >>> A
        ans = [[11. 62. 90.]
         [29.  9. 98.]]
         >>> apply_affine([[2,4],[5,6],[7,8]],A)
        ans = [[360. 192.]
         [517. 297.]
         [663. 373.]]
         >>> apply_affine(ans,affine_inverse(A))
        ans = [[2. 4.]
         [5. 6.]
         [7. 8.]]
    """
    affine=append_zeros_row(affine)
    affine[-1][-1]=1
    return np.linalg.inv(affine)[:2]

def identity_affine(ndim=2):
    """
    EXAMPLE:
      CODE:
        identity_affine(2)
      RESULT:
        [[1. 0. 0.]
         [0. 1. 0.]]
    EXAMPLE:
      CODE:
        identity_affine(3)
      RESULT:
        [[1. 0. 0. 0.]
         [0. 1. 0. 0.]
         [0. 0. 1. 0.]]
    """
    return append_zeros_column(np.eye(ndim))

def combined_affine(*affines):
    """
    Return the affine matrix needed to apply all matrices in 'affines' in the order they were given
    TODO: Add more input assertions, such as all affines must have same shape, etc
    apply_affine(points,combined_affine(affine_1,affine_2))  is the same as  apply_affine(apply_affine(points,affine_1),affine_2)
    PROPERTIES:
     Associative:  C(a,C(b,c)) ==== C(C(a,b),c) ==== C(a,b,c)  where C is combined_affine
    EXAMPLE:
     CODE:
        af1=[[5, 3, 8], [8, 3, 6]]
        af2=[[8, 3, 3], [1, 5, 3]]
        p  =[[3, 6], [8, 4], [9,2]]
        print(apply_affine(apply_affine(p,af1),af2))
        print(apply_affine(p,combined_affine(af1,af2)))
     OUTPUT:
       [[475. 284.]
        [729. 473.]
        [727. 482.]]
       [[475. 284.]
        [729. 473.]
        [727. 482.]]
     ANALYSIS:
       Note that the two outputs are exactly equivalent.
    """
    assert affines,'combined_affine must take in at least one affine or else it has no idea what matrix shape to return'
    affines=tuple(map(np.asarray,affines))#If this breaks, its up to the user of this function to fix any errors
    shape=affines[0].shape
    out=np.eye(shape[1])
    for affine in affines:
        affine=np.asarray(affine)
        assert affine.shape[1]==affine.shape[0]+1,'m doesnt have the dimensions of an affine matrix'
        affine=append_zeros_row(affine)
        affine[-1,-1]=1
        out=np.matmul(affine,out)
    return out[:2]

def apply_affine(points,affine):#,*,copy=True):
    """
    This function applies a given affine transform (specified as a matrix) to a list of points and returns the list of resulting points
    This function generalizes to affines of all dimensions (not just 2d)
    'points' is like [(x0,y0), (x1,y1), ...] or [[x0,y0], [x1,y1],...], or some numpy equivalent
    affine is a 2x3 (for 2d) or 3x4 (for 3d) or 4x5 (for 4d) or (etc) affine-transform matrix.
    EXAMPLE: For examples, see the documentation for least_squares_euclidean_affine (it's a function in r.py, which can be obtained in a pypi package called 'rp')
    """
    affine=np.asarray(affine)#If this or the next line cause errors, then it's up to the user of this function to figure out why.
    points=np.asarray(points)
    assert len(points.shape)==2,'Points should be a matrix, but points.shape=='+str(points.shape)
    assert len(affine.shape)==2,'Affine should be a matrix, but affine.shape=='+str(affine.shape)
    npoint=points.shape[0]#npoint stands for 'number of points'
    ndim  =points.shape[1]#ndim stands for 'number of dimensions'. This function should generalize to n-dimensional space, not just 2d or 3d etc.
    assert affine.shape==(ndim,ndim+1),'An affine transform matrix for '+str(ndim)+'-dimensional points should have shape '+str((ndim,ndim+1))+', but instead affine.shape=='+str(affine.shape)
    return (affine@append_ones_row(points.T)).T#The '@' character is a matrix multiplication operator in numpy

def icp_least_squares_euclidean_affine(from_points,to_points,max_iter=5,*,include_extra=False):
    """
    icp stands for "iterative closest point". It's an algorithm used to match point-clouds.
    The length of from_points and to_points does NOT have to match. However, they must both have at least two points each (otherwise it's impossible to determine a euclidean transform between them).
    In this function we're matching point clouds, but in specifically two dimensions, and allowing only translation, rotation and scale
    from_points and to_points are like [(x0,y0,...), (x1,y1,...), ...] or [(x0,y0,z0,...), (x1,y1,z1,...), ...], or some numpy equivalent
    Returns a 2x3 affine transform matrix
    TEST CODE:
      a=random_element(contours).squeeze()
      b=random_element(contours).squeeze()
      scatter_plot([])
      scatter_plot(b,clear=False)
      scatter_plot(a,clear=False)
      for _ in range(5):
          result=icp_least_squares_euclidean_affine(a,b,include_extra=True,max_iter=_+1)
          scatter_plot(result.points,clear=False)
    """
    #END TEST CODE
    from_points=np.asarray(from_points)#If this or the next line cause errors, then it's up to the user of this function to figure out why.
    to_points=np.asarray(to_points)
    # from_points=from_points+(np.mean(to_points,0)-np.mean(from_points,0))
    def point_cloud_angle(points):
        x,y=zip(*points)
        #x and y are lists of x and y values for a point cloud
        #return an angle describing the point cloud's rotation, calculated via looking at the most stretched-out part of the covariance matrix
        #Note: When testing contours, must try both this and this flipped by 180 degrees
        points=np.matrix([x,y])
        cov=np.cov(points)
        eig_vals,eig_vecs=np.linalg.eig(cov)
        index=max_valued_index(eig_vals)#index of the larger eigenvector/value
        vec=eig_vecs[:,index]
        vec_x,vec_y=vec
        angle=np.arctan(vec_y/vec_x)
        angle%=pi
        return angle


    #Calculate initial guess:
    output_affine=combined_affine(translation_affine(-from_points.mean(0)),
                                  rotation_affine_2d(point_cloud_angle(to_points)-point_cloud_angle(from_points)),
                                  translation_affine(to_points.mean(0))
                                 )#output_affine will be modified as this ICP algorithm iterates. This is just our initial guess.
    fit_points=apply_affine(from_points,output_affine)

    assert max_iter>=0,'Cannot have a negative number of iterations!'
    if not max_iter:
        assert not include_extra,'include_extra is not (currently) supported when there are no iterations'#This can be implemented in the future if it's important
        return output_affine#is currently the identity affine
    for _ in range(max_iter):
        # exec(mini_terminal)
        matched_points=closest_points(fit_points,to_points,return_values=True)
        # print("==========================================",
            # fit_points,
            # "+++++++++++++++++"
            # ,to_points,matched_points)
        number_of_unique_matched_points=len(np.unique(matched_points,axis=0))
        fit_result   =least_squares_euclidean_affine(fit_points,matched_points,include_extra=True)
        fit_affine   =fit_result.affine
        fit_error    =fit_result.error
        fit_residuals=fit_result.residuals
        fit_scale_factor=np.sum(fit_affine[:,0]**2)**.5#The total difference in scale caused by fit_affine
        #TODO: This is NOT clean. fit_error, for example, will give the wrong result if number_of_unique_matched_points starts/ends as 1.
        if number_of_unique_matched_points<=1:
            #This is a degerate edge case (picture matching two circles that don't overlap, and so all points on one circle get matched to exactly one point on the other circle)
            #In the event that all points get matched to the same place, only allow translation.
            #Don't allow scale or rotation, because it will collapse to a single point - even though we know at least two points exist in both from_points and to_points.
            #**I'm not sure, but this might be why implementations I've found online keep failing.
            # fit_affine[:,:2]=np.eye(2)#This is a degenerate case where all points are matched to the same place, and the scale gets reduced to 0. If this happens, undo this change, and force neither the scale nor rotation to change. Translation is left untouched.
            delta_x,delta_y=np.mean(matched_points,0)-np.mean(fit_points,0)
            fit_affine=np.asarray([[1,0,delta_x],[0,1,delta_y]])
            # print("FIT AFFINE:\n",fit_affine,'~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~')
            # print(fit_affine,matched_points,to_points)
            #MY HACK: Randomly select one point from both to_points and fit_points and align them via just translation.
            # random_from_point=random_element(from_points)
            # random_to_point=random_element(to_points)
            # fit_points=from_points+random_to_point-random_from_point
        else:
            #We're OK, we've matched MORE than one point...
            pass#(This is intentionally an empty statement so i can explicitly say what it means to be here in the above comment)
        output_affine=combined_affine(output_affine,fit_affine)
        fit_points=apply_affine(from_points,output_affine)
        # exec(mini_terminal)
    if not include_extra:
        return fit_affine
    class result:
        affine   =output_affine
        error    =fit_error
        residuals=fit_residuals
        points   =fit_points
    return result

def is_euclidean_affine_matrix(affine):
    affine=np.asarray(affine)
    if not is_affine_matrix(affine):
        return False
    return affine[0][0]==affine[1,1] and affine[1][0]==-affine[0][1]
def is_affine_matrix(affine):
    affine=np.asarray(affine)
    return affine.shape==(2,3)
def euclidean_affine_to_complex_linear_coeffs(affine):
    """
    mx+b in the complex plane corresponds to a euclidean transform
    This function takes a euclidean affine and returns it's complex m, b coeffs (from the y=mx+b convention)
    Example:
    Given affine matrix [[a  b  c]
                        [d  e  f]]
    We can assert that a=e and d=-b because it's euclidean and rewrite it as
                       [[a -d  c]
                        [d  a  f]]
    Which corresponds to a transform represented by transforming complex number x:
      x' = mx+b = (a+di)x+(c+fi)
    And therefore m=a+di and b=c+fi
    """
    assert is_euclidean_affine_matrix(affine),'The given affine is not a euclidean transform. affine=='+repr(affine)
    m=affine[0][0]+affine[1][0]*1j #Corresponds to rotation and scale
    b=affine[0][2]+affine[1][2]*1j #Corresponds to tranlation
    return m,b
def complex_linear_coeffs_to_euclidean_affine(m,b):
    """
    This is the inverse of euclidean_affine_to_complex_linear_coeffs
    Where F=complex_linear_coeffs_to_euclidean_affine and G=euclidean_affine_to_complex_linear_coeffs,
    F(*G(X))  ==== X for all euclidean affines X and
    G(F(m,b)) ==== m,b for all complex numbers m,b
    Please see euclidean_affine_to_complex_linear_coeffs's documentation for an explanation of what this function does
    """
    return np.asarray([[m.real,-m.imag,b.real],[m.imag,m.real,b.imag]])




#region Hashing functions

class HandyHashable:
    """ A wrapper for any data that makes it hashable """
    def __init__(self,value):
        self.value=value
        self._hash=handy_hash(value)
    def __hash__(self):
        return self._hash
    def __eq__(self,x):
        if not isinstance(x,HandyHashable):
            return False
        try:
            return self.value==x.value
        except ValueError:#ValueError: The truth value of an array with more than one element is ambiguous. Use a.any() or a.all()
            handy_hash(self.value)==handy_hash(x.value)
    def __repr__(self):
        return "HandyHashable("+repr(self.value)+")"

class HandyDict(dict):
    """
    A dict that can use more than just normal keys by using handyhash
    This class might get more methods over time.
    """
    def __init__(self,*args,**kwargs):
        return dict.__init__(self,*args,**kwargs)
    def __setitem__(self, key, value):
        return dict.__setitem__(self,HandyHashable(key),value)
    def __delitem__(self, key):
        return dict.__delitem__(self,HandyHashable(key))
    def __getitem__(self, key):
        return dict.__getitem__(self,HandyHashable(key))
    def __iter__(self):
        return (key.value for key in dict.__iter__(self))
    def __contains__(self,x):
        return dict.__contains__(self,HandyHashable(x))

#TODO: HandySet

def _torch_tensor_to_bytes_for_hashing(tensor):
    #https://stackoverflow.com/questions/63880081/how-to-convert-a-torch-tensor-into-a-byte-string - not using numpy
    #This includes the device!
    import torch 
    import io
    buff = io.BytesIO()
    torch.save(tensor, buff)
    buff.seek(0)  # <--  this is what you were missing
    return buff.read()


def handy_hash(value):
    """
    This function is really handy!
    Meant for hashing things that can't normally be hashed, like lists and dicts and numpy arrays. It pretends they're immutable.
    This function can hash all sorts of values. Anything mutable should be frozen, we're not just returning an ID.
    For example, lists are turned into tuples, and dicts like {"A":"B","C":"D"} are turned into ((""))
    If it can't hash something, it will just use fallback to hash it. By default, though, fallback is
    """

    def fallback(value):
        # fansi_print('Warning: fallback_hash was called on value where repr(value)=='+repr(value),'yellow') #This is annoying
        return id(value)

    try:return hash(value)
    except Exception:pass#This is probably going to happen a lot in this function lol (that's kinda the whole point)

    bytes_hasher = get_sha256_hash

    if is_torch_tensor(value): return hash(('TORCH SHA256', bytes_hasher(_torch_tensor_to_bytes_for_hashing(value.detach().cpu()))))
    if is_numpy_array (value): return hash(('NUMPY SHA256', bytes_hasher(value.tobytes())))

    value_type=type(value)

    try:return hash(('DILL HASH', bytes_hasher(_dill_dumps(value))))#The dill library is capable of hashing a great many things...including numpy arrays! This was added after my original implementation of handy_hash, as dill is able to handle a huuuggggeee amount of different types
    except Exception:pass

    hasher=__hashers[value_type] if value_type in __hashers else fallback
    return hasher(value)

#region Type-specific hashers
__secret_number=71852436691752251090#Used for hashing things. Don't use this value anywhere! It's not generated dynamically (aka it doesnt use randint) because we want consistent hash values across python processes.
__hashers={}#Used by handy_hash


def _set_hash(x):
    assert isinstance(x,set)
    return hash((__secret_number,frozenset(x)))
__hashers[set]=_set_hash

def _dict_hash(x,value_hasher=handy_hash):
    assert isinstance(x,dict)
    set_to_hash=set()
    for key,value in x.items():
        set_to_hash.add(hash((__secret_number,'dict_hash_pair',key,value_hasher(value))))
    return hash((__secret_number,frozenset(set_to_hash)))
__hashers[dict]=_dict_hash

def _list_hash(x,value_hasher=handy_hash):
    assert isinstance(x,list)
    return hash((__secret_number,'_list_hash',tuple(map(value_hasher,x))))
__hashers[list]=_list_hash

def _tuple_hash(x,value_hasher=handy_hash):
    assert isinstance(x,tuple)
    return hash((__secret_number,'_tuple_hash',tuple(map(value_hasher,x))))
__hashers[tuple]=_tuple_hash

def _slice_hash(x,value_hasher=handy_hash):
    assert isinstance(x,slice)
    return hash((__secret_number,'_slice_hash',(value_hasher(x.start),value_hasher(x.step),value_hasher(x.stop))))
__hashers[slice]=_slice_hash

#endregion


def args_hash(function,*args,**kwargs):
    """
    Return the hashed input that would be passed to 'function', using handy_hash. This function is used for memoizers. function must be provided for context so that arguments passed that can be passed as either kwargs or args both return the same hash.
    
    Enhanced Documentation:
    
    Creates a deterministic hash of function arguments for memoization purposes. 
    This function intelligently normalizes arguments by converting positional args
    to keyword args when possible, ensuring the same hash for equivalent calls.
    
    Args:
        function (callable): The function whose arguments are being hashed.
                            Must be callable to determine argument names.
        *args: Positional arguments to hash
        **kwargs: Keyword arguments to hash
    
    Returns:
        int: A deterministic hash value representing the argument signature.
             Same arguments in different forms (args vs kwargs) produce same hash.
    
    Examples:
        >>> def test_func(a, b, c=3):
        ...     return a + b + c
        >>> hash1 = args_hash(test_func, 1, 2, c=3)
        >>> hash2 = args_hash(test_func, 1, b=2, c=3) 
        >>> hash1 == hash2  # Same hash for equivalent calls
        True
        >>> isinstance(hash1, int)
        True
    
    Related Functions:
        - memoized(): Uses args_hash for function memoization
        - handy_hash(): Used internally for hashing individual arguments
    
    Usage Patterns:
        - Primary use is in memoization decorators and cached functions
        - Handles unhashable arguments by converting them with handy_hash
        - Normalizes argument representation for consistent caching
    
    Tags: hashing, memoization, caching, function-arguments, utilities
    """
    assert callable(function),'Cant hash the inputs of function because function isnt callable and therefore doesnt receive arguments. repr(function)=='+repr(function)
    args=list(args)
    try:
        #Whenever we can, we take things from args and put them in kwargs instead...
        from inspect import getfullargspec
        arg_names=list(getfullargspec(function).args)#This often doesn't work, particularly for built-in functions. TODO this is possible to fix, given that rp can complete argument names of even opencv functions. But for the most part, memoization is used in loops where the function is called with the same signature over and over again, so I'm going to push off improving this till later.
    except Exception:
        #...but it's not a necessity, I GUESS...(if the function is always called the same way)
        arg_names=[]
        pass
    while arg_names and args:
        #Take things from args and put them in kwargs instead, for as many args as we know the names of...
        kwargs[arg_names.pop(0)]=args.pop(0)
    hashes=set()
    for index,arg in enumerate(args):
        hashes.add(hash(('arg',index,handy_hash(arg))))
    for kw   ,arg in kwargs.items() :
        hashes.add(hash(('kwarg',kw ,handy_hash(arg))))
    return hash(frozenset(hashes))


#def memoized(function):
#    #TODO: when trying to @memoize fibbonacci, and calling fibbonacci(4000), python crashes with SIGABRT. I have no idea why. This function really doesn't use any non-vanilla python code.
#    #Uses args_hash to hash function inputs...
#    #This is meant to be a permanent cache (as opposed to a LRU aka 'Least Recently Used' cache, which deletes cached values if they haven't been used in a while)
#    #If you wish to temporarily memoize a function (let's call if F), you can create a new function cached(F), and put it in a scope that will run out eventually so that there are no memory leaks.
#    #Some things can't be hashed by default, I.E. lists etc. But all lists can be converted to tuples, which CAN be hashed. This is where hashers come in. Hashers are meant to help you memoize functions that might have non-hashable arguments, such as numpy arrays.
#    cache=dict()
#    assert callable(function),'You can\'t memoize something that isn\'t a function (you tried to memoize '+repr(function)+', which isn\'t callable)'
#    def memoized_function(*args,**kwargs):
#        key=args_hash(function,*args,**kwargs)
#        if not key in cache:
#            cache[key]=function(*args,**kwargs)
#        return cache[key]
#    memoized_function.__name__+=function.__name__
#    memoized_function.original_function=function #So we can inspect it in rp
#    memoized_function.cache=cache #So we can inspect it, or even clear it
#    return memoized_function

def memoized(function):
    """
    TODO: Make this function smarter - use the same arg techniques as in gather_args, so even when we give overrides to default args it still caches properly. Same for cachedinstances
    TODO: when trying to @memoize fibbonacci, and calling fibbonacci(4000), python crashes with SIGABRT. I have no idea why. This function really doesn't use any non-vanilla python code.
    Uses args_hash to hash function inputs...
    This is meant to be a permanent cache (as opposed to a LRU aka 'Least Recently Used' cache, which deletes cached values if they haven't been used in a while)
    If you wish to temporarily memoize a function (let's call if F), you can create a new function cached(F), and put it in a scope that will run out eventually so that there are no memory leaks.
    Some things can't be hashed by default, I.E. lists etc. But all lists can be converted to tuples, which CAN be hashed. This is where hashers come in. Hashers are meant to help you memoize functions that might have non-hashable arguments, such as numpy arrays.
    """
    import functools
    cache = dict()
    assert callable(function), 'You can\'t memoize something that isn\'t a function (you tried to memoize '+repr(function)+', which isn\'t callable)'

    @functools.wraps(function)
    def memoized_function(*args, **kwargs):
        key = args_hash(function, *args, **kwargs)
        if key not in cache:
            cache[key] = function(*args, **kwargs)
        return cache[key]

    memoized_function.original_function = function
    memoized_function.cache = cache
    return memoized_function

def memoized_lru(function_or_maxsize=None, *, maxsize=128):
    """
    LRU (Least Recently Used) version of memoized. Evicts least recently used items when cache reaches maxsize.
    Uses args_hash to handle complex arguments like the original memoized function so it can handle nearly any python object, unlike functools.lru_cache
    
    Example:
        >>> @memoized_lru(2)  # or @memoized_lru(maxsize=2)
        ... def expensive_func(x):
        ...     return x ** 2
        >>> expensive_func(1); print(expensive_func.cache)
        OrderedDict({...})
        >>> expensive_func(2); print(expensive_func.cache)  
        OrderedDict({...: 1, ...: 4})
        >>> expensive_func(3); print(expensive_func.cache)  # evicts 1
        OrderedDict({...: 4, ...: 9})
        >>> expensive_func.maxsize
        2
        >>> expensive_func.maxsize = 10  # Can be changed dynamically
        >>> expensive_func.maxsize
        10

    Example:
        >>> @memoized_lru(maxsize=3)
        ... def expensive_func(x):
        ...     return x**2
        ... 
        ... for arg in [
        ...     1,2,3,                     #Fill the cache
        ...     as_numpy_array([1, 2, 3]), #It can hash most python objects
        ...     as_numpy_array([1, 2, 3]), #No cache change
        ...     3,                         #Bring 3**2 to the front
        ...     4,                         #4th element: starts deleting items (replaces 2**2)
        ... ]:
        ...     expensive_func(arg)
        ...     print(arg, '\t', expensive_func.cache.values())
        ... 
        1       odict_values([1])                      
        2       odict_values([1, 4])                   
        3       odict_values([1, 4, 9])                
        [1 2 3] odict_values([4, 9, array([1, 4, 9])]) 
        [1 2 3] odict_values([4, 9, array([1, 4, 9])]) 
        3       odict_values([4, array([1, 4, 9]), 9]) 
        4       odict_values([array([1, 4, 9]), 9, 16])
    """
    # Handle @memoized_lru(123) as shorthand for maxsize=123
    if isinstance(function_or_maxsize, int):
        maxsize = function_or_maxsize
        function_or_maxsize = None
    
    def decorator(func):
        import functools
        from collections import OrderedDict
        assert callable(func), "You can't memoize something that isn't a function (you tried to memoize "+repr(func)+", which isn't callable)"
        
        cache = OrderedDict()
        
        @functools.wraps(func)
        def memoized_function(*args, **kwargs):
            key = args_hash(func, *args, **kwargs)
            if key in cache:
                cache.move_to_end(key)
                return cache[key]
            result = func(*args, **kwargs)
            cache[key] = result
            if len(cache) > memoized_function.maxsize:
                cache.popitem(last=False)
            return result
        
        memoized_function.original_function = func
        memoized_function.cache = cache
        memoized_function.maxsize = maxsize
        return memoized_function
    
    if function_or_maxsize is None:
        return decorator
    else:
        return decorator(function_or_maxsize)

def memoized_property(method):
    """
    This method is meant to be used as a substitute for @property
    Often, when using @property you'll see a method like this:
    
       @property
       def thing(self):
           try:
               return self._thing
           except Exception:
               self.thing=fancy_calculations()
    
               return self._thing
    This function takes the hassle of creating a private variable away, and automatically creates the self._thing
    The completely equivalent function, using @memoized_property, is shown below
        
       @memoized_property
       def thing(self):
           return fancy_calculations()
    
    """
    assert callable(method)
    property_name='_'+method.__name__
    def memoized_property(self):
        if not hasattr(self,property_name):
            setattr(self,property_name,method(self))
        return getattr(self,property_name)
    memoized_property.__name__+=method.__name__
    memoized_property=property(memoized_property)
    return memoized_property

def _omni_load_animated_image(path):
    """ gif and webp and png can be either a video or image depending on context... """
    video = load_video(path, show_progress=False) #A non-animated gif will return a video with a single frame
    if len(video)==1:
        return video[0]
    return video

def _omni_save_animated_image(video,path):
    """ gif and webp and png can be either a video or image depending on context... """
    if is_image(video):
        return save_image(video, path)
    else:
        return save_video(video, path)


def _omni_save_default_extension(x):
    if is_image(x) or is_pil_image(x): return '.png'
    if is_numpy_array(x) and x.ndim==4 and x.shape[0]>1 and x.shape[1]>10 and x.shape[2]>10 and x.shape[3] in [3,4]: return '.mp4'
    if is_torch_tensor(x): return '.pt'
    if is_numpy_array(x): return '.npy'
    if len(x) > 50 and is_valid_python_syntax(x): return '.py'
    if isinstance(x, str): return '.txt'
    if isinstance(x, list) and all(isinstance(y, str) for y in x) and not any('\n' in line for line in x): return '.lines'
    if _is_pandas_dataframe(x): return '.csv'
    return ''


def omni_load(path):
    """
    Internal smart file loading dispatcher based on file extension and type detection.
    
    Enhanced Documentation:
    Private function that automatically determines file type and routes to appropriate
    loading function. Uses file extension matching, data URI parsing, and MIME type
    detection for comprehensive file type recognition.

    Parameters:
    - path (str): File path or data URI to load

    Returns:
    - Any: Loaded object using the appropriate format-specific loader
    
    Dispatch logic:
    - Data URIs → Dispatched by mediatype (image/video/audio)
    - .json → load_json()
    - .yaml → load_yaml()
    - Image formats → load_image() or _omni_load_animated_image()
    - Video formats → load_video()
    - Audio formats → load_sound_file()
    - .npy → np.load()
    - .csv/.tsv/.parquet → respective loaders
    - .pkl → load_pickled_value()
    - .rpo → file_to_object()
    - .pt/.pth/.ckpt → torch.load()
    - Fallback: MIME type detection for images/videos, UTF-8 text, or file_to_object()
    
    Internal usage:
    - Used by higher-level loading functions
    - Part of RP's automatic format detection system
    - Enables polymorphic loading behavior
    
    Example (internal usage):
    >>> data = _omni_load('config.json')  # Returns EasyDict
    >>> image = _omni_load('photo.jpg')   # Returns numpy array
    >>> model = _omni_load('weights.pkl') # Returns pickled object
    >>> mario = _omni_load('data:image/png;base64,iVBORw0...')  # Returns numpy array from data URI
    """
    # Check for data URIs first (before file-based checks)
    if is_valid_data_uri(path):
        mediatype = _get_uri_mediatype(path)
        if   mediatype == 'image': return rp.load_image(path)
        elif mediatype == 'video': return rp.load_video(path)
        elif mediatype == 'audio': return rp.load_sound_file(path)

    # File-based dispatching
    if ends_with_any(path, '.json'): return rp.load_json(path)
    if ends_with_any(path, '.yaml'): return rp.load_yaml(path)
    if ends_with_any(path, '.png .webp .gif'.split()): return _omni_load_animated_image(path)
    if ends_with_any(path, '.jpg .jpeg .jxl .exr .psd .tga .tiff .tif .jp2 .bmp'.split()): return rp.load_image(path)
    if ends_with_any(path, '.mp4 .avi .mkv .flv .mov .m4v .wmv .webm'.split()): return rp.load_video(path)
    if ends_with_any(path, '.wav .mp3 .ogg .aac .wma .aiff .m4a .flac'.split()): return rp.load_sound_file(path)
    if ends_with_any(path, '.npy'.split()): return np.load(path)
    if ends_with_any(path, '.tsv'.split()): return load_tsv(path,show_progress=True)
    if ends_with_any(path, '.csv'.split()): return load_csv(path,show_progress=True)
    if ends_with_any(path, '.parquet'.split()): return load_parquet(path,show_progress=True)
    if ends_with_any(path, '.safetensors'.split()): return load_safetensors(path)
    if ends_with_any(path, '.pt .pth .ckpt'.split()): return __import__('torch').load(path,map_location='cpu')
    if ends_with_any(path, '.lines'.split()): return load_file_lines(path)
    if ends_with_any(path, '.pkl'.split()): return load_pickled_value(path)
    if ends_with_any(path, '.rpo'.split()): return file_to_object(path) #RP Object File
    if is_image_file(path): return load_image(path)
    if is_video_file(path): return load_video(path)
    if is_utf8_file(path): return load_text_file(path)
    return file_to_object(path)

_omni_load = omni_load

def omni_save(object, path, *, auto_extension=False):

    if auto_extension and not has_file_extension(path):
        path += _omni_save_default_extension(object)

    if ends_with_any(path.lower(), '.json'): return rp.save_json(object,path)
    if ends_with_any(path.lower(), '.yaml'): return rp.save_yaml(object,path)
    if ends_with_any(path.lower(), '.png .webp .gif'.split()): return rp.r._omni_save_animated_image(object, path)
    if ends_with_any(path.lower(), '.jpg .jpeg .jxl .exr .psd .tga .tiff .tif .jp2 .bmp'.split()): return rp.save_image(object, path)
    if ends_with_any(path.lower(), '.mp3'.split()): return rp.save_mp3_file(object, path)
    if ends_with_any(path.lower(), '.wav'.split()): return rp.save_wav_file(object, path)
    if ends_with_any(path.lower(), '.mp4'.split()): return rp.save_video(object, path)
    if ends_with_any(path.lower(), '.npy'.split()): return np.save(path,object)
    if ends_with_any(path.lower(), '.pt .pth .ckpt'.split()): return __import__('torch').save(object,path)
    if ends_with_any(path.lower(), '.safetensors'.split()): return save_safetensors(object,path)
    if ends_with_any(path.lower(), '.lines'.split()): return save_file_lines(object,path)
    if ends_with_any(path.lower(), '.pkl'.split()): return save_pickled_value(object,path)
    if ends_with_any(path.lower(), '.rpo'.split()): return object_to_file(object,path) #RP Object File
    if isinstance(object, str): return save_text_file(object, path)
    if isinstance(object, bytes): return bytes_to_file(object, path)
    return object_to_file(object,path)

_omni_save = omni_save

def file_cache_call(
    path,
    func,
    *args,
    save=None,
    load=None,
    invalid=None,
    **kwargs
):
    """
    Caches the result of a function call to a file.
    If the file exists, return its contents. Otherwise, call the function and save the result.
    
    Args:
        path: Path to the cache file

        func: Function to call if cache miss
        *args: Arguments to pass to func
        **kwargs: Keyword arguments to pass to func

        save: Function to save result to file
              Expected signature: save(obj, path)
        load: Function to load result from file
              Expected signature: load(path) -> obj
        invalid: Chooses whether to invalidate the cache
            None (default): Never invalidates the cache, if the cache file exists it always will used
            float or int: Invalidates if path is older than that number of seconds
            bool: Forces invalidation if True
        
    Returns:
        The result of func(*args, **kwargs) or the cached result
    """
    save = save or _omni_save
    load = load or _omni_load

    path = get_absolute_path(path, physical=False)

    #Cache Invalidation Logic
    if invalid == True:
        #Simply force invalidation by setting it to True
        overwrite = True
    elif invalid is None:
        overwrite = not file_exists(path)
    elif not file_exists(path):
        overwrite = True
    elif is_number(invalid):
        #If file is older than "invalid" number of seconds, overwrite it
        overwrite = time.time() - date_modified(path).timestamp() > invalid
    else:
        raise ValueError(get_current_function_name() + ": Unsupported 'invalid' argument of type " + str(type(invalid)))

    if not overwrite:
        return load(path)
    
    result = func(*args, **kwargs)
    
    make_parent_folder(path)
    save(result,path)
    
    return result


def file_cache_wrap(path, save=None, load=None):
    """
    Decorator that wraps a function with file_cached_call.

    Notes: For intermediate math results, consider using joblib! Look into joblib...this is more for dataset generation...

    Args:
        path: Path to the cache file
        save: Function to save result to file (default: _omni_save)
        load: Function to load result from file (default: _omni_load)

    Returns:
        A decorator function that wraps the target function with file_cached_call
        
    Attributes of decorated function:
        cache_path: Path to the cache file
        save: The save function used
        load: The load function used
        clear_cache: Method to delete the cache file
    """
    #TODO: Give this function more features. It's pretty dang barebones right now...
    
    path = get_absolute_path(path, physical=False)

    import functools
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            return file_cache_call(
                path,
                func,
                *args,
                **kwargs,
                save=save,
                load=load,
            )

        def clear_cache():
            delete_file(path)

        wrapper.cache_path = path
        wrapper.save = save
        wrapper.load = load
        wrapper.clear_cache = clear_cache
        
        # Add a better __repr__ for debugging: Didn't work! Apparently you can't override funcs' __repr__'s.
        # wrapper.__repr__ = lambda: "<rp.file_cache_wrap for %r at %r>" % (func.__name__, path)

        return wrapper

    return decorator

#endregion

class ClassProperty:
    """
    Descriptor (used as a decorator) that creates class-level 'properties' in Python.
    
    This class is a workaround for the lack of support for @classmethod in combination with @property in Python <= 3.8.
    It mimics the behavior of the @property decorator but for classes, not instances.
    
    Syntax in Python 3.9 and later:
        class MyClass:
            _class_data = 10

            @classmethod
            @property
            def class_data(cls):
                return cls._class_data
        
        print(MyClass.class_data)  # prints: 10
    
    Equivalent syntax using ClassProperty in Python 3.8 and earlier:
        class MyClass:
            _class_data = 10

            @ClassProperty
            def class_data(cls):
                return cls._class_data

        print(MyClass.class_data)  # prints: 10
    """
    def __init__(self, getter):
        self.getter = getter

    def __get__(self, instance, owner):
        return self.getter(owner)



class CachedInstances:
    """
    TODO: Make this function smarter - use the same arg techniques as in gather_args, so even when we give overrides to default args it still caches properly. Same for memoized

    A base class that provides caching for instances of derived classes.

    The CachedInstances class is designed to cache and reuse instances of the classes
    that inherit from it. It helps prevent the creation of multiple instances with the
    same arguments, which can be useful when dealing with resource-intensive objects
    like deep learning models or other objects that require significant setup time.

    Instances are cached based on the constructor's arguments. When an object is
    requested with the same arguments as a previously created object, the cached
    instance is returned instead of creating a new one.

    A new class property, self.instance_cache, is added - you can use this to 
    clear the cache to make room for some VRAM elsewhere etc

    The cache is implemented using an rp.HandyDict, which allows for more complex keys,
    such as lists and other non-hashable types, to be used cached arguments.

    Example Use Cases:
        1. Prevent multiple connections to the same database by caching instances of a database connection class.
        2. Avoid repeating complex mathematical calculations by caching instances of a class that performs these calculations.
        3. Save time and resources by avoiding redundant machine learning model training with the caching of instances of a class that trains these models.

    Coded with the partial aid of Gpt4: https://sharegpt.com/c/ZJTpahK

    Example 1:
        class MyClass(CachedInstances):
            def __init__(self, value):
                self.value = value

        # Create an instance with value 42
        instance1 = MyClass(42)
        instance2 = MyClass(42)

        # Create an instance with value 100
        instance3 = MyClass(100)

        # Test that instances with the same arguments are the same object
        assert instance1 is instance2
        assert instance1 is not instance3
        assert instance2 is not instance3

    Example 2:
        class MyDeviceClass(CachedInstances):
            def __init__(self, device):
                self.device = device
                self.warn_if_multiple_devices()

            def warn_if_multiple_devices(self):
                devices = set(instance.device for instance in type(self).instance_cache.values())
                if len(devices) > 1:
                    print("Multiple devices detected: {}. It's recommended to use a single device.".format(devices))

        instance1 = MyDeviceClass("cpu")
        instance2 = MyDeviceClass("gpu0")
        instance3 = MyDeviceClass("gpu1")

        # A warning will be issued about multiple devices being used. This is useful for torch in particular, where we might not want to use multiple GPU's for the same task
        # OUTPUT:
        #    Multiple devices detected: {'gpu0', 'cpu'}. It's recommended to use a single device.
        #    Multiple devices detected: {'gpu0', 'cpu', 'gpu1'}. It's recommended to use a single device.

    Example 3:
        #The power of HandyDict: caching non-typically-hashable objects

        class MyListClass(CachedInstances):
            def __init__(self, my_list):
                self.my_list = my_list

        # Create instances with the same list
        list1 = [1, 2, 3]
        list2 = [1, 2, 3]
        list3 = [4, 5, 6]

        instance1 = MyListClass(list1)
        instance2 = MyListClass(list2)
        instance3 = MyListClass(list3)

        # Test that instances with the same lists are the same object
        assert instance1 is instance2
        assert instance1 is not instance3

    Example 4:
        # It correctly handles subclasses
        
        class ParentClass(CachedInstances):
            def __init__(self, value):
                self.value = value

        class ChildClass(ParentClass):
            pass

        # Create instances of ParentClass and ChildClass with the same value
        parent_instance1 = ParentClass(42)
        child_instance1 = ChildClass(42)

        # Create instances of ParentClass and ChildClass with different values
        parent_instance2 = ParentClass(100)
        child_instance2 = ChildClass(100)

        # Test that instances with the same arguments but different classes are not the same object
        assert parent_instance1 is not child_instance1
        assert parent_instance2 is not child_instance2

        # Test that instances with the same arguments and same class are the same object
        assert parent_instance1 is ParentClass(42)
        assert child_instance1 is ChildClass(42)

        # Test that instances with different arguments and same class are not the same object
        assert parent_instance1 is not parent_instance2
        assert child_instance1 is not child_instance2
    """

    # TODO: Fix CachedInstances to handle default arguments correctly.
    # Ensure instances with unspecified default arguments are treated
    # as equivalent to instances with specified default arguments.
    # Consider robustness when working with built-in or C-implemented classes.
    # Example issue:
    #   class Thing(CachedInstances):
    #       def __init__(self, key=0):
    #           pass
    #   assert Thing() is Thing(0), 'This assertion fails! TODO: Fix it'
    # More info: https://shareg.pt/TDRhtYp

    _init_lock = threading.Lock()

    def __new__(cls, *args, **kwargs):
        """
        Create a new instance or return an existing one from the cache based on the arguments.

        Args:
            cls (Type[object]): The class for which the new instance is being created.
            *args (Any): Positional arguments passed to the constructor.
            **kwargs (Any): Keyword arguments passed to the constructor.

        Returns:
            object: The new or cached instance of the class.
        """
        all_args = (args, kwargs)
        cache = cls.instance_cache
        if all_args not in cache:
            # Cache miss: Create a new instance
            instance = super().__new__(cls)
            cache[all_args] = instance
            # __init__ will be called automatically after __new__
        else:
            # Cache hit: Use the existing instance

            # We want to prevent __init__ from being called twice because it may
            # cause unintended side effects, such as resetting the state of an
            # existing object or causing resource leaks (e.g., opening multiple
            # connections to a database). Therefore, when an existing instance
            # is returned from the cache, we temporarily replace the __init__
            # method with a no-op function to avoid calling it again.

            with cls._init_lock:
                # Temporarily replace the __init__ method with a no-op function
                old_init = cls.__init__

                def new_init(self, *args, **kwargs):
                    """Restore the original __init__ method."""
                    cls.__init__ = old_init  # Restore the original __init__ method

                cls.__init__ = new_init  # Replace the __init__ method with the new_init function

        return cache[all_args]
    
    @ClassProperty
    def instance_cache(cls):
        """
        Return the cache dictionary for instances of the derived class.

        Returns:
            HandyDict: A HandyDict object that maps cache keys (based on the constructor arguments) to the cached instances.
        """
        assert isinstance(cls, type)
        if not "_instance_cache" in vars(cls):
            # Check if '_instance_cache' is in the dictionary of the current class (vars(cls))
            # instead of using hasattr(cls, '_instance_cache') to prevent the cache from being
            # shared between the parent class and its subclasses.
            #
            # Using hasattr(cls, '_instance_cache') would return True if any ancestor of cls has
            # '_instance_cache' attribute. This would lead to sharing the same cache between the
            # parent class and its subclasses, which is not desired. By checking if
            # '_instance_cache' is in vars(cls), we ensure that each class has its own separate cache.
            #
            # See Example 4
            cls._instance_cache = HandyDict()
        return cls._instance_cache


def _get_hash(source, hash_func_name, func_display_name, *, show_progress, format):
    """
    Helper function to calculate a hash of the provided data or file contents.
    
    :param source: The data or file path to hash
    :param hash_func_name: Name of the hash function to use (e.g., 'md5', 'sha256')
    :param func_display_name: Display name for error messages
    :param show_progress: Whether to display a progress bar
    :param format: Output format of the hash. Options: 'hex', 'int', 'bytes', 'base64'
    :return: The hash in the specified format
    """
    import hashlib
    import os
    import io
    import base64
    
    hash_obj = getattr(hashlib, hash_func_name)()
    
    if isinstance(source, bytes):
        byte_stream = io.BytesIO(source)
        total_size = len(source)
        progress_desc = "Hashing data via " + hash_func_name
    elif isinstance(source, str) and os.path.isfile(source):
        byte_stream = open(source, 'rb')
        total_size = os.path.getsize(source)
        progress_desc = "Hashing file via " + hash_func_name
    else:
        raise TypeError("rp." + func_display_name + ": Unsupported input type '" + type(source).__name__ + "'")
    
    progress_bar = None
    if show_progress:
        #TODO: Make it unified with load_files, one unified tqdm/eta function - supporting the same argument formats
        pip_import('tqdm')
        from tqdm import tqdm
        progress_bar = tqdm(total=total_size, desc=progress_desc, unit='B', unit_scale=True, unit_divisor=1024)
    
    chunk_size = 4096 #A nice big chunk size
    while True:
        chunk = byte_stream.read(chunk_size)
        if not chunk:
            break
        hash_obj.update(chunk)
        if show_progress:
            progress_bar.update(len(chunk))
    
    if hasattr(byte_stream, 'close'):
        byte_stream.close()
    
    # Return hash in requested format
    if format in ('hex', 'base16'):
        return hash_obj.hexdigest()
    elif format == 'int':
        return int(hash_obj.hexdigest(), 16)
    elif format == 'bytes':
        return hash_obj.digest()
    elif format == 'base64':
        return bytes_to_base64(hash_obj.digest())
    else:
        raise ValueError("rp." + func_display_name + ": Unsupported format '" + format + "'. Options: 'hex', 'int', 'bytes', 'base64'")

def get_md5_hash(source, format='hex', *, show_progress: bool = False):
    """
    Calculate the MD5 hash of the provided data or the contents of a file specified by its path.
    It is both slower AND worse than get_sha256_hash

    :param source:
        - bytes: The data to hash.
        - str: The file path whose contents are to be hashed.
    :param show_progress: Keyword-only argument that dictates whether to display a progress bar during the hash computation.
    :param format: Keyword-only argument that specifies the output format of the hash. 
                  Options: 'hex'/'base16', 'int', 'bytes', 'base64'.
    :return: The MD5 hash in the specified format.

    EXAMPLES:
        >>> get_md5_hash('/Users/ryan/Downloads/A dolphin reading.png')
        ans = 3d8909facff8e0c479d984d6adcf51c1
        >>> get_md5_hash(b'test bytestring')
        ans = 18485e0c32cc8aa30d80dbecdd4fac50
        >>> get_md5_hash(object_to_bytes([1,2,3,4,5]))
        b'3b5d3c7d207e37dceeedd301e35e2e58'
        >>> get_md5_hash('/path/to/file', format='int')
        78854878685254270795816465693052641713
        >>> get_md5_hash('/path/to/file', format='base64')
        'kJh5Sq2LpZ3v2dYHPvs8uQ=='
    """
    return _get_hash(source, 'md5', 'get_md5_hash', show_progress=show_progress, format=format)

def get_sha256_hash(source, format='hex', *, show_progress: bool = False):
    """
    Calculate the SHA-256 hash of the provided data or the contents of a file specified by its path.
    It is both better AND faster than get_md5_hash

    :param source:
        - bytes: The data to hash.
        - str: The file path whose contents are to be hashed.
    :param show_progress: Keyword-only argument that dictates whether to display a progress bar during the hash computation.
    :param format: Keyword-only argument that specifies the output format of the hash. 
                  Options: 'hex'/'base16', 'int', 'bytes', 'base64'.
    :return: The SHA-256 hash in the specified format.

    EXAMPLES:
        >>> get_sha256_hash('/Users/ryan/Downloads/A dolphin reading.png')
        ans = cd318d7a18c5ebf899c53d5a580d5bb659e99c1ca2961ac0507cb0d2946498f6
        >>> get_sha256_hash(b'test bytestring')
        ans = 79945b2e3369479b02fabcd95a6d124524a2939e40003c2dcba182c35cf6c10a
        >>> get_sha256_hash(object_to_bytes([1,2,3,4,5]))
        ans = ab10184fc37b8a4bf42feef31ada80b347d2096b0dc161921579753765565577
        >>> get_sha256_hash('/path/to/file', format='int')
        69026873886289201118877060911792184041901559641924946149006354401672729373074
        >>> get_sha256_hash('/path/to/file', format='base64')
        'g+YuWoycDlhZ9+VRXimYoxXqkR+OFv4PlM0/Y9p9D0E='
    """
    return _get_hash(source, 'sha256', 'get_sha256_hash', show_progress=show_progress, format=format)


def _labeled_image_text_to_image(text,
                                 align,
                                 font):
    
    if font is None: font = 3 #The default font for opencv

    if   isinstance(font, int): output = cv_text_to_image(text, align=align, font=font)
    elif isinstance(font, str): output = pil_text_to_image(text, align=align, font=font, size=100)
    else: assert False, 'labeled_image: font must be a string or an int between 0 and 8'

    return output

def labeled_image(image,
                  text:str,
                  size=15,
                  position='top',
                  align='center',
                  text_color=(1,1,1,1),
                  background_color=(0,0,0,1),
                  flip_text=False,
                  size_by_lines=False,
                  font=None,
                 ):
    """
    Adds a label to an image and returns an image
    'size' is either measured in pixels (int), or is in proportion to the image size (float)

    Args:
        image (numpy.ndarray): The input image to be labeled.
        text (str): The text to be added as the label.
        size (int or float): The size of the label. If an integer, it represents the height of the label in pixels.
                             If a float, it represents the proportion of the label height relative to the image height.
                             If size < 0, will overlay the label over the image. Best with translucent background_color such as "translucent green"
                             Default is 15.
        position (str): The position of the label relative to the image. Can be 'top', 'bottom', 'left', or 'right'.
                        Default is 'top'.
        align (str): The alignment of the label text. Can be 'left', 'right', or 'center'. Default is 'center'.
        text_color (tuple): The color of the label text as an RGB tuple (red, green, blue). Default is white (255, 255, 255).
        background_color (tuple): The background color of the label as an RGB tuple (red, green, blue). Default is black (0, 0, 0).
        flip_text (bool): Whether to flip the label text upside down. Default is False. Can be useful when position in ['left', 'right']
        size_by_lines (bool): If True, the `size` argument is multiplied by the number of lines in the text, so it can grow accordingly
                              Good in combination with wrap_string_to_width
        font (int, str, optional): Can be an int between 0 and 8 (to use opencv) or a string (to use PIL) such as "Menlo", "Times New Roman", "Courier", "Futura", "Comic Sans", "Calibri" etc (get a bit list of available fonts with rp.get_system_fonts() )

    Returns:
        numpy.ndarray: The image with the label added.

    EXAMPLES:
        >>> image=load_image('http://hi-bk.com/wp-content/uploads/2020/08/hibkdog.png',use_cache=True)
        >>> display_image(labeled_image(image,'hello',10))
        >>> display_image(labeled_image(image,'hello',25))
        >>> display_image(labeled_image(image,'hello',.1))
        >>> display_image(labeled_image(image,'hello',.1,align='left'))
        >>> display_image(labeled_image(image,'hello',.1,align='right'))
        >>> display_image(labeled_image(image,'hello',.1,align='center'))
        >>> display_image(labeled_image(image,'hello',.1,position='top'))
        >>> display_image(labeled_image(image,'hello',.1,position='bottom'))
        >>> display_image(labeled_image(image,'hello',.5))
        >>> display_image(labeled_image(image,'hello',.9))
        >>> display_image(labeled_image(image,'hello habba booboo gabba',.9))
        >>> display_image(labeled_image(image,'hello habba booboo gabba',.1))

    EXAMPLE:

        >>> for font in [
        ...     #OpenCV Fonts
        ...     0, 1, 2, 3, 4, 5, 6, 7,
        ...     #Regular fonts
        ...     "Gill Sans",
        ...     "Baskerville",
        ...     "Rockwell",
        ...     "Calibri",
        ...     "Trebuchet MS",
        ...     "Verdana",
        ...     "Times New Roman",
        ...     "Futura",
        ...     "Courier",
        ...     "Century Gothic",
        ...     "Georgia",
        ...     "Arial",
        ...     "Bookman",
        ...     "Palatino",
        ...     "Impact",
        ...     "Copperplate",
        ...     "Papyrus",
        ...     #Google Fonts
        ...     "G:Quicksand",
        ...     "G:Protest Guerrilla",
        ...     "G:Roboto Slab",
        ...     "G:Bebas Neue",
        ...     "G:Matemasie",
        ...     "G:Zilla Slab",
        ...     "G:Abril Fatface",
        ...     "G:Permanent Marker",
        ...     "G:Macondo",
        ...     "G:Cinzel",
        ...     "G:Vollkorn",
        ...     "G:Orbitron",
        ...     "G:Marcellus",
        ...     "G:Rubik Mono One",
        ...     "G:Prata",
        ... ]:
        ...     from rp import *
        ...
        ...     urls = [
        ...         "https://github.com/RyannDaGreat/Diffusion-Illusions/blob/gh-pages/images/emma.png?raw=true",
        ...         "https://www.petplan.co.uk/images/breed-info/bichon-frise/behaviour--personality_bichon-frise.png",
        ...         "https://www.vidavetcare.com/wp-content/uploads/sites/234/2022/04/labrador-retriever-dog-breed-info.jpeg",
        ...         "https://i5.walmartimages.com/asr/997e0170-abd7-484e-96b0-81e314b86c20.1d66d31352eae16052b7b9d5e1b34583.jpeg?odnHeight=768&odnWidth=768&odnBg=FFFFFF",
        ...     ]
        ...     labels = ["Ryan's Dog", "Bichon Dog", "Labordor Dog", "Hot Dog"]
        ...     images = load_images(urls,use_cache=True)
        ...
        ...     # Resize the images evenly on both dimensions so their height is 512
        ...     images = resize_images_to_fit(images, height=256)
        ...
        ...     # Adds labels to the top of the images
        ...     images = labeled_images(
        ...         images,
        ...         labels,
        ...         # Some optional kwargs:
        ...         size=30,  # Each label is exactly this tall (in pixels)
        ...         position="top",
        ...         text_color="cyan",
        ...         background_color="black",
        ...         font=font,
        ...     )
        ...
        ...     # Concat them all horizontally
        ...     combined = horizontally_concatenated_images(images)
        ...     combined = labeled_image(
        ...         combined,
        ...         "Types of Dogs\nFont = " + str(font),
        ...         size=30,
        ...         font=font,
        ...         size_by_lines=True,
        ...     )
        ...
        ...     save_image(combined, "output.png")
        ...     display_image(combined)
        ...     input(font)

    """

    #We use float colors now
    background_color = as_rgba_float_color(background_color)
    text_color       = as_rgba_float_color(text_color)

    image=as_numpy_image(image, copy=False)

    assert is_image(image)
    assert position in ['top','bottom','left','right']
    assert align in ['left','right','center']
    assert isinstance(size,float) or isinstance(size,int)

    text=str(text)

    if size_by_lines:
        num_lines = bool(text) * number_of_lines(text)
        size *= num_lines

    if size==0:
        from copy import copy
        return copy(image)

    if position in ['left', 'right']:
        angle = 90 if position=='left' else -90
         
        image=rotate_image(image,angle)
        image = labeled_image(
            image=image,
            text=text,
            size=size,
            position="top",
            align=align,
            text_color=text_color,
            background_color=background_color,
            flip_text=flip_text,
            font=font,
        )
        image=rotate_image(image,-angle)

        return image
    
    if position in ['top','bottom']:
        if isinstance(size,float):
            height=int(get_image_height(image)*size)
        else:
            assert isinstance(size,int)
            height=size

        image_height, image_width=get_image_dimensions(image)
            
        if height < 0:
            #Overlay the label over the image
            height = abs(height)
            height = min(height, image_height)
            overlay = uniform_float_color_image(image_height - height, image_width, background_color[:3]+(0,))
            overlay = gather_args_call(labeled_image, overlay, size=height, size_by_lines=False)
            return blend_images(image, overlay)

        height=max(height,1)         #Label height in pixels
        
        label=_labeled_image_text_to_image(text,align=align,font=font)

        label=resize_image_to_fit(label, height=height)
        if get_image_width(label)>image_width:
            label=cv_resize_image(label,image_width/get_image_width(label))
            
        label=as_rgb_image(label)
        
        label=crop_image(label,height=height,origin='center')
        label=crop_image(label,width=image_width,origin=align)

        #Apply colors to label
        #We need to turn the RGB byte color into RGBA float color.
        #TODO: Make a method for this in RP along with other color conversion methods...
        label=blend_images(background_color,text_color,label)

        if flip_text:
            label=rp.rotate_image(label,180)
        
        if position=='top':
            return vertically_concatenated_images(label,image)
        
        if position=='bottom':
            return vertically_concatenated_images(image,label)
        
    assert False,'This line should be unreachable'


def _images_are_all_same_size(images):
    """ TODO: Use this for video processing functions instead of using for loops to check if image sizes are the same... """
    if is_numpy_array(images):
        return True
    if is_torch_tensor(images):
        return True
    sizes = [get_image_dimensions(x) for x in images]
    return len(set(sizes))==1

def labeled_images(images,labels,show_progress=False,lazy=False,*args,**kwargs):
    """
    The plural of labeled_image
    See rp.labeled_image's documentation
    TODO: Optimize this when video is numpy array

    Enhanced Documentation:
    
    Adds text labels to multiple images at once. Batch version of labeled_image
    with identical functionality and parameters for each image.
    
    Parameters:
        images: Iterable of images in any RP format (PIL, numpy, torch)
        labels: Iterable of text labels (one per image)
        show_progress (bool): Display progress bar for batch processing
        lazy (bool): Return generator instead of list for memory efficiency
        *args, **kwargs: Additional arguments passed to labeled_image()
    
    Returns:
        list or generator: Labeled images in same format as inputs
    
    Usage Examples:
        >>> images = [load_image(f"img{i}.jpg") for i in range(3)]
        >>> labels = ["Cat", "Dog", "Bird"]
        >>> labeled = labeled_images(images, labels)
        
        >>> # With progress bar for large batches
        >>> labeled = labeled_images(images, labels, show_progress=True)
    
    Related Functions:
        - labeled_image(): Single image labeling
        - labeled_videos(): Video labeling equivalent
    
    Tags: image, labels, text, batch, plural
    """
    assert is_iterable(labels), type(labels)
    assert is_iterable(images), type(images)

    #TODO: Make it lazier
    images=list(images)

    if isinstance(labels,str):
        labels=[labels]*len(images)
    else:    
        labels=list(labels)

    assert len(images)==len(labels)

    output = (labeled_image(image,label,*args,**kwargs) for image,label in zip(images,labels))

    output = IteratorWithLen(output, len(images))
    if show_progress: output = eta(output, title = 'rp.'+get_current_function_name())
    if not lazy: output = list(output)
    return output

def labeled_videos(videos,labels,show_progress=False,lazy=False,lazy_frames=False,*args,**kwargs):
    """
    The plural of labeled_images
    See rp.labeled_image's documentation
    TODO: Optimize this when videos are numpy arrays
    """
    assert is_iterable(labels), type(labels)
    assert is_iterable(videos), type(images)


    if not is_numpy_array(videos) and not is_torch_tensor(videos):
        videos=list(videos)

    if isinstance(labels,str):
        labels=[labels]*len(videos)
    else:    
        labels=list(labels)

    assert len(videos)==len(labels), (len(videos), len(labels))

    output = (labeled_images(video,label,lazy=lazy_frames,*args,**kwargs) for video,label in zip(videos,labels))

    output = IteratorWithLen(output, len(videos))
    if show_progress: output = eta(output, title = 'rp.'+get_current_function_name())
    if not lazy: output = list(output)
    return output


@memoized
def _cv_char_to_image(char: str, width=None, height=None, **kwargs):
    assert len(char) == 1, len(char)
    output = cv_text_to_image(char, **kwargs)
    if width is not None or height is not None:
        output = crop_image(output, height, width, origin="center")
    return output


def _cv_text_to_image_monospace(text, **kwargs):
    """
    Right now this is approx 2x slower than non-monospace, even with the optimizations. I could probably optimize it more but meh
    Note: This guarentees that all characters in the output image will be monospaced
    However, it does NOT guarentee that two output images will have the *same* spacing, though it gets close.
    TODO: Do that...we will probably need to simply add all printable chars to the 'chars' variable
    """

    import string

    def horizontally_concatenated_images(images):
        #Faster for this use case; we have better assumptions about the images.
        return np.column_stack(tuple(images))

    #def vertically_concatenated_images(images):
    #    #Faster for this use case; we have better assumptions about the images.
    #    return np.row_stack(tuple(images))

    chars = set(text) | set(string.printable)
    chars = {char: _cv_char_to_image(char, **kwargs) for char in chars}
    char_height = max(x.shape[0] for x in chars.values())
    char_width  = max(x.shape[1] for x in chars.values())
    chars = {
        char: _cv_char_to_image(char, char_height, char_width, **kwargs)
        for char in chars
    }

    lines = line_split(text)
    lines = [[chars[char] for char in line] for line in lines]
    lines = [horizontally_concatenated_images(line) for line in lines]

    output = vertically_concatenated_images(lines)

    return output

def cv_text_to_image(text,
                     *,
                     scale=2,
                     font=3,
                     thickness=2,
                     color=(255, 255, 255),
                     tight_fit=False,
                     background_color=(0, 0, 0),
                     monospace=False,
                     align='left'
                    ):
    """
    Uses OpenCV to write words on an image, and returns that image

    EXAMPLE DEMO (Shows all fonts):
        >>> collages=[]
        ... for scale in [1,2,3,4,5]:
        ...     images=[]
        ...     for font in range(8):
        ...         images.append(cv_text_to_image('Hello World! My name is Clara',font=font,scale=scale))
        ...     images=labeled_images(images,['Font #%i'%i for i in range(8)],align='left')
        ...     images=crop_images_to_max_width(images)
        ...     collage=tiled_images(images,length=1,border_color='red')
        ...     collage=labeled_image(collage,'rp.cv_text_to_image: Scale = %i'%scale,text_color=(255,0,0),size=15*scale)
        ...     collages.append(collage)
        ... display_image_slideshow(collages)
    """


    if monospace:
        return _cv_text_to_image_monospace(
            text,
            scale=scale,
            font=font,
            thickness=thickness,
            color=color,
            tight_fit=tight_fit,
            background_color=background_color,
        )

    lines = text.splitlines()
    images = []
    for line in lines:
        images.append(
            _single_line_cv_text_to_image(
                line,
                scale=scale,
                font=font,
                thickness=thickness,
                color=color,
                tight_fit=tight_fit,
                background_color=background_color,
            )
        )

    return grid_concatenated_images([[x] for x in images], origin=align)

def _single_line_cv_text_to_image(text,*,scale,font,thickness,color,tight_fit,background_color):
    """
    EXAMPLE:
       display_image(cv_text_to_image('HELLO WORLD! '))
    This is a helper function for cv_text_to_image, which can handle multi-line text
    """
    assert isinstance(text,str), type(text)
    assert isinstance(font,int), type(font)
    cv2=pip_import('cv2')
    dims=cv2.getTextSize(text,font,scale,thickness)[0][::-1] #The dimensions of the output image
    #UPDATE: added the *2 in dims[0]*2 below to prevent the lowercase letter y's tail from being cut off
    temp=2 if tight_fit else 1
    from math import ceil
    image=np.zeros((ceil(dims[0]*1.333*temp+thickness//2+1),dims[1]*temp,3),np.uint8)
    image[:,:,0]+=background_color[0]
    image[:,:,1]+=background_color[1]
    image[:,:,2]+=background_color[2]
    image= cv2.putText(image,text,(0,dims[0]),font,scale,color,thickness)
    if tight_fit:
        image=crop_image_zeros(image)
    return image


@memoized
def _slow_pil_text_to_image(
    text,
    *,
    size=12,
    font="Courier",
    color=(255, 255, 255, 255), #Is a byte color
    background_color=(0, 0, 0, 0)  #Is a byte color
):
    """
    Works well! But is SO FUCKING SLOW on its own...by putting characters together and concating them we can do better...
    CONTEXT: https://chatgpt.com/share/a7f61066-b6dd-41e4-a28f-b9ccc84aa632
    """

    pip_import("PIL")
    from PIL import Image, ImageDraw, ImageFont
    import numpy as np


    try:
        assert font is not None
        font = ImageFont.truetype(font, size)
    except (IOError, AssertionError):
        font = ImageFont.load_default(size=size)
        #print("Failed to load %s, using default font: %s" % (font, font))

    # Use a wide character sequence to normalize the starting and ending boundaries
    delimiter_text = (
        "   "
        + "QWERTYUIOP{}|ASDFGHJKL:\"ZXCVBNM<>?~!@#$%^&*()_+=-0987654321`qwertyuiop[]\\';lkjhgfdsazxcvbnm,./   "
    )
    full_text = delimiter_text + text + delimiter_text

    # Get bounding box of the full text
    full_text_bbox = font.getbbox(full_text)
    full_text_width = full_text_bbox[2] - full_text_bbox[0]
    full_text_height = full_text_bbox[3] - full_text_bbox[1]

    # Draw the text on an image
    image = Image.new(
        "RGBA", (full_text_width, full_text_height), color=background_color
    )
    draw = ImageDraw.Draw(image)
    draw.text(
        (-full_text_bbox[0], -full_text_bbox[1]), full_text, font=font, fill=color
    )

    # Determine width of the delimiter text for cropping
    delimiter_text_width = font.getbbox(delimiter_text)[2]

    numpy_image = np.array(image)[:, delimiter_text_width:-delimiter_text_width]
    return numpy_image

def as_rgba_float_color(color,*,clamp=True):
    """Converts any color representation to a standardized RGBA float tuple.
    
    Accepts a wide variety of color input formats and normalizes them to a consistent
    4-element tuple of floats (Red, Green, Blue, Alpha) where each component is 
    typically in the range [0.0, 1.0].
    
    Enhanced Documentation:
    
    This function is a central color conversion utility in RP, designed to handle the
    "accept anything" philosophy for color inputs. It's used extensively throughout
    RP's image processing functions to normalize color parameters.
    
    Supported input formats:
    - Single numbers: Converted to grayscale (0.5 -> (0.5, 0.5, 0.5, 1.0))
    - RGB tuples/lists: Alpha=1.0 added ((1,0,0) -> (1.0, 0.0, 0.0, 1.0))
    - RGBA tuples: Used directly ((1,0,0,0.5) -> (1.0, 0.0, 0.0, 0.5))
    - Color names: Standard web colors ("red" -> (1.0, 0.0, 0.0, 1.0))
    - Hex strings: With or without # ("ff0000" -> (1.0, 0.0, 0.0, 1.0))
    - NumPy arrays: Shape (), (3,), or (4,) supported
    - Special strings: "transparent", "translucent", compound names
    
    Transparency handling:
    - "transparent" -> alpha=0 (fully transparent)
    - "translucent" -> alpha=0.5 (half transparent)  
    - "transparent red" -> red color with alpha=0
    - "translucent blue" -> blue color with alpha=0.5
    
    Args:
        color: Color specification in any supported format. Can be:
              - Number (grayscale): int, float
              - Sequence: tuple, list, NumPy array of RGB or RGBA values
              - String: color name, hex code, or special transparency names
        clamp (bool, keyword-only): If True (default), clamp all values to [0,1].
                                   If False, allow values outside this range.
    
    Returns:
        tuple: 4-element tuple of floats (R, G, B, A) representing the color.
               All elements are floats, typically in [0.0, 1.0] range if clamped.
    
    Raises:
        AssertionError: If input format is not supported or array has wrong shape.
        ValueError: If string color cannot be parsed as hex or color name.
    
    Examples:
        >>> # Single numbers become grayscale
        >>> as_rgba_float_color(1)
        (1.0, 1.0, 1.0, 1.0)
        >>> as_rgba_float_color(0.5)
        (0.5, 0.5, 0.5, 1.0)
        
        >>> # RGB tuples get alpha=1.0 added
        >>> as_rgba_float_color((1, 0.5, 0))
        (1.0, 0.5, 0.0, 1.0)
        
        >>> # RGBA tuples used directly
        >>> as_rgba_float_color((1, 0.5, 0, 0.8))
        (1.0, 0.5, 0.0, 0.8)
        
        >>> # Color names
        >>> as_rgba_float_color("red")
        (1.0, 0.0, 0.0, 1.0)
        >>> as_rgba_float_color("blue")
        (0.0, 0.0, 1.0, 1.0)
        
        >>> # Hex colors (with or without #)
        >>> as_rgba_float_color("ff0000")
        (1.0, 0.0, 0.0, 1.0)
        >>> as_rgba_float_color("#00ff00")
        (0.0, 1.0, 0.0, 1.0)
        
        >>> # Transparency modifiers
        >>> as_rgba_float_color("transparent red")
        (1.0, 0.0, 0.0, 0.0)
        >>> as_rgba_float_color("translucent blue")
        (0.0, 0.0, 1.0, 0.5)
        >>> as_rgba_float_color("transparent")
        (0, 0, 0, 0)
        
        >>> # NumPy arrays
        >>> import numpy as np
        >>> as_rgba_float_color(np.array([1.0, 0.5, 0.0]))
        (1.0, 0.5, 0.0, 1.0)
        
        >>> # Clamping behavior
        >>> as_rgba_float_color((-0.5, 1.5, 0.5), clamp=True)
        (0.0, 1.0, 0.5, 1.0)
        >>> as_rgba_float_color((-0.5, 1.5, 0.5), clamp=False)  
        (-0.5, 1.5, 0.5, 1.0)
        
        >>> # Use in image functions
        >>> # uniform_float_color_image(100, 100, "translucent red")
        >>> # blend_images(img1, img2, as_rgba_float_color("cyan"))
    
    Related Functions:
        - as_rgb_float_color: Same function but returns only RGB (first 3 elements)
        - as_rgba_float_colors: Batch conversion for multiple colors
        - hex_color_to_float_color: Direct hex to RGB conversion
        - color_name_to_float_color: Direct color name to RGB conversion
        - uniform_float_color_image: Uses this for color parameter normalization
        - blend_images: Uses this for color blending operations
    
    Implementation Notes:
        - Handles PyTorch tensors and NumPy arrays via shape checking
        - Falls back from hex parsing to color name parsing for strings
        - Always returns tuple of exactly 4 float elements
        - List inputs may cause concatenation errors (use tuples or NumPy arrays)
    
    Tags: color, rgba, conversion, normalization, image-processing, accept-anything

        >>> colors = '''
        ... //Generating all unique color combinations of two words or less:
        ... //import itertools;ans=line_join(unique(list(r._rp_colors)+[' '.join(x) for x in itertools.product(*[list(r._rp_colors)]*2)],key=as_rgba_float_color))
        ... red
        ... green
        ... blue
        ... cyan
        ... magenta
        ... yellow
        ... white
        ... black
        ... grey
        ... orange
        ... hotpink
        ... purple
        ... chartreuse
        ... maroon
        ... navy
        ... olive
        ... teal
        ... silver
        ... red blue
        ... red white
        ... red grey
        ... red orange
        ... red hotpink
        ... red purple
        ... red chartreuse
        ... red maroon
        ... red navy
        ... red olive
        ... red teal
        ... red silver
        ... green cyan
        ... green white
        ... green black
        ... green grey
        ... green orange
        ... green hotpink
        ... green purple
        ... green chartreuse
        ... green maroon
        ... green navy
        ... green olive
        ... green teal
        ... green silver
        ... blue cyan
        ... blue magenta
        ... blue white
        ... blue grey
        ... blue orange
        ... blue purple
        ... blue chartreuse
        ... blue maroon
        ... blue navy
        ... blue olive
        ... blue teal
        ... blue silver
        ... cyan white
        ... cyan grey
        ... cyan orange
        ... cyan hotpink
        ... cyan purple
        ... cyan chartreuse
        ... cyan navy
        ... cyan olive
        ... cyan teal
        ... cyan silver
        ... magenta white
        ... magenta grey
        ... magenta orange
        ... magenta hotpink
        ... magenta purple
        ... magenta chartreuse
        ... magenta maroon
        ... magenta olive
        ... magenta teal
        ... magenta silver
        ... yellow white
        ... yellow grey
        ... yellow orange
        ... yellow hotpink
        ... yellow purple
        ... yellow chartreuse
        ... yellow olive
        ... yellow teal
        ... yellow silver
        ... white grey
        ... white orange
        ... white hotpink
        ... white purple
        ... white chartreuse
        ... white olive
        ... white teal
        ... white silver
        ... black grey
        ... black orange
        ... black purple
        ... black maroon
        ... black navy
        ... black olive
        ... black teal
        ... black silver
        ... grey orange
        ... grey purple
        ... grey silver
        ... orange hotpink
        ... orange purple
        ... orange silver
        ... hotpink purple
        ... hotpink maroon
        ... hotpink silver
        ... purple chartreuse
        ... purple maroon
        ... purple navy
        ... purple olive
        ... purple teal
        ... purple silver
        ... chartreuse navy
        ... chartreuse silver
        ... maroon navy
        ... maroon silver
        ... navy teal
        ... navy silver
        ... olive silver
        ... teal silver
        ... 
        ... //Some others for demonstration purposes
        ... dark green
        ... green
        ... light green
        ... magenta
        ... red magenta
        ... blue magenta
        ... red blue
        ... light red
        ... blue green
        ... blue cyan
        ... light blue cyan
        ... light blue
        ... light light blue
        ... light light light blue
        ... red green
        ... light red green
        ... magenta cyan
        ... cyan
        ... cyan orange
        ... orange
        ... yellow
        ... light purple
        ... purple
        ... dark purple
        ... blue purple
        ... purple
        ... red purple
        ... red yellow
        ... orange
        ... yellow orange
        ... 
        ... lime
        ... green
        ... yellow green
        ... 
        ... light hot pink
        ... hot pink
        ... dark hot pink
        ... 
        ... pink
        ... light light dark red
        ... light red
        ... 
        ... yellow cyan 
        ... yellow yellow cyan
        ... 
        ... dark gray orange
        ... 
        ... //Transparency / Translucency
        ... transparent orange
        ... translucent red
        ... translucent red yellow
        ... translucent navy green
        ... translucent brown
        ... transparent dark green
        ... transparent black
        ... 
        ... //Hex Codes
        ... #007FFF
        ... FF0077
        ... abcdef
        ... 
        ... //Hex Codes with Translucency
        ... translucent 123456
        ... 
        ... 
        ... '''.strip().splitlines()
        ... colors = [x for x in colors if x.strip() and not x.startswith('//')]
        ... images = crop_images_to_max_size(
        ...     [
        ...         cv_resize_image(
        ...             pil_text_to_image(
        ...                 " " + color_name + " ",
        ...                 font="G:Zilla Slab",
        ...                 size=256,
        ...                 background_color='transparent white',
        ...             ),
        ...             1 / 8,
        ...         )
        ...         for color_name in colors
        ...     ]
        ... )
        ... images = [
        ...     blend_images(name, with_drop_shadow(image), 1)
        ...     for name, image in zip(colors, images)
        ... ]
        ... out_image=with_alpha_checkerboard(tiled_images(images, border_thickness=0, length=4,),tile_size=32)
        ... display_image(out_image)
        ... print("SAVED",save_image(out_image,'color_chart.png'))

    """

    if is_numpy_array(color) or is_torch_tensor(color):
        assert color.shape in [(), (3,), (4,)]
        color = tuple(color)

    assert isinstance(color, (tuple, list, str)) or is_number(color)

    if isinstance(color,str):
        if color.strip().lower() in 'transparent invisible'.split():
            return (0,0,0,0)
    
        if 'transparent' in color:
            alpha=0
            color = color.replace('transparent','').strip()
        elif 'translucent' in color:
            alpha=.5
            color = color.replace('translucent','').strip()
        else:
            alpha=1

        try:
            color=hex_color_to_float_color(color)
        except ValueError:
            color=color_name_to_float_color(color)

        color = color + (alpha,)

    if is_number(color):
        color = (color, ) * 3
    if len(color) == 3:
        color = tuple(color)
        color = color + (1,)
    assert len(color) == 4 
    if clamp:
        color = tuple(max(0,min(1,x)) for x in color)

    color = tuple(map(float,color)) #Make sure they're all floats! 

    return color

def as_rgb_float_color(color, clamp=True):
    """ The RGB counterpart to as_rgba_float_color. See rp.as_rgba_float_color for full documentation! """
    return as_rgba_float_color(color, clamp=clamp)[:3]
    

def as_rgba_float_colors(colors,clamp=True):
    """Apply as_rgba_float_color to multiple colors. See as_rgba_float_color for details.
    
    Args:
        colors: List/iterable of colors to convert to RGBA float format
        clamp: Whether to clamp values to [0,1] range
        
    Returns:
        List of RGBA float color tuples
    """
    return [as_rgba_float_color(x) for x in colors]

def as_rgb_float_colors(colors,clamp=True):
    """Apply as_rgb_float_color to multiple colors. See as_rgb_float_color for details.
    
    Args:
        colors: List/iterable of colors to convert to RGB float format
        clamp: Whether to clamp values to [0,1] range
        
    Returns:
        List of RGB float color tuples
    """
    return [as_rgb_float_color(x) for x in colors]


_ryan_fonts = {
    "R:Futura"      : "https://github.com/Eyeline-Research/Go-with-the-Flow/raw/refs/heads/website/fonts/Futura.ttc",
    ###
    "R:Helvetica"   : "https://github.com/RyannDaGreat/Images/blob/master/fonts/Helvetica.ttc", 
    "R:Monaco"      : "https://github.com/RyannDaGreat/Images/blob/master/fonts/Monaco.ttf", 
    "R:Menlo"       : "https://github.com/RyannDaGreat/Images/blob/master/fonts/Menlo.ttc", 
    "R:Optima"      : "https://github.com/RyannDaGreat/Images/blob/master/fonts/Optima.ttc", 
    "R:Palatino"    : "https://github.com/RyannDaGreat/Images/blob/master/fonts/Palatino.ttc", 
}

@memoized
def _get_font_paths(font, strict=False):
    assert isinstance(font, str)

    #Likely will be overridden
    fonts = [font]

    if font.startswith("G:"):
        # "G:Quicksand" or "G:Zilla Slab" from google fonts
        fonts = [download_google_font(font[len("G:") :])]
    elif font in _ryan_fonts:
        # Starts with R: by convention
        # "R:Futura" from _ryan_fonts
        fonts = [_ryan_fonts[font]]

    if is_valid_url(font):
        # A proper URL
        fonts = [download_font(font)]
    elif not has_file_extension(font):
        matching_paths = [
            path
            for path in get_system_fonts()
            if get_file_name(path, include_file_extension=False) == font
        ]
        if matching_paths:
            # If we have any system fonts that match this name, return their path
            # For example, font="Futura" --> matching_paths=['/System/Library/Fonts/Supplemental/Futura.ttc']
            fonts = matching_paths
        elif strict:
            error_message = "Cannot find font path: " + repr(font)
            raise FileNotFoundError(error_message)
        else:
            # Just return the font as is. It's actually ok if its not a path, as long as it's a system font, like "Arial" etc. Often this is ok.
            pass

    return fonts



@memoized
def _get_font_path(font, strict=False, extensions=None):
    fonts = _get_font_paths(font, strict=strict)

    if extensions:
        fonts = [x for x in fonts if get_file_extension(x) in extensions]

    if not fonts:
        if extensions:
            error_message = "Cannot find font path with any of extensions="+repr(extensions)+": " + repr(font)
        else:
            error_message = "Cannot find font path: " + repr(font)
        raise FileNotFoundError(error_message)

    font = fonts[0]

    return font



@memoized
def get_font_supported_chars(font):
    """
    Get supported characters of a given font
    
    EXAMPLE:
        
        >>> font = '/Users/ryan/Library/Fonts/Ubuntu Mono Nerd Font Complete.ttf'
        ... 
        ... char_to_name = get_font_supported_chars(font)
        ... 
        ... #Simple: Do it in black/white
        ... print(' '.join(char_to_name))·
        ... 
        ... #Pretty: Do it in color in an evenly spaced grid
        ... from wcwidth import wcwidth
        ... items = [
        ...     fansi(
        ...         char.rjust(max(0, 3 - wcwidth(char))),
        ...         text_color=hsv_to_rgb_float_color(hash(name[:2]) / 10000 % 1, 0.2, 1),
        ...         #style='underline',#Slowed down alacritty too much
        ...     )
        ...     for char, name in char_to_name.items()
        ... ]
        ... rows = split_into_sublists(items, get_terminal_width() // 2)
        ... rows = ["".join(x).strip() for x in rows]
        ... print_lines(rows)
        ... 
        ... #Skia was unable to render nerd fonts last time I checked

    """
    rp.pip_import('fontTools')
    
    font_path=_get_font_path(font,strict=True)
    
    from fontTools.ttLib import TTFont
    from collections import OrderedDict
    font_obj = TTFont(font_path,fontNumber=0)
    
    char_to_name = {}
    
    # Iterate through the font's character map (cmap) tables
    for table in font_obj.get("cmap").tables:
        if table.isUnicode():
            char_to_name.update(
                {chr(char_id): char_name for char_id, char_name in table.cmap.items()}
            )
    
    return char_to_name

@lru_cache(maxsize=128)
def pil_text_to_image(
    text,
    *,
    size=64,
    font=None,
    align="left",
    color=(1, 1, 1, 1), #Is a float color
    background_color=(0, 0, 0, 0) #Is a float color
):
    r"""
    Uses PIL as an alternative backend to cv_text_to_image
    Returns an image with the given text and size and font etc

    Supports much more fonts than cv_text_to_image, which is the main appeal of this function
    
    The 'size' parameter determines the height of the output image per-line - though sometimes the height may be off by 1 or 2 pixels from the gien size

    color and background_color can be a number, an RGB float tuple, an RGBA float tuple, or a color name like 'red'

    This function uses _slow_pil_text_to_image - and somehow concatting letters is faster than using pil natively ¯\_(ツ)_/¯
    
    EXAMPLE:
        >>> for font in ["Menlo", "Times New Roman", "Courier", "Futura", "Comic Sans MS", "Calibri"] + list(r._ryan_fonts):
        ...     color = random_rgb_float_color()
        ...     display_alpha_image(
        ...         with_drop_shadow(
        ...             pil_text_to_image(
        ...                 "Hello World\nClara\n" + font,
        ...                 size=120,
        ...                 font=font,
        ...                 align="center",
        ...                 color=color,
        ...             ),
        ...             color=tuple(x/2 for x in color),x=2,y=2
        ...         )
        ...     )

        >>> for color in [0,.1,.2,.3,.4,.5,.6,.7,.8,.9,10,'red','green','blue','cyan','magenta','purple','yellow']:
        ...     display_alpha_image(
        ...             pil_text_to_image(
        ...                 "Hello World\nClara\n" + str(color),
        ...                 size=120,
        ...                 align="center",
        ...                 color=color,
        ...             ),
        ...     )

        >>> fonts = get_system_fonts()
        ... fonts = random_batch(fonts, 5)
        ... for _ in range(100):
        ...     text = "Hello World!"
        ...     letters = [
        ...         pil_text_to_image(letter, size=120, font=random_element(fonts))
        ...         for letter in text
        ...     ]
        ...     letters = bordered_images_solid_color(letters, color=(1, 0, 0, 1))
        ...     letters = [rotate_image(letter, random_int(-30, 30)) for letter in letters]
        ...     letters = [cv_resize_image(letter, random_float(0.7, 1.3)) for letter in letters]
        ...     #letters = crop_images_to_max_size(letters, origin="center")
        ...     display_image(horizontally_concatenated_images(letters, origin="center"))
    """
    
    font = _get_font_path(font)

    text = str(text)
    assert align in ["left", "right", "center"]
    assert font is None or isinstance(font, str)

    color = as_rgba_float_color(color)
    background_color = as_rgba_float_color(background_color)

    text_lines = text.split("\n")

    image_lines = [
        horizontally_concatenated_images(
            [
                _slow_pil_text_to_image(
                    char,
                    color=(1, 1, 1, 1),
                    background_color=(0, 0, 0, 0),
                    size=size,
                    font=font,
                )
                for char in (text_line or [''])
            ]
        )
        for text_line in text_lines
    ]

    image_grid = [[x] for x in image_lines]

    image = grid_concatenated_images(image_grid, origin=align)

    color = np.asarray(list(color), dtype=np.float32)
    background_color = np.asarray(list(background_color), dtype=np.float32)

    image = (image * color) + (1 - image) * background_color
    
    return image

@lru_cache(maxsize=128)
def skia_text_to_image(
    text,
    *,
    size=64, #Size of the font in pixels - is approximate
    font="Futura",  # Primary font
    width=None, #Width in pixels of the ourput
    style=None, #A string that specifies style
    #Can be controlled by style:
    align=None,
    color=None,
    background_color=None,
    character_spacing=0,
    word_spacing=0
):
    """
    Renders multiline text via python-skia - a very fast graphics library that powers Google Chrome.
    Supports color emoji rendering and Unicode characters.
    Returns RGBA byte images.

    This function covers a small subset of what python-skia can do, but in a very easy to use way.
    See the following link to customize text further.
    See https://github.com/kyamagu/skia-python/blob/main/notebooks/Paint-Overview.ipynb
    
    Args:
        text (str): The text content to render
        size (int, optional): Font size in pixels (approximate). Defaults to 64.
        font (str, optional): Primary font name. Defaults to "Futura".
            NOTE: Right now, custom font paths are not supported! It must be a system font family
            I tried to allow custom font paths, but couldn't find a solution that allowed emoji
            fallbacks + a custom font path in under an hour of attempts. TODO for the future!
        width (int, optional): Output width in pixels. If None, calculated from text.
        style (str or set, optional): Text styling - can include alignment (left/right/center),
            formatting (bold/italic), and decorations (underline/strike/undercurl/underdash/underdots).
            Is intentionally similar to rp.fansi - i.e. you can do "bold yellow on dark blue" to specify
            background/foreground colors.
        align (str, optional): Text alignment override (left/right/center). Overrides style if specified.
        color (str, optional): Text color. Defaults to 'white'. Overrides style if specified.
        background_color (str, optional): Background color. Defaults to 'transparent'. Overrides style if specified.
        character_spacing (int, optional): Additional spacing between characters in pixels. Defaults to 0.
        word_spacing (int, optional): Additional spacing between words in pixels. Defaults to 0.
    
    Returns:
        numpy.ndarray: RGBA image array with rendered text
        
    Note:
        Automatically handles color emoji rendering through font fallbacks including
        Apple Color Emoji, Noto Emoji, and Segoe UI Emoji.
        
    Example:
        >>> image = skia_text_to_image("Hello World", style="bold center blue on white")
        >>> image = skia_text_to_image("Multi\nLine\nText", font="Arial", size=32)
        >>> image = skia_text_to_image("Text with emojis 👍🔥🍋", style="center")

    EXAMPLE:
        
        >>> text = "Never Gonna Give\nYou Up Never Gonna Let You Down\nHere are some emojis\n👍🇺🇸🔥🍋\nAnd here are some unicode characters ⮕.Ǝ❄😼"
        ... text += (
        ...     "\n"
        ...     + '''
        ... ⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣀⣠⣤⣤⣤⣤⣤⣀⣀
        ... ⠀⠀⠀⠀⠀⠀⣠⣴⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣶⣤⡀
        ... ⠀⠀⠀⢀⣴⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⡄
        ... ⠀⠀⣠⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠋
        ... ⠀⣰⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠿⠿⠿⣿⣿⣿⣿⡿⠋⠀⠀⢄⠪⣐
        ... ⢠⣿⣿⣿⣿⣿⣿⣿⣿⣿⠋⠁⠀⠀⠀⠀⠀⠉⠻⠉⡀⢄⢌⢎⢎⢎⢮⢕
        ... ⣼⣿⣿⣿⣿⣿⣿⣿⡿⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠐⣎⣮⣺⣪⣗⡯⣯⢯⡆
        ... ⣿⣿⣿⣿⣿⣿⣿⣿⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣷⣷⣿⣾⣷⣿⣟⣯⡇
        ... ⣿⣿⣿⣿⣿⣿⣿⣿⣧⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣿⣿⣿⣿⣾⣿⣿⣿⡇
        ... ⢸⣿⣿⣿⣿⣿⣿⣿⣿⣦⡀⠀⠀⠀⠀⠀⠀⠀⣠⣾⣿⣿⣿⣿⣿⣿⣿⣿
        ... ⠀⢻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣶⣤⣤⣤⣤⣴⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⠃
        ... ⠀⠈⢻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠃
        ... ⠀⠀⠀⠙⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠟⠁
        ... ⠀⠀⠀⠀⠀⠙⠻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠟⠁
        ... ⠀⠀⠀⠀⠀⠀⠀⠀⠉⠛⠻⠿⠿⣿⣿⢿⠿⠿⠋⠋⠁
        ... 
        ... ⠀⠠⠂⠁⠑⠀⠀⠀⠈⠢⡀⠔⠁⠀⠀⠀⠜⡄⠀⠀⠀⠀⠨⢢⠀⠀⠢
        ... ⠀⢃⠀⠀⠀⠀⠀⠀⠀⠀⢍⠀⠀⠀⠀⡸⠠⠔⡄⠀⠀⠀⢨⠀⠑⢄⢑
        ... ⠀⠈⠒⠐⠂⠁⠀⠀⠀⠀⠑⠀⠀⠀⠐⠀⠀⠀⠐⠀⠀⠀⠐⠀⠀⠈⠂'''
        ... )
        ... 
        ... styles = [
        ...     "center white on navy blue",
        ...     "right white on navy blue",
        ...     "left white on navy blue",
        ...     "navy blue on black",
        ...     "cyan on translucent gray",
        ...     "yellow on dark green",
        ...     "bold yellow on dark green",
        ...     "bold italic yellow on dark green",
        ...     "italic yellow on dark green",
        ...     "yellow on dark green",
        ...     "yellow on translucent dark green",
        ...     "white on navy blue underdash",
        ...     "white on navy blue underline",
        ...     "white on navy blue strike",
        ...     "white on navy blue undercurl",
        ...     "white on navy blue underdash",
        ...     "white on navy blue underdots",
        ... ]
        ... 
        ... import rp, time
        ... 
        ... for style in styles:
        ...     print("=" * 100)
        ...     rp.fansi_print(text + "\n" + style, style)
        ...     image = rp.skia_text_to_image(
        ...         text + "\n" + style,
        ...         style=style,
        ...         font="Futura",
        ...     )
        ...     rp.display_alpha_image(image)
        ...     time.sleep(1)

    """
    #Requirements
    pip_import('skia')
    pip_import('numpy')
    import skia
    import numpy as np
    import math

    text = str(text)
    
    #0. Text styling options - similar to fansi. This section lets us parse the style argument
    basic_options = {'bold','italic'}
    align_options = {'left','right','center'}
    underline_options = {'undercurl','underdouble','underline','overline','strike','underdash','underdots'}
    all_style_options = basic_options | align_options | underline_options
    #
    if style is None:
        style = set()
    elif isinstance(style, str):    
        #
        style = style.lower()
        #
        _color, _style, _background_color = _transform_fansi_arg(style,all_style_options)
        _style = set(_style.split()) if _style else set()
        #
        _unused = _style - all_style_options #Validate the style args
        if _unused:
            raise ValueError("rp.skia_text_to_image: Invalid style options detected: "+', '.join(_unused))
        #
        _align = align_options & _style
        if len(_align) > 1: raise ValueError("rp.skia_text_to_image: Multiple align options detected: "+', '.join(_align))
        _align = _align.pop() if _align else None
        #
        _underline = align_options & _style
        if len(_underline) > 1: raise ValueError("rp.skia_text_to_image: Multiple underline options detected: "+', '.join(_underline))
        _underline = _underline.pop() if _underline else None
        #
        if color            is None: color            = _color
        if background_color is None: background_color = _background_color
        if align            is None: align            = _align
        #
        style = _style
    # 
    style=BoolSet(style) #Syntactic Sugar
    assert style <= all_style_options

    #Default Options
    fallback_fonts = None #Not mature enough to expose as an arg yet # fallback_fonts (list, optional): List of fallback font names. If None, uses default emoji fonts.
    if color            is None: color            = 'white'
    if background_color is None: background_color = 'transparent'
    if align            is None: align            = 'left'
    if fallback_fonts   is None: fallback_fonts   = ["Apple Color Emoji", "Noto Emoji", "Segoe UI Emoji"]

    #Standard RP Coloring
    color            = float_color_to_byte_color(as_rgba_float_color(color           ))
    background_color = float_color_to_byte_color(as_rgba_float_color(background_color))

    # # Load/Download the font, such as Helvetica, a font URL, a path to a font, or shorthand like R:Futura etc
    # font = _get_font_path(font)

    # 1. SETUP PARAGRAPH AND FONT STYLES
    # The FontCollection is the key to finding multiple system fonts.
    font_collection = skia.textlayout.FontCollection()
    font_collection.setDefaultFontManager(skia.FontMgr.RefDefault())

    p_style = skia.textlayout.ParagraphStyle()
    t_style = skia.textlayout.TextStyle()

    # Set font size and color
    t_style.setFontSize(size)
    t_style.setColor(skia.Color(*color))


    # Spacing on the text style (pixels)
    t_style.setLetterSpacing(character_spacing)   # float, px
    t_style.setWordSpacing(word_spacing)          # float, px

    # Set alignment from argument
    if   align=='left'  : p_style.setTextAlign(skia.textlayout.TextAlign.kLeft  )
    elif align=='right' : p_style.setTextAlign(skia.textlayout.TextAlign.kRight )
    elif align=='center': p_style.setTextAlign(skia.textlayout.TextAlign.kCenter)
    else:
        raise ValueError(
            "rp.skia_text_to_image: Invalid skia text alignment"
            + repr(align)
            + ", please choose from r._skia_text_alignments: "
            + repr(align_options)
        )

    # Set bold/italic options
    if style.bold and style.italic: t_style.setFontStyle(skia.FontStyle.BoldItalic())
    elif style.bold               : t_style.setFontStyle(skia.FontStyle.Bold      ())
    elif style.italic             : t_style.setFontStyle(skia.FontStyle.Italic    ())
    else: assert not basic_options & style, 'Should be impossible'

    #You can make more options than these. I restrict it to these for simplicity. It closely resembles fansi.
    if   style.undercurl  : t_style.setDecorationStyle(skia.textlayout.TextDecorationStyle.kWavy  ) ; t_style.setDecoration(skia.textlayout.TextDecoration.kUnderline  )
    elif style.underdouble: t_style.setDecorationStyle(skia.textlayout.TextDecorationStyle.kDouble) ; t_style.setDecoration(skia.textlayout.TextDecoration.kUnderline  )
    elif style.underline  : t_style.setDecorationStyle(skia.textlayout.TextDecorationStyle.kSolid ) ; t_style.setDecoration(skia.textlayout.TextDecoration.kUnderline  )
    elif style.underdash  : t_style.setDecorationStyle(skia.textlayout.TextDecorationStyle.kDashed) ; t_style.setDecoration(skia.textlayout.TextDecoration.kUnderline  )
    elif style.underdots  : t_style.setDecorationStyle(skia.textlayout.TextDecorationStyle.kDotted) ; t_style.setDecoration(skia.textlayout.TextDecoration.kUnderline  )
    elif style.strike     : t_style.setDecorationStyle(skia.textlayout.TextDecorationStyle.kSolid ) ; t_style.setDecoration(skia.textlayout.TextDecoration.kLineThrough)
    else: assert not underline_options & style, 'Should be impossible'
    
    # Provide a list of font fallback families. Skia will try them in order.
    t_style.setFontFamilies([font] + fallback_fonts)

    # 2. BUILD AND LAYOUT THE PARAGRAPH
    builder = skia.textlayout.ParagraphBuilder(p_style, font_collection, skia.Unicode())
    builder.pushStyle(t_style)
    builder.addText(text)
    #builder.pop()  # TODO: Removing this line doesn't seem to affect anything. Do we need it?
    paragraph = builder.Build()

    #Calculate the width of the image
    if width is None:
        paragraph.layout(99999)  # Let the max line length be super long. It will be recalculated.
        width = paragraph.MaxIntrinsicWidth
    paragraph.layout(width+1)
    #
    width  = math.ceil(width           )
    height = math.ceil(paragraph.Height)

    # Edge case: Skia will break otherwise
    if width == 0 or height == 0:
        return np.zeros((height, width, 4), dtype=np.uint8)

    # 3. DRAW THE PARAGRAPH ONTO A SINGLE CANVAS
    surface = skia.Surface(width, height)
    with surface as canvas:
        canvas.clear(skia.Color(*background_color))
        paragraph.paint(canvas, 0, 0)

    return surface.toarray()

_skia_blend_modes = {
    # --- Common Modes ---
    "blend": "kSrcOver",         # Default alpha blending (foreground over background)
    "replace": "kSrc",           # Replace canvas with sprite, ignoring alpha
    "add": "kPlus",              # Additive blending, good for lighting effects
    # --- Photoshop/CSS Blend Modes ---
    "multiply": "kMultiply",     # Multiply the colors, resulting in a darker image
    "screen": "kScreen",         # Opposite of multiply, resulting in a brighter image
    "overlay": "kOverlay",       # Combines multiply and screen for contrast
    "darken": "kDarken",         # Selects the darker of the two pixels
    "lighten": "kLighten",       # Selects the lighter of the two pixels
    # --- Other Useful Modes ---
    "difference": "kDifference", # Subtracts the darker color from the lighter one
    "exclusion": "kExclusion",   # Similar to difference but with lower contrast
    "burn": "kColorBurn",
    "dodge": "kColorDodge",
    # --- HSV ---
    "hue": "kHue",
    "saturation": "kSaturation",
    "luminosity": "kLuminosity",
}

def _get_skia_blend_mode(mode:str):
    mode = mode.lower()
    pip_import("skia")
    import skia

    if not isinstance(mode, str):
        raise ValueError("_get_skia_blend_mode: Modes must be strings, but got "+str(type(mode)))

    try:
        return getattr(skia.BlendMode, _skia_blend_modes[mode])

    except AttributeError:
        raise ValueError("_get_skia_blend_mode: Invalid mode "+repr(mode)+" - please choose from "+repr(list(_skia_blend_modes)))

def skia_stamp_image(
    canvas,
    sprite,
    offset = None,
    *,
    alpha = 1,
    copy: bool = False,
    mode: str = "blend",
    sprite_origin = None,
    canvas_origin = None
):
    """
    Stamps a sprite onto a canvas using the high-performance Skia library.
    Signature based on rp.stamp_tensor

    NOTE: To take full advantage of Skia's speed, please make sure the given sprite is a contiguous RGBA byte image
    If you do repeated stamps, set copy=False - but please use the output! The output MIGHT not be mutated, particularly
    if you feed it an RGB or RGBA float image - so always use the output of this function. Repeated use with copy=False will
    result in speed boosts though, as it converts to RGBA byte images.

    Args:
        canvas: Target RGBA uint8 NumPy array to stamp onto.
        sprite: Source RGBA uint8 NumPy array to stamp. NOTE: You will get massive speed boost if this is contiguous!
            If stamping the same sprite many times, convert it via np.ascontiguousarray(rp.as_byte_image(rp.as_rgba_image(sprite))) first
        offset: (x, y) position to place the sprite. Defaults to (0, 0).
        copy (bool): False by default. If True, a copy of the canvas is made. If False, it might modify the canvas in place.
        mode (str): Blend mode to use. Available options:
            blend, replace, add, sub, multiply, screen, overlay, darken, lighten, difference, exclusion, burn, dodge, hue, saturation, luminosity
        sprite_origin: The anchor point on the sprite. E.g., 'center', 'top-left', or proportional (x,y) form i.e. (0.5, 0.5).
        canvas_origin: The reference point on the canvas for the offset.

    Returns:
        An RGBA Byte image (i.e. a HW4 numpy array of type np.uint8)
    
    EXAMPLE:

        >>> def demo_video(emojis):
        ...     image = rp.uniform_float_color_image(1024, 1024, "blue")
        ...     for _ in range(100):
        ...         for _ in range(10):
        ...             x, y = rp.random_floats(2)
        ...             text_image = rp.skia_text_to_image(
        ...                 rp.random_element(emojis),
        ...                 size=rp.random_float(64, 256),
        ...             )
        ...             image = skia_stamp_image(
        ...                 image,
        ...                 text_image,
        ...                 copy=False,
        ...                 sprite_origin=(x, y),
        ...                 canvas_origin=(1 - x, 1 - y),
        ...                 mode="blend",
        ...             )
        ...         yield image.copy()
        ... 
        ... 
        ... emojis = (
        ...     "🎉🥳🤩✨💫🌟🚀🌈🦄🦋🌺🌸🌼🌻🌞🌝🌍🌎🌏☄️💥🔥💧🌊"
        ...     "🌬🌀⚡☔❄☃⛄🏔🏖🏝🌵🌴🌳🌲🌱🌿🌾🍄🌰🍞🥐🥖🥨🧀🥓"
        ...     "🥩🍗🍖🍔🍟🍕🌮🌯🥙🍝🍜🍲🍛🍣🍤🍙🍚🥟🍢🍡🍦🍧🍨🍩🍪"
        ...     "🎂🍰🧁🥧🍫🍬🍭🍮🍯🍼🥛☕🍵🍶🍾🍷🍸🍹🍺🍻🥂🥃🧉🧂🌶"
        ...     "🧅🥔🍠🥕🌽🥦🥒🥬🥑🍆🧄🥜"
        ... )
        ... 
        ... 
        ... rp.display_video(
        ...     rp.video_with_progress_bar(
        ...         demo_video(emojis),
        ...         length=100,
        ...         size=30,
        ...         lazy=True,
        ...     ),
        ...     loop=True,
        ... )

    """

    import rp
    rp.pip_import('skia')
    import skia
    import numpy as np

    sprite = rp.as_rgba_image(rp.as_byte_image(sprite, copy=False), copy=False)
    canvas = rp.as_rgba_image(rp.as_byte_image(canvas, copy=False), copy=False)
    canvas = np.ascontiguousarray(canvas) #Makes it faster for subsequent calls

    def _parse_origin_to_pixels(origin, shape):
        """Converts a proportional or named origin into pixel coordinates."""
        if origin is None:
            return (0.0, 0.0)

        # (Height, Width) from shape
        h, w = shape[0], shape[1]

        if isinstance(origin, str):
            origin_map = {
                "top left": (0.0, 0.0),
                "top": (0.5, 0.0),
                "top right": (1.0, 0.0),
                "left": (0.0, 0.5),
                "center": (0.5, 0.5),
                "right": (1.0, 0.5),
                "bottom left": (0.0, 1.0),
                "bottom": (0.5, 1.0),
                "bottom right": (1.0, 1.0),
            }
            prop_x, prop_y = origin_map[origin.lower()]
        else:
            # Assumes a tuple/list of proportional coordinates (x, y)
            prop_x, prop_y = origin[0], origin[1]

        # Return pixel offset (x, y)
        return (w * prop_x, h * prop_y)

    # --- 1. Input Validation and Setup ---
    if not (
        canvas.ndim == 3
        and canvas.shape[2] == 4
        and sprite.ndim == 3
        and sprite.shape[2] == 4
    ):
        raise ValueError(
            "Both canvas and sprite must be 3D RGBA NumPy arrays (H, W, 4)."
        )

    blend_mode = _get_skia_blend_mode(mode)

    if copy:
        canvas = canvas.copy()

    if not all(sprite.shape) or not all(canvas.shape):
        #Avoid skia errors like "ValueError: Width and height must be greater than 0. (width=0, height=60)"

        return canvas

    if 0 in sprite.shape:
        # Edge case: Don't draw anything
        # Skia will error
        return canvas

    # --- 2. Calculate Final Stamping Position ---
    offset = offset or (0, 0)

    # Get pixel offsets from origins
    canvas_origin_px = _parse_origin_to_pixels(canvas_origin, canvas.shape)
    sprite_origin_px = _parse_origin_to_pixels(sprite_origin, sprite.shape)

    # Combine offsets to get the final top-left drawing coordinate for the sprite
    final_x = canvas_origin_px[0] + offset[0] - sprite_origin_px[0]
    final_y = canvas_origin_px[1] + offset[1] - sprite_origin_px[1]

    # --- 3. Perform Stamping with Skia ---
    # Create a Skia Surface that wraps the NumPy array's memory directly.
    # This is highly efficient as it avoids a full data copy.
    surface = skia.Surface(canvas)

    # Convert sprite NumPy array to a Skia Image object
    sprite = np.ascontiguousarray(sprite)
    sprite_image = skia.Image.fromarray(sprite, copy=False)

    # Create a Paint object to define how to draw (e.g., blend mode)
    paint = skia.Paint(BlendMode=blend_mode)
    if alpha!=1:
        paint.setAlphaf(alpha)

    # Use the surface's canvas to perform the drawing operation
    with surface as skia_canvas:
        skia_canvas.drawImage(
            sprite_image, float(final_x), float(final_y), skia.SamplingOptions(), paint
        )

    return canvas


def skia_stamp_video(
    bot,
    top,
    *,
    alpha=1,
    mode: str = "blend",

    sprite_origin = None,
    canvas_origin = None,
    offset = None,

    lazy=False,
    show_progress=False
):
    """
    Similar to skia_stamp_image, but for video.
    Please see skia_stamp_image for details.
    Needs to be ironed out a bit more:
        - Need to handle different length videos. What if bot is shorter than top?
        - Need to decide origin and offset: is it 3d (txy) or 2d (xy) with another time_shift and time_origin args etc?
    """
    if not lazy:
        bot = np.ascontiguousarray(as_rgba_images(as_byte_images(bot, copy=False), copy=False))
        top = np.ascontiguousarray(as_rgba_images(as_byte_images(top, copy=False), copy=False))

    pairs = zip_repeat_last(bot, top)

    if show_progress:
        length = max(len(bot), len(top))
        pairs = eta(pairs, title=get_current_function_name(), length=length)

    def helper():
        for bot_frame, top_frame in pairs:
            blended = skia_stamp_image(
                bot_frame,
                top_frame,
                alpha=alpha,
                mode=mode,
                offset=offset,
                sprite_origin=sprite_origin,
                canvas_origin=canvas_origin,
            )
            yield blended

    output = helper()

    if not lazy:
        output = list(output)
        output = as_numpy_array(output)

    return output


# -----------------------------------------------------------------------------
# Private: cached unit-UV grid and triangle indices for a (H_g, W_g) mesh
# -----------------------------------------------------------------------------

# @lru_cache(maxsize=256)
# def _meshgrid_uv_and_indices(H_g, W_g):
#     """
#     Returns (uv_unit, indices) for a regular H_g x W_g grid.
#
#     uv_unit: ndarray [H_g, W_g, 2] with UV in 0..1
#     indices: 1D int32 array of triangle indices (two tris per cell), flattened.
#     """
#     import numpy as np
#
#     if H_g<=1 or W_g<=1:
#         raise ValueError
#
#     u = np.linspace(0.0, 1.0, W_g, dtype=np.float32)
#     v = np.linspace(0.0, 1.0, H_g, dtype=np.float32)
#     UU, VV = np.meshgrid(u, v, indexing="xy")
#     uv_unit = np.stack([UU, VV], axis=-1)  # [H_g, W_g, 2]
#
#     cols = W_g
#     r = np.arange(H_g - 1, dtype=np.int64)[:, None]
#     c = np.arange(W_g - 1, dtype=np.int64)[None, :]
#
#     v0 = r * cols + c
#     v1 = v0 + 1
#     v3 = v0 + cols
#     v2 = v3 + 1
#
#     tri1 = np.stack([v0, v1, v2], axis=-1)
#     tri2 = np.stack([v0, v2, v3], axis=-1)
#     indices = np.concatenate([tri1, tri2], axis=-1).reshape(-1).astype(np.int32)
#     return uv_unit, indices


@lru_cache(maxsize=256)
def _meshgrid_uv_and_indices(H_g, W_g):
    """
    Returns (uv_unit, indices) for a regular H_g x W_g grid.

    uv_unit: ndarray [H_g, W_g, 2] with UV in 0..1 (geometry unchanged)
    indices: 1D int32 array of triangle indices (two tris per cell), flattened.
    """
    import numpy as np
    if H_g <= 1 or W_g <= 1:
        raise ValueError

    # UV geometry (unchanged)
    u = np.linspace(0.0, 1.0, W_g, dtype=np.float32)
    v = np.linspace(0.0, 1.0, H_g, dtype=np.float32)
    UU, VV = np.meshgrid(u, v, indexing="xy")
    uv_unit = np.stack([UU, VV], axis=-1)  # [H_g, W_g, 2]

    # Cell row/col indices
    r = np.arange(H_g - 1, dtype=np.int64)[:, None]  # 0..H-2
    c = np.arange(W_g - 1, dtype=np.int64)[None, :]  # 0..W-2

    # Row-major numbering: idx = r*W + c
    stride = W_g
    v0 = r * stride + c
    v1 = v0 + 1
    v3 = v0 + stride
    v2 = v3 + 1

    tri1 = np.stack([v0, v1, v2], axis=-1)
    tri2 = np.stack([v0, v2, v3], axis=-1)
    indices = np.concatenate([tri1, tri2], axis=-1).reshape(-1).astype(np.int32)

    return uv_unit, indices

# -----------------------------------------------------------------------------
# Private: interp → Skia SamplingOptions (STRICT; no fallbacks)
# -----------------------------------------------------------------------------
_skia_interp_modes = {
    "bilinear": "kLinear",
    "nearest":  "kNearest",
}

def _get_skia_sampling(interp):
    """
    Map string interp -> Skia SamplingOptions (STRICT).
    Supported: 'bilinear', 'nearest' only.
    """
    from rp import pip_import
    pip_import("skia")
    import skia

    if not isinstance(interp, str):
        raise ValueError("_get_skia_sampling: Interp must be a string, but got " + str(type(interp)))

    key = interp.lower()
    mode = _skia_interp_modes.get(key)
    if mode is None:
        raise ValueError("_get_skia_sampling: Invalid interp " + repr(interp)
                         + " - please choose from " + repr(list(_skia_interp_modes)))

    # Build SamplingOptions strictly for the requested filter
    filt = getattr(skia.FilterMode, mode)  # kLinear or kNearest
    return skia.SamplingOptions(filt)



def skia_draw_mesh_grid(
    canvas,
    mesh,
    texture,
    *,
    alpha=1,
    copy=False,
    mode=None,
    interp="bilinear",
    mipmap=False
):
    """
    Draws a textured mesh grid onto a canvas using the high-performance Skia library.
    Signature and argument conventions mirror rp.skia_stamp_image.

    The mesh is a regular grid of control points; each cell is rendered as two textured
    triangles (via skia.Vertices). The full texture is mapped across the mesh domain.

    Args:
        canvas: Target RGBA uint8 NumPy array to draw onto. (H, W, 4)
        mesh (np.ndarray): Float array of shape [H_g, W_g, 2] giving per-vertex XY *pixel*
            destinations on the canvas (X = column / width axis, Y = row / height axis).
            Requires H_g >= 2 and W_g >= 2 to form triangles.
        texture: Source RGBA uint8 NumPy array providing the texels mapped over the mesh.
                 The entire texture is mapped from (u,v)∈[0,1]x[0,1] across the grid.
        alpha (float, optional): 0..1 global opacity multiplier for the draw. Default 1.
        copy (bool, optional): If True, draw onto a copy of `canvas`. If False (default),
            the function may draw in-place (like skia_stamp_image).
        mode (str, optional): Blend mode string used for compositing onto the destination.
        interp (str, optional): Texture sampling filter. 'bilinear' (default) or 'nearest'.
        mipmap (bool, optional): Build/use mipmaps when sampling the texture. Default True.

    Note: The mesh's height*width cannot exceed 2**16. Please prepare your mesh accordingly.
          This is because Skia can't handle a mesh larger than this and will throw errors.

    Returns:
        numpy.ndarray: RGBA byte image (H, W, 4). If copy=False, this *may* be the
        same array (modified in place); always use the returned value.

    EXAMPLE:

        >>> # Sanity check
        >>> image=load_image('https://github.com/RyannDaGreat/Images/blob/master/test_images/lenna.png?raw=true',use_cache=True)
        ... canvas=uniform_byte_color_image(512,512,'translucent green')
        ... grid=xy_float_images(10,10).transpose(1,2,0)*512
        ... output=skia_draw_mesh_grid(canvas,grid,image)
        ... display_image(output)

    """
    import rp
    rp.pip_import('skia')
    import skia
    import numpy as np

    if mode is None:
        mode = 'blend'

    # --- Normalize inputs ---
    canvas  = rp.as_rgba_image(rp.as_byte_image(canvas , copy=False), copy=False)
    texture = rp.as_rgba_image(rp.as_byte_image(texture, copy=False), copy=False)
    canvas = np.ascontiguousarray(canvas)
    if copy:
        canvas = canvas.copy()

    # --- Validate mesh (explicit, 3.5-safe) ---
    if not (hasattr(mesh, "shape") and hasattr(mesh, "ndim")):
        raise ValueError("mesh must be a NumPy array of shape [H_g, W_g, 2], but got type="
                         + str(type(mesh)))
    if not (mesh.ndim == 3 and mesh.shape[-1] == 2):
        raise ValueError("mesh must be a NumPy array of shape [H_g, W_g, 2], but got shape="
                         + str(getattr(mesh, "shape", None)))

    H_g, W_g = mesh.shape[0], mesh.shape[1]
    if H_g < 2 or W_g < 2:
        return canvas

    # --- Cached UV grid + indices ---
    uv_unit, indices = _meshgrid_uv_and_indices(H_g, W_g)

    # --- Vectorized build of vertices (flatten with matching order) ---
    tex_h, tex_w = texture.shape[0], texture.shape[1]

    mesh_f = np.ascontiguousarray(mesh.reshape(-1, 2), dtype=np.float32)

    uv_px = np.empty_like(uv_unit)
    uv_px[..., 0] = uv_unit[..., 0] * tex_w
    uv_px[..., 1] = uv_unit[..., 1] * tex_h
    uv_f = np.ascontiguousarray(uv_px.reshape(-1, 2), dtype=np.float32)

    positions = [skia.Point(float(p[0]), float(p[1])) for p in mesh_f]
    texcoords = [skia.Point(float(t[0]), float(t[1])) for t in uv_f]

    try:
        verts = skia.Vertices.MakeCopy(
            skia.Vertices.kTriangles_VertexMode,
            positions,
            texcoords,
            None,
            indices.tolist(),
        )
    except RuntimeError as e:
        raise RuntimeError(
            get_current_function_name()
            + " : "
            + str(e)
            + " : This is likely becuause the mesh is too large! Mesh size is "
            + str(mesh.shape)
        )

    # --- Skia surface + shader + paint ---
    surface = skia.Surface(canvas)

    texture = np.ascontiguousarray(texture)
    tex_image = skia.Image.fromarray(texture, copy=False)

    sampling = _get_skia_sampling(interp)   # STRICT: 'bilinear' or 'nearest'
    shader = tex_image.makeShader(skia.TileMode.kClamp, skia.TileMode.kClamp, sampling)

    sampling = _get_skia_sampling(interp)   # STRICT: 'bilinear' or 'nearest'
    # If requested, upgrade SamplingOptions to include mipmaps (when minifying).
    if mipmap:
        filt = skia.FilterMode.kLinear if interp == "bilinear" else skia.FilterMode.kNearest
        sampling = skia.SamplingOptions(filt, skia.MipmapMode.kLinear)
    shader = tex_image.makeShader(
        skia.TileMode.kClamp, skia.TileMode.kClamp, sampling
    )

    blend_mode = _get_skia_blend_mode(mode)
    paint = skia.Paint(BlendMode=blend_mode)
    paint.setShader(shader)
    if alpha != 1:
        paint.setAlphaf(float(alpha))

    with surface as sk:
        sk.drawVertices(verts, paint, skia.BlendMode.kModulate)

    return canvas

def skia_draw_trail(
    canvas,
    contour,
    texture,
    thickness=None,
    *,
    alpha=1,
    loop=False,
    mode=None,
    copy=True,
    inner_radius=None,
    outer_radius=None,
    interp='bilinear',
    mipmap=False
):
    """
    Draw a textured ribbon following a 2D contour.

    Builds a SxN mesh (S=v_subdivs) from the contour by dilating it inward and outward
    (using dilate_contour) and interpolating rows between inner and outer rails.
    U runs along the contour (0→1), V spans inner(0)→outer(1).

    Args:
        canvas: Target RGBA uint8 NumPy array (H, W, 4).
        contour: Path in any RP format (points, complex, cv contour, etc).
        texture: RGBA uint8 image used as the ribbon texture.
        thickness (float, optional): Convenience diameter (px). If provided and
            inner/outer not set, both radii default to thickness/2.
        alpha (float): 0..1 opacity multiplier.
        loop (bool): Whether the contour is closed.
        mode (str): Blend mode string (see _get_skia_blend_mode).
        copy (bool): Draw on a copy of the canvas if True.
        inner_radius (float or array, optional): Per-vertex inward offset (px).
        outer_radius (float or array, optional): Per-vertex outward offset (px).
        interp (str): 'bilinear' or 'nearest' (STRICT).

    Returns:
        numpy.ndarray: RGBA (H, W, 4). If copy=False, may be the same array.
        

    DEMO: Paint Studio App
        >>> #!/bin/bash
        >>> #SEE DEMO VIDEO: https://www.youtube.com/watch?v=RqmV44Kckr8
        >>> rp exec 'exeval(load_gist(url))' ---url https://gist.github.com/SqrtRyan/32d82baa50383a4dda0f4e5771c1674b

    EXAMPLE: Animate text rolling around a mooose

        >>> #Draw Text Around a Moose
        ... image = load_image(
        ...     "https://www.shutterstock.com/image-vector/vector-illustration-black-silhouette-elk-600nw-736283863.jpg",
        ...     use_cache=True,
        ... )
        ... contour = max(cv_find_contours(image), key=len)
        ... contour = as_complex_vector(contour)
        ... contour = circ_gauss_blur(contour, 5)
        ... contour=dilate_contour(contour,7)
        ... contour = circ_gauss_blur(contour, 5)
        ... contour = evenly_split_path(contour, 1000)
        ... 
        ... repeats=40
        ... text = 'I am a Moose! ' * repeats
        ... text_image = skia_text_to_image(text,color='white blue cyan')
        ... text_image=with_drop_shadow(text_image)
        ... text_image=with_drop_shadow(text_image)
        ... text_image=with_drop_shadow(text_image)
        ... text_image=with_drop_shadow(text_image)
        ... 
        ... text_image=vertically_flipped_image(text_image)
        ... 
        ... width=get_image_width(text_image)
        ... 
        ... def make_video():
        ...     shifts = np.linspace(0,width/repeats,num=60,endpoint=False)
        ...     for shift in eta(shifts):
        ...         ribbon_image = roll_image(text_image,dx=shift)
        ...         drawn_image = skia_draw_trail(image,contour,ribbon_image,)
    thickness = as_numpy_array(thickness)
        ...         yield drawn_image
        ...         
        ... display_video(make_video(),loop=True)

    EXAMPLE:

        >>> #Sanity Check: Make sure it renders from left to right
        ... image=load_image('https://github.com/RyannDaGreat/Images/blob/master/test_images/lenna.png?raw=true',use_cache=True)
        ... canvas=uniform_byte_color_image(512,512,'translucent green')
        ... contour=[[0,256],[512,256]]
        ... output=skia_draw_trail(canvas,contour,image,thickness=512,copy=True)
        ... output=cv_draw_arrow(output,*list_flatten(contour))
        ... display_image(output)

    EXAMPLE:
        
        >>> def demo_trail_number_2(
        ...     *,
        ...     H=720,
        ...     W=720,
        ...     font_size=560,
        ...     #stroke_url="https://www.onlygfx.com/wp-content/uploads/2022/03/simple-gold-brush-stroke-banner-1.png",
        ...     stroke_url="https://www.onlygfx.com/wp-content/uploads/2017/05/colorful-watercolor-brush-stroke-banner-2-25-1024x229.png",
        ...     interp="bilinear",
        ...     loop=False,             # R is open outline by default; keep False
        ...     seed=None,
        ...     mode=None,
        ...     mipmap=True,
        ...     alpha=1,
        ... ):
        ...     import rp, numpy as np
        ... 
        ...     # --- background canvas ---
        ...     canvas = rp.uniform_float_color_image(H, W, "gray gray blue")
        ...     canvas = rp.as_rgba_image(rp.as_byte_image(canvas, copy=False), copy=False)
        ... 
        ...     # --- load texture (gold brush stroke) ---
        ...     tex = load_image(stroke_url, use_cache=True)  # RGBA uint8
        ... 
        ...     # --- render 'R' to a mask using Skia (large, centered) ---
        ...     rp.pip_import('skia'); import skia
        ...     # Create a text image: big white 'R' on black
        ...     txt_surface = skia.Surface(W, H)
        ...     c = txt_surface.getCanvas()
        ...     c.clear(skia.Color(0,0,0,255))
        ...     paint = skia.Paint(AntiAlias=True, Color=skia.Color(255,255,255,255))
        ...     # Typeface & layout
        ...     font = skia.Font(skia.Typeface(None, skia.FontStyle.Bold()), font_size)
        ...     # Measure and center
        ...     bounds = font.measureText("2", skia.TextEncoding.kUTF8)
        ...     # Approx center draw (Skia text baseline mechanics): tweak Y to center visually
        ...     x = (W - bounds) * 0.5
        ...     y = (H + font_size * 0.35) * 0.5
        ...     c.drawString("2", x, y, font, paint)
        ...     text_img = txt_surface.makeImageSnapshot().toarray()  # RGBA
        ... 
        ...     # --- get contour of the letter (largest contour) ---
        ...     mask = rp.as_grayscale_image(text_img, copy=False)
        ...     contours = cv_find_contours(mask)
        ...     if not contours:
        ...         return canvas
        ...     contour = max(contours, key=len)
        ...     contour = as_complex_vector(contour)
        ...     
        ...     
        ... 
        ...     # optional smoothing & resampling for nicer trails
        ...     contour = circ_gauss_blur(contour, 2)
        ...     contour = evenly_split_path(contour, 1800)  # dense path
        ...     contour_pts = as_points_array(contour)
        ...     contour_pts = contour_pts[::-1]
        ... 
        ...     # --- draw trail with vertical subdivisions (default 30) ---
        ...     # Make the ribbon width vary slightly for a brushy feel
        ...     n = len(contour_pts)
        ...     u = np.linspace(0.0, 1.0, n, dtype=np.float32)
        ...     wobble = .25 * np.sin(10*np.pi*3.0*u)
        ...     inner = 22.0 + 6.0 * wobble
        ...     outer = 42.0 + 10.0 * wobble
        ... 
        ...     out = skia_draw_trail(
        ...         canvas,
        ...         contour_pts,
        ...         tex,
        ...         thickness=None,
        ...         alpha=alpha,
        ...         loop=loop,
        ...         mode=mode,
        ...         copy=True,
        ...         inner_radius=inner,
        ...         outer_radius=outer,
        ...         interp=interp,
        ...         mipmap=mipmap,
        ...         # v_subdivs defaults to 30; you can pass v_subdivs=40 for extra smoothness
        ...     )
        ...     return out
        ... display_image(demo_trail_number_2())
        
    """

    #ARG:
    #    v_subdivs (int): Number of rows across the ribbon thickness (>=2). Default 30.

    n = len(contour)
    if n < 2:
        return canvas

    v_subdivs=8   # <--- vertical subdivisions across thickness. More looks better.
    while n * v_subdivs > 2**16 and v_subdivs >2:
        #Theres a limit to the mesh size before skia crashes...
        #I think that limit is 2**16 (from empirical tests)
        v_subdivs -= 1
    if n * v_subdivs > 2**16:
        #If it's still to long, we have to reduce the number of contour points
        contour = as_complex_vector(contour)
        contour = resize_vector(contour, 2**15) #Playing it safe
        contour = as_points_array(contour)
        n = len(contour)
    # print(n * v_subdivs, '>', 2**16, "=", v_subdivs, "*", n) #This debugging worked - it works for arbitrarily long trails now
    pts = as_points_array(contour).astype(np.float32, copy=False)

    # ---- Normalize radii ----
    if thickness is None:
        thickness = 15.0
    thickness = as_numpy_array(thickness)
    if outer_radius is None: outer_radius = thickness / 2.0
    if inner_radius is None: inner_radius = thickness / 2.0

    # Scalar → per-vertex arrays (length n)
    if is_number(outer_radius): outer_radius = [outer_radius] * n
    if is_number(inner_radius): inner_radius = [inner_radius] * n

    outer_radius = resize_vector(outer_radius, n, interp='linear')
    inner_radius = resize_vector(inner_radius, n, interp='linear')

    # ---- rails via dilate_contour (left-hand normal basis) ----
    cont_c  = as_complex_vector(pts)
    outer_c = dilate_contour(cont_c, shift=-as_numpy_array(outer_radius), loop=loop)
    inner_c = dilate_contour(cont_c, shift= as_numpy_array(inner_radius), loop=loop)

    outer_xy = np.column_stack([outer_c.real, outer_c.imag]).astype(np.float32, copy=False)
    inner_xy = np.column_stack([inner_c.real, inner_c.imag]).astype(np.float32, copy=False)

    # ---- interpolate S rows between inner (v=0) and outer (v=1) ----
    # mesh shape [S, N, 2]
    t = np.linspace(0.0, 1.0, v_subdivs, dtype=np.float32)[:, None, None]  # [S,1,1]
    mesh = (1.0 - t) * inner_xy[None, :, :] + t * outer_xy[None, :, :]

    #Triangle ordering correction: Makes sure the ordering of triangles on the mesh allows later
    #points in the trail to be rendered on top. Important for self-intersections to be rendered correctly
    #Otherwise, the left part of the trail is always on top and the right always on bottom regardless
    #of how far along the trail a given point is.
    mesh    = mesh   .transpose(1,0,2)
    texture = texture.transpose(1,0,2)

    # ---- delegate to mesh renderer ----
    try:
        return skia_draw_mesh_grid(
            canvas,
            mesh,
            texture,
            alpha=alpha,
            copy=copy,
            mode=mode,
            interp=interp,
            mipmap=mipmap,
        )
    except RuntimeError as e:
        raise RuntimeError(
            get_current_function_name()
            + " : "
            + str(e)
            + " : This is becuause the number of points in the contour is too large! len(contour)=="
            + str(len(contour))
        )
    

def download_google_font(font_name, *, skip_existing=True):
    """
    Original code from: https://gist.github.com/ravgeetdhillon/0063aaee240c0cddb12738c232bd8a49

    download_google_font('G:Roboto') is equivalent to download_google_font('Roboto')
    
    EXAMPLE:
        >>> #Go to https://fonts.google.com to explore more fonts!
        ... vertically_concatenated_images(
        ...     [
        ...         pil_text_to_image(
        ...             get_folder_name(get_parent_folder(font))
        ...             + ": Hello World! My name is Clara",
        ...             size=128,
        ...             font=font,
        ...             color=random_rgb_float_color(),
        ...         )
        ...         for font in download_google_fonts(
        ...             "Quicksand",
        ...             "Nanum Gothic",
        ...             "Roboto",
        ...             "SUSE",
        ...             "Mingzat",
        ...             "Protest Guerrilla",
        ...             "Open Sans",
        ...             "Playwrite CU",
        ...             "Poppins",
        ...             "Roboto Mono",
        ...             "Handjet",
        ...             "Nunito",
        ...             "Ubuntu",
        ...             "Roboto Slab",
        ...             "Bebas Neue",
        ...             "Pixelify Sans",
        ...             "Tomorrow",
        ...             "Abel",
        ...             "Caveat",
        ...             "Matemasie",
        ...             "Zilla Slab",
        ...             "Abril Fatface",
        ...             "Permanent Marker",
        ...             "Satisfy",
        ...             "Macondo",
        ...             "Cinzel",
        ...             "Vollkorn",
        ...             "Orbitron",
        ...             "Marcellus",
        ...             "Rubik Mono One",
        ...             "Prata",
        ...         )
        ...     ]
        ... )

    EXAMPLE:
        >>> #Generates an image with over 200 fonts in it for your viewing pleasure
        >>> font_names = [
        ...     "G:Abril Fatface", "G:Acme", "G:Adamina", "G:Akronim", "G:Alef", "G:Alegreya",
        ...     "G:Alegreya Sans", "G:Aleo", "G:Alfa Slab One", "G:Amatic SC", "G:Amiri", "G:Angkor",
        ...     "G:Anonymous Pro", "G:Antic Didone", "G:Anton", "G:Arima Madurai", "G:Arimo", "G:Armata",
        ...     "G:Arvo", "G:Asap", "G:Aubrey", "G:Azeret Mono", "G:Baloo Bhai", "G:Bangers",
        ...     "G:Barlow", "G:Barriecito", "G:Baumans", "G:Bebas Neue", "G:Berkshire Swash", "G:Bigelow Rules",
        ...     "G:Bilbo Swash Caps", "G:Bitter", "G:Bree Serif", "G:Bungee", "G:Bungee Hairline", "G:Bungee Outline",
        ...     "G:Butcherman", "G:Cabin", "G:Caesar Dressing", "G:Calligraffitti", "G:Cardo", "G:Catamaran",
        ...     "G:Caveat", "G:Cedarville Cursive", "G:Chango", "G:Chela One", "G:Cherry Cream Soda", "G:Chicle",
        ...     "G:Chivo", "G:Cinzel", "G:Codystar", "G:Comfortaa", "G:Corben", "G:Cormorant",
        ...     "G:Courgette", "G:Courier Prime", "G:Coustard", "G:Covered By Your Grace", "G:Crafty Girls", "G:Creepster",
        ...     "G:Damion", "G:Dancing Script", "G:Diplomata", "G:Dokdo", "G:Domine", "G:Dosis",
        ...     "G:Encode Sans", "G:Euphoria Script", "G:Ewert", "G:Exo", "G:Fascinate", "G:Faster One",
        ...     "G:Faustina", "G:Felipa", "G:Finger Paint", "G:Fira Sans", "G:Fredericka the Great", "G:Fredoka One",
        ...     "G:Frijole", "G:Galindo", "G:Gelasio", "G:Gentium Book Basic", "G:Geo", "G:Give You Glory",
        ...     "G:Gloria Hallelujah", "G:Great Vibes", "G:Gudea", "G:Hanalei", "G:Henny Penny", "G:Herr Von Muellerhoff",
        ...     "G:Hind", "G:Homemade Apple", "G:IBM Plex Mono", "G:IM Fell French Canon", "G:Iceberg", "G:Iceland",
        ...     "G:Inconsolata", "G:Indie Flower", "G:Inria Serif", "G:Irish Grover", "G:Istok Web", "G:Italiana",
        ...     "G:Jaldi", "G:JetBrains Mono", "G:Jolly Lodger", "G:Josefin Sans", "G:Judson", "G:Just Me Again Down Here",
        ...     "G:Kalam", "G:Karla", "G:Kaushan Script", "G:Kavoon", "G:Kirang Haerang", "G:Kristi",
        ...     "G:La Belle Aurore", "G:Lacquer", "G:Lakki Reddy", "G:Lato", "G:League Script", "G:Libre Baskerville",
        ...     "G:Lily Script One", "G:Limelight", "G:Linden Hill", "G:Lobster", "G:Londrina Shadow", "G:Lora",
        ...     "G:Markazi Text", "G:Maven Pro", "G:Meddon", "G:Merriweather", "G:Metal Mania", "G:Miniver",
        ...     "G:Mitr", "G:Monofett", "G:Monoton", "G:Montserrat", "G:Mrs Sheppards", "G:Muli",
        ...     "G:Neuton", "G:Nobile", "G:Nosifer", "G:Noto Sans KR", "G:Noto Sans Mono", "G:Noto Serif",
        ...     "G:Nunito", "G:Odibee Sans", "G:Old Standard TT", "G:Open Sans", "G:Oswald", "G:Overpass",
        ...     "G:Oxygen", "G:PT Sans", "G:PT Serif", "G:Pacifico", "G:Passion One", "G:Pathway Gothic One",
        ...     "G:Permanent Marker", "G:Pirata One", "G:Playfair Display", "G:Pontano Sans", "G:Poppins", "G:Princess Sofia",
        ...     "G:Prociono", "G:Prompt", "G:Proza Libre", "G:Quantico", "G:Quattrocento Sans", "G:Quicksand",
        ...     "G:Rakkas", "G:Raleway", "G:Ravi Prakash", "G:Righteous", "G:Roboto", "G:Roboto Condensed",
        ...     "G:Roboto Mono", "G:Roboto Slab", "G:Rubik", "G:Ruda", "G:Ruge Boogie", "G:Ruslan Display",
        ...     "G:Sacramento", "G:Sancreek", "G:Sarabun", "G:Satisfy", "G:Shadows Into Light", "G:Shojumaru",
        ...     "G:Shrikhand", "G:Sigmar One", "G:Sintony", "G:Slabo 27px", "G:Snowburst One", "G:Sonsie One",
        ...     "G:Source Code Pro", "G:Space Mono", "G:Spectral", "G:Spicy Rice", "G:Sriracha", "G:Stint Ultra Expanded",
        ...     "G:Supermercado One", "G:Tajawal", "G:Trade Winds", "G:Trirong", "G:Ubuntu", "G:Ultra",
        ...     "G:UnifrakturMaguntia", "G:Unlock", "G:Vampiro One", "G:Varela Round", "G:Vesper Libre", "G:Vibes",
        ...     "G:Vollkorn", "G:Voltaire", "G:Wallpoet", "G:Warnes", "G:Wellfleet", "G:Work Sans",
        ...     "G:Yellowtail", "G:Zilla Slab",
        ... ]
        ... ims = [
        ...     *eta(
        ...         IteratorWithLen(
        ...             (
        ...                 cv_resize_image(
        ...                     pil_text_to_image(
        ...                         f[2:],
        ...                         font=f,
        ...                         color=tuple(x * 2 for x in random_rgb_float_color()),
        ...                         size=128,
        ...                     ),
        ...                     0.5,
        ...                     alpha_weighted=True,
        ...                 )
        ...                 for f in font_names
        ...             ),
        ...             length=len(font_names),
        ...         )
        ...     )
        ... ]
        ... ims = crop_images_to_max_size(ims, origin="center")
        ... collage = tiled_images(
        ...     ims,
        ...     border_thickness=0,
        ...     length=6,
        ... )
        ... # collage = with_drop_shadow(collage, x=5, y=5, blur=20, opacity=0.5, color=(1, 1, 1, 1))
        ... collage = blend_images(0, collage)
        ... display_image(collage)
        ... print(save_image(collage, "collage.png"))
        ... open_file_with_default_application("collage.png")


    """

    import requests
    import os
    
    def get_urls(content):
        '''
        Parses the css file and retrieves the font urls.
        Parameters: 
        content (string): The data which needs to be parsed for the font urls.
        Returns: 
        A list of urls.
        '''

        urls = []
        for i in range(len(content)):

            # read the content until you encounter url string
            # after you encounter the url string, scan the content until a closing parenthesis is encountered
            # store the fetched url in the urls list
            if content[i: i+3] == 'url':
                j = i + 4
                url = ''
                while content[j] != ')':
                    url += content[j]
                    j += 1
                urls.append(url)

        return urls


    def fetch_data(urls):
        '''
        Downloads the font files from the `urls` list.
        Parameters: 
        urls (list): List of urls from which the font files have to be downloaded.
        Returns: 
        None
        '''
        paths=[]

        for index, url in enumerate(urls):

            # get the font's name from it url
            font_name = url.split('/')[-1]

            # download the font data from its url
            response = requests.get(url)

            # save the downloaded data to the font file
            path = fonts_folder+'/'+font_name
            with open(path, 'wb') as f:
                f.write(response.content)
                paths.append(path)

            print('rp.download_google_font: Downloaded {}, {} of {} fonts.'.format(font_name, index + 1, len(urls)))

        return paths

    def main(method, src):
        '''
        Main Function for the app.
        Parameters: 
        method (string): Add link if the `src` is a HTTP/HTTPS link.
                         Add file if the `src` is a local CSS file.
        src (string):    Add the path of the link or the file, depending on the `method` parameter.
        Returns: 
        None
        '''

        if method == 'file':
            with open(src) as f:
                content = f.read()

        elif method == 'link':
            content = str(requests.get(src).content)

        assert connected_to_internet()

        # extract the urls from the content
        urls = get_urls(content)
        print('rp.download_google_font: Fetched {} urls.'.format(len(urls)))

        # download the font files
        paths = fetch_data(urls)

        return paths

    if font_name.startswith('G:'):
        font_name = font_name[len('G:'):]

    fonts_folder = path_join(_google_fonts_download_folder,font_name)

    if folder_exists(fonts_folder):
        paths = get_all_files(fonts_folder)
    else:
        make_directory(fonts_folder)

        template = 'https://fonts.googleapis.com/css?family=%s&display=swap'
        url = template % font_name
        paths = main('link', url)

        # if not len(paths):
        #     #Don't fool the cache into thinking we downloaded anything
        #     #On second thought, this will make repeated mistakes quick to rectify...
        #     delete_folder(fonts_folder)

    assert len(paths)>=1, "Failed to retrieve a google font with the name "+repr(font_name)+" Please check \n\thttps://fonts.google.com/specimen/"+font_name+"\nand see if it's a real google font"
    assert len(paths)==1, "Right now we assume only one font is downloaded - but there are multiple: "+"\n\t".join(paths)+"\nPlease modify this func by giving it an argument to disambiguate which font to return - or find another solution!"

    path = paths[0]
    return path

@memoized
def download_font(url, *, skip_existing=True):
    """
    https://github.com/ctrlcctrlv/lcd-font/raw/master/otf/LCD14.otf
    """

    #Special Cases
    if url in _ryan_fonts:
        url = _ryan_fonts[url]
    elif url.startswith('G:'):
        return download_google_font(url)

    assert connected_to_internet()

    make_directory(_downloaded_font_dir)
    return download_url(url, _downloaded_font_dir, skip_existing=skip_existing)

def download_fonts(
    *font_names,
    skip_existing=True,
    show_progress=False,
    strict=True,
    num_threads=None,
    lazy=False
):
    """See download_google_font's docstring. This is it's plural form."""
    font_names = detuple(font_names)
    return gather_args_call(
        load_files,
        lambda font_name: download_font(font_name, skip_existing=skip_existing),
        font_names,
    )

def download_google_fonts(
    *font_names,
    skip_existing=True,
    show_progress=False,
    strict=True,
    num_threads=None,
    lazy=False
):
    """See download_google_font's docstring. This is it's plural form."""
    font_names = detuple(font_names)
    return gather_args_call(
        load_files,
        lambda font_name: download_google_font(font_name, skip_existing=skip_existing),
        font_names,
    )

def get_downloaded_fonts():
    """ Returns a list of font files downloaded by rp """
    def get(x):
        try:
            return _get_all_paths_fast(
                x,
                recursive=True,
                include_folders=False,
                include_files=True,
                include_hidden=False,
            )
        except FileNotFoundError:
            return []
    return get(_downloaded_font_dir) + get(_google_fonts_download_folder)


#All Google fonts I'm aware of - this is NOT exhaustive! It keeps changing! See https://fonts.google.com
_all_google_fonts=[
    '42dot Sans', 'ABeeZee', 'ADLaM Display', 'AR One Sans', 'Abel', 'Abhaya Libre', 'Aboreto', 'Abril Fatface', 'Actor', 'Adamina', 'Advent Pro', 'Afacad Flux', 'Agbalumo', 'Agdasima', 'Agu Display',
    'Aguafina Script', 'Akatab', 'Akaya Telivigala', 'Akshar', 'Aladin', 'Alata', 'Alatsi', 'Albert Sans', 'Alef', 'Alegreya', 'Alegreya SC', 'Alegreya Sans', 'Alegreya Sans SC', 'Aleo', 'Alex Brush', 'Alexandria',
    'Alfa Slab One', 'Alice', 'Alike Angular', 'Alkalami', 'Alkatra', 'Allerta Stencil', 'Allura', 'Almarai', 'Almendra', 'Almendra Display', 'Almendra SC', 'Alumni Sans Collegiate One', 'Alumni Sans Inline One',
    'Alumni Sans Pinstripe', 'Amarante', 'Amaranth', 'Amethysta', 'Amiri', 'Amiri Quran', 'Amita', 'Anaheim', 'Andika', 'Anek Bangla', 'Anek Devanagari', 'Anek Gujarati', 'Anek Gurmukhi', 'Anek Kannada',
    'Anek Latin', 'Anek Malayalam', 'Anek Odia', 'Anek Tamil', 'Anek Telugu', 'Angkor', 'Annapurna SIL', 'Annie Use Your Telescope', 'Anonymous Pro', 'Anta', 'Antic', 'Antic Didone', 'Antic Slab', 'Anton', 'Anton SC',
    'Antonio', 'Anybody', 'Aoboshi One', 'Arapey', 'Arbutus', 'Arbutus Slab', 'Architects Daughter', 'Archivo', 'Archivo Black', 'Archivo Narrow', 'Aref Ruqaa', 'Arima', 'Arimo', 'Arizonia', 'Arsenal', 'Artifika', 'Arvo',
    'Arya', 'Asap', 'Asap Condensed', 'Asar', 'Asset', 'Assistant', 'Astloch', 'Asul', 'Athiti', 'Atkinson Hyperlegible', 'Atkinson Hyperlegible Mono', 'Atkinson Hyperlegible Next', 'Atma', 'Atomic Age', 'Audiowide',
    'Autour One', 'Average', 'Average Sans', 'Averia Gruesa Libre', 'Averia Libre', 'Averia Serif Libre', 'Azeret Mono', 'B612', 'B612 Mono', 'BIZ UDGothic', 'BIZ UDMincho', 'BIZ UDPGothic', 'BIZ UDPMincho',
    'Bacasime Antique', 'Bad Script', 'Badeen Display', 'Bagel Fat One', 'Bahiana', 'Bahianita', 'Bai Jamjuree', 'Bakbak One', 'Ballet', 'Baloo 2', 'Baloo Bhaijaan 2', 'Baloo Bhaina 2', 'Baloo Chettan 2', 'Baloo Da 2',
    'Baloo Paaji 2', 'Baloo Tamma 2', 'Baloo Tammudu 2', 'Baloo Thambi 2', 'Balthazar', 'Bangers', 'Barlow', 'Barlow Condensed', 'Barlow Semi Condensed', 'Barriecito', 'Barrio', 'Baskervville',
    'Baskervville SC', 'Battambang', 'Baumans', 'Bayon', 'Be Vietnam Pro', 'Beau Rivage', 'Bebas Neue', 'Beiruti', 'Belgrano', 'Bellefair', 'Belleza', 'Bellota', 'Bellota Text', 'BenchNine', 'Benne', 'Bentham',
    'Berkshire Swash', 'Besley', 'Beth Ellen', 'Bevan', 'BhuTuka Expanded One', 'Big Shoulders Display', 'Big Shoulders Inline Display', 'Big Shoulders Inline Text', 'Big Shoulders Stencil Display',
    'Big Shoulders Stencil Text', 'Bigelow Rules', 'Bigshot One', 'Bilbo', 'BioRhyme', 'BioRhyme Expanded', 'Birthstone', 'Birthstone Bounce', 'Biryani', 'Bitter', 'Black And White Picture', 'Black Han Sans', 'Blaka',
    'Blaka Hollow', 'Blaka Ink', 'Bodoni Moda', 'Bodoni Moda SC', 'Bokor', 'Bona Nova', 'Bona Nova SC', 'Bonbon', 'Bonheur Royale', 'Boogaloo', 'Borel', 'Bowlby One', 'Braah One', 'Brawler', 'Bricolage Grotesque',
    'Bruno Ace', 'Bruno Ace SC', 'Brygada 1918', 'Bubblegum Sans', 'Bubbler One', 'Bungee', 'Bungee Hairline', 'Bungee Inline', 'Bungee Outline', 'Bungee Shade', 'Bungee Spice', 'Bungee Tint',
    'Butcherman', 'Butterfly Kids', 'Cabin', 'Cabin Condensed', 'Cactus Classical Serif', 'Caesar Dressing', 'Cagliostro', 'Cairo', 'Caladea', 'Calistoga', 'Calligraffitti', 'Candal', 'Cantarell',
    'Cantata One', 'Cantora One', 'Capriola', 'Caramel', 'Carattere', 'Cardo', 'Carlito', 'Carrois Gothic SC', 'Carter One', 'Castoro', 'Catamaran', 'Caudex', 'Caveat', 'Caveat Brush', 'Cedarville Cursive', 'Chakra Petch',
    'Changa', 'Changa One', 'Chango', 'Charis SIL', 'Charm', 'Charmonman', 'Chathura', 'Chela One', 'Chelsea Market', 'Chenla', 'Cherish', 'Cherry Bomb One', 'Cherry Cream Soda', 'Cherry Swash', 'Chicle',
    'Chilanka', 'Chivo', 'Chivo Mono', 'Chocolate Classical Sans', 'Chokokutai', 'Cinzel', 'Cinzel Decorative', 'Clicker Script', 'Climate Crisis', 'Codystar', 'Coiny', 'Combo', 'Comfortaa', 'Comforter',
    'Comforter Brush', 'Comic Neue', 'Comme', 'Commissioner', 'Concert One', 'Condiment', 'Content', 'Contrail One', 'Convergence', 'Cookie', 'Corben', 'Corinthia', 'Cormorant', 'Cormorant Infant', 'Cormorant Upright',
    'Courgette', 'Courier Prime', 'Cousine', 'Coustard', 'Covered By Your Grace', 'Crafty Girls', 'Creepster', 'Crete Round', 'Crimson Pro', 'Crimson Text', 'Croissant One', 'Crushed', 'Cuprum',
    'Cute Font', 'Cutive', 'DM Mono', 'DM Sans', 'DM Serif Display', 'DM Serif Text', 'Dai Banna SIL', 'Dancing Script', 'Danfo', 'Dangrek', 'Darumadrop One', 'David Libre', 'Days One', 'Dekko', 'Dela Gothic One',
    'Delicious Handrawn', 'Delius Swash Caps', 'Delius Unicase', 'Della Respira', 'Devonshire', 'Dhurjati', 'Didact Gothic', 'Diphylleia', 'Diplomata', 'Diplomata SC', 'Do Hyeon', 'Dokdo', 'Domine', 'Donegal One',
    'Doppio One', 'Dorsa', 'Dosis', 'Dr Sugiyama', 'DynaPuff', 'EB Garamond', 'Eater', 'Economica', 'Eczar', 'Edu NSW ACT Foundation', 'Edu QLD Beginner', 'Edu TAS Beginner', 'Edu VIC WA NT Beginner',
    'El Messiri', 'Elsie', 'Elsie Swash Caps', 'Emblema One', 'Emilys Candy', 'Encode Sans', 'Encode Sans Condensed', 'Encode Sans Expanded', 'Encode Sans SC', 'Encode Sans Semi Condensed', 'Engagement', 'Englebert',
    'Enriqueta', 'Ephesis', 'Epilogue', 'Erica One', 'Ewert', 'Exo', 'Expletus Sans', 'Explora', 'Faculty Glyphic', 'Familjen Grotesk', 'Fanwood Text', 'Farsan', 'Fascinate', 'Fascinate Inline', 'Fasthand',
    'Fauna One', 'Faustina', 'Federant', 'Federo', 'Felipa', 'Fenix', 'Figtree', 'Fira Code', 'Fira Sans', 'Fira Sans Condensed', 'Fira Sans Extra Condensed', 'Fjord One', 'Flavors', 'Fleur De Leah',
    'Flow Block', 'Flow Rounded', 'Foldit', 'Fondamento', 'Fontdiner Swanky', 'Forum', 'Fragment Mono', 'Francois One', 'Frank Ruhl Libre', 'Fraunces', 'Freckle Face', 'Fredericka the Great', 'Fredoka', 'Freeman', 'Frijole',
    'Fruktur', 'Fugaz One', 'Fuggles', 'Funnel Display', 'Funnel Sans', 'Fustat', 'Fuzzy Bubbles', 'GFS Didot', 'GFS Neohellenic', 'Ga Maamli', 'Gabarito', 'Gabriela', 'Gaegu', 'Gafata', 'Gajraj One', 'Galada',
    'Galindo', 'Gamja Flower', 'Gantari', 'Gasoek One', 'Gayathri', 'Geist', 'Geist Mono', 'Genos', 'Gentium Book Plus', 'Gentium Plus', 'Geo', 'Geologica', 'Georama', 'Geostar', 'Geostar Fill', 'Germania One',
    'Gideon Roman', 'Gidugu', 'Girassol', 'Give You Glory', 'Glass Antiqua', 'Glegoo', 'Gloria Hallelujah', 'Glory', 'Gluten', 'Goblin One', 'Goldman', 'Golos Text', 'Gorditas', 'Gothic A1', 'Gowun Batang', 'Gowun Dodum',
    'Grandiflora One', 'Grape Nuts', 'Gravitas One', 'Grechen Fuemen', 'Grenze', 'Grenze Gotisch', 'Grey Qo', 'Griffy', 'Gruppo', 'Gudea', 'Gugi', 'Gulzar', 'Gupter', 'Gurajada', 'Gwendolyn', 'Habibi', 'Hachi Maru Pop',
    'Hahmlet', 'Hammersmith One', 'Hanalei', 'Hanalei Fill', 'Handjet', 'Handlee', 'Hanken Grotesk', 'Hanuman', 'Happy Monkey', 'Harmattan', 'Headland One', 'Hedvig Letters Sans', 'Hedvig Letters Serif', 'Heebo',
    'Herr Von Muellerhoff', 'Hina Mincho', 'Hind', 'Hind Guntur', 'Hind Siliguri', 'Hind Vadodara', 'Holtwood One SC', 'Homemade Apple', 'Homenaje', 'Honk', 'Host Grotesk', 'Hubballi', 'Hurricane',
    'IBM Plex Mono', 'IBM Plex Sans', 'IBM Plex Sans Arabic', 'IBM Plex Sans Condensed', 'IBM Plex Sans Devanagari', 'IBM Plex Sans Hebrew', 'IBM Plex Sans JP', 'IBM Plex Sans KR',
    'IBM Plex Sans Thai', 'IBM Plex Sans Thai Looped', 'IBM Plex Serif', 'IM Fell DW Pica', 'IM Fell DW Pica SC', 'IM Fell Double Pica', 'IM Fell Double Pica SC', 'IM Fell English SC', 'IM Fell French Canon',
    'IM Fell French Canon SC', 'IM Fell Great Primer', 'IM Fell Great Primer SC', 'Ibarra Real Nova', 'Iceland', 'Imperial Script', 'Imprima', 'Inclusive Sans', 'Inconsolata', 'Inder',
    'Indie Flower', 'Ingrid Darling', 'Inika', 'Inknut Antiqua', 'Inria Sans', 'Inria Serif', 'Inspiration', 'Instrument Sans', 'Inter', 'Inter Tight', 'Irish Grover', 'Island Moments', 'Istok Web', 'Italianno', 'Itim',
    'Jacquard 12', 'Jacquard 12 Charted', 'Jacquard 24 Charted', 'Jacquarda Bastarda 9', 'Jacquarda Bastarda 9 Charted', 'Jacques Francois Shadow', 'Jaini', 'Jaini Purva', 'Jaro', 'Jersey 10',
    'Jersey 10 Charted', 'Jersey 15', 'Jersey 15 Charted', 'Jersey 20', 'Jersey 25', 'Jersey 25 Charted', 'JetBrains Mono', 'Jim Nightshade', 'Joan', 'Jolly Lodger', 'Jomhuria', 'Jomolhari', 'Josefin Sans',
    'Josefin Slab', 'Jost', 'Jua', 'Judson', 'Julee', 'Julius Sans One', 'Junge', 'Jura', 'Just Another Hand', 'Just Me Again Down Here', 'Kablammo', 'Kaisei Decol', 'Kaisei HarunoUmi', 'Kaisei Opti', 'Kalam',
    'Kalnia', 'Kalnia Glaze', 'Kameron', 'Kanit', 'Karantina', 'Karla', 'Karla Tamil Inclined', 'Karla Tamil Upright', 'Katibeh', 'Kaushan Script', 'Kavoon', 'Kdam Thmor Pro', 'Keania One', 'Kelly Slab', 'Kenia', 'Khand',
    'Khmer', 'Khula', 'Kings', 'Kirang Haerang', 'Kite One', 'Kiwi Maru', 'Knewave', 'Kodchasan', 'Kode Mono', 'Koh Santepheap', 'Kolker Brush', 'Konkhmer Sleokchher', 'Kosugi', 'Kosugi Maru', 'Koulen', 'Kreon',
    'Kristi', 'Krona One', 'Krub', 'Kulim Park', 'Kumar One', 'Kumar One Outline', 'Kumbh Sans', 'Kurale', 'LXGW WenKai Mono TC', 'LXGW WenKai TC', 'La Belle Aurore', 'Labrada', 'Lacquer', 'Laila',
    'Lalezar', 'Lancelot', 'Langar', 'Lateef', 'Lato', 'Lavishly Yours', 'League Spartan', 'Ledger', 'Lekton', 'Lemon', 'Lexend', 'Lexend Deca', 'Lexend Exa', 'Lexend Mega', 'Lexend Peta', 'Lexend Tera', 'Lexend Zetta',
    'Libre Barcode 128', 'Libre Barcode 128 Text', 'Libre Barcode 39', 'Libre Barcode 39 Extended', 'Libre Barcode 39 Extended Text', 'Libre Barcode 39 Text', 'Libre Barcode EAN13 Text', 'Libre Baskerville',
    'Libre Bodoni', 'Libre Caslon Display', 'Libre Caslon Text', 'Libre Franklin', 'Licorice', 'Life Savers', 'Lilita One', 'Lily Script One', 'Limelight', 'Linden Hill', 'Linefont', 'Lisu Bosa',
    'Liter', 'Literata', 'Liu Jian Mao Cao', 'Livvic', 'Lobster', 'Lobster Two', 'Londrina Outline', 'Londrina Shadow', 'Londrina Solid', 'Long Cang', 'Lora', 'Love Light', 'Love Ya Like A Sister',
    'Loved by the King', 'Lovers Quarrel', 'Luckiest Guy', 'Lugrasimo', 'Lunasima', 'Lustria', 'Luxurious Roman', 'Luxurious Script', 'M PLUS 1', 'M PLUS 1 Code', 'M PLUS 1p', 'M PLUS 2', 'M PLUS Rounded 1c', 'Macondo',
    'Macondo Swash Caps', 'Mada', 'Madimi One', 'Maiden Orange', 'Maitree', 'Mako', 'Mali', 'Mallanna', 'Maname', 'Mandali', 'Manjari', 'Manrope', 'Mansalva', 'Manuale', 'Marcellus', 'Marcellus SC', 'Marck Script',
    'Margarine', 'Markazi Text', 'Marmelad', 'Martel', 'Martel Sans', 'Martian Mono', 'Marvel', 'Mate', 'Matemasie', 'Maven Pro', 'McLaren', 'Mea Culpa', 'Meddon', 'MedievalSharp', 'Medula One', 'Meera Inimai',
    'Megrim', 'Meie Script', 'Merienda', 'Merriweather', 'Merriweather Sans', 'Metal', 'Metal Mania', 'Metamorphous', 'Metrophobic', 'Michroma', 'Micro 5', 'Micro 5 Charted', 'Milonga', 'Miltonian', 'Miltonian Tattoo',
    'Mina', 'Mingzat', 'Miniver', 'Miriam Libre', 'Mirza', 'Miss Fajardose', 'Mochiy Pop P One', 'Modak', 'Modern Antiqua', 'Moderustic', 'Mogra', 'Mohave', 'Moirai One', 'Molengo', 'Mona Sans', 'Monda', 'Monofett',
    'Monomakh', 'Monomaniac One', 'Monoton', 'Montaga', 'Montagu Slab', 'MonteCarlo', 'Montserrat', 'Montserrat Alternates', 'Montserrat Subrayada', 'Montserrat Underline', 'Moo Lah Lah', 'Mooli',
    'Moul', 'Moulpali', 'Mountains of Christmas', 'Mr Bedfort', 'Mr Dafoe', 'Mrs Saint Delafield', 'Mrs Sheppards', 'Mukta', 'Mukta Mahee', 'Mukta Vaani', 'Mulish', 'Murecho', 'MuseoModerno', 'My Soul', 'Mynerve',
    'Mystery Quest', 'NTR', 'Nabla', 'Namdhinggo', 'Nanum Brush Script', 'Nanum Gothic', 'Nanum Gothic Coding', 'Nanum Myeongjo', 'Nanum Pen Script', 'Narnoor', 'Neonderthaw', 'Nerko One', 'Neucha',
    'Neuton', 'New Rocker', 'New Tegomin', 'News Cycle', 'Niconne', 'Niramit', 'Nixie One', 'Nokora', 'Norican', 'Nosifer', 'Notable', 'Nothing You Could Do', 'Noticia Text', 'Noto Color Emoji', 'Noto Emoji',
    'Noto Music', 'Noto Rashi Hebrew', 'Noto Sans', 'Noto Sans Adlam', 'Noto Sans Adlam Unjoined', 'Noto Sans Anatolian Hieroglyphs', 'Noto Sans Arabic', 'Noto Sans Armenian', 'Noto Sans Balinese',
    'Noto Sans Bamum', 'Noto Sans Bassa Vah', 'Noto Sans Batak', 'Noto Sans Bengali', 'Noto Sans Bhaiksuki', 'Noto Sans Brahmi', 'Noto Sans Buginese', 'Noto Sans Buhid', 'Noto Sans Canadian Aboriginal',
    'Noto Sans Carian', 'Noto Sans Cham', 'Noto Sans Coptic', 'Noto Sans Cuneiform', 'Noto Sans Cypriot', 'Noto Sans Cypro Minoan', 'Noto Sans Devanagari', 'Noto Sans Display', 'Noto Sans Duployan',
    'Noto Sans Elbasan', 'Noto Sans Elymaic', 'Noto Sans Ethiopic', 'Noto Sans Georgian', 'Noto Sans Glagolitic', 'Noto Sans Gothic', 'Noto Sans Gujarati', 'Noto Sans Gunjala Gondi', 'Noto Sans Gurmukhi',
    'Noto Sans Hanifi Rohingya', 'Noto Sans Hanunoo', 'Noto Sans Hatran', 'Noto Sans Hebrew', 'Noto Sans Imperial Aramaic', 'Noto Sans Inscriptional Pahlavi', 'Noto Sans Inscriptional Parthian',
    'Noto Sans Javanese', 'Noto Sans Kaithi', 'Noto Sans Kawi', 'Noto Sans Kayah Li', 'Noto Sans Kharoshthi', 'Noto Sans Khojki', 'Noto Sans Khudawadi', 'Noto Sans Lao', 'Noto Sans Lao Looped',
    'Noto Sans Limbu', 'Noto Sans Linear A', 'Noto Sans Linear B', 'Noto Sans Lisu', 'Noto Sans Lycian', 'Noto Sans Lydian', 'Noto Sans Mahajani', 'Noto Sans Malayalam', 'Noto Sans Mandaic',
    'Noto Sans Manichaean', 'Noto Sans Marchen', 'Noto Sans Masaram Gondi', 'Noto Sans Math', 'Noto Sans Mayan Numerals', 'Noto Sans Medefaidrin', 'Noto Sans Mende Kikakui', 'Noto Sans Meroitic', 'Noto Sans Miao',
    'Noto Sans Modi', 'Noto Sans Mono', 'Noto Sans Mro', 'Noto Sans Myanmar', 'Noto Sans Nabataean', 'Noto Sans Nag Mundari', 'Noto Sans Nandinagari', 'Noto Sans New Tai Lue', 'Noto Sans Newa', 'Noto Sans Ogham',
    'Noto Sans Ol Chiki', 'Noto Sans Old Hungarian', 'Noto Sans Old Italic', 'Noto Sans Old North Arabian', 'Noto Sans Old Permic', 'Noto Sans Old Persian', 'Noto Sans Old Sogdian',
    'Noto Sans Old South Arabian', 'Noto Sans Old Turkic', 'Noto Sans Osage', 'Noto Sans Osmanya', 'Noto Sans Pahawh Hmong', 'Noto Sans Palmyrene', 'Noto Sans Pau Cin Hau', 'Noto Sans Phoenician',
    'Noto Sans Psalter Pahlavi', 'Noto Sans Runic', 'Noto Sans Saurashtra', 'Noto Sans Sharada', 'Noto Sans Shavian', 'Noto Sans Siddham', 'Noto Sans SignWriting', 'Noto Sans Sogdian', 'Noto Sans Sora Sompeng',
    'Noto Sans Soyombo', 'Noto Sans Sundanese', 'Noto Sans Syloti Nagri', 'Noto Sans Symbols', 'Noto Sans Symbols 2', 'Noto Sans Syriac', 'Noto Sans Tagalog', 'Noto Sans Tagbanwa', 'Noto Sans Tai Le',
    'Noto Sans Tai Tham', 'Noto Sans Tai Viet', 'Noto Sans Takri', 'Noto Sans Tamil', 'Noto Sans Tamil Supplement', 'Noto Sans Tangsa', 'Noto Sans Telugu', 'Noto Sans Thaana', 'Noto Sans Thai',
    'Noto Sans Thai Looped', 'Noto Sans Tirhuta', 'Noto Sans Ugaritic', 'Noto Sans Vithkuqi', 'Noto Sans Wancho', 'Noto Sans Warang Citi', 'Noto Sans Yi', 'Noto Sans Zanabazar Square', 'Noto Serif',
    'Noto Serif Ahom', 'Noto Serif Armenian', 'Noto Serif Balinese', 'Noto Serif Bengali', 'Noto Serif Devanagari', 'Noto Serif Display', 'Noto Serif Dogra', 'Noto Serif Ethiopic', 'Noto Serif Georgian',
    'Noto Serif Grantha', 'Noto Serif Gurmukhi', 'Noto Serif Hebrew', 'Noto Serif Hentaigana', 'Noto Serif Khmer', 'Noto Serif Khojki', 'Noto Serif Lao', 'Noto Serif Malayalam', 'Noto Serif Myanmar',
    'Noto Serif Nyiakeng Puachue Hmong', 'Noto Serif Old Uyghur', 'Noto Serif Ottoman Siyaq', 'Noto Serif Sinhala', 'Noto Serif Tamil', 'Noto Serif Tangut', 'Noto Serif Telugu', 'Noto Serif Thai',
    'Noto Serif Tibetan', 'Noto Serif Todhri', 'Noto Serif Toto', 'Noto Serif Yezidi', 'Noto Znamenny Musical Notation', 'Nova Cut', 'Nova Flat', 'Nova Mono', 'Nova Oval', 'Nova Script', 'Nova Slim',
    'Nunito', 'Nunito Sans', 'Nuosu SIL', 'Odibee Sans', 'Offside', 'Oi', 'Ojuju', 'Old Standard TT', 'Oldenburg', 'Oleo Script', 'Oleo Script Swash Caps', 'Onest', 'Open Sans', 'Oranienbaum', 'Orbit',
    'Oregano', 'Orienta', 'Original Surfer', 'Oswald', 'Outfit', 'Overlock', 'Overlock SC', 'Overpass', 'Overpass Mono', 'Oxanium', 'Oxygen Mono', 'PT Sans', 'PT Sans Caption', 'PT Serif', 'PT Serif Caption', 'Pacifico',
    'Padauk', 'Palanquin', 'Palanquin Dark', 'Palette Mosaic', 'Paprika', 'Parisienne', 'Parkinsans', 'Passero One', 'Passion One', 'Passions Conflict', 'Pathway Extreme', 'Pathway Gothic One',
    'Patrick Hand', 'Patrick Hand SC', 'Pattaya', 'Patua One', 'Paytone One', 'Peralta', 'Permanent Marker', 'Petemoss', 'Petrona', 'Phetsarath', 'Philosopher', 'Phudu', 'Piazzolla', 'Piedra', 'Pirata One',
    'Pixelify Sans', 'Plaster', 'Play', 'Playfair', 'Playfair Display', 'Playpen Sans', 'Pochaevsk', 'Podkova', 'Poetsen One', 'Poiret One', 'Poller One', 'Poly', 'Ponnala', 'Pontano Sans', 'Poor Story', 'Poppins',
    'Port Lligat Sans', 'Port Lligat Slab', 'Potta One', 'Pragati Narrow', 'Praise', 'Prata', 'Preahvihear', 'Press Start 2P', 'Pridi', 'Princess Sofia', 'Prociono', 'Prompt', 'Prosto One', 'Protest Guerrilla',
    'Protest Riot', 'Protest Strike', 'Proza Libre', 'Public Sans', 'Purple Purse', 'Qahiri', 'Quando', 'Quantico', 'Quattrocento', 'Quattrocento Sans', 'Questrial', 'Quicksand', 'Qwitcher Grypen', 'REM',
    'Racing Sans One', 'Radio Canada', 'Rajdhani', 'Rakkas', 'Raleway', 'Ramabhadra', 'Ramaraja', 'Rambla', 'Rammetto One', 'Rampart One', 'Ranchers', 'Rancho', 'Ranga', 'Rasa', 'Rationale', 'Ravi Prakash',
    'Recursive', 'Red Hat Display', 'Red Hat Text', 'Redacted', 'Redacted Script', 'Reddit Mono', 'Reddit Sans', 'Reddit Sans Condensed', 'Redressed', 'Reem Kufi', 'Reem Kufi Fun', 'Reem Kufi Ink', 'Reenie Beanie',
    'Reggae One', 'Revalia', 'Rhodium Libre', 'Ribeye', 'Ribeye Marrow', 'Risque', 'Roboto Condensed', 'Roboto Mono', 'Roboto Serif', 'Roboto Slab', 'Rochester', 'Rock 3D', 'Rock Salt', 'RocknRoll One', 'Rokkitt',
    'Romanesco', 'Ropa Sans', 'Rosario', 'Rosarivo', 'Rouge Script', 'Rowdies', 'Rozha One', 'Rubik', 'Rubik 80s Fade', 'Rubik Beastly', 'Rubik Broken Fax', 'Rubik Bubbles', 'Rubik Dirt', 'Rubik Distressed',
    'Rubik Doodle Shadow', 'Rubik Doodle Triangles', 'Rubik Gemstones', 'Rubik Glitch', 'Rubik Glitch Pop', 'Rubik Iso', 'Rubik Lines', 'Rubik Maps', 'Rubik Marker Hatch', 'Rubik Microbe', 'Rubik Mono One',
    'Rubik Moonrocks', 'Rubik Pixels', 'Rubik Puddles', 'Rubik Scribble', 'Rubik Spray Paint', 'Rubik Storm', 'Rubik Vinyl', 'Ruda', 'Rufina', 'Ruge Boogie', 'Ruluko', 'Ruslan Display', 'Russo One', 'Ruthie', 'Ruwudu',
    'SUSE', 'Sacramento', 'Sahitya', 'Sail', 'Saira', 'Saira Semi Condensed', 'Saira Stencil One', 'Salsa', 'Sanchez', 'Sancreek', 'Sankofa Display', 'Sansita', 'Sansita Swashed', 'Sarabun', 'Sarala', 'Sarina', 'Sarpanch',
    'Sassy Frass', 'Satisfy', 'Sawarabi Gothic', 'Sawarabi Mincho', 'Scheherazade New', 'Schibsted Grotesk', 'Scope One', 'Seaweed Script', 'Secular One', 'Sedan', 'Sedan SC', 'Sedgwick Ave', 'Sedgwick Ave Display',
    'Sen', 'Send Flowers', 'Seymour One', 'Shadows Into Light', 'Shadows Into Light Two', 'Shalimar', 'Shantell Sans', 'Shanti', 'Share', 'Share Tech Mono', 'Shippori Antique', 'Shippori Antique B1',
    'Shippori Mincho', 'Shippori Mincho B1', 'Shizuru', 'Shojumaru', 'Siemreap', 'Sigmar', 'Sigmar One', 'Signika', 'Signika Negative', 'Silkscreen', 'Simonetta', 'Single Day', 'Sirin Stencil', 'Six Caps',
    'Sixtyfour Convergence', 'Skranji', 'Slabo 13px', 'Slabo 27px', 'Slackey', 'Slackside One', 'Smooch', 'Smooch Sans', 'Smythe', 'Snippet', 'Snowburst One', 'Sofadi One', 'Sofia', 'Sofia Sans Extra Condensed',
    'Sofia Sans Semi Condensed', 'Solitreo', 'Solway', 'Sometype Mono', 'Song Myung', 'Sono', 'Sora', 'Sorts Mill Goudy', 'Sour Gummy', 'Source Code Pro', 'Source Sans 3', 'Source Serif 4', 'Space Grotesk',
    'Space Mono', 'Special Elite', 'Spectral', 'Spectral SC', 'Spicy Rice', 'Spinnaker', 'Spirax', 'Splash', 'Spline Sans', 'Spline Sans Mono', 'Squada One', 'Square Peg', 'Sree Krushnadevaraya', 'Sriracha', 'Srisakdi',
    'Staatliches', 'Stalemate', 'Stalinist One', 'Stardos Stencil', 'Stick', 'Stick No Bills', 'Stint Ultra Condensed', 'Stint Ultra Expanded', 'Stoke', 'Stylish', 'Sue Ellen Francisco', 'Suez One', 'Sulphur Point', 'Sumana',
    'Sunshiney', 'Supermercado One', 'Sura', 'Suranna', 'Suravaram', 'Suwannaphum', 'Swanky and Moo Moo', 'Syncopate', 'Syne', 'Syne Mono', 'Syne Tactile', 'Tac One', 'Tai Heritage Pro', 'Tangerine', 'Tapestry',
    'Taprom', 'Tauri', 'Taviraj', 'Teachers', 'Teko', 'Telex', 'Tenali Ramakrishna', 'Tenor Sans', 'Text Me One', 'Texturina', 'Thasadith', 'The Girl Next Door', 'The Nautigal', 'Tienne', 'Tillana',
    'Tilt Prism', 'Tilt Warp', 'Timmana', 'Tinos', 'Tiny5', 'Tiro Bangla', 'Tiro Devanagari Marathi', 'Tiro Devanagari Sanskrit', 'Tiro Gurmukhi', 'Tiro Kannada', 'Tiro Tamil', 'Tiro Telugu', 'Titan One', 'Titillium Web',
    'Tomorrow', 'Tourney', 'Trade Winds', 'Triodion', 'Trirong', 'Trispace', 'Trochut', 'Truculenta', 'Tsukimi Rounded', 'Tulpen One', 'Twinkle Star', 'Ubuntu', 'Ubuntu Condensed', 'Ubuntu Mono', 'Ubuntu Sans',
    'Ubuntu Sans Mono', 'Uchen', 'Ultra', 'Unbounded', 'Uncial Antiqua', 'Underdog', 'Unica One', 'Unkempt', 'Unlock', 'Unna', 'Updock', 'Urbanist', 'VT323', 'Varela Round', 'Varta', 'Vast Shadow', 'Vazirmatn',
    'Vesper Libre', 'Viaoda Libre', 'Vibes', 'Vibur', 'Victor Mono', 'Vidaloka', 'Viga', 'Vina Sans', 'Voces', 'Volkhov', 'Vollkorn', 'Vollkorn SC', 'Voltaire', 'Waiting for the Sunrise', 'Wallpoet',
    'Walter Turncoat', 'Water Brush', 'Waterfall', 'Wavefont', 'Wellfleet', 'Whisper', 'Wire One', 'Wittgenstein', 'Wix Madefor Display', 'Wix Madefor Text', 'Work Sans', 'Workbench', 'Xanh Mono', 'Yaldevi',
    'Yanone Kaffeesatz', 'Yantramanav', 'Yarndings 12', 'Yarndings 12 Charted', 'Yarndings 20', 'Yarndings 20 Charted', 'Yatra One', 'Yellowtail', 'Yeseva One', 'Yesteryear', 'Yomogi', 'Ysabeau', 'Ysabeau Infant',
    'Ysabeau Office', 'Yuji Boku', 'Yuji Hentaigana Akari', 'Yuji Mai', 'Yuji Syuku', 'ZCOOL KuaiLe', 'ZCOOL QingKe HuangYou', 'ZCOOL XiaoWei', 'Zain', 'Zen Antique', 'Zen Antique Soft', 'Zen Dots',
    'Zen Kaku Gothic New', 'Zen Kurenaido', 'Zen Loop', 'Zen Maru Gothic', 'Zen Old Mincho', 'Zen Tokyo Zoo', 'Zeyada', 'Zhi Mang Xing', 'Zilla Slab', 'Zilla Slab Highlight'
]

def download_all_google_fonts(*,show_progress=True):
    """ 
    Download all Google fonts I know of: 120.1MB
    Returns a list of paths to all downloaded fonts
    """
    return gather_args_call(download_google_fonts, _all_google_fonts)

def _get_file_path(path_or_url):
    """If given a url, get a file path that can be used for things"""
    #TODO: Use this to make strip_file_extension etc work for URL's as well
    # if path_or_url.startswith(('http://', 'https://')):
    if is_valid_url(path_or_url):
        from urllib.parse import urlparse
        parsed_url = urlparse(path_or_url)
        return parsed_url.path
    return path_or_url

def strip_file_extension(file_path):
    """
    'x.png'        --> 'x'
    'text.txt'     --> 'text'
    'text'         --> 'text'
    'text.jpg.txt' --> 'text.jpg'
    'a/b/c.png'    --> 'a/b/c'
    'a/b/c'        --> 'a/b/c'
     For more, see: https://stackoverflow.com/questions/678236/how-to-get-the-filename-without-the-extension-from-a-path-in-python
    """
    return os.path.splitext(file_path)[0]

def strip_file_extensions(*file_paths):
    "Plural of strip_file_extension"
    return [strip_file_extension(x) for x in detuple(file_paths)]

def get_file_extension(file_path):
    """
    'x.png'        --> 'png'
    'text.txt'     --> 'txt'
    'text'         --> ''
    'text.jpg.txt' --> 'txt'
    'a/b/c.png'    --> 'png'
    'a/b/c'        --> ''
     For more, see: https://stackoverflow.com/questions/541390/extracting-extension-from-filename-in-python
    
    Enhanced Documentation:
    Extracts the file extension from a file path, returning only the extension 
    part without the dot. Works with both absolute and relative paths.
    
    Args:
        file_path (str): Path to file (can be absolute or relative, file need not exist)
        
    Returns:
        str: File extension without dot, or empty string if no extension
        
    Examples:
        >>> get_file_extension('image.png')
        'png'
        >>> get_file_extension('document.pdf')  
        'pdf'
        >>> get_file_extension('file_without_extension')
        ''
        >>> get_file_extension('archive.tar.gz')  # Only gets last extension
        'gz'
        
    Related Functions:
        - has_file_extension(): Check if file has any extension
        - with_file_extension(): Add/replace file extension
        - strip_file_extension(): Remove extension from path
        
    Tags: file, path, extension, utility, filesystem
    """
    file_path = _get_file_path(file_path)
    return os.path.splitext(file_path)[1].rpartition('.')[2]

def get_file_extensions(file_paths):
    return list(map(get_file_extension,file_paths))

def with_file_extension(path:str,extension:str,*,replace=False):
    """
    Replaces or adds a file extension to a path
    
    If extension is blank, and replace=False, path won't be changed
    If extension is blank, and replace=True, then this is equivalent to strip_file_extension
    
    EXAMPLES:
        >>> with_file_extension('doggy','')
       ans = doggy
        >>> with_file_extension('doggy.png','jpg')
       ans = doggy.png.jpg
        >>> with_file_extension('doggy.png','.jpg')
       ans = doggy.png.jpg
        >>> with_file_extension('doggy.png','.jpg',replace=True)
       ans = doggy.jpg
        >>> with_file_extension('doggy.png','.png')
       ans = doggy.png
        >>> with_file_extension('doggy.png','..png')
       ans = doggy.png..png
        >>> with_file_extension('doggy.png','png')
       ans = doggy.png
        >>> with_file_extension('doggy','png')
       ans = doggy.png
        >>> with_file_extension('path/to/doggy','png')
       ans = path/to/doggy.png
    
    Enhanced Documentation:
    Adds or replaces the file extension of a path. By default appends the extension
    (creating double extensions if one exists), but can replace existing extension
    with replace=True.
    
    Args:
        path (str): File path to modify
        extension (str): New extension to add (with or without leading dot)
        replace (bool): If True, replaces existing extension; if False, appends
        
    Returns:
        str: Path with new extension applied
        
    Examples:
        >>> with_file_extension('file', 'txt')
        'file.txt'
        >>> with_file_extension('document.pdf', 'txt', replace=True)
        'document.txt'
        >>> with_file_extension('image.png', 'jpg')  # Default appends
        'image.png.jpg'
        >>> with_file_extension('document.pdf', '', replace=True)  # Remove extension
        'document'
        
    Related Functions:
        - get_file_extension(): Get current extension
        - has_file_extension(): Check if extension exists
        - strip_file_extension(): Remove extension entirely
        - with_file_extensions(): Plural version for multiple paths
        
    Tags: file, path, extension, modify, utility
    
    EXAMPLES:
        path='path'        ; assert path==with_file_extension(path,get_file_extension(path))
        path='path.'       ; assert path==with_file_extension(path,get_file_extension(path))
        path='path.png'    ; assert path==with_file_extension(path,get_file_extension(path))
        path='path.png.jpg'; assert path==with_file_extension(path,get_file_extension(path))
    """

    if extension.startswith('.'):
        extension=extension[1:]
    if not has_file_extension(path) and extension!="":
        return path+'.'+extension
    else:
        if get_file_extension(path)==extension:
            return path
        else:
            if replace:
                path=strip_file_extension(path)
            if extension!="":
                path+='.'+extension
            return path

def with_file_extensions(*paths,extension:str=None,replace=False):
    """Generate multiple file paths with extensions. See with_file_extension for details.
    
    Args:
        *paths: Multiple file paths to process
        extension: File extension to add/replace
        replace: Whether to replace existing extensions
        
    Returns:
        list: List of paths with extensions applied
    """
    if extension is None:
        if len(paths)==2 and isinstance(paths[1],str):
            extension=paths[1] # Enable with_file_extensions(['a','b'],'png')
            paths=paths[:1]
        else:
            assert False, 'Please specify a file extension'

    paths=detuple(paths)

    return [with_file_extension(path, extension, replace=replace) for path in paths]

def with_file_name(path:str,name:str,*,keep_extension=True):
    """
    Returns the path with a new file name, keeping the old file extension
    If the file extension in 'name' is specified though, it will keep the new extension

    EXAMPLE:
       >>> with_file_name('some/parent/folder/file.txt','untitled')
       ans = some/parent/folder/untitled.txt
       >>> with_file_name('Hello.com','Berty',keep_extension=True)
       ans = Berty.com
       >>> with_file_name('./Hello.com','Berty',keep_extension=True)
       ans = ./Berty.com
       >>> with_file_name('./Hello.com','Berty',keep_extension=False)
       ans = ./Berty
       >>> with_file_name('.Hello.com','Berty',keep_extension=False)
       ans = Berty
       >>> with_file_name('.Hello.com','Berty',keep_extension=True)
       ans = Berty.com
   """

    parent_folder=get_parent_folder(path)
    file_name=get_file_name(path)
    assert get_absolute_path(path)==get_absolute_path(path_join(parent_folder,file_name))
    
    extension=get_file_extension(file_name) if not has_file_extension(name) else get_file_extension(name)
    file_name=strip_file_extension(file_name)
    file_name=name
    if keep_extension:
        file_name=with_file_extension(file_name,extension)
    output = path_join(parent_folder,file_name)

    if output.startswith('./') and not path.startswith('./'):
        output = output[len('./'):]

    return output


def with_folder_name(path:str, name:str):
    """
    Like with_file_name, except it will always replace the extension (unlike with_file_name, where if name doesn't have an extension it preserves path's extension)
    >>> with_folder_name('Hello/world.jpg','power')
    ans = Hello/power
    """
    return path_join(get_parent_folder(path),name)
    return with_file_name(path,name+'.temp')[:-len('.temp')] #Equivalent to the above


def get_path_name(path,include_file_extension=True):
    """
    '/tmp/d/a.dat' --> 'a.dat'
    For more, see: https://stackoverflow.com/questions/8384737/extract-file-name-from-path-no-matter-what-the-os-path-format
    """
    from pathlib import Path
    output= Path(path).name
    if not include_file_extension:
        output=strip_file_extension(output)
    return output
get_folder_name=get_directory_name=get_file_name=get_path_name

def get_path_names(*paths, include_file_extensions=True):
    "Plural of get_path_name"
    return [get_path_name(path, include_file_extensions) for path in detuple(paths)]
get_folder_names=get_directory_names=get_file_names=get_path_names

def get_relative_path(path,root=None):
    """
    Take an absolute path, and turn it into a relative path starting from root_directory
    root_directory's default is get_current_directory()

    Enhanced Documentation:
    
    Converts an absolute path to a relative path from a specified root directory.
    Useful for creating portable paths, displaying shortened paths, and working
    with paths relative to project directories.
    
    Parameters:
        path (str): The absolute or relative path to convert to relative.
        root (str, optional): Root directory to calculate relative path from.
                             Defaults to current working directory if None.
    
    Returns:
        str: Relative path from root to the target path.
             Uses '..' notation for parent directories when needed.
    
    Usage Examples:
        >>> # Get relative path from current directory
        >>> abs_path = "/Users/ryan/project/data/file.txt"
        >>> # Assuming current dir is /Users/ryan/project
        >>> rel_path = get_relative_path(abs_path)
        >>> print(rel_path)  # data/file.txt
        
        >>> # Specify custom root directory
        >>> target = "/Users/ryan/project/results/output.csv"
        >>> root = "/Users/ryan/project/data"
        >>> rel_path = get_relative_path(target, root)
        >>> print(rel_path)  # ../results/output.csv
        
        >>> # Get relative paths for multiple files
        >>> base_dir = "/Users/ryan/documents"
        >>> files = ["/Users/ryan/documents/readme.txt", "/Users/ryan/documents/src/main.py"]
        >>> rel_paths = [get_relative_path(f, base_dir) for f in files]
        >>> print(rel_paths)  # ['readme.txt', 'src/main.py']
        
        >>> # Display shortened paths in UI
        >>> current_project = get_current_directory()
        >>> long_path = "/Users/ryan/very/long/project/path/data/results/final.json"
        >>> short_display = get_relative_path(long_path, current_project)
        >>> print(f"File: {short_display}")  # File: data/results/final.json
    
    Related Functions:
        - get_current_directory(): Get current working directory (default root)
        - get_absolute_path(): Convert relative paths to absolute (inverse operation)
        - path_join(): Join path components together
        - localized_path(): Get relative path with additional formatting
        - get_relative_paths(): Plural version for multiple paths
        
    Notes:
        - Based on os.path.relpath() for cross-platform compatibility
        - Returns paths with forward slashes on all platforms
        - Handles '..' for parent directory navigation automatically
        - Works with both absolute and relative input paths
        - Root parameter must be a string path
        
    Tags: path, relative, absolute, filesystem, conversion, portable
    """
    if root is None:
        root=get_current_directory()
    assert isinstance(root,str),'root must be a string representing the root path to compare the given path against'
    return os.path.relpath(path,root)

def get_relative_paths(*paths, root=None):
    """
    Plural of get_relative_path
    Supports broadcasting (see examples) - it can take multiple paths and/or multiple roots

    EXAMPLES:

        >>> get_relative_paths('A/B/C','D/E/F',root=['A/B/C','D'])
        ['.', 'E/F']

        >>> #Basic usage with single path and root
        >>> get_relative_paths('/a/b/c', root='/a/b')
        ['c']

        >>> # Broadcasting root to multiple paths
        >>> get_relative_paths('/x/y/z', '/x/y/w', root='/x/y')
        ['z', 'w']

        >>> # Element-wise root for multiple paths
        >>> get_relative_paths('/p/q/r', '/s/t/u', root=['/p/q', '/s/t'])
        ['r', 'u']

        >>> # Paths as tuples input
        >>> get_relative_paths(['/m/n/o', '/m/n/p'], root='/m/n')
        ['o', 'p']

        >>> # Single path as string input
        >>> get_relative_paths('/u/v/w', root='/u/v')
        ['w']

        >>> # More complex nested paths
        >>> get_relative_paths('/alpha/beta/gamma/delta/epsilon', '/alpha/beta/foo/bar', root=['/alpha/beta/gamma', '/alpha/beta'])
        ['delta/epsilon', 'foo/bar']

    """

    paths = _detuple_paths(paths)

    roots = root
    if isinstance(roots, str) or roots is None:
        roots = [roots]
    else:
        roots = list(roots)

    paths, roots = broadcast_lists(paths, roots)

    return [get_relative_path(path, root) for root, path in zip(roots, paths)]

def get_absolute_path(path,*,physical=True):
    """
    Given a relative path, return its absolute path
    If physical, expand all symlinks in the path - can be slow
    
    Enhanced Documentation:
    Converts relative paths to absolute paths with optional symlink resolution.
    Essential utility for file operations that need canonical paths.
    
    Args:
        path (str): Path to convert - can be relative, absolute, or contain ~
        physical (bool): If True, resolves all symlinks to actual filesystem paths
        
    Returns:
        str: Absolute path string, with symlinks resolved if physical=True
        
    Examples:
        >>> from rp.r import get_absolute_path
        >>> # Convert relative path
        >>> abs_path = get_absolute_path("../test")
        >>> abs_path.startswith('/') 
        True
        >>> # Home directory expansion
        >>> home_path = get_absolute_path("~/Documents")
        >>> "/Users/" in home_path or "/home/" in home_path
        True
        >>> # Preserve symlinks
        >>> logical_path = get_absolute_path("/tmp", physical=False)
        >>> physical_path = get_absolute_path("/tmp", physical=True)
        
    Processing Steps:
        1. Expand ~ to user home directory (os.path.expanduser)
        2. Resolve symlinks if physical=True (os.path.realpath)
        3. Convert to absolute path (os.path.abspath)
        
    Related Functions:
        - get_absolute_paths(): Batch version for multiple paths
        - get_relative_path(): Convert absolute to relative paths
        - path_join(): Safe path joining
        
    Tags: path-utilities, filesystem, absolute-path, symlinks
    """
    path=os.path.expanduser(path)#In case the path has a '~' in it
    if physical:
        path=os.path.realpath(path)#Get rid of any symlinks in the path
    return os.path.abspath(path)

def _detuple_paths(paths):
    paths=detuple(paths)
    if isinstance(paths,str):
        paths=shlex.split(paths)
    return list(paths)

def get_absolute_paths(*paths,physical=True):
    "Plural of get_absolute_path"
    return [get_absolute_path(path, physical=physical) for path in _detuple_paths(paths)]

def has_file_extension(file_path):
    """
    Enhanced Documentation:
    Checks whether a file path has any file extension.
    
    Args:
        file_path (str): Path to file (can be absolute or relative, file need not exist)
        
    Returns:
        bool: True if file has an extension, False otherwise
        
    Examples:
        >>> has_file_extension('image.png')
        True
        >>> has_file_extension('document.pdf')
        True
        >>> has_file_extension('file_without_extension')
        False
        >>> has_file_extension('')
        False
        
    Related Functions:
        - get_file_extension(): Get the actual extension string
        - with_file_extension(): Add/replace file extension
        - strip_file_extension(): Remove extension from path
        
    Tags: file, path, extension, validation, boolean
    """
    return get_file_extension(file_path)!=''

def date_modified(path):
    """ Get the date a path was modified """
    timestamp=os.path.getmtime(path)#Measured in seconds
    import datetime
    return datetime.datetime.fromtimestamp(timestamp)

def date_created(path):
    """ Get the date a path was created """
    timestamp=os.path.getctime(path)#Measured in seconds
    import datetime
    return datetime.datetime.fromtimestamp(timestamp)

def date_accessed(path):
    """ Get the date a path was accessed """
    timestamp=os.path.getatime(path)#Measured in seconds
    import datetime
    return datetime.datetime.fromtimestamp(timestamp)
    
def get_all_paths(*directory_path                    ,
                   sort_by                  = 'name' ,
                   file_extension_filter    = None   ,
                   recursive                = False  ,
                   include_files            = True   ,
                   include_folders          = True   ,
                   just_file_names          = False  ,
                   include_file_extensions  = True   ,
                   relative                 = False  ,
                   physical                 = None   ,
                   ignore_permission_errors = False  ,
                   include_hidden           = True   ,
                   include_symlinks         = True   ,
                   explore_symlinks         = True   ,
                   folder_placement         = 'first',
                   hidden_placement         = 'last' ):
    """
    Get all file and/or directory paths in a directory with extensive filtering and sorting options.
    
    Enhanced Documentation:
    - Usage patterns: File discovery, batch operations, directory analysis, building file lists
    - Core workhorse function for file system operations throughout RP
    - Supports powerful filtering by extension, hidden files, symlinks, file vs directory types
    - Smart sorting including 'number' sort for frame sequences (frame1, frame2, ..., frame10)
    - Used extensively in image processing, video operations, and file management
    - Foundation for get_all_files(), get_all_image_files(), and other specialized variants
    
    Parameters:
        directory_path: Variable arguments for directory path components to join
        sort_by (str): Sort method - 'name', 'size', 'date', 'date modified', 'date created', 'date accessed', 'number', or None
        file_extension_filter (str): Space-separated extensions to include (e.g., 'py txt md')
        recursive (bool): Search subdirectories recursively
        include_files (bool): Include files in results
        include_folders (bool): Include directories in results  
        just_file_names (bool): Return only filenames without paths
        include_file_extensions (bool): Include file extensions in output
        relative (bool): Return relative paths instead of absolute
        physical (bool): Resolve symlinks to real paths
        ignore_permission_errors (bool): Skip directories without read permission
        include_hidden (bool): Include hidden files (starting with '.')
        include_symlinks (bool): Include symbolic links in results
        explore_symlinks (bool): Follow symbolic links when recursing
        folder_placement (str): Place folders 'first' or 'last' in results
        hidden_placement (str): Place hidden files 'first' or 'last' in results
    
    Returns:
        list: List of file/directory paths matching the specified criteria
        
    Examples:
        >>> # Basic usage - get all items in current directory
        >>> paths = get_all_paths()
        >>> len(paths)
        149
        
        >>> # Get only Python files, sorted by name
        >>> py_files = get_all_paths(file_extension_filter='py', sort_by='name')
        >>> py_files[:2]
        ['/path/to/script1.py', '/path/to/script2.py']
        
        >>> # Recursive search for image files
        >>> images = get_all_paths('photos', recursive=True, file_extension_filter='jpg png gif')
        >>> len(images)
        245
        
        >>> # Smart number sorting for frame sequences  
        >>> frames = get_all_paths('frames', sort_by='number', file_extension_filter='png')
        >>> frames[:3]
        ['frames/frame1.png', 'frames/frame2.png', 'frames/frame10.png']
        
        >>> # Get only directories
        >>> dirs = get_all_paths(include_files=False, include_folders=True)
        >>> len(dirs)
        25
    
    Tags: files, directories, listing, filtering, sorting
    """
    #Returns global paths.
    #If relative is False, we return global paths. Otherwise, we return relative paths to the current working directory 
    #If relative is a string, we return paths relative to that string (otherwise if it's just True, we default to directory_path)
    #If physical is None, physical defaults to not relative
    #Here's a summary of how relative and physical play together:
    #    Suppose:
    #        CURRENT LOCATION:
    #            /A/B
    #        DESTINATION PATH:
    #            C
    #        FOLDER STRUCTURE:
    #            /A
    #            /A/B
    #            /A/B/C --> /A/D
    #            /A/D
    #        RESULT:     physical and     relative: ../D
    #        RESULT: not physical and     relative: C
    #        RESULT:     physical and not relative: /A/D
    #        RESULT: not physical and not relative: /A/B/C        
    #If physical is not True, symlinks won't be expanded.
    #TODO: Make sure this function isn't redundant before committing to keeping it forever!
    #TODO: In particular, make sure this isn't redundant with respect to get_all_file_names, or else merge them together.
    #TODO: Add a recursive option, filters, etc.
    #NOTE: Sort by number is SUPER useful when you have files like [frame0,frame1,frame2...frame10,frame11,frame12...] because if you sort them alphabetically you get [frame1,frame10,frame11,...frame2,frame20,frame21...] BUT ...
    # ... when you sort_by='number', it will order them correctly even without digit padding because names with shorter lengths will come first. This means ['frame1','frame2',...frame10,frame11,...]
    #directory_path can be composed of multiple paths (specified in varargs); this function will join them for you.
    #If include_global_path is true, we return the whole global file path of all files in the directory (as opposed to just returning their names)
    #If file_extension_filter is not None and file_types is a space-separated string, only accept those file extensions
    #If not include_symlinks, it will notinclude any symlinks in the output. If it is recursive, it will skip any symlink directories.
    #sort_by can be None, or it can be a string. If it's None, the files won't be sorted.
    #hidden_placement can be None, or it can be a string
    #folder_placement can be None, or it can be a string
    #EXAMPLES:
    #
    #    >>> get_all_paths('Tests/First','Inputs',sort_by='name')
    #    ans = ['Tests/First/Inputs/01.png',
    #           'Tests/First/Inputs/02.jpg',
    #           'Tests/First/Inputs/03.gif',
    #           'Tests/First/Inputs/04.bmp']
    #
    #    >>> get_all_paths('Tests/First','Inputs')                 #Without sort_by specified, the output could potentially be shuffled
    #    ans = ['Tests/First/Inputs/02.jpg',
    #           'Tests/First/Inputs/04.bmp',
    #           'Tests/First/Inputs/03.gif',
    #           'Tests/First/Inputs/01.png']
    #
    #    >>> get_all_paths('Tests/First','Inputs',sort_by='name',just_file_names=True)
    #    ans =  ['01.png', '02.jpg', '03.gif', '04.bmp']
    #
    #    >>> get_all_paths('Tests/First','Inputs',sort_by='name',just_file_names=True,include_file_extension=False)
    #    ans =  ['01', '02', '03', '04']
    #
    #    >>> get_all_paths('Tests/First','Inputs',sort_by='name',just_file_names=True,include_file_extension=False,file_extension_filter='bmp png')  #Filtering the extension type to just .bmp and .png images
    #    ans =  ['01', '04']
    #

    if physical is None:
        physical=not relative

    if sort_by is not None:
        sort_by=sort_by.lower()#Don't be case-sensitive. That's annoying. Reassign it here so we dont need to make it nonlocal.

    if directory_path==():#If the user didn't specify a path...
        directory_path=get_current_directory()#...default to the current directory
    else:
        directory_path=os.path.join(*directory_path)#Turn ('Ryan','Documents','Images') into 'Ryan/Documents/Images'

    visited_directories = set()

    def recursion_helper(directory_path):
        assert isinstance(directory_path,str),'This is an internal assertion that should never fail. If this assertion does fail, get_all_paths has a bug.'
        assert directory_exists(directory_path),'get_file_paths error: '+repr(directory_path)+' is not a directory'

        visited_directories.add(get_absolute_path(directory_path))

        try:
            all_paths=[os.path.join(directory_path,name) for name in os.listdir(directory_path)]
        except PermissionError:
            if ignore_permission_errors:
                return []
            else:
                raise

        subdirectory_paths=list(filter(directory_exists,all_paths))
        file_paths        =list(filter(file_exists     ,all_paths))
        #OLD VERSION: file_paths=[os.path.join(directory_path,file_name) for file_name in next(os.walk(directory_path))[2]]#next(os.walk(...)) returns something like (u2039directory_pathu203a, [], ['0.png','1.png',...])

        output=[]
        if include_files  :output+=file_paths
        if include_folders:output+=subdirectory_paths

        if not include_symlinks:
            output             = [x for x in output             if not is_symbolic_link(x)]
            subdirectory_paths = [x for x in subdirectory_paths if not is_symbolic_link(x)]

        if recursive:
            for subdirectory_path in subdirectory_paths:
                if get_absolute_path(subdirectory_path) in visited_directories:
                    #Don't let symlink loops make this function recurse forever
                    continue
                if is_symlink(subdirectory_path) and not explore_symlinks:
                    #If not explore_symlinks, don't recurse into them
                    continue
                output+=recursion_helper(subdirectory_path)

        if sort_by is not None:
            #If sort_by is None, don't bother trying to sort the file paths (they could appear in some random order. Setting sort_by to None implies this doesn't matter. Technically it's a bit faster, too (but likely not by much))
            assert type(sort_by)==str,'sort_by should either be None or be a string, but instead repr(type(sort_by))=='+repr(type(sort_by))
            sort_by_options={
                #sort_by_options's are Functions that take a file path and return values that we can sort file paths by
                'name':identity,
                'size':os.path.getsize,
                'date':date_modified,#By default, date refers to the date last modified. This might change. 'date' is an option here as syntactic sugar!
                'date modified':date_modified,
                'date created' :date_created ,
                'date accessed':date_accessed,
                'number':lambda x:(len(x),x)
            }
            assert sort_by in sort_by_options,'get_file_paths: sort_by specifies how to sort the files. Please set sort_by to one of the following strings: '+', '.join(map(repr,sorted(sort_by_options)))+'. (You chose repr(sort_by)=='+repr(sort_by)+' with repr(type(sort_by))=='+repr(type(sort_by))
            output.sort(key=sort_by_options[sort_by])
                
        if file_extension_filter is not None:
            #'x.png' --> 'x', 'text.txt' --> 'txt', etc. (See strip_file_extension for more details)
            assert type(file_extension_filter)==str,'get_file_paths: For file_extension_filter, right now only space-split whitelists are supported, such as "png jpg bmp gif"'
            file_extension_whitelist=file_extension_filter.split()
            output=[path for path in output if get_file_extension(path) in file_extension_whitelist]

        if just_file_names:
            #Extract the file names from each file path (these could have been sorted, which is why we aren't re-using the file names we got when we originally calculated file_paths)
            #Example: if not include_file_extensions, then 'Documents/Textures/texture.png'  --->  'texture.png' (see get_file_name for more details)
            output=list(map(get_file_name,output))

        if not include_file_extensions:
            #'x.png' --> 'x', 'text.txt' --> 'txt', etc. (See strip_file_extension for more details)
            output=list(map(strip_file_extension,output))

        return output

    output=recursion_helper(directory_path)

    assert not folder_placement or folder_placement in ['first','last']
    assert not hidden_placement or hidden_placement in ['first','last']
    hidden_placement = hidden_placement.lower().strip()
    folder_placement = folder_placement.lower().strip()

    def is_hidden(path):
        return get_file_name(path).startswith('.')

    if not include_hidden:
        output=[path for path in output if not is_hidden(path)]

    if hidden_placement:
        hidden_paths    =[path for path in output if     is_hidden(path)]
        non_hidden_paths=[path for path in output if not is_hidden(path)]
        if hidden_placement=='last' : output[:]=non_hidden_paths+hidden_paths
        if hidden_placement=='first': output[:]=hidden_paths+non_hidden_paths

    if folder_placement:
        folder_paths    =[path for path in output if     is_a_folder(path)]
        non_folder_paths=[path for path in output if not is_a_folder(path)]
        if folder_placement=='last' : output[:]=non_folder_paths+folder_paths
        if folder_placement=='first': output[:]=folder_paths+non_folder_paths

    if physical :
        #If physical, expand all symlinks in the path
        output=[os.path.realpath(path) for path in output]

    if relative:
        #Return relative paths instead of absolute paths
        relative_to=relative if isinstance(relative,str) else directory_path
        output=[get_relative_path(path,relative_to) for path in output]
    else:
        output=[get_absolute_path(path,physical=False) for path in output]

    return output

def get_all_files(*args,**kwargs):
    """
    Get all files from specified directories (folders excluded).
    See `get_all_paths` for parameter details.
    """
    return get_all_paths(*args,**{'include_folders':False,'include_files':True,**kwargs})

def get_all_image_files(*args,**kwargs):
    """ Like get_all_files, but only returns image files. This function is just sugar.  """
    #TODO: Once get_all_paths supports lazyness, so should this.
    file_paths = get_all_paths(
        *args,
        **{
            "include_folders": False,
            "include_files": True,
            "sort_by" : "number",
            **kwargs,
        }
    )
    return list(filter(is_image_file,file_paths))

def get_all_audio_files(*args,**kwargs):
    """ Like get_all_files, but only returns audio files. This function is just sugar.  """
    file_paths = get_all_paths(
        *args,
        **{
            "include_folders": False,
            "include_files": True,
            "sort_by" : "number",
            **kwargs,
        }
    )
    return list(filter(is_sound_file,file_paths))

def get_all_runnable_python_files(
    folder=".",
    *,
    recursive=True,
    explore_symlinks=False,
    ignore_permission_errors=True,
    include_hidden=False,
    lazy=False
):
    """
    Retrieve all runnable Python files from a specified folder.

    A runnable Python file is defined as a file with a '.py' or '.rpy' extension
    that contains an 'if __name__ == "__main__":' block.

    EXAMPLE:
        Was helpful when I wanted to find the "torchrun" command's main file:
            First ran get_all_runnable_python_files() in torch's directory,
            followed by FDTA in pseudo_terminal to search for 'torchrun'

    Args:
        folder (str, optional): The folder to search for runnable Python files.
            Defaults to the current directory (".").
        recursive (bool, optional): Whether to search subdirectories recursively.
            Defaults to True.
        explore_symlinks (bool, optional): Whether to follow symbolic links during
            the search. Defaults to False.
        ignore_permission_errors (bool, optional): Whether to ignore permission
            errors when accessing files or directories. Defaults to True.
        include_hidden (bool, optional): Whether to include hidden files and
            directories in the search. Defaults to False.
        lazy (bool, optional): Whether to return a lazy generator instead of a
            list of files. Defaults to False.

    Returns:
        list or generator: A list or lazy generator of runnable Python files,
            depending on the `lazy` argument.
    """
    import re

    file_extensions = [".py", ".rpy"]

    def _has_if_name_main(code):
        pattern = r"\nif\s+\_\_name\_\_\s*==\s*['\"]{1,3}\_\_main\_\_['\"]{1,3}\s*:"
        matches = re.finditer(pattern, code, re.IGNORECASE | re.MULTILINE)
        output = [match.group(0) for match in matches]
        output = bool(output)
        return output

    files = (
        x
        for x in _get_all_paths_fast(
            include_folders=False,
            include_files=True,
            lazy=True,
            recursive=recursive,
            ignore_permission_errors=ignore_permission_errors,
            explore_symlinks=explore_symlinks,
            include_hidden=include_hidden,
        )
        if ends_with_any(x, file_extensions)
    )

    files = (x for x in files if _has_if_name_main(load_text_file(x)))

    if not lazy:
        files = list(files)

    return files


def get_all_folders(*args,**kwargs):
    """
    Get all folders from specified directories (files excluded).
    See `get_all_paths` for parameter details.
    """
    return get_all_paths(*args,**{'include_folders':True,'include_files':False,**kwargs})
get_all_directories=get_all_folders


def get_subfolders(folder,*,relative=False,sort_by=None):
    """ Take a folder, and return a list of all of its subfolders """
    assert folder_exists(folder),'Folder '+repr(folder)+' doesnt exist!'
    return get_all_paths(folder,include_files=False,include_folders=True,recursive=False,relative=relative,sort_by=sort_by)
get_subdirectories=get_subfolders

def _os_listdir_files(folder):
    """
    like get_all_files, but returns only file names and is a little faster
    https://stackoverflow.com/questions/3207219/how-do-i-list-all-files-of-a-directory
    """

    return [entry.name for entry in os.scandir(folder) if entry.is_file()]

    #OLD CODE - WORKS FINE BUT SHOULD BE SLOWER THAN SCANDIR
    from os import listdir
    from os.path import isfile, join
    onlyfiles = [f for f in listdir(folder) if isfile(join(folder, f))]
    return onlyfiles

def folder_is_empty(folder: str = ".") -> bool:
    """
    Determines whether a folder is empty or not.

    This function uses os.scandir() to iterate over the contents of the given folder,
    which is more efficient than os.listdir() as it avoids generating a complete list
    of the folder contents. The function will return False as soon as it finds a single
    entry in the folder, minimizing the amount of data needed to be transferred.

    Errors raised:
        - TypeError: If the folder argument is not a string.
        - FileNotFoundError: If the folder does not exist.
        - NotADirectoryError: If the given path exists but is not a directory.

    Args:
        folder (str): The path to the folder whose emptiness will be checked.

    Returns:
        bool: True if the folder is empty, False otherwise.

    Examples:
        >>> folder_is_empty('/path/to/empty/folder')
        True

        >>> folder_is_empty('/path/to/non-empty/folder')
        False

    Coded partially with GPT4: https://shareg.pt/Vk8tu6k
    """
    if not isinstance(folder, str):
        raise TypeError("The 'folder' argument must be a string, but got type %s" % str(type(folder)))

    try:
        entries = os.scandir(folder)
        for _ in entries:
            entries.close()
            return False
        entries.close()
        return True
    except FileNotFoundError:
        raise FileNotFoundError("The folder '%s' does not exist."%folder)
    except NotADirectoryError:
        raise NotADirectoryError("The path '%s' exists but is not a directory."%folder)

directory_is_empty=folder_is_empty

def get_random_file(folder=None):
    """
    Returns the paths of random files in that folder
    If the folder is None, returns the name of a random file in the current directory
    Returns a list of strings (file paths) whose length=quantity
    OLD NAME: random_file
    """
    return get_random_files(quantity=1,folder=folder)[0]

    

def get_random_files(quantity:int,folder=None):
    """
    Returns the paths of random files in that folder
    If the folder is None, returns the name of a random file in the current directory
    Returns a list of strings (file paths) whose length=quantity
    OLD NAME: random_files
    """
    assert isinstance(folder,str) or folder is None
    assert isinstance(quantity,int)
    assert quantity>=0
    
    if quantity==0:
        return []
    
    if folder is None:
        folder='.'
        return get_file_names(get_random_files(quantity,'.')) #Return something like '__main__.py'
    else:
        assert folder_exists(folder), 'rp.random_file: Folder does not exist: '+repr(folder)
        files=_os_listdir_files(folder)
        assert not len(files)==0, 'rp.random_file: There are no files in '+repr(folder)
        output=random_batch_with_replacement(files,quantity)
        return path_join(folder,output) #Return something like './__main__.py'

def get_random_folders(quantity:int, root_dir='.', *, include_symlinks=True, include_hidden=True):
    folders = gather_args_call(_get_all_paths_fast, root_dir, include_folders=True, include_files=False, include_symlink_folders = include_symlinks)
    output=random_batch_with_replacement(folders,quantity)
    return output
    
# get_random_subfolders=get_random_folders

def get_random_folder(root_dir='.', *, include_symlinks=True, include_hidden=True):
    """
    Get a single random folder from the specified directory.

    Enhanced Documentation:
    Utility function for selecting random directories for testing, sampling, or batch processing.
    Common use cases include random dataset sampling, test directory selection, and directory shuffling.
    Uses gather_args for efficient argument handling and calls get_random_folders internally.

    Parameters:
        root_dir (str, optional): Directory to search in. Defaults to current directory ('.')
        include_symlinks (bool, optional): Include symbolic link folders. Defaults to True
        include_hidden (bool, optional): Include hidden folders (starting with '.'). Defaults to True

    Returns:
        str: Path to a single randomly selected folder

    Examples:
        >>> import os
        >>> # Create test directories first
        >>> os.makedirs('test_dir1', exist_ok=True)
        >>> os.makedirs('test_dir2', exist_ok=True)
        >>> folder = get_random_folder('.')
        >>> print(type(folder))
        <class 'str'>
        >>> os.path.isdir(folder)
        True

    Related Functions:
        - get_random_folders(): Get multiple random folders
        - get_random_directory: Alias for this function

    Tags: random, folders, directories, sampling
    """
    return gather_args_call(get_random_folders,1)[0]

# get_random_subfolder=get_random_folder
get_random_directory=get_random_folder

#endregion


def _has_globbing_characters(pattern):
    """
    Check if a pattern string contains any of the special globbing characters used by Python's glob module.

    Special globbing characters in Python's glob module are exhaustively:
    * : Matches any number of any characters including none
    ? : Matches any single character
    [ : Marks the start of a character class
    ] : Marks the end of a character class

    Note: As of the last update in September 2021, these special characters are not listed programmatically
    within the Python glob module itself but are described in its documentation.

    Args:
    pattern (str): The pattern string to check.

    Returns:
    bool: True if the pattern contains special globbing characters, False otherwise.
    """
    import re
    return bool(re.search(r'[\*\?\[\]]', pattern))


def rp_iglob(
    *files, 
    # follow_symlinks=False, 
    check_exists=False
    ):
    '''
    Generator that recursively yields file paths based on glob-like patterns, multi-line 
    strings, or iterables like lists and file objects. Suitable for streaming large sets 
    of file paths without loading them all into memory at once.
    
    Parameters:
        - *files: Accepts one or more glob-like patterns, multi-line strings, or iterables 
                  like lists or file objects.
        # - follow_symlinks (bool): Whether to follow symbolic links when encountered. This is 
        #                           particularly relevant when using glob patterns that are 
        #                           inherently recursive like '**/*'.
        - check_exists (bool): If True, filters out paths that don't exist. It's useful to 
                               disable this for many reasons, including that doing that can be slow.
                               Default glob.glob behaviour eliminates nonexistent files though, so:
                                   assert glob.glob('file_that_doesnt_exist.txt')==[]
                               Inconsistent with default glob.glob, we default check_exists=False
                  
    Yields:
        - File paths as strings that match the given patterns.
    
    Examples:
    
        1. Using simple globs
            for filepath in rp_iglob("*.txt"):
                print(filepath)
        
        2. Using multi-line strings
            multi_line_str = """
            *.txt
            *.md
            """
            for filepath in rp_iglob(multi_line_str):
                print(filepath)
        
        3. Using a list of patterns
            patterns = ["*.txt", "*.md"]
            for filepath in rp_iglob(patterns):
                print(filepath)
        
        4. Using a file object
            with open("file_patterns.txt", "r") as f:
                for filepath in rp_iglob(f):
                    print(filepath)
        
        5. Mixing multiple types
            patterns = ["*.txt", "*.md"]
            multi_line_str = """
            *.py
            *.json
            """
            with open("file_patterns.txt", "r") as f:
                for filepath in rp_iglob("*.csv", patterns, multi_line_str, f):
                    print(filepath)
        
        6. Using recursion to search sub-directories
            for filepath in rp_iglob("**/*.txt"):
                print(filepath)
        
        # 7. Gets all files/folders recursively but doesnt go into symlinks
        #     for filepath in rp_iglob("**", follow_symlinks=False):
        #         print(filepath)
    
    Edge Cases:
    
        1. Unsupported type: Raises TypeError if an unsupported type is encountered.
        2. Empty lines in multi-line string: Ignored.
        
    '''
    import glob
    import os
    for file_pattern in files:
        # If the input is a multi-line string
        if isinstance(file_pattern, str) and '\n' in file_pattern:
            for line in file_pattern.strip().split('\n'):
                if line:  # Skip empty lines
                    yield from rp_iglob(line)#, follow_symlinks=follow_symlinks)
        # If the input is an iterable but not a str (e.g., a list or a file object)
        elif not isinstance(file_pattern, str) and hasattr(file_pattern, '__iter__'):
            for item in file_pattern:
                if item:  # Skip empty lines/items
                    yield from rp_iglob(item)#, follow_symlinks=follow_symlinks)
        # If the input is a single-line string
        elif isinstance(file_pattern, str):
            if not _has_globbing_characters(file_pattern) and not check_exists:
                yield file_pattern
            else:
                for filepath in glob.iglob(file_pattern, recursive=True):  # Enable recursion
                    # if follow_symlinks or not os.path.islink(filepath):
                        yield filepath
        else:
            raise TypeError("Unsupported type: "+str(type(file_pattern))+")")

def rp_glob(*files, check_exists=False):
    """
    Non-lazy version of `rp_iglob` that returns all results as a list.
    """
    return list(rp_iglob(*files, check_exists=check_exists))



def fractional_integral_in_frequency_domain(coefficients,n=1):
    """
    WARNING: Make sure to use the right kind of fft (np.fft.rfft vs np.fft.fft)
    This function integrates or differentiates signals using just their fourier coefficients, and returns a new set coefficients
    n is the number of times we integrate this function. n can be negative, which would imply a derivative. The 0'th coefficient (the average value of the respective time domain) is preserved with this function; even when taking the derivative. This is because of a division by zero error that would occur otherwise and must therefore be handled somehow.
    Some properties:
      Let f=fractional_integral_in_frequency_domain
      For all c: f(c,0) = c
      For all n, m and c: f(f(c,n),m) = f(c,n+m)
    """
    coefficients=np.asarray(coefficients,np.complex128)
    assert is_complex_vector(coefficients),'coefficients should be a complex vector'
    coefficients[1:]*=(1j/np.arange(len(coefficients))[1:])**n
    return coefficients


class FlannDict:
    """
    TODO: Finish bundling PyFlann's binaries into rp.
    FLANN is an algorithm that calculates the (approximate) nearest neigbours of a point very, very quickly. Originally called nearest_neighbor_dict, but this is ambiguous in the case that we wish to use other algorithms.
    in addition to real keys, FlannDict supports complex keys of any numpy shape. But, just try to be consistent else it will throw errors (by design).
    This is abstraction above FLANN that lets you use nearest neighbor search with the interface of a dictionary; automatically rebuilding the index as needed (for a huge performance boost).
    This interface can be replaced by a brute force search...but why do that?
    FlannDict caches any queries you make, so if you query the same point twice it will just reuse those calculations. This cache is automatically reset upon rebuilding the FLANN tree.
    Uses nearest-neighbor to match keys. Currently uses FLANN.
    Use splicing to get k nearest neighbours like this: d[point:k] (will return a list with k nearest neighbours.) K must be >=0.
    nn stands for nearest neighbor, and knn stands for 'k nearest neighbours'
    Example:
     >>> n=FlannDict()
     >>> n[[0,0,0,0]]='Hello!'
     >>> n[[1,0,0,0]]='First!'
     >>> n[[0,1,0,0]]='Second!'
     >>> n[[0,0,1,0]]='Third!'
     >>> n[[0,0,0,1]]='Fourth!'
    
     >>> n[[0,0,799,75]]
     ans = Third!
    
     >>> n[[0,0,0]]
     ERROR: AssertionError: Wrong key shape! key.shape==(3,) but self._key_shape==(4,)
    
     >>> n[[0,0,7,75]]
     ans = Fourth!
    
    ANOTHER EXAMPLE:
      d=FlannDict()
      d[1,2,3]=3
      d[1,2,3.1]=1
      d[1,2,3.2]=2
      d[1,2,3.3]=3
      d[1,2,3.4]=4
      ans=d[1,2,3.2:3] #Returns ans=[2, 3, 1]
    """

    def __init__(self,*,branching=32,iterations=7,checks=160,complex_keys=False,include_dists=False):
        # pip_import('pyflann')#I would use this library, but it's broken for python3 and has to be fixed with 2to3 before using it (even the one on pypi)
        from rp.libs.pyflann import FLANN#See https://github.com/primetang/pyflann
        self._flann=FLANN()
        self.branching=branching
        self.iterations=iterations
        self.checks=checks
        self.include_dists=include_dists
        self._keys=[]
        self._original_keys=[]
        self._values=[]
        self._need_to_rebuild_index=True
        self._key_shape=None#This is set the first time you set an item
        self._complex_keys=complex_keys#This is set the first time you set an item
        # self._use_cache#It's kinda glitchy because handyhash has a problem hashing numpy arrays because checking for equality with == fails
        self._cache=HandyDict()
        self._old_settings_hash=self._settings_hash()

    def _settings_hash(self):
        #Clear _cache when checks, iterations, branching, or include_dists, etc changes
        return hash((self.branching,self.iterations,self.checks,self.include_dists))#Used to determine whether we have to clear _cache

    def __getitem__(self,key):
        if self._old_settings_hash!=self._settings_hash():
            self._old_settings_hash=self._settings_hash()
            self._cache=HandyDict()#Clear the cache if settings change
        original_key=key
        k=1#k as in "k nearest neighbours. This can be set in slicing.
        if isinstance(key,tuple) and isinstance(key[-1],slice):
            #This is what lets the following code work: d[1,2,3:4] (as opposed to d[(1,2,3):4], which allready works)
            #This part of the code converts d[1,2,3:4] to d[(1,2,3):4]
            #Because (1, 2, slice(3, 4))   ->   slice((1,2,3),4)
            key=slice((*key[:-1],key[-1].start),key[-1].stop)
        return_multiple=False#Whether to return a list of results
        if isinstance(key,slice):
            return_multiple=True
            key,k=key.start,key.stop
            assert isinstance(k,int),'To get k nearest neighbours, use some F=FlannDict() like this: F[point:k]. But you gave k as a non-integer: '+str(k)
            assert k>=0,'Negative values of k are not supported. k='+str(k)
            k=min(k,len(self))
        assert self._keys,'FlannDict is empty!'
        key=self._keyify(key)
        if original_key in self._cache:
            return self._cache[original_key]
        if self._need_to_rebuild_index:#Will be set to true upon adding data
            self._flann.build_index(np.asarray(self._keys).astype(float))#Only do this upon getting; not setting.
            self._cache=HandyDict()#Clear the cache
            self._need_to_rebuild_index=False
        results,dists=self._flann.nn_index(qpts=np.asarray([key]),num_neighbors=k,algorithm="kmeans",branching=self.branching,iterations=self.iterations,checks=self.checks)
        #We're only querying one point, so results and dists should both have length 1...
        dists=dists.squeeze()
        if return_multiple:
            out= [self._values[result] for result in results[0]]
            if self.include_dists:
                out=out,dists**.5#pyflann returns the squared distances, so we must take the square root to find the actual distances
        else:
            out= self._values[results[0]]
            if self.include_dists:
                out= out,dists[0]**.5
        self._cache[original_key]=out
        return out
    def __setitem__(self,key,value):
        self._original_keys.append(key)
        key=self._keyify(key)
        self._keys.append(key)
        self._values.append(value)
        self._need_to_rebuild_index=True
    def __len__(self):
        assert len(self._keys)==len(self._values)
        return len(self._keys)
    def __iter__(self):
        return iter(original_key for original_key in self._original_keys)
    def _keyify(self,key):
        key=np.asarray(key)
        if  self._key_shape is None:
            self._key_shape=key.shape
            self._complex_keys=self._complex_keys or np.iscomplexobj(key)
        else:assert key.shape==self._key_shape,'FlannDict: error: you can\'t use inconsistently-shaped keys -- how are we supposed to compare them? key.shape=='+repr(key.shape)+' but self._key_shape=='+repr(self._key_shape)
        if self._complex_keys:
            key=np.concatenate(([key.real],[key.imag]))
        else:
            assert not np.iscomplexobj(key),'FlannDict: error: you can\' use complex keys with this FlannDict. Please create another with \'complex_keys\' set to True in the constructor.'
        key=key.flatten().astype(float)#Let us use non-vector keys
        return key.tolist()

def best_flann_dict_matches(queries,flann_dict,n:int=None,query_to_vector=lambda x:x):
    """
    Match multiple vectors to points in a FlannDict and return the results in sorted order of distance as tuples [(query,flann_result,distance)...]
    Return the top n matches for each query, all sorted by flann's distance metric
    HINT: If this function is too slow, try set
    EXAMPLE:
        f=FlannDict()
        for c in [4+6j,2+9j,1+1j,0+0j]:
            f[c]=c
        class test:
            def __init__(self,x):self.x=x
            def __repr__(self):return 'test('+str(self.x)+')'
        print(closest_flann_matches([test(3+1j),test(3+6j),test(7+301j)],flann_dict=f,n=3,query_to_vector=lambda _:_.x))
    PRINTS:
     [((4+6j), test((3+6j  )), 1.0000),
      ((1+1j), test((3+1j  )), 2.0000),
      ((0+0j), test((3+1j  )), 3.1622),
      ((2+9j), test((3+6j  )), 3.1622),
      ((4+6j), test((3+1j  )), 5.0990),
      ((1+1j), test((3+6j  )), 5.3851),
      ((2+9j), test((7+301j)), 292.04),
      ((4+6j), test((7+301j)), 295.01),
      ((1+1j), test((7+301j)), 300.05)]
    """

    assert isinstance(flann_dict,FlannDict)
    assert callable(query_to_vector)
    assert n is None or n>=0
    if n is None:n=len(flann_dict)#By default, use the largest (and slowest) possible value of n (that returns the most accurate results)

    old_include_dists=flann_dict.include_dists
    flann_dict.include_dists=True

    matches=[]
    for query in queries:
        results,dists=flann_dict[query_to_vector(query):n]
        matches+=[(result[1],random_float(),(result[0],query,result[1])) for result in zip(results,dists)]#The random_float is to prevent it from trying to sort something that might not be sortable if we have two identical distances (which is a very real possibility)
    matches=sorted(matches)#TODO: If this is a bottle neck, we can use heapq.merge to return a lazy result that merges all the results together in a generator (in-case we just want the top-N results among all of these)
    matches=[match[2]for match in matches]#Just keep the results

    flann_dict.include_dists=old_include_dists

    return matches

def knn_clusters(vectors,k=5,spatial_dict=FlannDict):
    """
    Given a list of vectors, return a list of sets of vectors belonging to each cluster resulting from the k-nearest neighbor clustering algorithm
    Requires MUTUAL neighbors to make an edge (aka given two vertices a and b, a must be within b's first closest k neighbours AND b must be within a's closest k neighbours to form an edge. This condition is both sufficient and necessary to form an edge.)
    
    Enhanced Documentation:
    
    Parameters:
        vectors: List or array of n-dimensional vectors (points) to cluster
        k (int, default=5): Number of nearest neighbors to consider for mutual connectivity
        spatial_dict (callable, default=FlannDict): Spatial indexing data structure factory
    
    Returns:
        List[Set[Tuple]]: List of sets, where each set contains the vector tuples in a cluster
    
    Requirements:
        - Requires FLANN library for spatial indexing (FlannDict)
        - If FLANN is not available, will raise an error
    
    Algorithm:
        1. For each point, find k nearest neighbors
        2. Create edges only between mutual neighbors (bidirectional k-NN)
        3. Find connected components in the resulting graph
        4. Each connected component becomes a cluster
    
    Usage Patterns:
        - Dense clustering: Use small k (1-3) for tight clusters
        - Loose clustering: Use larger k (5-10) for broader clusters  
        - Single linkage: k=1 creates very restrictive clusters
        - Visualization: Combine with drawing functions to visualize clusters
    
    Related Functions:
        - FlannDict: Spatial data structure used for efficient nearest neighbor search
        - as_points_array(): Convert different point formats to standard array
        - cv_draw_contour(): Draw cluster connections for visualization
        - randints_complex(): Generate random complex points for testing
    
    EXAMPLE WITH VISUALIZATION: (Try changing n and k)
         def test(n=40,k=3):
             r=10#Resolution multiplier
             image=np.zeros((r*100,r*100,3))
             ans=randints_complex(n)
             ans=as_points_array(ans)
             p= ans
             ans=knn_clusters(p,k)
             for s in ans:
                 for c in s:
                     for C in s:
                         image=cv_draw_contour(image,np.asarray([c,C])*r,color=(0,255,255))

             for pp in p:
                 image=cv_draw_circle(image,*(pp*r).astype(int),radius=3)
             display_image(image,False)
         test()
    """

    spatial_dict=spatial_dict()#If you want to override the default FlannDict paramers, pass a lambda through this function's spatial_dict parameter
    #Note: This method is logically clean-ish but is probably much less efficient than it could be. If it matters, there's probably many better ways to implement this function.
    assert k>=1
    vectors=np.asarray(vectors)
    assert len(vectors.shape)==2
    vectors=set(map(tuple,vectors))#Make them all hashable...
    for vector in vectors:
        spatial_dict[vector]=vector
    @memoized
    def neighbors(vector):
        return set(map(tuple,spatial_dict[vector:k]))
    unvisited=set(vectors)
    def helper(vector):
        unvisited.remove(vector)
        yield vector
        for neighbor in neighbors(vector):
            if neighbor in unvisited and vector in neighbors(neighbor):
                yield from helper(neighbor)
    out=[]
    while unvisited:
        out.append(set(helper(next(iter(unvisited)))))
    out=sorted(out,key=len,reverse=True)
    return out

def r_transform(path):
    """
    Stands for Ryan-Transform. Used for path matching in my 2019 Zebra summer internship. Removes translation, rotation and scale freedom from the path.
    NOTE: According to wolfram alpha, d/dx ln(d/dx f(x)) == f''(x)/f'(x) (this is not true in the discrete domain, however.)
    """
    path=as_complex_vector(path)
    path=circ_diff(path)#Translation invariance: Get all the deltas of the curve. Essentially, take the derivative.
    path=circ_quot(path)#Scale and rotation invariance: essentially get the rotation vectors needed to proportionally scale and twist one delta to the next
    path=np.log(path)#Secret sauce (makes it more useful s.t. when direction/speed doesnt change much, then that part of the output will have a small magnitude. Also taking the log usually raised my eyebrows because of the multiple solution issue. However, there are several reasons this is not a problem. First of all, as we subdivide our cirve more (assuming it's continuously differentiable everywhere, which is why we need a gauss blur), the changes in angle will become very small and will never wrap around pi. Secondly, assuming it's a closed path with no self-intersections, it's accurate to say that a 179 degree turn is very different from a -179 degree turn, because an exact 180 degree turn is impossible (it would imply the curve self-intersects), and that one degree difference determines the direction of the next points on the curve (because they can't self-intersect.) Therefore, even if we DON'T subdivide the curve too much, log is STILL a good measurement and we still don't have to worry about the multiple-solutions of the complex logarithm.)
    return path

def r_transform_inverse(path):
    """
    Note that we lose scale, rotation and translational information
    r_transform(r_transform_inverse(r_transform(path))) == r_transform(path)
    """
    path=as_complex_vector(path)
    path=circ_diff_inverse(path)
    path=np.exp(path)
    path=circ_diff_inverse(path)
    return path


def horizontally_concatenated_images(*image_list,origin=None):
    """
    First image in image_list goes on the left
    TODO: Handle non-RGB images (include RGBA, grayscale, etc)
    This is different from np.column_stack because it handles images of different resolutions.
    It also can mix RGB, greyscale, and RGBA images.

    If an int is given as one of the images, it will be treated as spacing


    TODO: Implement the origin argument for both this func and vertically_concatenated_images
    """

    image_list=detuple(image_list)

    #For any ints, turn them into zero-height spacing images
    #Use RGBA to ensure transparency but bool dtype to allow minimal datatype
    image_list = [
        image if not isinstance(image, int) else np.zeros((0, image, 4), bool)
        for image in image_list
    ]

    if origin is None:
        #Like top-left
        #OLDER AND MARGINALLY FASTER SOLUTION - please make the other branch optimized so we can finally let go of this older code
        #The newer version uses crop_image with copy=False, which is a little slower.
        #We need that for origins like center though
        image_converter = _common_image_converter(image_list)

        image_list=[image_converter(image,copy=False) for image in image_list]
        max_height=max(get_image_height(img) for img in image_list)
        def heightify(img):
            img=as_numpy_image(img,copy=False)
            s=list(img.shape)
            if s[0]==max_height:
                return img #Don't copy - its slow.
            s[0]=max_height
            out=np.zeros(tuple(s),dtype=img.dtype)
            out[:img.shape[0]]+=img
            return out
        #Make all images RGB instead of RGBA as a hack...
        return np.column_stack(tuple([heightify(img) for img in image_list]))

    else:

        if not is_numpy_array(image_list):
            image_converter = _common_image_converter(image_list)
            image_list=[image_converter(image,copy=False) for image in image_list]
            image_list=crop_images_to_max_height(image_list, origin=origin, copy=False)
            return np.column_stack(tuple(image_list))
        else:
            return np.column_stack(image_list)
    
def vertically_concatenated_images(*image_list,origin=None):
    """ 
    First image in image_list goes on the top

    EXAMPLE (Demo origin):
        >>> for origin in 'left center right'.split():
        ...     for i in range(100):
        ...         phase=i/10
        ...         freq=2
        ...         splits=20
        ...         url='https://www.heraldweekly.com/wp-content/uploads/cmg_images/186799/rid_4e7f32e1c4f1b2a5d07cb112d3cb8941/2AF9T8H-From-Past-to-Present-Transgender-Icons-That-Have-Shaped-History-Kim-Petras-scaled.jpg.pro-cmg.jpg'
        ...         image=load_image(url,use_cache=True)
        ...         image=resize_image_to_fit(image,height=512)
        ...         images=split_tensor_into_regions(image,splits)
        ...         scales=np.sin(np.linspace(start=0,stop=pi,num=splits)*freq+phase)*.5+1
        ...         images=[cv_resize_image(image,scale) for image,scale in zip(images,scales)]
        ...         images=bordered_images_solid_color(images,color='red')
        ...         collage=vertically_concatenated_images(images,origin=origin)
        ...         collage=cv_resize_image(collage,(512,512))
        ...         collage=labeled_image(collage,'Origin = '+origin)
        ...         display_image(collage)
    """
    image_list=detuple(image_list)

    if origin is not None and not is_numpy_array(image_list):
        #Todo: make it more efficient. But also, it's totally stable and will work perfectly.
        assert isinstance(origin, str)
        return grid_concatenated_images([[x] for x in image_list], origin=origin)

    return np.rot90(
        horizontally_concatenated_images(
            [
                np.rot90(image, -1) if not isinstance(image, int) else image
                for image in reversed(image_list)
            ]
        )
    )

def grid_concatenated_images(image_grid, *, origin=None):  
    """
    Given a list of lists of images, like [[image1, image2],[image3,image4]], join them all together into one big image
    If you want to skip an image in the grid, you can use None to represent a 1x1 transparent pixel
    Often, when given a list of images you want to put into a grid, 
       split_into_sublists(images, number_of_images_per_row) will be a good companion function!
       See the example functions...
    It's a combination of vertically_concatenated_images and horizontally_concatenated_images simultaneously that maintains a gridlike structure
    EXAMPLE:
        >>> doggy=load_image('https://nationaltoday.com/wp-content/uploads/2020/02/doggy-date-night.jpg')
        >>> imagechunks=[]
        >>> imagechunks+=list(split_tensor_into_regions(doggy,4,2))
        >>> imagechunks+=list(split_tensor_into_regions(doggy,2,4))
        >>> imagechunks=[bordered_image_solid_color(image,thickness=5,color=(0,1,0,1)) for image in imagechunks]
        >>> imagechunks=shuffled(imagechunks)
        >>> ans=split_into_sublists(imagechunks,4)
        >>> display_image(grid_concatenated_images(ans))
    EXAMPLE:
        >>> dog=load_image('https://nationaltoday.com/wp-content/uploads/2020/02/doggy-date-night.jpg')
        >>> dog=resize_image(dog,.25)
        >>> regions=split_tensor_into_regions(dog,3,5,flat=False)
        >>> regions=grid2d_map(regions, lambda image: bordered_image_solid_color(image,color=(1,0,0,1),thickness=5))
        >>> for angle in list(range(360)):
        >>>     display_image(grid_concatenated_images(grid2d_map(regions, lambda image: rotate_image(image,angle))))
    EXAMPLE:
        >>> ans='https://pbs.twimg.com/profile_images/945393898649665536/Ea5FkV5q.jpg'
        >>> ans=load_image(ans)
        >>> ans=split_tensor_into_regions(ans,10,10)
        >>> ans=[bordered_image_solid_color(x,color=random_rgba_float_color(),thickness=5) for x in ans]
        >>> ans=split_into_sublists(ans,10)
        >>> ans=grid_concatenated_images(ans)
        >>> display_image(ans)
    EXAMPLE:
        >>> ans='https://pbs.twimg.com/profile_images/945393898649665536/Ea5FkV5q.jpg'
        >>> ans=load_image(ans)
        >>> tiles=split_tensor_into_regions(ans,10,10)
        >>> ans=[horizontally_concatenated_images(tile,resize_image(cv_text_to_image(str(i)),.25)) for i,tile in enumerate(tiles)]
        >>> ans=[bordered_image_solid_color(an,color=(0,.5,1,1)) for an in ans]
        >>> ans=split_into_sublists(ans,10)
        >>> ans=grid_concatenated_images(ans)
        >>> display_image(ans)
    EXAMPLE:
        >>> i='https://hips.hearstapps.com/hmg-prod/images/dog-puppy-on-garden-royalty-free-image-1586966191.jpg?crop=0.752xw:1.00xh;0.175xw,0&resize=1200:*'
        >>> i=cv_resize_image(load_image(i),(256,256))
        >>> display_image(grid_concatenated_images([[None,i,None],[i,None,i],[None,i,None]]))
    """

    image_grid=list(image_grid)
    max_image_widths=[0]*max(map(len,image_grid))

    #A 0x0 transparent pixel
    null_img = uniform_float_color_image(0,0,(0,0,0,0))
    null_converter = _common_image_converter([x for x in list_flatten(image_grid) if is_image(x)])
    null_img = null_converter(null_img)

    for y,image_row in enumerate(image_grid):
        image_grid[y]=image_row=list(image_row)
        for x,image in enumerate(image_row):
            if image is None: image=null_img
            assert is_image(image),'All inputs must be images, but '+repr(image)+' is not an image as defined by rp.is_image()!'
            image_grid[y][x]=image
            max_image_widths[x]=max(max_image_widths[x],get_image_width(image))

    for y,image_row in enumerate(image_grid):
        for x,image in enumerate(image_row):
            image_row[x]=crop_image(image,width=max_image_widths[x], origin=origin)#Cropping can also make the image larger by padding it with zeroes
        image_grid[y]=horizontally_concatenated_images(image_row, origin=origin)

    output_image=vertically_concatenated_images(image_grid)
    return output_image

def tiled_images(
    images,
    length=None,
    border_color=(0.5, 0.5, 0.5, 1),
    border_thickness=1,
    transpose=False,
    origin = None,
):
    """
    EXAMPLE:
       display_image_in_terminal_color(tiled_images([load_image('https://i.pinimg.com/236x/36/69/39/36693999b6e24b1d06d0ee21c9ae320d--caged-nicolas-cage.jpg')]*25))
    Sugar for what I often do with grid_concatenated_images
    length can be None, an int, or a float specifying aspect ratio
    WARNING: Using length as an aspect ratio is NOT to be used in any code that will last; that functionality is subject to change! Please don't be afraid to make it better (right now the aspect ratio implicitly assumes all the given images are the same size and square...which they're not) 

    Setting border_thickness=0 can result in large speed boosts right now
    """

    #Replace None's with null_images
    null_image = uniform_float_color_image(0,0)
    images = [(x if x is not None else null_image) for x in images]

    if transpose:
        #Tile the images from top to bottom instead of left to right. Length is now vertical
        images=[rotate_image(i,90) for i in images]
        output=tiled_images(images,length=length,border_color=border_color,border_thickness=border_thickness,transpose=False)
        output=rotate_image(output,-90)
        return output

    images=list(images)
    from math import ceil

    length_is_aspect_ratio = isinstance(length, float) and 0 < length
    if length is None or length_is_aspect_ratio:
        ratio = length
        length = max(1, int(ceil(len(images) ** 0.5)))
        if length_is_aspect_ratio:
            length = int(ceil(length * ratio))

    format_image = (
        (
            lambda image: bordered_image_solid_color(
                image, color=border_color, thickness=border_thickness, top=0, left=0
            )
        )
        if border_thickness
        else identity
    )

    images=[format_image(image) for image in images]
    images=split_into_sublists(images,length)
    output=grid_concatenated_images(images, origin=origin)

    if border_thickness:
        output=bordered_image_solid_color(output,color=border_color,thickness=border_thickness,bottom=0,right=0)
    return output

def tiled_videos(videos, *, show_progress=False, border_thickness=0, **kwargs):
    """
    Tiles videos together. 
    Uses same args and kwargs as rp.tiled_images - see its docstring for what they do
    Assumes videos are in BTI form, where I is image (so I is like HWC or CHW or PIL etc)
    
    Todo: Also support lazy
    Todo: Heavily optimize! Right now faster when border_thickness=0, so that's the default value
    Todo: Add examples to docstring
    """

    from copy import copy

    if show_progress in ['eta', True]: show_progress='eta:rp.'+get_current_function_name()

    if not len(videos):
        #If the videos have no length...whatever. Return nothingness.
        return copy(videos)

    videos = trim_videos_to_max_length(videos, copy=False)
    length = len(videos[0])
    out_frames = []
    
    times = range(length)
    if show_progress:
        times = eta(times, title='rp.tiled_videos')

    for time in times:
        tiles = [video[time] for video in videos]
        out_frame = tiled_images(
            tiles,
            border_thickness=border_thickness,
            **kwargs,
        )
        out_frames.append(out_frame)
    
    if len(set(x.shape for x in out_frames))==1:
        #If possible, return a numpy array
        return as_numpy_array(out_frames)

    return out_frames



def vertically_flipped_image(image):
    """Flips (aka mirrors) an image vertically."""
    if is_pil_image(image):
        from PIL import Image
        return image.transpose(Image.FLIP_TOP_BOTTOM)
    else:
        return image[::-1]

def horizontally_flipped_image(image):
    """Flips (aka mirrors) an image horizontally."""
    if is_pil_image(image):
        from PIL import Image
        return image.transpose(Image.FLIP_LEFT_RIGHT)
    else:
        return image[:, ::-1]

def least_squares_regression_line_coeffs(X, Y=None, include_correlation=False):
    """
    Computes the coefficients for a least squares regression line.
    
    Parameters:
        - X: List of x-values, list of (x, y) pairs, or list of y-values.
            - If X is a list of x-values, Y must be provided.
            - If X is a list of (x, y) pairs, Y should be set to None.
            - If X is a list of y-values, Y should be set to None.
        - Y: List of y-values when X is a list of x-values; otherwise, should be None.
        - include_correlation: Boolean flag to include the correlation coefficient in the output.

    Returns:
        - A tuple (m, b) such that Y = m*X + b.
        - If include_correlation is True, returns a tuple (m, b, r).
    
    Notes:
        - Works with complex numbers.
        - Computational complexity is O(n).
    
    Examples:
        >>> least_squares_regression_line_coeffs([1, 2, 3], [2, 4, 3])
        (1, 1)
        
        >>> least_squares_regression_line_coeffs([(1, 2), (2, 4), (3, 3)])
        (1, 1)
        
        >>> least_squares_regression_line_coeffs([2, 4, 3])
        (1, 2)
    """
    #Return m, b such that Y ≈ m*X+b
    #TODO I can't figure out why vectorization makes this SLOWER in bulk....attempted code below...
    #Note: This generalizes to complex numbers (and can be used to calculate least-squares euclidean affine in LINEAR TIME)!
    #If include_correlation is True, it will include the correlation coefficient (r), and so this function would return a tuple of length 3 instead of length 2 (return m,b,r instead of just m,b)
    #Has O(n) complexity as opposed to least_squares_euclidean_affine's original matrix implementation, which takes at least O(n^3) time because of numpy's matrix multiplication implementation
    #X and Y can be separate X,Y values, or X can be a list of points (AKA either X=[2,5,7,3...] and Y=[2,4,8,3...] or X=[[1,2],[4,5],[6,7]...] and Y=None)

    assert len(X) > 0
    if Y is None:
        if is_number(X[0]):
            Y = X
            X = np.arange(len(X))
        else:
            X, Y = zip(*X)

    Y = as_numpy_array(Y)
    X = as_numpy_array(X)

    assert X.shape == Y.shape

    Σ = lambda x: np.sum(x)
    μ = lambda x: np.mean(x)

    #Xn is short for 'X normalized'
    Xn = X - μ(X)
    Yn = Y - μ(Y)

    ΣXnYn = Σ(Xn * Yn)
    ΣXnXn = Σ(Xn * Xn)

    m = ΣXnYn / ΣXnXn
    b = μ(Y) - μ(m * X)

    if include_correlation:
        #Formula from https://www.statsdirect.com/help/regression_and_correlation/simple_linear.htm
        normalized = lambda x: x / np.linalg.norm(x)
        r = np.sum(normalized(Xn) * np.conj(normalized(Yn)))#Centered-about-the-mean, normalized cosine-similarity is the same thing as correlation
        return m, b, r
    else:
        return m, b
    
def magnitude(x,**kwargs):
    """ Get the total magnitude """
    return np.sqrt(np.sum(np.abs(x)**2,**kwargs))

def normalized(x,axis=None):
    """ Normalize the vector/matrix/etc to have total magnitude 1 """
    x=np.asarray(x)
    return x/magnitude(x,axis=axis,keepdims=True)

_javascript_runtime=None#We have a global JS runtime. If you wish to have multiple runtimes, you'd best just use js2py directly.
def _get_javascript_runtime():
    pip_import('js2py')
    import js2py #This library runs javascript, implemented in pure python.
    global _javascript_runtime
    if _javascript_runtime is None:
        _javascript_runtime=js2py.EvalJs()
    return _javascript_runtime
def javascript(code):
    """
    I created this function to reuse code that I wrote in javascript.
    Evaluate code written in javascript and return it.
    """
    assert isinstance(code,str)
    return _get_javascript_runtime().eval(code)
js=javascript
def javascript_console():
    """ Enter the javascript console, which lets you use JS in python interactively """
    return _get_javascript_runtime().console()

@memoized
def _get_byte_to_binary_grayscale_image_floyd_steinburg_dithering_function():
    """
    Code Originally from http://study.marearts.com/2018/10/dithering-python-opencv-source-code.html 
    I optimized it to use numba, which yielded a speedup of over 1000
    """
    
    pip_import('numba')#We're going to need numba to speed things up, or else this isn't practical
    import numba    

    @numba.jit
    def minmax(v):
        if v > 255:
            v = 255
        if v < 0:
            v = 0
        return v

    @numba.jit
    def dithering_gray(inMat, samplingF=1):
        #https://en.wikipedia.org/wiki/Floyd–Steinberg_dithering
        #https://www.youtube.com/watch?v=0L2n8Tg2FwI&t=0s&list=WL&index=151
        #input is supposed as color
        # grab the image dimensions
        h = inMat.shape[0]
        w = inMat.shape[1]
        global math
        # loop over the image
        for y in range(0, h-1):
            for x in range(1, w-1):
                # threshold the pixel
                old_p = inMat[y, x]
                new_p = np.round(samplingF * old_p/255.0) * (255/samplingF)
                inMat[y, x] = new_p
                
                quant_error_p = old_p - new_p
                
                # inMat[y, x+1] = minmax(inMat[y, x+1] + quant_error_p * 7 / 16.0)
                # inMat[y+1, x-1] = minmax(inMat[y+1, x-1] + quant_error_p * 3 / 16.0)
                # inMat[y+1, x] = minmax(inMat[y+1, x] + quant_error_p * 5 / 16.0)
                # inMat[y+1, x+1] = minmax(inMat[y+1, x+1] + quant_error_p * 1 / 16.0)
                
                inMat[y  , x+1] = minmax(inMat[y  , x+1] + quant_error_p * 7 / 16.0)
                inMat[y+1, x-1] = minmax(inMat[y+1, x-1] + quant_error_p * 3 / 16.0)
                inMat[y+1, x  ] = minmax(inMat[y+1, x  ] + quant_error_p * 5 / 16.0)
                inMat[y+1, x+1] = minmax(inMat[y+1, x+1] + quant_error_p * 1 / 16.0)


                #   quant_error  := oldpixel - newpixel
                #   pixel[x + 1][y    ] := pixel[x + 1][y    ] + quant_error * 7 / 16
                #   pixel[x - 1][y + 1] := pixel[x - 1][y + 1] + quant_error * 3 / 16
                #   pixel[x    ][y + 1] := pixel[x    ][y + 1] + quant_error * 5 / 16
                #   pixel[x + 1][y + 1] := pixel[x + 1][y + 1] + quant_error * 1 / 16

        # return the thresholded image
        return inMat
    
    return dithering_gray

def _binary_floyd_steinburg_dithering(image):
    """
    Takes an image and returns a dithered binary image
    I chose not to expose this method right now outside of rp (aka the _ in _binary_flo...) because the name is ugly, but it will be going into as_binary_image.
    Warning: Using the PROF with numba results in a seg-fault!
    """
    assert is_image(image)

    if is_binary_image(image):
        #If it's already a binary image, dithering will have no effect
        return image.copy()

    pip_import('numba')
    dither=_get_byte_to_binary_grayscale_image_floyd_steinburg_dithering_function()
    image=as_byte_image(image)

    if is_grayscale_image(image):
        image=dither(image)
    else:
        for channel in range(image.shape[2]):
            image[:,:,channel]=dither(image[:,:,channel])

    return as_binary_image(image)

#region Image Channel Conversions
def is_image(image):
    """
    An image must be either grayscale (a numpy matrix), rgb (a HWC tensor), or rgba (a HWC tensor) and have be either a bool, np.uint8, or floating point (between 0 and 1) dtype
    It can also be a PIL image

    Enhanced Documentation:
    - Primary use: Input validation in assertions and filtering mixed-type lists
    - Accepts: PIL images, NumPy arrays (HW grayscale, HWC color), proper dtypes (bool/uint8/float)
    - Rejects: Torch tensors, wrong shapes, invalid dtypes

    Common patterns:
        >>> assert is_image(my_input)  # Validation before processing
        >>> images = [x for x in mixed_list if is_image(x)]  # Filter from mixed types
        >>> if is_image(data): process_as_image(data)  # Branching logic

    Tags: validation, input-check, image-processing
    """
    try:
        if is_torch_tensor(image):
            return False
        if is_pil_image(image):
            return True
        image=as_numpy_array(image)
    except Exception:
        return False
    return (is_grayscale_image(image) or is_rgb_image (image) or is_rgba_image  (image))  and\
           (is_float_image    (image) or is_byte_image(image) or is_binary_image(image))

def is_grayscale_image(image):
    """Check if image is grayscale (2D array with shape HW).
    
    Returns True for 2-dimensional numpy arrays or convertible inputs.
    Returns False for color images, torch tensors, or invalid inputs.
    See also: is_rgb_image, is_rgba_image, as_grayscale_image

    Enhanced Documentation:
    
    Validates whether an image is in grayscale format (2D array). This is a key function in RP's
    image type validation system, used by dozens of image processing functions to determine
    appropriate processing paths.
    
    Args:
        image: Input to test. Can be numpy array, PIL image, or any type convertible by as_numpy_array()
        
    Returns:
        bool: True if image has exactly 2 dimensions (height, width), False otherwise
        
    Examples:
        >>> import numpy as np
        >>> # Grayscale image (2D)
        >>> gray_img = np.random.randint(0, 255, (100, 100), dtype=np.uint8)
        >>> is_grayscale_image(gray_img)
        True
        
        >>> # Color image (3D) 
        >>> color_img = np.random.randint(0, 255, (100, 100, 3), dtype=np.uint8)
        >>> is_grayscale_image(color_img)
        False
        
        >>> # 1D array (not an image)
        >>> is_grayscale_image([1, 2, 3])
        False
        
    Note: This function is used by 25+ RP functions including as_rgba_image, crop_image, 
    apply_uv_map, and cv_equalize_histogram. It's part of RP's image format detection system
    alongside is_rgb_image and is_rgba_image.
    
    Related: is_rgb_image, is_rgba_image, is_image, as_grayscale_image, as_grayscale_images
    
    Tags: validation, images, grayscale, format-detection, computer-vision
    """
    try:
        image=as_numpy_array(image)
    except Exception:
        return False
    return len(image.shape)==2

def is_rgb_image(image):
    """Check if image is RGB (3D array with shape HW3).
    
    Returns True for arrays with exactly 3 color channels.
    Returns False for grayscale, RGBA, torch tensors, or invalid inputs.
    See also: is_grayscale_image, is_rgba_image, as_rgb_image

    Enhanced Documentation:
    
    Validates whether an image has RGB format (3 color channels: Red, Green, Blue). This is the
    most common color image format in RP's image processing system and is used to route images
    through appropriate processing pipelines.
    
    Args:
        image: Input to test. Can be numpy array, PIL image, or any type convertible by as_numpy_array()
        
    Returns:
        bool: True if image has exactly 3 dimensions with 3 channels (H, W, 3), False otherwise
        
    Examples:
        >>> import numpy as np
        >>> # RGB image (3 channels)
        >>> rgb_img = np.random.randint(0, 255, (100, 100, 3), dtype=np.uint8)
        >>> is_rgb_image(rgb_img)
        True
        
        >>> # RGBA image (4 channels)
        >>> rgba_img = np.random.randint(0, 255, (100, 100, 4), dtype=np.uint8)
        >>> is_rgb_image(rgba_img)
        False
        
        >>> # Grayscale image (2D)
        >>> gray_img = np.random.randint(0, 255, (100, 100), dtype=np.uint8)
        >>> is_rgb_image(gray_img)
        False
        
    Note: This function is used by 15+ RP functions including as_rgb_image, as_rgba_image, 
    cv_draw_arrow, and extract_image_channels. Essential for color image processing workflows.
    
    Related: is_rgba_image, is_grayscale_image, is_image, as_rgb_image, as_rgb_images
    
    Tags: validation, images, rgb, color, format-detection, computer-vision
    """
    try:
        image=as_numpy_array(image)
    except Exception:
        return False
    shape=image.shape
    if len(shape)!=3:return False
    number_of_channels=shape[2]
    return number_of_channels==3

def is_rgba_image(image):
    """Check if image is RGBA (3D array with shape HW4).
    
    Returns True for arrays with exactly 4 channels (RGB + alpha).
    Returns False for grayscale, RGB, torch tensors, or invalid inputs.
    See also: is_rgb_image, is_grayscale_image, as_rgba_image

    Enhanced Documentation:
    
    Validates whether an image has RGBA format (4 channels: Red, Green, Blue, Alpha). Essential 
    for transparency-aware image processing in RP's image pipeline. Used extensively by functions
    that need to handle alpha channels correctly.
    
    Args:
        image: Input to test. Can be numpy array, PIL image, or any type convertible by as_numpy_array()
        
    Returns:
        bool: True if image has exactly 3 dimensions with 4 channels (H, W, 4), False otherwise
        
    Examples:
        >>> import numpy as np
        >>> # RGBA image (4 channels)
        >>> rgba_img = np.random.randint(0, 255, (100, 100, 4), dtype=np.uint8)
        >>> is_rgba_image(rgba_img)
        True
        
        >>> # RGB image (3 channels)
        >>> rgb_img = np.random.randint(0, 255, (100, 100, 3), dtype=np.uint8)  
        >>> is_rgba_image(rgb_img)
        False
        
        >>> # Grayscale image (2D)
        >>> gray_img = np.random.randint(0, 255, (100, 100), dtype=np.uint8)
        >>> is_rgba_image(gray_img)
        False
        
    Note: This function is used by 20+ RP functions including as_rgba_image, with_image_saturation,
    inverted_image, and cv_inpaint_image. Critical for transparency handling in image operations.
    
    Related: is_rgb_image, is_grayscale_image, is_image, as_rgba_image, is_opaque_image
    
    Tags: validation, images, rgba, transparency, alpha-channel, format-detection
    """
    try:
        image=as_numpy_array(image)
    except Exception:
        return False
    shape=image.shape
    if len(shape)!=3:return False
    number_of_channels=shape[2]
    return number_of_channels==4

def _grayscale_image_to_grayscale_image(image):return as_numpy_array(image).copy()
def _grayscale_image_to_rgb_image(image):
    """Convert grayscale to RGB by duplicating channels. Internal helper for as_rgb_image()."""
    return grayscale_to_rgb(image)
def _grayscale_image_to_rgba_image     (image):return _rgb_image_to_rgba_image(_grayscale_image_to_rgb_image(image))

def _rgb_image_to_grayscale_image      (image):return _rgb_to_grayscale(image)
def _rgb_image_to_rgb_image(image):
    """Copy RGB image unchanged. Internal helper for as_rgb_image() copy=True."""
    return as_numpy_array(image).copy()
def _rgb_image_to_rgba_image           (image):
    alpha = np.ones((*image.shape[:2], 1), image.dtype)

    if is_byte_image(image):
        alpha *= 255

    return np.concatenate((image, alpha), 2)

#OLD CODE, WORKS BUT IS SLOW
# def _rgb_image_to_rgba_image           (image):
#     # assert False,'_rgb_image_to_rgba_image: Please fix me Im broken?!'
#     if is_byte_image(image):
#         #This is a dirty hack. Idk why this method can't handle byte images, and instead of looking deeper into it I'll just make do with this slightly slower version shown in the next line.
#         return as_byte_image(_rgb_image_to_rgba_image(as_float_image(image)))
#     assert not is_byte_image(image),'This function is currently broken for byte images! It adds too many dimensions to the shape'
#     return np.concatenate((image,np.ones((*image.shape[:2],255 if is_byte_image(image) else 1),image.dtype)),2)#TODO TEST ME!!!

def _rgba_image_to_grayscale_image     (image):return _rgb_image_to_grayscale_image(_rgba_image_to_rgb_image(image))
def _rgba_image_to_rgb_image(image):
    """Convert RGBA to RGB by dropping alpha channel. Internal helper for as_rgb_image()."""
    return as_numpy_array(image)[:,:,:3]
def _rgba_image_to_rgba_image          (image):return as_numpy_array(image).copy()

def as_grayscale_image(image,*,copy=True):
    """ Returns a 2-dimensional numpy array in HW form (height, width) 
    
    Enhanced Documentation:
    =====================
    Converts any image type to grayscale format. This is a fundamental function in 
    RP's image processing system that normalizes different image formats into a 
    consistent single-channel representation.
    
    Args:
        image (numpy.ndarray): Input image - can be grayscale (HW), RGB (HW3), or RGBA (HW4)
        copy (bool): If True, returns a copy for grayscale inputs; if False, returns original
        
    Returns:
        numpy.ndarray: 2D grayscale image in HW format (height, width)
                      Values preserved from input image type and range
        
    Usage Patterns:
        - Preprocessing for grayscale-only algorithms
        - Reducing computational complexity by removing color
        - Computer vision tasks that don't require color information
        - Converting mixed image types to consistent format
        
    Examples:
        >>> # Convert RGB to grayscale
        >>> rgb_img = load_image("color_photo.jpg")  # Shape: (480, 640, 3)
        >>> gray_img = as_grayscale_image(rgb_img)
        >>> print(gray_img.shape)  # (480, 640)
        
        >>> # Convert RGBA to grayscale (alpha channel discarded)
        >>> rgba_img = load_image("logo.png")  # Shape: (200, 200, 4)
        >>> gray_img = as_grayscale_image(rgba_img)
        >>> print(gray_img.shape)  # (200, 200)
        
        >>> # Process already grayscale image
        >>> gray_input = load_image("bw_photo.jpg")  # Shape: (300, 400)
        >>> gray_copy = as_grayscale_image(gray_input, copy=True)
        >>> gray_view = as_grayscale_image(gray_input, copy=False)
        
        >>> # Chain with other image operations
        >>> processed = blur_image(as_grayscale_image(rgba_image))
        >>> edges = detect_edges(as_grayscale_image(color_photo))
        
    Related Functions:
        - as_rgb_image(): Convert to 3-channel RGB format
        - as_rgba_image(): Convert to 4-channel RGBA format
        - is_grayscale_image(): Check if image is already grayscale
        - as_grayscale_images(): Batch conversion for multiple images
        
    Implementation Details:
        - Uses specialized conversion functions for each input type
        - RGB conversion: Weighted average (standard luminance formula)
        - RGBA conversion: First converts to RGB, then to grayscale
        - Grayscale input: Returns copy or view based on copy parameter
        - Preserves data type (uint8, float32, etc.) from input
        
    Performance Notes:
        - Most efficient for already-grayscale inputs (copy=False)
        - RGB conversion uses optimized weighted averaging
        - RGBA conversion requires intermediate RGB step
        
    Error Handling:
        - Validates input with is_image() assertion
        - Raises AssertionError if input is not a valid image
        
    Tags: image, grayscale, conversion, preprocessing, computer-vision
    """
    assert is_image(image),'Error: input is not an image as defined by rp.is_image()'
    if is_grayscale_image(image):return _grayscale_image_to_grayscale_image(image) if copy else image
    if is_rgb_image      (image):return       _rgb_image_to_grayscale_image(image)
    if is_rgba_image     (image):return      _rgba_image_to_grayscale_image(image)
    assert False,'This line should be impossible to reach because is_image(image).'

def as_rgb_image(image,*,copy=True):
    """ Returns a 3-dimensional numpy array in HW3 form (height, width, channels) 
    
    Enhanced Documentation:
    Converts any image type to RGB format. This is a key function in RP's image system
    that normalizes different image formats (grayscale, RGB, RGBA) into a consistent
    3-channel RGB representation.
    
    Args:
        image: Input image (numpy array) - can be grayscale (HW), RGB (HW3), or RGBA (HW4)
        copy (bool): If True, returns a copy; if False, returns original for RGB inputs
        
    Returns:
        numpy.ndarray: RGB image as (height, width, 3) array with same dtype as input
        
    Examples:
        >>> import numpy as np
        >>> from rp.r import as_rgb_image
        >>> # Convert grayscale to RGB
        >>> gray = np.ones((100, 100), dtype=np.uint8) * 128
        >>> rgb = as_rgb_image(gray)
        >>> rgb.shape
        (100, 100, 3)
        >>> # Convert RGBA to RGB (removes alpha channel)
        >>> rgba = np.ones((50, 50, 4), dtype=np.uint8) * 200
        >>> rgb = as_rgb_image(rgba)
        >>> rgb.shape
        (50, 50, 3)
        
    Related Functions:
        - is_rgb_image(): Check if image is RGB format
        - as_rgba_image(): Convert to RGBA format
        - as_grayscale_image(): Convert to grayscale format
        
    Tags: image-conversion, color-format, rgb, image-processing
    """
    assert is_image(image),'Error: input is not an image as defined by rp.is_image()'
    if is_grayscale_image(image):return _grayscale_image_to_rgb_image(image)
    if is_rgb_image      (image):return       _rgb_image_to_rgb_image(image) if copy else image
    if is_rgba_image     (image):return      _rgba_image_to_rgb_image(image)
    assert False,'This line should be impossible to reach because is_image(image).'

def as_rgba_image(image,*,copy=True):
    """ Returns a 3-dimensional numpy array in HW4 form (height, width, channels) """
    assert is_image(image),'Error: input is not an image as defined by rp.is_image()'
    if is_grayscale_image(image):return _grayscale_image_to_rgba_image(image)
    if is_rgb_image      (image):return       _rgb_image_to_rgba_image(image)
    if is_rgba_image     (image):return      _rgba_image_to_rgba_image(image) if copy else image
    assert False,'This line should be impossible to reach because is_image(image).'

# Channel dtype conversions:
def is_float_image(image):
    """
    A float image is made with floating-point real values between 0 and 1
    https://stackoverflow.com/questions/37726830/how-to-determine-if-a-number-is-any-type-of-int-core-or-numpy-signed-or-not?noredirect=1&lq=1
    
    Enhanced Documentation:
    Checks if an image is in floating-point format with values typically
    in the range [0, 1]. Float images are RP's standard format for precise
    mathematical operations and processing.
    
    Args:
        image: Image to check
        
    Returns:
        bool: True if image has floating-point dtype, False otherwise
        
    Examples:
        >>> import numpy as np
        >>> float_img = np.array([[[0.5, 1.0, 0.2]]], dtype=np.float32)
        >>> is_float_image(float_img)
        True
        >>> byte_img = np.array([[[128, 255, 51]]], dtype=np.uint8)
        >>> is_float_image(byte_img)
        False
        
    Related Functions:
        - as_float_image(): Convert to float format
        - is_byte_image(): Check if byte format
        - is_binary_image(): Check if binary format
        
    Tags: image, validation, float, dtype, format
    """
    if is_torch_tensor(image): return False
    image=np.asarray(image)
    return np.issubdtype(image.dtype,np.floating)

def is_byte_image(image):
    """
    A byte image is made of unsigned bytes (aka np.uint8)
    Return true if the datatype is an integer between 0 and 255

    Enhanced Documentation:
    
    Validates whether an image is in byte format (8-bit unsigned integers, 0-255 range).
    This is the most common image format for display, file operations, and OpenCV functions.
    Used extensively in RP's image processing pipeline for format validation.
    
    Args:
        image: Input to test. Can be numpy array, PIL image, or any type convertible by as_numpy_array()
        
    Returns:
        bool: True if image has uint8 datatype, False otherwise
        
    Examples:
        >>> import numpy as np
        >>> # Byte image (uint8)
        >>> byte_img = np.random.randint(0, 255, (100, 100, 3), dtype=np.uint8)
        >>> is_byte_image(byte_img)
        True
        
        >>> # Float image (not byte)
        >>> float_img = np.random.random((100, 100, 3)).astype(np.float32)
        >>> is_byte_image(float_img)
        False
        
        >>> # Integer image with wrong dtype
        >>> int_img = np.random.randint(0, 255, (100, 100, 3), dtype=np.int32)
        >>> is_byte_image(int_img)
        False
        
    Note: Used by 15+ RP functions including as_byte_image, inverted_image, as_float_image,
    and is_opaque_image. Essential for routing images through appropriate processing paths.
    
    Related: is_float_image, is_binary_image, as_byte_image, is_image
    
    Tags: validation, images, byte, uint8, datatypes, format-detection
    """
    if is_torch_tensor(image): return False
    image=np.asarray(image)
    return image.dtype==np.uint8

def is_binary_image(image):
    """
    A binary image is made of boolean values (AKA true or false)

    Enhanced Documentation:
    - Used for special handling in OpenCV functions and image conversions
    - Often followed by as_byte_image() conversion for display compatibility

    Common usage:
        >>> if is_binary_image(img): img = as_byte_image(img)  # OpenCV compatibility

    Tags: binary, boolean, dtype-check
    """
    if is_torch_tensor(image): return False
    image=np.asarray(image)
    return image.dtype==bool

def _clamp_float_image(image):
    """
    Take some floating image and make sure that it has no negative numbers or numbers >1
    """
    assert is_float_image(image)

    #This is only faster to check because usually the range IS between 0 and 1
    # if image.size: and (image.min()<0 or image.max()>1):

    image = np.clip(image, 0, 1)

    return image

def _float_image_dtype(): return np.float32
def _float_image_to_float_image  (image):return image.copy()
# def _float_image_to_byte_image   (image):return (np.asarray(_clamp_float_image(image),dtype=float)*255).astype(np.uint8) #May 13 2025: Why we convert float's dtype here? Seems redundant...
def _float_image_to_byte_image   (image):return (_clamp_float_image(image)*255).astype(np.uint8)
def _float_image_to_binary_image (image):return np.round(_clamp_float_image(image)).astype(bool)
def _byte_image_to_float_image   (image):return image.astype(_float_image_dtype())/255 #Since we're converting from bytes, we can use a lower precision float here
def _byte_image_to_byte_image    (image):return image.copy()
def _byte_image_to_binary_image  (image):return _float_image_to_binary_image(_byte_image_to_float_image(image))
def _binary_image_to_float_image (image):return np.asarray(image,dtype=_float_image_dtype())
def _binary_image_to_byte_image  (image):return _float_image_to_byte_image(_binary_image_to_float_image(image))
def _binary_image_to_binary_image(image):return image.copy()



_channel_conversion_error_message='The given input image has an unrecognized dtype (there are no converters for it)'
def as_float_image(image,*,copy=True):
    """ Returns a numpy array with floating point values (usually between 0 and 1) 
    
    Enhanced Documentation:
    Converts any image format to floating-point representation with values
    typically in the range [0, 1]. This is RP's standard format for precise
    image processing and mathematical operations.
    
    Args:
        image: Input image in any supported format
        copy (bool): Whether to copy the image if already float format
        
    Returns:
        numpy.ndarray: Float image with values typically [0, 1]
        
    Examples:
        >>> import numpy as np
        >>> byte_img = np.array([[[255, 128, 0]]], dtype=np.uint8)
        >>> float_img = as_float_image(byte_img)
        >>> float_img.dtype
        dtype('float32')
        >>> float_img[0, 0]  # [1.0, ~0.5, 0.0]
        
    Related Functions:
        - as_byte_image(): Convert to byte format [0, 255]
        - as_binary_image(): Convert to binary format {0, 1}
        - is_float_image(): Check if image is float format
        
    Tags: image, conversion, float, normalize, preprocessing
    """
    assert is_image(image),'Error: input is not an image as defined by rp.is_image()'
    if is_float_image (image):return  _float_image_to_float_image(image) if copy else image
    if is_byte_image  (image):return   _byte_image_to_float_image(image)
    if is_binary_image(image):return _binary_image_to_float_image(image)
    assert False,_channel_conversion_error_message

def as_byte_image(image,*,copy=True):
    """ Returns a numpy array with dtype np.uint8 

    Enhanced Documentation:
    
    Converts images to byte format (8-bit unsigned integers, 0-255 range). This is the
    standard format for most image processing operations and display. Part of RP's
    comprehensive image conversion system.
    
    Args:
        image: Input image to convert. Accepts numpy arrays, PIL images, torch tensors
        copy (bool): Whether to create a copy if conversion isn't needed (default True)
        
    Returns:
        np.ndarray: Image as uint8 numpy array with values in 0-255 range
        
    Examples:
        >>> import numpy as np
        >>> # Convert float image (0.0-1.0) to byte image (0-255)
        >>> float_img = np.random.random((100, 100, 3))
        >>> byte_img = as_byte_image(float_img)
        >>> byte_img.dtype
        dtype('uint8')
        >>> byte_img.min(), byte_img.max()
        (0, 255)
        
        >>> # Convert binary image to byte image
        >>> binary_img = np.random.choice([True, False], (50, 50))
        >>> byte_result = as_byte_image(binary_img)
        >>> np.unique(byte_result)  # Should be [0, 255]
        array([  0, 255], dtype=uint8)
        
    Note: Used by 70+ RP functions including save_image, cv_imshow, display functions,
    and OpenCV operations. Handles float->byte (multiply by 255) and binary->byte 
    (False=0, True=255) conversions automatically.
    
    Related: as_float_image, as_binary_image, as_pil_image, is_byte_image
    
    Tags: conversion, images, byte, uint8, format-conversion, display
    """
    assert is_image(image),'Error: input is not an image as defined by rp.is_image()'
    if is_float_image (image):return  _float_image_to_byte_image(image)
    if is_byte_image  (image):return   _byte_image_to_byte_image(image) if copy else image
    if is_binary_image(image):return _binary_image_to_byte_image(image)
    assert False,_channel_conversion_error_message

def as_binary_image(image,dither=False,*,copy=True):
    """
    Returns a nummpy array with dtype bool
    EXAMPLE of 'dither': while True: display_image(as_binary_image(load_image_from_webcam(),dither=True))
    """
    assert is_image(image),'Error: input is not an image as defined by rp.is_image()'

    if dither:
        return _binary_floyd_steinburg_dithering(image)

    if is_float_image (image):return  _float_image_to_binary_image(image)
    if is_byte_image  (image):return   _byte_image_to_binary_image(image)
    if is_binary_image(image):return _binary_image_to_binary_image(image) if copy else image
    assert False,_channel_conversion_error_message

#TODO: Make these faster in special cases, such as where images is a numpy array - and in this case also return a numpy array
def _images_conversion(func, images, *, copy_check ,copy=True):
    """
    Private helper function for batch image format conversion with optimization.
    
    Used internally by RP's plural image conversion functions (as_byte_images, as_rgb_images, etc.) 
    to efficiently convert arrays of images. Contains specialized optimizations for numpy arrays
    to avoid unnecessary copying and type conversion.
    
    Args:
        func: Single image conversion function (e.g., as_byte_image, as_rgb_image)
        images: Array of images or iterable of images to convert
        copy_check: Function to check if images are already in target format
        copy (bool): Whether to make copies during conversion (default True)
        
    Returns: 
        Converted images in requested format (numpy array or list)
        
    Note: This is a private optimization function used by as_byte_images, as_grayscale_images,
    as_binary_images, as_rgb_images, as_rgba_images, and as_float_images.
    """

    if _is_numpy_array(images):
        #Optimization: A bit of complicated, ugly logic here - but it's all just for optimization. 
        #Deleting this block won't affect the functionality of this function, only the speed in certain edge cases.

        if not len(images):
            #Copying zero-length tensors takes basically 0 time
            return images.copy()

        #We might need to do a check on the first image to avoid reimplementing a image-type check function
        assert len(images)

        if not copy and copy_check(images[0]):
            #Optimization: Potentially skip a conversion alltogether
            #Example: A video
            #     >>> q=as_byte_images(ans,)
            #    TICTOC: 0.28664    seconds
            #     >>> q=as_byte_images(ans,copy=False)
            #    TICTOC: 0.00007    seconds
            return images

        elif func==as_float_image:
            if images.dtype==np.uint8:
                return images/255
            elif images.dtype==bool:
                return images+0.0
            else:
                return images.copy()

        elif func==as_byte_image:
            if images.dtype==np.uint8:
                return images.copy()
            elif images.dtype==bool:
                return images.astype(np.uint8) * 255
            else:
                return np.clip(images*255, 0, 255).astype(np.uint8)

        elif func==as_byte_image:
            if images.dtype==np.uint8:
                return images>127
            elif images.dtype==bool:
                return images.copy()
            else:
                return images>.5

        elif func==as_rgb_image:
            if len(images.shape)==3: #Given grayscale images
                #BHW matrices turn into RGB tensors
                #Duplicate the grayscale along all RGB channels
                return images[...,None].repeat(3,axis=3)
            assert len(images.shape)==4, images.shape #BHWC
            C=images.shape[3]
            if C==3:
                return images.copy()
            else:
                assert C==4, C
                return images[:,:,:,:3]

        elif func==as_rgba_image:
            if len(images.shape)==3: #Given grayscale images
                #BHW matrices turn into RGBA tensors
                #Duplicate the grayscale along all RGBA channels
                #Alpha channel is 1 if floating point else 255 if uint8 else True if bool, the other 3 are duplciated from the grayscale matrices...
                output = images[...,None].repeat(4,axis=3)
                if images.dtype==np.uint8:
                    output[...,3]=255
                elif images.dtype==bool:
                    output[...,3]=True
                else:
                    output[...,3]=1
                return output
                
            assert len(images.shape)==4, images.shape #BHWC
            C=images.shape[3]
            if C==3:
                alpha=np.ones_like(images[:,:,:,:1])
                if images.dtype==np.uint8:
                    alpha*=255
                return np.concatenate((images,alpha),3)
            else:
                assert C==4, C
                return images.copy()

        elif func==as_grayscale_image:
            if len(images.shape)==3: #Given grayscale images
                return images.copy()
            assert len(images.shape)==4, images.shape #BHWC
            #Average the RGB channels and turn back to uint8 or float or binary...
            if images.dtype==np.uint8:
                return (images[...,:3].sum(3)//3).astype(np.uint8)
            elif images.dtype==bool:
                return  images[...,:3].mean(3)>.5
            else:
                return  images[...,:3].mean(3)


        #TODO: Handle all edge cases of as_binary_images


    #TODO: This can be optimized in the case that images is a numpy array by doing vectorized operations on it
    output = [func(as_numpy_image(image,copy=copy),copy=copy) for image in images]
    if _is_numpy_array(images):
        output = as_numpy_array(output)

    return output

def as_float_images    (images,*,copy=True): 
    """Convert multiple images to float format (0-1). See as_float_image for details.
    
    Args: images, copy=True
    Returns: List of float images
    """
    return _images_conversion(as_float_image    , images, copy=copy, copy_check=is_float_image    )  
def as_byte_images     (images,*,copy=True): 
    """Convert multiple images to byte format (uint8 0-255). See as_byte_image for details.
    
    Args: images, copy=True
    Returns: List of byte images
    """
    return _images_conversion(as_byte_image     , images, copy=copy, copy_check=is_byte_image     )  
def as_binary_images   (images,*,copy=True): 
    """Convert multiple images to binary format (bool). See as_binary_image for details.
    
    Args: images, copy=True
    Returns: List of binary images
    """
    return _images_conversion(as_binary_image   , images, copy=copy, copy_check=is_binary_image   )  
def as_rgb_images      (images,*,copy=True): 
    """Convert multiple images to RGB format (HW3). See as_rgb_image for details.
    
    Args: images, copy=True
    Returns: List of RGB images
    """
    return _images_conversion(as_rgb_image      , images, copy=copy, copy_check=is_rgb_image      )  
def as_rgba_images     (images,*,copy=True): 
    """Convert multiple images to RGBA format (HW4). See as_rgba_image for details.
    
    Args: images, copy=True
    Returns: List of RGBA images
    """
    return _images_conversion(as_rgba_image     , images, copy=copy, copy_check=is_rgba_image     )  
def as_grayscale_images(images,*,copy=True):
    """Convert list of images to grayscale. See as_grayscale_image for single images."""
    return _images_conversion(as_grayscale_image, images, copy=copy, copy_check=is_grayscale_image)  

def _common_image_channel_converter(images):
    """Given a list of images, choose the cheapest as_*_image function that preserves as much data as possible"""
    if   any(map(is_rgba_image, images)): return as_rgba_image
    elif any(map(is_rgb_image , images)): return as_rgb_image
    else                                : return as_grayscale_image

def _common_image_dtype_converter(images):
    """Given a list of images, choose the cheapest as_*_image function that preserves as much data as possible"""
    if   any(map(is_float_image, images)): return as_float_image
    elif any(map(is_byte_image , images)): return as_byte_image
    else                                 : return as_binary_image

def _common_image_converter(images):
    dtype_converter = _common_image_dtype_converter(images)
    channel_converter = _common_image_channel_converter(images)
    def converter(image, copy=True):
        return channel_converter(dtype_converter(image,copy=copy),copy=copy)
    return converter

#SINGULAR RANDOM COLORS
def random_rgb_byte_color():
    """Generate a random RGB color as byte values (0-255).
    
    Returns a tuple of three random integers representing red, green, and blue
    color components, each in the range [0, 255].
    
    Examples:
    >>> color = random_rgb_byte_color()
    >>> print(color)  # e.g., (127, 234, 89)
    (127, 234, 89)
    
    Returns:
        tuple: (red, green, blue) where each value is in [0, 255]
        
    Related Functions:
    - random_rgba_byte_color: Include alpha channel
    - random_grayscale_byte_color: Single grayscale value
    - random_rgb_float_color: Float values [0.0, 1.0]
    
    Tags: color, rgb, random, byte, graphics, image
    """
    return (randint(255),randint(255),randint(255))
def random_rgba_byte_color():
    """Generate a random RGBA color as byte values (0-255).
    
    Returns a tuple of four random integers representing red, green, blue,
    and alpha (transparency) color components, each in the range [0, 255].
    
    Examples:
    >>> color = random_rgba_byte_color()
    >>> print(color)  # e.g., (127, 234, 89, 180)
    (127, 234, 89, 180)
    
    Returns:
        tuple: (red, green, blue, alpha) where each value is in [0, 255]
        
    Related Functions:
    - random_rgb_byte_color: RGB without alpha channel
    - random_grayscale_byte_color: Single grayscale value
    - random_rgba_float_color: Float values [0.0, 1.0]
    
    Tags: color, rgba, random, byte, alpha, transparency, graphics
    """
    return (randint(255),randint(255),randint(255),randint(255))
def random_grayscale_byte_color():
    """Generate a random grayscale color as a byte value (0-255).
    
    Returns a single random integer representing a grayscale intensity
    value in the range [0, 255], where 0 is black and 255 is white.
    
    Examples:
    >>> gray = random_grayscale_byte_color()
    >>> print(gray)  # e.g., 142
    142
    
    Returns:
        int: Grayscale intensity value in [0, 255]
        
    Related Functions:
    - random_rgb_byte_color: Full RGB color
    - random_rgba_byte_color: RGBA with alpha
    - random_grayscale_float_color: Float values [0.0, 1.0]
    
    Tags: color, grayscale, random, byte, intensity, monochrome
    """
    return (randint(255))

def random_rgb_float_color():
    return (random_float(1),random_float(1),random_float(1))
def random_rgba_float_color():
    return (random_float(1),random_float(1),random_float(1),random_float(1))
def random_grayscale_float_color():
    return (random_float(1))

def random_rgb_binary_color():
    return (random_chance(1/2),random_chance(1/2),random_chance(1/2))
def random_rgba_binary_color():
    return (random_chance(1/2),random_chance(1/2),random_chance(1/2),random_chance(1/2))
def random_grayscale_binary_color():
    return (random_chance(1/2))

def random_hex_color(hashtag=True):
    return byte_color_to_hex_color(random_rgb_byte_color())


#PLURAL VERSIONS
def random_rgb_byte_colors(N):
    """Generate multiple random RGB colors as byte values. See random_rgb_byte_color for details.
    
    Args:
        N: Number of colors to generate
        
    Returns:
        list: List of (red, green, blue) tuples with values in [0, 255]
    """
    return [random_rgb_byte_color() for _ in range(N)]
def random_rgba_byte_colors(N):
    """Generate multiple random RGBA colors as byte values. See random_rgba_byte_color for details.
    
    Args:
        N: Number of colors to generate
        
    Returns:
        list: List of (red, green, blue, alpha) tuples with values in [0, 255]
    """
    return [random_rgba_byte_color() for _ in range(N)]
def random_grayscale_byte_colors(N):
    """Generate multiple random grayscale colors as byte values. See random_grayscale_byte_color for details.
    
    Args:
        N: Number of colors to generate
        
    Returns:
        list: List of grayscale intensity values in [0, 255]
    """
    return [random_grayscale_byte_color() for _ in range(N)]

def random_rgb_float_colors(N):
    """Generate multiple random RGB colors as float values. See random_rgb_float_color for details.
    
    Args:
        N: Number of colors to generate
        
    Returns:
        list: List of (red, green, blue) tuples with values in [0.0, 1.0]
    """
    return [random_rgb_float_color() for _ in range(N)]
def random_rgba_float_colors(N):
    """Generate multiple random RGBA colors as float values. See random_rgba_float_color for details.
    
    Args:
        N: Number of colors to generate
        
    Returns:
        list: List of (red, green, blue, alpha) tuples with values in [0.0, 1.0]
    """
    return [random_rgba_float_color() for _ in range(N)]
def random_grayscale_float_colors(N):
    """Generate multiple grayscale float colors. See random_grayscale_float_color for details.
    Args: N (int) - number of colors to generate
    Returns: list of float values (0-1)"""
    return [random_grayscale_float_color() for _ in range(N)]

def random_rgb_binary_colors(N):
    """Generate multiple RGB binary colors. See random_rgb_binary_color for details.
    Args: N (int) - number of colors to generate
    Returns: list of (r,g,b) tuples with boolean values"""
    return [random_rgb_binary_color() for _ in range(N)]
def random_rgba_binary_colors(N):
    """Generate multiple RGBA binary colors. See random_rgba_binary_color for details.
    Args: N (int) - number of colors to generate
    Returns: list of (r,g,b,a) tuples with boolean values"""
    return [random_rgba_binary_color() for _ in range(N)]
def random_grayscale_binary_colors(N):
    """Generate multiple grayscale binary colors. See random_grayscale_binary_color for details.
    Args: N (int) - number of colors to generate
    Returns: list of boolean values"""
    return [random_grayscale_binary_color() for _ in range(N)]

def random_hex_colors(N, hashtag=True):
    """Generate multiple hex colors. See random_hex_color for details.
    Args: N (int) - number of colors to generate, hashtag (bool) - include '#' prefix
    Returns: list of hex color strings"""
    return [random_hex_color(hashtag=hashtag) for _ in range(N)]


def is_color_vector(color):
    """
    Check if input represents a color value (any numeric format).
    
    General-purpose color validation that accepts any iterable of numeric values.
    More permissive than specific color type validators (is_float_color, is_byte_color).
    Used as a first-pass validation before more specific color processing.
    
    Args:
        color: Input to test. Can be list, tuple, numpy array, or any iterable of numbers
        
    Returns:
        bool: True if color is iterable with all numeric elements, False otherwise
        
    Examples:
        >>> # Float color values
        >>> is_color_vector([0.5, 0.8, 0.2])
        True
        
        >>> # Integer/byte color values  
        >>> is_color_vector([128, 200, 50])
        True
        
        >>> # RGBA with alpha
        >>> is_color_vector([255, 128, 0, 200])
        True
        
        >>> # Non-numeric values (invalid)
        >>> is_color_vector(['red', 'green', 'blue'])
        False
        
        >>> # Single number (not a color)
        >>> is_color_vector(128)
        False
        
    Note: Used by uniform_float_color_image and blend_images. Base validation function
    in RP's color type system - more specific validators build on this.
    
    Related: is_float_color, is_byte_color, is_binary_color, is_iterable
    
    Tags: validation, colors, numeric, general-purpose
    """
    return is_iterable(color) and len(color) in {3,4} and all(is_number(x) for x in color)

def is_binary_color(color):
    """
    Check if input represents a binary color value (boolean values).
    
    Validates that input is an iterable containing numeric values with boolean datatypes.
    Used for binary/mask-based color processing where colors are represented as True/False.
    
    Args:
        color: Input to test. Can be list, tuple, numpy array, or any iterable of numbers
        
    Returns:
        bool: True if color is iterable with all boolean elements, False otherwise
        
    Examples:
        >>> # Binary color (boolean values)
        >>> is_binary_color([True, False, True])
        True
        
        >>> # All True (white in binary)
        >>> is_binary_color([True, True, True])
        True
        
        >>> # All False (black in binary)
        >>> is_binary_color([False, False, False])
        True
        
        >>> # Integer values (not binary)
        >>> is_binary_color([1, 0, 1])
        False
        
        >>> # Float values (not binary)
        >>> is_binary_color([1.0, 0.0, 1.0])
        False
        
    Note: Used by inverted_color for binary color inversion. Part of RP's color type 
    detection system alongside is_float_color and is_byte_color.
    
    Related: is_float_color, is_byte_color, is_color_vector, is_binary_image
    
    Tags: validation, colors, binary, boolean, mask-processing
    """
    return is_iterable(color) and all(is_number(x) for x in color) and all(np.asarray(x).dtype==bool for x in as_numpy_array(color))
def is_byte_color(color):
    """
    Check if input represents a byte/integer color value.
    
    Validates that input is an iterable containing numeric values with integer datatypes.
    Used throughout RP's color processing system to identify byte-based colors (typically 0-255 range).
    
    Args:
        color: Input to test. Can be list, tuple, numpy array, or any iterable of numbers
        
    Returns:
        bool: True if color is iterable with all integer numeric elements, False otherwise
        
    Examples:
        >>> # Byte RGB color (typical range 0-255)
        >>> is_byte_color([128, 200, 50])
        True
        
        >>> # Byte RGBA color
        >>> is_byte_color([255, 128, 0, 200])
        True
        
        >>> # Float color (not byte)
        >>> is_byte_color([0.5, 0.8, 0.2])
        False
        
        >>> # Mixed types (not valid)
        >>> is_byte_color([128, 0.5, 200])
        False
        
    Note: Used by color conversion functions like byte_color_to_hex_color and inverted_color.
    Part of RP's color type detection system alongside is_float_color and is_binary_color.
    
    Related: is_float_color, is_binary_color, is_color_vector, byte_color_to_hex_color
    
    Tags: validation, colors, byte, integer, datatypes, color-processing
    """
    return is_iterable(color) and all(is_number(x) for x in color) and all(np.issubdtype(x.dtype,np.integer) for x in as_numpy_array(color))

def is_float_color(color):
    """
    Check if input represents a floating-point color value.
    
    Validates that input is an iterable containing numeric values with floating-point datatypes.
    Used throughout RP's color processing system to identify float-based colors (typically 0.0-1.0 range).
    
    Args:
        color: Input to test. Can be list, tuple, numpy array, or any iterable of numbers
        
    Returns:
        bool: True if color is iterable with all floating-point numeric elements, False otherwise
        
    Examples:
        >>> # Float RGB color (typical range 0.0-1.0)
        >>> is_float_color([0.5, 0.8, 0.2])
        True
        
        >>> # Float RGBA color
        >>> is_float_color([0.1, 0.5, 0.9, 0.7])
        True
        
        >>> # Byte/integer color (not float)
        >>> is_float_color([128, 200, 50])
        False
        
        >>> # Mixed types (not valid)
        >>> is_float_color([0.5, 128, 0.3])
        False
        
    Note: Used by color manipulation functions like get_color_saturation, blend_images, 
    and inverted_color. Part of RP's color type detection system alongside is_byte_color 
    and is_binary_color.
    
    Related: is_byte_color, is_binary_color, is_color_vector, as_float_color
    
    Tags: validation, colors, float, datatypes, color-processing
    """
    return is_iterable(color) and all(is_number(x) for x in color) and all(np.issubdtype(x.dtype,np.floating) for x in as_numpy_array(color))

def hex_color_to_byte_color(hex_color:str):
    """
    EXAMPLE:
         >>> hex_color_to_byte_color('#007FFF')
        ans = (0, 127, 255)
    """
    if len(hex_color)==len('#ABCDEF'):
        hex_color=hex_color[1:]
    if not len(hex_color)==len('ABCDEF'): raise ValueError('Hex colors must be RGB')
    r=int(hex_color[0:2],16)
    g=int(hex_color[2:4],16)
    b=int(hex_color[4:6],16)
    return r,g,b
hex_color_to_tuple = hex_color_to_byte_color #This was its old name. This is here to preserve compatiability.

def hex_color_to_float_color(hex_color:str):
    """
    EXAMPLE:
        >>> hex_color_to_byte_color('#007FFF')
       ans = (0, .5, 1)
    """
    color=hex_color_to_byte_color(hex_color)
    return tuple(x/255 for x in color)

def byte_color_to_hex_color(byte_color:tuple, hashtag=True):
    """
    EXAMPLE:
       >>> byte_color_to_hex_color((0,255,127))
       ans = #00FF7F
    """
    assert is_byte_color(byte_color)

    byte_color = [int(min(255,max(0,x))) for x in byte_color]
    
    return ('#' if hashtag else '')+''.join('{:02X}'.format(a) for a in byte_color)


def byte_color_to_float_color(byte_color):
    """
    Converts byte color (0-255) to float color (0.0-1.0)
    EXAMPLE:
        >>> byte_color_to_float_color((0, 255, 127)) == (0.0, 1.0, 0.498...)
        >>> byte_color_to_float_color((255, 0, 0))   == (1.0, 0.0, 0.0)
    """
    return tuple(x/255 for x in byte_color)

def float_color_to_byte_color(float_color):
    """
    Enhanced Documentation:
    
    Converts float color values (0.0-1.0) to byte color values (0-255).
    
    Args:
        float_color (tuple): RGB color tuple with float values 0.0-1.0
    
    Returns:
        tuple: RGB color tuple with int values 0-255
    
    Examples:
        >>> rp.float_color_to_byte_color((1.0, 0.5, 0.0))
        (255, 128, 0)
        >>> rp.float_color_to_byte_color((0.0, 1.0, 0.5))
        (0, 255, 128)
    
    Related Functions:
        - byte_color_to_float_color(): Inverse conversion
        - float_colors_to_byte_colors(): Plural version
        - float_color_to_hex_color(): Convert to hex format
    
    Tags: color-conversion, float-to-byte, rgb
    """
    return tuple(round(clamp(x*255,0,255)) for x in float_color)

def float_color_to_hex_color(float_color, hashtag=True):
    """
    Enhanced Documentation:
    
    Converts float color values to hex color string representation.
    
    Args:
        float_color (tuple): RGB color tuple with float values 0.0-1.0
        hashtag (bool, optional): Include '#' prefix. Default: True
    
    Returns:
        str: Hex color string (e.g., '#FF8000' or 'FF8000')
    
    Examples:
        >>> rp.float_color_to_hex_color((1.0, 0.5, 0.0))
        '#FF8000'
        >>> rp.float_color_to_hex_color((1.0, 0.0, 0.0), hashtag=False)
        'FF0000'
    
    Tags: color-conversion, float-to-hex, rgb
    """
    return byte_color_to_hex_color(float_color_to_byte_color(float_color),hashtag=hashtag)

def float_color_to_byte_color(float_color):
    if isinstance(float_color, str): float_color = as_rgba_float_color(float_color)
    return tuple(round(clamp(x*255,0,255)) for x in float_color)

def float_colors_to_byte_colors(*float_colors):
    """Convert multiple float colors to byte colors. See float_color_to_byte_color for details.
    Args: *float_colors
    Returns: List of byte color tuples
    EXAMPLE: [(1.0, 0.5, 0.0), (0.5, 0.5, 0.5)] --> [(255, 128, 0), (128, 128, 128)]"""
    return [float_color_to_byte_color(x) for x in detuple(float_colors)]

def float_colors_to_hex_colors(*float_colors):
    """Convert multiple float colors to hex colors. See float_color_to_hex_color for details.
    Args: *float_colors
    Returns: List of hex color strings
    EXAMPLE: [(1.0, 0.5, 0.0), (0.0, 1.0, 0.5)] --> ['#FF8000', '#00FF80']"""
    return [float_color_to_hex_color(x) for x in detuple(float_colors)]

def byte_colors_to_hex_colors(*byte_colors):
    """Convert multiple byte colors to hex colors. See byte_color_to_hex_color for details.
    Args: *byte_colors
    Returns: List of hex color strings
    EXAMPLE: [(255, 128, 0), (128, 128, 128)] --> ['#FF8000', '#808080']"""
    return [byte_color_to_hex_color(x) for x in detuple(byte_colors)]
 
def byte_colors_to_float_colors(*byte_colors):
    """Convert multiple byte colors to float colors. See byte_color_to_float_color for details.
    Args: *byte_colors
    Returns: List of float color tuples
    EXAMPLE: [(255, 128, 0), (128, 128, 128)] --> [(1.0, 0.502, 0.0), (0.502, 0.502, 0.502)]"""
    return [byte_color_to_float_color(x) for x in detuple(byte_colors)]
 
def hex_colors_to_byte_colors(*hex_colors):
    """Convert multiple hex colors to byte colors. See hex_color_to_byte_color for details.
    Args: *hex_colors
    Returns: List of byte color tuples
    EXAMPLE: ['#FF8000', 'FF8000'] --> [(255, 128, 0), (255, 128, 0)]  # Both formats work"""
    return [hex_color_to_byte_color(x) for x in detuple(hex_colors)]
 
def hex_colors_to_float_colors(*hex_colors):
    """Convert multiple hex colors to float colors. See hex_color_to_float_color for details.
    Args: *hex_colors
    Returns: List of float color tuples
    EXAMPLE: ['#FF8000', 'FF8000'] --> [(1.0, 0.502, 0.0), (1.0, 0.502, 0.0)]  # Both formats work"""
    return [hex_color_to_float_color(x) for x in detuple(hex_colors)]
 


_altbw_flipflop=False
def _altbw():
    global _altbw_flipflop
    _altbw_flipflop=not _altbw_flipflop
    return _altbw_flipflop

_rp_colors = dict(
    # My color definitions
    red=(1.0, 0.0, 0.0),
    green=(0.0, 1.0, 0.0),
    blue=(0.0, 0.0, 1.0),
    cyan=(0.0, 1.0, 1.0),
    magenta=(1.0, 0.0, 1.0),
    yellow=(1.0, 1.0, 0.0),
    white=(1.0, 1.0, 1.0),
    black=(0.0, 0.0, 0.0),
    grey=(0.5, 0.5, 0.5),
    gray=(0.5, 0.5, 0.5),
    orange=(1.0, 0.5, 0.0),
    hotpink=(1.0, 0.0, 0.5),
    purple=(.5, 0.0, 0.75),
    # CSS3 Weird Names
    aqua=(0.0, 1.0, 1.0),
    lime=(0.0, 1.0, 0.0),
    fuchsia=(1.0, 0.0, 1.0),
    # CSS3 Names with perfect fractions
    chartreuse=(0.5, 1.0, 0.0),
    maroon=(0.5, 0.0, 0.0),
    navy=(0.0, 0.0, 0.5),
    olive=(0.5, 0.5, 0.0),
    teal=(0.0, 0.5, 0.5),
    silver=(0.8, 0.8, 0.8),
    dark=(0.0,0.0,0.0),
    light=(1.0,1.0,1.0),
    random=lambda:random_rgb_float_color(),
    randomgray=lambda:(random_float(),)*3,
    randomgrey=lambda:(random_float(),)*3,
    randomhue=lambda:hsv_to_rgb_float_color(random_float(),1,1),
    randombw=lambda:(float(random_chance()),)*3,
    altbw=lambda:(float(_altbw()),)*3,
)

_cached_rp_colors={}
def _get_rp_color(name):
    """
    Allows mixing of colors, like "blue green", "dark gray" and "light light red"
    "lightred" is equivalent to "light red" etc
    """
    import re

    if name in _cached_rp_colors:
        return _cached_rp_colors[name]

    def blend_colors(color_a, color_b):
        if callable(color_a):color_a=color_a()
        if callable(color_b):color_b=color_b()
        ra,ga,ba=color_a
        rb,gb,bb=color_b
        return ((ra+rb)/2,(ga+gb)/2,(ba+bb)/2)
    
    color_names = sorted(_rp_colors.keys(), key=len, reverse=True)
    color_regex = re.compile('|'.join(color_names))
    
    color = None
    color_names = []
    start_index = 0
    while True:
        match = color_regex.search(name, start_index)
        if not match:
            break
        
        color_name = match.group()
        color_names.append(color_name)
        start_index = match.end()
    
    if not color_names:
        raise ValueError("Unknown color: {}".format(name))
    
    if start_index != len(name):
        raise ValueError("Invalid color name: {}".format(name))
    
    for color_name in color_names[::-1]:
        if color is None:
            color = _rp_colors[color_name]
            if callable(color):
                color=color()
        else:
            color = blend_colors(color, _rp_colors[color_name])

    if not 'random' in name and not 'alt' in name:
        _cached_rp_colors[name]=color
    
    return color

def color_name_to_float_color(color_name: str):
    """
    Given a color name, this function returns an RGB float color
    EXAMPLE:
        assert color_name_to_float_color('green') == (0., 1., 0.)
    """
    assert isinstance(color_name, str), type(color_name)
    color_name = color_name.strip()
    color_name = color_name.lower()
    color_name = color_name.replace("_", "")
    color_name = color_name.replace(" ", "")
    
    try:
        return _get_rp_color(color_name)
    except ValueError:
        pass

    try:
        #It's ok! Try using CSS colors instead

        # Use CSS3 webcolors
        # There are only 147 webcolors - we can probably inline this
        pip_import("webcolors")
        import webcolors

        hex_color = webcolors.name_to_hex(color_name)
        float_color = hex_color_to_float_color(hex_color)
        return float_color
    except ValueError:
        #We failed to get it from CSS3 too!
        pass

    raise ValueError("rp.color_name_to_float_color: Unrecognized color name in either RP's color system or CSS3: "+repr(color_name))

def color_name_to_byte_color(color_name):
    """
    Given a color name, this function returns an RGB byte color (0-255 range)
    EXAMPLE:
        >>> color_name_to_byte_color('green')          == (0, 255, 0)
        >>> color_name_to_byte_color('light green')    == (128, 255, 128)
    """
    return float_color_to_byte_color(color_name_to_float_color(color_name))

def color_name_to_hex_color(color_name):
    """
    Given a color name, this function returns a hex color string
    EXAMPLE:
        >>> color_name_to_hex_color('green')      == '#00FF00'
        >>> color_name_to_hex_color('dark green') == '#008000'
        >>> color_name_to_hex_color('yellow')     == '#FFFF00'
    """
    return float_color_to_hex_color(color_name_to_float_color(color_name))

def get_color_hue(color):
    assert is_float_color(color),'For now, get_color_hue only works with float_colors and returns a float between 0 and 1'
    import colorsys
    hue=colorsys.rgb_to_hsv(*color)[0]
    return hue

def get_color_saturation(color):
    assert is_float_color(color),'For now, get_color_saturation only works with float_colors and returns a float between 0 and 1'
    import colorsys
    hue=colorsys.rgb_to_hsv(*color)[1]
    return hue

def get_color_brightness(color):
    assert is_float_color(color),'For now, get_color_brightness only works with float_colors and returns a float between 0 and 1'
    import colorsys
    hue=colorsys.rgb_to_hsv(*color)[2]
    return hue

def get_color_alpha(color):
    """Returns the alpha value of a color. Returns 1.0 for RGB colors, actual alpha for RGBA colors."""
    if is_color_vector(color) and len(color) == 3:
        return 1.0
    else:
        return as_rgba_float_color(color)[3]

def get_image_dimensions(image, *, as_dict=False):
    """ Return (height,width) of an image 
    
    Enhanced Documentation:
    Gets the dimensions (height, width) of an image in pixels. Works with all
    RP-supported image formats including numpy arrays, PIL images, torch tensors,
    and Skia images.
    
    Args:
        image: Image in any supported format (numpy array, PIL, torch tensor, Skia)
        as_dict (bool): If True, return as dictionary with 'height' and 'width' keys
        
    Returns:
        tuple[int, int] or dict: (height, width) tuple, or dict if as_dict=True
        
    Examples:
        >>> import numpy as np
        >>> img = np.zeros((100, 200, 3), dtype=np.uint8)
        >>> get_image_dimensions(img)
        (100, 200)
        >>> get_image_dimensions(img, as_dict=True)
        {'height': 100, 'width': 200}
        
    Related Functions:
        - get_image_height(): Get height only
        - get_image_width(): Get width only  
        - get_max_image_dimensions(): Get max dimensions across multiple images
        - resize_image_to_fit(): Resize image to fit dimensions
        
    Tags: image, dimensions, size, height, width
    """
    assert is_image(image) or is_torch_image(image), type(image)
    height, width =  get_image_height(image),get_image_width(image)
    if as_dict:
        return gather_vars('height width')
    else:
        return height, width

#def get_images_dimensions(*images):
#    """ Return [(height,width), ...] for all given images """
#    #SCRAPPED THIS FUNCTION
#    #BECUASE ITS AMBIGUOUS IF WE GIVE SINGLE RGB IMAGE IN
#    #AND THEN IT RETURNS LIKE [(1920, 3), (1920, 3), (1920, 3), (1920, 3), (1920, 3) ...]
#    #BECAUSE WE DONT KNOW IF THE IMAGES ARE ALL GRAYSCALE OR NOT
#    images=detuple(images)
#
#    if _is_numpy_array(images):
#        #Shortcut - make things faster if we're given a tensor of images!
#        num_images=len(images)
#        assert is_image(images[0]), 'All inputs must be images'
#        assert images.ndim>=3, 'Internal assertion' #(B, H, W) or (B, H, W, C)
#        return [images.shape[1:3]]*num_images
#
#    return [get_image_dimensions(x) for x in images]

def get_image_height(image):
    """
    Return the image's height measured in pixels
    
    Enhanced Documentation:
    Gets the height of an image in pixels. Handles different image formats
    including numpy arrays (HW, HW3, HW4), PIL images, torch tensors (CHW),
    and Skia images.
    
    Args:
        image: Image in any supported format
        
    Returns:
        int: Height of the image in pixels
        
    Examples:
        >>> import numpy as np
        >>> img = np.zeros((100, 200, 3), dtype=np.uint8)
        >>> get_image_height(img)
        100
        
    Related Functions:
        - get_image_width(): Get image width
        - get_image_dimensions(): Get both height and width
        - get_max_image_dimensions(): Get max dimensions across multiple images
        
    Tags: image, height, size, pixels, dimensions
    """
    if is_torch_image(image): return image.shape[1] #Assumes a CHW image
    assert is_image(image), type(image)
    if is_pil_image(image):return image.height
    if _is_skia_image(image):return image.height()
    return image.shape[0]

def get_image_width(image):
    """
    Return the image's width measured in pixels
    
    Enhanced Documentation:
    Gets the width of an image in pixels. Handles different image formats
    including numpy arrays (HW, HW3, HW4), PIL images, torch tensors (CHW),
    and Skia images.
    
    Args:
        image: Image in any supported format
        
    Returns:
        int: Width of the image in pixels
        
    Examples:
        >>> import numpy as np
        >>> img = np.zeros((100, 200, 3), dtype=np.uint8)
        >>> get_image_width(img)
        200
        
    Related Functions:
        - get_image_height(): Get image height
        - get_image_dimensions(): Get both height and width
        - get_max_image_dimensions(): Get max dimensions across multiple images
        
    Tags: image, width, size, pixels, dimensions
    """
    if is_torch_image(image): return image.shape[2] #Assumes a CHW image
    assert is_image(image), type(image)
    if is_pil_image(image):return image.width
    if _is_skia_image(image):return image.width()
    return image.shape[1]

def get_video_height(video):
    """
    Get height of video from different formats (THWC NumPy, TCHW torch, or sequence of images).

    Examples:
        >>> import numpy as np  
        >>> video = np.random.rand(10, 480, 640, 3)  # THWC: 10 frames, 480x640, RGB
        >>> get_video_height(video)
        480
        >>> images = [np.random.rand(100, 200, 3), np.random.rand(150, 300, 3)]  # Different sizes  
        >>> get_video_height(images)  # Returns max height
        150

    Tags: video, dimensions, height
    """
    if is_numpy_array (video) and video.ndim==4:return video.shape[ 1] #THWC form
    if is_torch_tensor(video) and video.ndim==4:return video.shape[-2] #TCHW form
    return max(map(get_image_height, video))

def get_video_heights(*videos):
    """
    Get heights from multiple videos. Plural version of get_video_height.
    
    Enhanced Documentation:
    Applies get_video_height to each input video and returns list of heights.
    Accepts either multiple video arguments or a single list of videos.
    
    Usage patterns:
    - Processing batches of videos for resizing operations
    - Getting dimensions before video concatenation/stacking
    - Used by resize_videos_to_min_size and resize_videos_to_max_size
    
    Parameters:
        *videos: Either multiple video objects, or a single iterable of videos
                Each video can be NumPy array (THWC), torch tensor (TCHW), or image sequence
    
    Returns:
        list[int]: Height of each input video
        
    Examples:
        >>> import numpy as np
        >>> video1 = np.random.rand(10, 480, 640, 3)  # 10 frames, 480x640
        >>> video2 = np.random.rand(5, 360, 800, 3)   # 5 frames, 360x800
        >>> get_video_heights(video1, video2)
        [480, 360]
        >>> get_video_heights([video1, video2])  # Same result
        [480, 360]
        
    Related functions: get_video_height, get_video_widths, get_max_video_dimensions
    Tags: video, dimensions, height, plural, batch
    """
    videos = detuple(videos)
    return [get_video_height(x) for x in videos]

def get_video_width(video):
    """
    Get width of video from different formats (THWC NumPy, TCHW torch, or sequence of images).

    Examples:
        >>> import numpy as np
        >>> video = np.random.rand(10, 480, 640, 3)  # THWC: 10 frames, 480x640, RGB
        >>> get_video_width(video)
        640
        >>> images = [np.random.rand(100, 200, 3), np.random.rand(150, 300, 3)]  # Different sizes
        >>> get_video_width(images)  # Returns max width
        300

    Tags: video, dimensions, width
    """
    if is_numpy_array (video) and video.ndim==4:return video.shape[ 2] #THWC form
    if is_torch_tensor(video) and video.ndim==4:return video.shape[-1] #TCHW form
    return max(map(get_image_width, video))

def get_video_widths(*videos):
    """
    Get widths from multiple videos. Plural version of get_video_width.
    
    Enhanced Documentation:
    Applies get_video_width to each input video and returns list of widths.
    Accepts either multiple video arguments or a single list of videos.
    
    Usage patterns:
    - Processing batches of videos for resizing operations
    - Getting dimensions before video concatenation/stacking
    - Used by resize_videos_to_min_size and resize_videos_to_max_size
    
    Parameters:
        *videos: Either multiple video objects, or a single iterable of videos
                Each video can be NumPy array (THWC), torch tensor (TCHW), or image sequence
    
    Returns:
        list[int]: Width of each input video
        
    Examples:
        >>> import numpy as np
        >>> video1 = np.random.rand(10, 480, 640, 3)  # 10 frames, 480x640
        >>> video2 = np.random.rand(5, 360, 800, 3)   # 5 frames, 360x800
        >>> get_video_widths(video1, video2)
        [640, 800]
        >>> get_video_widths([video1, video2])  # Same result
        [640, 800]
        
    Related functions: get_video_width, get_video_heights, get_max_video_dimensions
    Tags: video, dimensions, width, plural, batch
    """
    videos = detuple(videos)
    return [get_video_width(x) for x in videos]

#TODO: Finish color conversions

#endregion

@memoized
def running_in_ipython():
    """Detects if the Python interpreter is running within an IPython/Jupyter environment.
    
    This function checks for the presence of IPython by attempting to import and call 
    get_ipython(). It's useful for conditional behavior where code should behave 
    differently in interactive notebook environments vs regular Python scripts.
    
    Enhanced Documentation:
    
    Usage Patterns:
    - Enable inline plotting and rich display in notebooks
    - Switch between different display mechanisms (inline vs external)
    - Conditional import of notebook-specific libraries
    - Adapt output formatting for interactive environments
    
    Examples:
    >>> # Basic usage
    >>> is_interactive = running_in_ipython()
    >>> print(f"In IPython/Jupyter: {is_interactive}")
    In IPython/Jupyter: False
    
    >>> # Conditional display behavior
    >>> if running_in_ipython():
    ...     # In notebook - use inline display
    ...     display_image(img)  # Shows inline
    ... else:
    ...     # In script - open external viewer
    ...     display_image(img)  # Opens separate window
    
    >>> # Conditional import of notebook features
    >>> if running_in_ipython():
    ...     from IPython.display import HTML, display
    ...     display(HTML("<h3>Rich notebook content</h3>"))
    
    Args:
        None
        
    Returns:
        bool: True if running in IPython/Jupyter environment, False otherwise
        
    Related Functions:
    - running_in_jupyter_notebook: Alias for this function (identical)
    - Used by: display_image, cv_imshow, play_sound_from_samples, visualize_pytorch_model
    
    Implementation Notes:
    - Safely handles ImportError if IPython is not installed
    - Uses IPython's get_ipython() to detect the environment
    - Returns False for any exception during detection
    
    Tags: environment, detection, ipython, jupyter, interactive, conditional
    """
    try:
        #Can detect if we're in a jupyter notebook
        # pip_import('IPython') #nooo duhh, if we don't have this installed then obviously we're not running in it...
        from IPython import get_ipython
        return get_ipython() is not None
    except Exception:
        return False#If we get an error when trying to import IPython...then..well, we're definately NOT running in iPython!
running_in_jupyter_notebook=running_in_ipython

class JupyterImageChannel:
    """
    A class for displaying and updating images dynamically in Jupyter notebooks.
    
    Creates an image display channel that can be updated in-place without creating
    new output cells. Useful for animations, live plots, or iterative visualizations.
    
    Example:
        >>> channel = JupyterImageChannel()
        >>> channel.display()  # Shows initial placeholder
        >>> channel.update(my_image)  # Updates same display area
    """
    def __init__(self, name=None):
        assert rp.running_in_jupyter_notebook()
        
        self._display_id = rp.random_namespace_hash(4)
        self._init_update()

    def _get_html(self, image) -> str:
        """Overwrite this when subclassing"""
        base64_image = rp.encode_image_to_base64(image)
        return '<img src="data:image/png;base64,%s"/>' % base64_image

    def _init_update(self):
        """Overwrite this when subclassing"""
        self.update(rp.cv_text_to_image(self._display_id, color=rp.random_rgb_byte_color()))

    def display(self):
        """Adds a new viewport"""
        from IPython.display import display, HTML
        display(self._html, display_id=self._display_id)
        return self
    
    def update(self, image):
        """Updates the viewport"""
        from IPython.display import update_display
        self._html = HTML(self._get_html(image))
        update_display(self._html, display_id=self._display_id)
        return self

def get_notebook_name(*,include_file_extension=False):
    """
    Assumes we're running in a Jupyter notebook
    Returns the name of the notebook
    By default, does not include the file extension
    EXAMPLE:
        get_notebook_path()                             -->  "/path/to/notebook.ipynb"
        get_notebook_name()                             -->           "notebook"
        get_notebook_name(include_file_extension=True)  -->           "notebook.ipynb"
    """
    assert running_in_jupyter_notebook(), "rp.get_notebook_name: called outside of a Jupyter notebook"
    
    path = get_notebook_path()
    return get_file_name(path, include_file_extension=include_file_extension)

def get_notebook_path():
    """
    Assumes we're running in a Jupyter notebook
    Returns an absolute path of the notebook file
    EXAMPLE:
        get_notebook_path()  -->  "/path/to/notebook.ipynb"
    """
    assert running_in_jupyter_notebook(), "rp.get_notebook_path: called outside of a Jupyter notebook"
    
    #Solution from: https://stackoverflow.com/questions/62815318/get-current-jupyter-lab-notebook-name-for-jupyter-lab-version-2-1-and-3-0-1-and
    pip_import("ipynbname")
    import ipynbname
    return str(ipynbname.path())

#Commented this out because ipynbname returned the wrong answer! Idk why but it returned a path to a notebook that didn't exist...see the example; it got the class wrong somehow...
#def get_current_notebook_path()->str:
#    #Returns the path of the current jupyter notebook
#    #Example:
#    #    get_current_notebook_path() --->  '/home/ryan/CleanCode/SBU/Classes/CSE512_Machine_Learning/project_guided/CSE527_HW5_fall20.ipynb'
#    #
#    assert running_in_jupyter_notebook(),'get_current_notebook_path() must be called from inside a Jupyter Notebook'    
#    pip_import('ipynbname')
#    import ipynbname
#    path=ipynbname.path()
#    path=str(path)
#    return path
#def get_current_notebook_folder()->str:
#    return get_parent_folder(get_current_notebook_path())
#get_current_notebook_directory=get_current_notebook_folder

def launch_terminal_in_colab(height='400',fullscreen=False):
    """
    Launches a full terminal right inside of google colab, right in the notebook itself!
    Note: You might want to set TERM=xterm-color and some other environment variables
    Based on https://colab.sandbox.google.com/gist/afvanwoudenberg/904ae132a578fb61f8bd1149b0dc6b53/terminal.ipynb#scrollTo=UpsaRfQ7vQaW
    """

    height=400 #It didn't seem 
    width='100%'
    port=8000

    assert running_in_google_colab()
    from google.colab.output import serve_kernel_port_as_window
    from google.colab.output import serve_kernel_port_as_iframe

    if not system_command_exists('shellinaboxd'):
        print('Installing shellinabox...')
        os.system('apt install shellinabox &> /dev/null')

    os.system(r'nohup shellinaboxd --disable-ssl --no-beep --port=8000 --css /etc/shellinabox/options-enabled/00_White\ On\ Black.css -s "/:root:root:/root:/bin/bash -c bash -i" &> /dev/null &')
    os.system('yes | /usr/local/sbin/unminimize &> /dev/null')

    if fullscreen:
        serve_kernel_port_as_window(port)
    else:
        serve_kernel_port_as_iframe(port)

def running_in_google_colab():
    """
    Return true iff this function is called from google colab
    """
    import sys
    return 'google.colab' in sys.modules

@memoized
def get_cloud_provider():
    """ WARNING: This can be slow if there's actually no cloud! Like, on a laptop, this can take a while... """
    pip_import('cloud_detect','cloud-detect')
    import cloud_detect
    return cloud_detect.provider(timeout=1)

def running_in_gcp():
    return get_cloud_provider()=='gcp'

  
def _is_python_exe_root(root):
    exe=sys.executable
    if currently_running_windows():
        return exe==path_join(root,'python.exe')
    else: 
        return get_path_parent(exe)==path_join(root,'bin') and 'python' in get_file_name(exe).lower() # python python3 python3.10

def running_in_ssh():
    """
    Returns True iff this Python session was started over SSH
    https://stackoverflow.com/questions/35352921/how-to-check-if-python-script-is-being-called-remotely-via-ssh
    """
    return 'SSH_CLIENT' in os.environ or 'SSH_TTY' in os.environ or 'SSH_CONNECTION' in os.environ

def running_in_mamba():
    "Returns True if this python process is from a Mamba environment, and False otherwise"
    return 'MAMBA_ROOT_PREFIX' in os.environ and running_in_conda() #TODO: Make this more robust, for both Windows AND Unix

def running_in_conda():
    "Returns True if this python process is from a Conda environment, and False otherwise"
    key='CONDA_PREFIX'
    env=os.environ
    if key not in env:
        return False
    return _is_python_exe_root(env[key])

def running_in_venv():
    "Returns True if this python process is from a virtual environment (venv), and False otherwise"
    key='VIRTUAL_ENV'
    env=os.environ
    if key not in env:
        return False
    return _is_python_exe_root(env[key])

def get_conda_name():
    "Returns the name of the current conda environment. Throws an error if it can't find it."
    key='CONDA_DEFAULT_ENV'
    env=os.environ
    if key not in env:
        assert not running_in_conda(), 'If this assertion fails, I misunderstood how conda affects environment variables. This assertion should never fail.'
        raise KeyError("r.get_conda_env_name: Can't get the name of the current conda environment - CONDA_DEFAULT_ENV is not set. Are you sure this python process is running in conda?")
    return env[key]

def get_venv_name():
    "Returns the name of the current python virtual environment (venv). Throws an error if it can't find it."
    key='VIRTUAL_ENV'
    env=os.environ
    if key not in env:
        assert not running_in_venv(), 'If this assertion fails, I misunderstood how venv affects environment variables. This assertion should never fail.'
        raise KeyError("r.get_conda_env_name: Can't get the name of the current python virtual environment (venv) - VIRTUAL_ENV is not set. Are you sure this python process is running in a python virtual environment?")
    return get_folder_name(env[key])

def running_in_tmux():
    """Checks if the current process is running inside a tmux session."""
    return 'TMUX' in os.environ

def running_in_docker():
    """Returns True if we're in docker, False otherwise"""
    #https://stackoverflow.com/questions/43878953/how-does-one-detect-if-one-is-running-within-a-docker-container-within-python
    from pathlib import Path
    cgroup = Path('/proc/self/cgroup')
    return Path('/.dockerenv').is_file() or cgroup.is_file() and 'docker' in cgroup.read_text()

def running_in_marimo_notebook():
    """Returns True if we're running in Marimo environment, False otherwise"""
    try:
        # Try to import marimo's runtime context functions
        from marimo import running_in_notebook
        # Check if a Marimo runtime context is actually installed/active
        return running_in_notebook()
    except ImportError:
        # Marimo is not available
        return False


def split_tensor_into_regions(tensor,*counts,flat=True,strict=False):
    """
    Return the tensor into multiple rectangular regions specified by th number of cuts we make on each dimension.
    Uses: Splitting pictures that contain multiple entried of the mnist dataset into usable chunks of data
     Let ‹a,b,c...› represent some numpy tensor with shape (a,b,c...)
     If strict==True, then all of tensor.shape's dimensions must evenly divide counts. Otherwise, if strict==False, tensor will be automatically cropped to accomodate the given *counts
     EXAMPLES:
         split_tensor_into_regions(‹a,b,c›, x, y, flat=False)  ->  ‹x, y, a//x, b//y, c›  #Think of c as 3, and a and b the width of some RGB image
         split_tensor_into_regions(‹a,b,c›, x, y, flat=True )  ->  ‹x*y , a//x, b//y, c›  #Instead of addresing the regions by coordinates, we get a flat list of regions
         split_tensor_into_regions(‹a,b,c›, x, y, z, flat=True )  ->  ‹x*y*z  , a//x, b//y, c//z›
         split_tensor_into_regions(‹a,b,c›, x, y, z, flat=False)  ->  ‹x, y, z, a//x, b//y, c//z›
         split_tensor_into_regions(‹a,b,c,d›, x, y, z, flat=False)  ->  ‹x, y, z, a//x, b//y, c//z, d›
         split_tensor_into_regions(‹a,b,c,d›, x, flat=False)  ->  ‹x, a//x, b, c, d›
         split_tensor_into_regions(‹a,b,c,d›, x, flat=True )  ->  ‹x, a//x, b, c, d›      #Flattening doesnt make a difference when we only split on one dimension
         split_tensor_into_regions(‹a,b,c,d›)  ->  ‹a, b, c, d›      #Passing no arguments to 'counts' just returns the value of the original tensor. Of course, the flat argument doesnt make a difference here as there are no splitting dimensions to flat.
    EXAMPLE APPLICATION:
       http://www.cs.unc.edu/~lazebnik/research/spring08/faces.jpg
       Look at that picture. You'll see an array of 10*10 faces. We need to pre-process this image to extract the individual faces.
       output=split_tensor_into_regions(‹that image›,10,10,flat=False)  (it doesn't matter if the image is RGB or not, it can handle either case)
       Then, output[0,0] is the upper left face, and output[0,1] is one-to-the-right of the top left image
       Also, output[1,:] is a list of all the faces on the second row ([face1,face2,face3...])
       But maybe we want to do PCA on all these images. We don't want to address these faces by coordinate anymore; we want to get rid of that information.
       We just want one big flattened list of faces. To do this, we would instead use
       output=split_tensor_into_regions(‹that image›,10,10,flat=True)
       This would give us a list of numpy arrays containing every face in the image, such that output[35] would exist (and, because it's a 10x10 faces image, give us the 5th face on the third row (taking into account starting at the 0th index))
    """

    tensor=np.asarray(tensor)

    assert len(counts)<=len(tensor.shape),'We can\'t split a tensor of shape '+str(shape)+' along '+str(len(counts))+' of its dimensions becuase it only has '+str(len(shape))+' dimensions'
        
    if not strict:
        #Try to crop the tensor to make sure it evenly divides counts
        slices=[]
        for size,count in zip(tensor.shape,counts):
            slices.append(slice(0,size//count*count))
        slices=tuple(slices)
        tensor=tensor[slices]

    shape=tensor.shape
    new_shape=list(shape)

    for index,count in reversed(list(enumerate(counts))):
        assert isinstance(count,int) and count>0,'All arguments to "counts" should be positive integers representing how many pieces we should slice the tensor in their respective dimension'
        assert shape[index]==new_shape[index],'Internal logical assertion. This should never fail.'
        assert not shape[index]%count,'All counts should evenly divide their respective dimension in the tensor, but '+str(shape[index])+'%'+str(count)+'!=0'
        new_shape[index]//=count
        new_shape.insert(index,count)

    def f(n,m):
        #This is a small helper function to create transpose_axes
        #EXAMPLES:
        # * Note: The | in the outputs is just for visual purposes to help you see the pattern faster
        # f(0,0)   -->   []
        # f(2,0)   -->   [0 2|1 3]
        # f(3,0)   -->   [0 2 4|1 3 5]
        # f(4,0)   -->   [0 2 4 6|1 3 5 7]
        # f(4,1)   -->   [0 2 4 6|1 3 5 7|8]
        # f(4,2)   -->   [0 2 4 6|1 3 5 7|8 9]
        # f(4,3)   -->   [0 2 4 6|1 3 5 7|8 9 10]
        # f(3,3)   -->   [0 2 4|1 3 5|6 7 8]
        # f(2,3)   -->   [0 2|1 3|4 5 6]
        # f(1,3)   -->   [0|1|2 3 4]
        # f(0,3)   -->   [0 1 2]
        o=list(range(2*n+m))
        a=o[0:2*n+0:2]
        b=o[1:2*n+1:2]
        o[0*n:1*n]=a
        o[1*n:2*n]=b
        return o
    transpose_axes=f(len(counts),len(shape)-len(counts))

    out=tensor.reshape(new_shape).transpose(transpose_axes)

    if flat:
        out=out.reshape(*(np.prod(out.shape[:len(counts)]),*out.shape[len(counts):]))

    return out

def apply_tensor_mapping(indices_tensor, mapping_tensor):
    """
    The final dim of the indices_tensor is mapped to its corresponding address in mapping tensor

    More simply, output[x,y]=mapping_tensor[indices_tensor[x,y]] for all x,y
    It uses broadcasting, so that can change the output shape depending on the length of the indices.

    SLOWER SIMPLER VERSION:
        def apply_tensor_mapping_slowly(indices_tensor, mapping_tensor):
            indices_tensor = indices_tensor.astype(int)
            mapping_tensor = mapping_tensor.astype(int)
            
            indices_shape = indices_tensor.shape
            mapping_shape = mapping_tensor.shape

            assert len(mapping_shape) >= indices_shape[-1]
            
            output_shape = indices_shape[:-1] + mapping_shape[indices_shape[-1]:]
            output = np.zeros(output_shape)

            for indices in itertools.product(*[range(dim) for dim in output_shape]):
                tensor_indices = indices[:len(indices_shape[:-1])]
                output_indices = indices[len(indices_shape[:-1]):]
                
                output[indices] = mapping_tensor[*indices_tensor[tensor_indices], *output_indices]

            return output
    """
    indices_tensor = indices_tensor.astype(int)

    indices_shape = indices_tensor.shape
    mapping_shape = mapping_tensor.shape

    assert len(mapping_shape) >= indices_shape[-1]

    T = indices_shape[:-1]
    M = mapping_shape[indices_shape[-1]:]

    output_shape = T + M

    # Flatten indices_tensor until the last dimension
    indices = indices_tensor.reshape(-1, indices_shape[-1]).T

    # Use this as index to the mapping_tensor
    output = mapping_tensor[tuple(indices)]

    # Reshape the result back to the output shape
    output = output.reshape(output_shape)

    return output

def bordered_image_solid_color(image,color=(1.,1.,1.,1.),thickness=1,width=None,height=None,top=None,bottom=None,left=None,right=None):
    """
    Add a pixel border of color around the image with a solid color
    Currently converts the input image into floating-point rgba
    You can specify the border by thickness (controls top,bottom,left and right all at once)
    You can override that thickness by setting width or height to some values (height overrides top and bottom if they're None and width overrides left and right if they're None)
    Negative thicknesses go into the image
    You can override top, bottom, left and right manually (if these are set, they override anything set by width or height etc)

    EXAMPLE:
        >>> for thickness in range(-100, 101):
        ...     display_image(
        ...         crop_image(
        ...             bordered_image_solid_color(
        ...                 uniform_float_color_image(200, 200, (0.25, 0.5, 1, 1)),
        ...                 thickness=10,
        ...                 height=thickness,
        ...             ),
        ...             height=400,
        ...             width=400,
        ...             origin="center",
        ...         )
        ...     )
    """
    
    color = as_rgba_float_color(color)
    assert len(color)==4,'Color must be rgba floats'

    #Inheritance options (better than having to specify top, bottom, left and right all manually if that would be redundant)
    width =thickness if width  is None else width
    height=thickness if height is None else height
    top   =height    if top    is None else top
    bottom=height    if bottom is None else bottom
    left  =width     if left   is None else left
    right =width     if right  is None else right

    #We convert the image into rgba-floating point format (with colors between 0 and 1)
    image=as_numpy_image(image,copy=False)
    image=as_rgba_image (image,copy=False)
    image=as_float_image(image,copy=False)

    colorize = lambda x: x*0+[[list(color)]]

    #A negative thickness means it points inward: it means the image size doesn't change.
    if top   <0: image[       :-top ]=colorize(image[       :-top ]) ; top   =0
    if bottom<0: image[bottom :     ]=colorize(image[ bottom:     ]) ; bottom=0
    if left  <0: image[:,     :-left]=colorize(image[:,     :-left]) ; left  =0
    if right <0: image[:,right:     ]=colorize(image[:,right:     ]) ; right =0

    #assert thickness>=0,'Cannot have a negative border thickenss (measured in pixels)'
    #assert height>=0 and width>=0,'Cannot have a negative border height or width (measured in pixels)'
    #assert top>=0 and bottom>=0 and left>=0 and right>=0,'Cannot have a negative border thickness top, bottom, left or right (measured in pixels)'

    top_pad   =top    and uniform_float_color_image(top   , get_image_width(image), color)
    bottom_pad=bottom and uniform_float_color_image(bottom, get_image_width(image), color)
    image=np.concatenate((( top_pad,)*bool(top )+ (image,) +(bottom_pad,)*bool(bottom)),axis=0)#Add top and bottom borders
    
    left_pad  =left   and uniform_float_color_image(get_image_height(image),  left, color)
    right_pad =right  and uniform_float_color_image(get_image_height(image), right, color)
    image=np.concatenate(((left_pad,)*bool(left)+ (image,) +(right_pad ,)*bool(right )),axis=1)#Add left and right borders
    
    return image

def bordered_images_solid_color(*images,color=(1.,1.,1.,1.),thickness=1,width=None,height=None,top=None,bottom=None,left=None,right=None):
    """ Plural of rp.bordered_image_solid_color """
    images=detuple(images)
    return [bordered_image_solid_color(x, color, thickness, width, height, top, bottom, left, right) for x in images]

def get_principle_components(tensors,number_of_components=None):
    """
    Returns orthogonal, normalized, sorted-by-eigenvalue-in-descending-order principle components (retaining the shape of the original tensors, to make eigenfaces easy to extract for example)
    For example, if we feed get_principle_components a list of images, we expect to return a list of images (not a list of vectors, like most PCA implementations require). This is for the sake of convenience.
    NOTE: This function also works on RGB images just like it does grayscale images (like in the demo)
    EXAMPLE:
       def demo(image_path,rows,cols):
           image=load_image(image_path,use_cache=True)
           faces=split_tensor_into_regions(image,rows,cols,flat=True)
           eigenfaces=get_principle_components(faces,number_of_components=20)
           print('Displaying principle components...')
           for face in eigenfaces:#Note how they get progressively more noisy the further down the components we go
               display_image(full_range(face))
               sleep(.5)
           print('Showing a few reconstructions...')
           for face in shuffled(faces)[:20]:
               face=face-face.mean()
               face=normalized(face)
               coeffs=(face*eigenfaces).sum(1).sum(1)
               reconstructed_face=np.sum(eigenfaces*np.expand_dims(np.expand_dims(coeffs,1),1),0)
               display_image(horizontally_concatenated_images(full_range(face),full_range(reconstructed_face)))
               sleep(.5)
       print("Eigenfaces demo:")
       demo("http://www.cs.unc.edu/~lazebnik/research/spring08/faces.jpg",10,10)
       print("Fashion MNIST demo:")
       demo("https://github.com/zalandoresearch/fashion-mnist/raw/master/doc/img/fashion-mnist-sprite.png",30,30)
    """
    cv2=pip_import('cv2')

    tensors=np.asarray(tensors)
    number_of_tensors=len(tensors)
    tensor_shape=tensors[0].shape
    assert len(tensor_shape)>=1,'This is a bug. I dont know why this breaks opencv, but your tensor must have more dimensions. Maybe I can build a workaround that inserts dimensions temporarily, but I\'ll leave that for another day'
    if number_of_components is None:
        #Warning: this is the max possible value, and can be quite slow
        number_of_components=min(number_of_tensors,100)#This cap at 100 is for safety's sake. The opencv code can't be cancelled with a KeyboardInterrupt, so if you give it too much to calculate you're stuck rebooting rp
    number_of_components=min(number_of_tensors,number_of_components)
    vectorized_tensors=tensors.reshape(number_of_tensors,np.prod(tensor_shape))
    mean, eigenvectors=cv2.PCACompute(vectorized_tensors, mean=None, maxComponents=number_of_components)#Why mean=None? See https://stackoverflow.com/questions/47016617/how-to-use-the-pcacompute-function-from-python-in-opencv-3
    principle_components=eigenvectors.reshape(number_of_components,*tensor_shape)
    principle_components=np.asarray(list(map(normalized,principle_components)))#Each principle component is almost, but not quite perfectly normalized by opencv (one had a magnitude of 1.0000377) but we're going to normalize them again for even more precision. Side-note: it's ok to use a python-loop here because there shouldn't be many principle_components to begin with (that's very slow)
    assert len(principle_components)==len(eigenvectors)==number_of_components,'This is an internal assertion that should never fail'
    return principle_components

def cv_box_blur(image,diameter=3,width=None,height=None,*,alpha_weighted=False):
    """ 
    A box blur using opencv. Width and height override diameter. See cv_gauss_blur for examples of alpha_weighted 
    EXAMPLE:
        >>> for sigma in range(100):
        ...    # This example shows the difference between regular gauss blur and this function
        ...    def create_test_image():
        ...        import cv2
        ...
        ...        height = width = 300
        ...        image = np.zeros((height, width, 4), dtype=np.uint8)
        ...        cv2.line(image, (50, 100), (250, 100), (0, 255, 0, 255), 10)
        ...        cv2.line(image, (50, 150), (250, 150), (255, 255, 0, 255), 10)
        ...        cv2.line(image, (50, 200), (250, 200), (255, 0, 0, 255), 10)
        ...        return image
        ...
        ...    test_image = create_test_image()
        ...    alpha_blurred = cv_box_blur(test_image, sigma, alpha_weighted=True)
        ...    regular_blurred = cv_box_blur(test_image, sigma, alpha_weighted=False)
        ...    display_alpha_image(
        ...        labeled_image(
        ...            grid_concatenated_images(
        ...                [
        ...                    labeled_images(
        ...                        with_alpha_checkerboards(
        ...                            test_image,
        ...                            alpha_blurred,
        ...                            regular_blurred,
        ...                            tile_size=10,
        ...                        ),
        ...                        [
        ...                            "RGB Blur",
        ...                            "Alpha-Weighted RGBA Blur",
        ...                            "Regular RGBA Blur",
        ...                        ],
        ...                        size_by_lines=True,
        ...                    ),
        ...                    as_rgb_images([test_image, alpha_blurred, regular_blurred]),
        ...                ],
        ...                origin="bottom",
        ...            ),
        ...            "rp.cv_box_blur : sigma = %i" % sigma,
        ...            size=30,
        ...        )
        ...    )
    """
    if alpha_weighted:
        return _alpha_weighted_rgba_image_func(
            cv_box_blur,
            image,
            diameter=diameter,
            width=width,
            height=height,
            alpha_weighted=False,
        )

    cv2=pip_import('cv2')
    image=np.asarray(image)
    image=_prepare_cv_image(image)
    width =diameter if width  is None else width
    height=diameter if height is None else height
    assert width>=0 and height>=0
    if not width or not height:
        return image.copy()
    return cv2.blur(image,(width,height))

def _highlighted_query_results(string,query):
    """
    Case insensitive fansi-highlighting of a query in a string
    Example: print(_highlighted_query_results('Hello, world wORld hello woRld!','world'))#All the 'world','wORld', etc's are printed green and bold
    """
    i=string.lower().find(query.lower())
    if i==-1:return string#No matches -> no highlighting.
    s =string[:i]
    j=i+len(query)
    s+=fansi(string[i:j],'green','bold')
    s+=_highlighted_query_results(string[j:],query)
    return s

def _rinsp_search_helper(root,query,depth=10):
    """
    Enhanced Documentation:
    Internal helper for recursively searching object attributes and nested structures by name.
    
    This function performs a breadth-first search through an object's attributes, methods, and
    nested structures to find matches for a given query string. It's designed to help discover
    functionality within complex objects like PyTorch modules where methods might be deeply nested.
    
    Parameters:
        root: The root object to start searching from (any object with attributes)
        query (str): The search string to match against attribute names (case-insensitive)
        depth (int, optional): Maximum depth to search through nested objects. Defaults to 10.
    
    Returns:
        generator: Yields lists representing the path to each matching attribute
        
    Example:
        >>> class TestObj:
        ...     def __init__(self):
        ...         self.test_attr = "hello"
        ...         self.another_attr = 42
        >>> obj = TestObj()
        >>> results = list(_rinsp_search_helper(obj, "test"))
        >>> results
        [['test_attr']]
        
        >>> import math
        >>> sin_matches = list(_rinsp_search_helper(math, "sin"))
        >>> sin_matches
        [['asin'], ['asinh'], ['isinf'], ['sin'], ['sinh']]
        
    Tags: internal, search, introspection, helper
    """

    def match(name):
        assert isinstance(query,str)
        assert isinstance(name,str)
        return query.lower() in name.lower()

    def keys(root):
        out=set()
        try:out.update(dir(root))
        except Exception:pass
        try:out.update(root.__dict__)
        except Exception:pass
        return sorted(out)

    def get(root,key):
        try:return getattr(root,key)
        except Exception:pass
        try:return root.__dict__[key]
        except Exception:pass
        try:return eval('root.'+key)
        except Exception:pass
        raise

    searched=set()
    def helper(root=root,depth=depth,path=[]):
        if not depth or id(root) in searched:
            return
        searched.add(id(root))
        for name in keys(root):
            if name.startswith('__') and name.endswith('__'): continue
            if match(name):yield path+[name]
            try:yield from helper(get(root,name),depth-1,path+[name])
            except Exception:pass

    return helper()

def rinsp_search(root,query,depth=10):
    """
    THIS IS A WORK IN PROGRESS
    example: trying to find the conv function in torch? Maybe it's nested in some modules...wouldn't it be nice to automatically search the whole tree of a, a.b, a.c, a.d, b, b.a, b.a.c, etc... this does that
    example:
    TODO: Allow searching the docs etc more than just searching the name
    TODO: Allow fuzzy search, better queries (fuzzy find for example with nice printed outputs)
    TODO: Make this breadth-first instead of depth first
    """

    import warnings
    with warnings.catch_warnings():
        warnings.simplefilter('ignore')

        printed_lines=[]
        def print_line(line):
            print(line)
            printed_lines.append(line)

        out=[]
        for result in _rinsp_search_helper(root,query,depth):
            print_line(_highlighted_query_results('.'.join(result),query))
            out+=result
            
        _maybe_display_string_in_pager(line_join(printed_lines))

        return out




# def rinsp_search(root,query,depth=10):
#     #THIS IS A WORK IN PROGRESS
#     #example: trying to find the conv function in torch? Maybe it's nested in some modules...wouldn't it be nice to automatically search the whole tree of a, a.b, a.c, a.d, b, b.a, b.a.c, etc... this does that
#     #example:
#     #TODO: Allow searching the docs etc more than just searching the name
#     #TODO: Allow fuzzy search, better queries (fuzzy find for example with nice printed outputs)
#     #TODO: Make this breadth-first instead of depth first
#     def match(name):
#         assert isinstance(query,str)
#         assert isinstance(name,str)
#         return query.lower() in name.lower()

#     def keys(root):
#         out=set()
#         try:out.update(dir(root))
#         except Exception:pass
#         try:out.update(root.__dict__)
#         except Exception:pass
#         return sorted(out)

#     def get(root,key):
#         try:return getattr(root,key)
#         except Exception:pass
#         try:return root.__dict__[key]
#         except Exception:pass
#         try:return eval('root.'+key)
#         except Exception:pass
#         raise

#     searched=set()
#     def helper(root=root,depth=depth,path=[]):
#         if not depth or id(root) in searched:
#             return
#         searched.add(id(root))
#         for name in keys(root):
#             if match(name):yield path+[name]
#             try:yield from helper(get(root,name),depth-1,path+[name])
#             except Exception:pass

#     printed_lines=[]
#     def print_line(line):
#         print(line)
#         printed_lines.append(line)

#     out=[]
#     for result in helper():
#         print_line(_highlighted_query_results('.'.join(result),query))
#         out+=result
        
#     _maybe_display_string_in_pager(line_join(printed_lines))

#     return out

def as_numpy_array(x):
    """
    Will convert x into type np.ndarray
    This should convert anything that can be converted into a numpy array
    Should work for torch tensors, taichi fields, PIL images, python lists of numbers, etc.
    In particular, this works even if a torch tensor is on a GPU
    Will necessarily make a copy of x so you dont have to worry about mutations etc

    Enhanced Documentation:
    
    A fundamental conversion function in RP that safely converts diverse input types to numpy arrays.
    This function is extensively used throughout the RP image processing pipeline and serves as the 
    backbone for the "accept anything" design philosophy.
    
    Args:
        x: Input data to convert to numpy array. Accepts:
           - Python lists (including nested lists)
           - Numpy arrays (creates a copy)
           - PyTorch tensors (auto-detaches and moves to CPU)
           - Any object compatible with np.asarray()
           
    Returns:
        np.ndarray: A numpy array representation of the input data
        
    Raises:
        AssertionError: If input cannot be converted to numpy array
        
    Examples:
        >>> # Basic list conversion
        >>> as_numpy_array([1, 2, 3])
        array([1, 2, 3])
        
        >>> # Nested list to 2D array
        >>> as_numpy_array([[1, 2], [3, 4]])
        array([[1, 2],
               [3, 4]])
               
        >>> # Safe copy of existing numpy array
        >>> original = np.array([1, 2, 3])
        >>> copy = as_numpy_array(original)
        >>> copy[0] = 999  # Won't affect original
        
    Note: This function is used by 70+ RP functions including image converters (as_rgb_image, 
    as_rgba_image), validation functions (is_grayscale_image, is_rgb_image), and mathematical 
    operations. It handles PyTorch tensor conversion including GPU tensors and float16 precision.
    
    Related: as_numpy_image, as_numpy_images, as_numpy_video, as_numpy_videos
    
    Tags: conversion, numpy, arrays, tensors, images, core
    """
    if isinstance(x, list):
        #as_numpy_array fixed for lists of torch tensors
        #Recrsively turn lists of torch tensors or numpy arrays etc into numpy-array compatible elements
        return np.stack([as_numpy_array(y) for y in x])  # Fails loudly on ragged sequences

    try:
        result = np.asarray(x)
        if result.dtype == object:
            raise ValueError("Ragged sequence detected - object dtype is inefficient and not allowed")
        return result
    except Exception:
        pass

    if _is_taichi_field(x):
        return x.to_numpy()

    try:
        if '16' in str(x.dtype):x=x.float() #Numpy screws up with floa16
        return x.detach().cpu().numpy()#For pytorch. We're not doing an isinstance check of type pytorch tensor becuse that involves importing pytorch, which might be slow. Try catch is faster here.
    except Exception:raise
    assert False,'as_numpy_array: Error: Could not convert x into a numpy array. type(x)='+repr(type(x))+' and x='+repr(x)

def input_multiline():
    fansi_print('Please enter text. It can be multiple lines long. When you\'re done, press control+c or control+d','blue','bold')
    lines=[]
    while True:
        try:
            lines+=[input()]
        except KeyboardInterrupt:
            break
    return line_join(lines)

def input_conditional(question,condition=lambda answer:True,on_fail=lambda answer: print('Please try again. Invalid input: '+repr(answer)),prompt=' > '):
    """Keeps asking the user for a console input until they satisfy the condition with their answer.
    
    This function provides a robust way to get validated user input from the console.
    It will continue prompting until the user provides input that satisfies the 
    given condition function.
    
    Enhanced Documentation:
    
    Usage Patterns:
    - Validate user input (integers, yes/no, specific options)
    - Constrain input to specific ranges or formats
    - Provide custom feedback for invalid input
    - Build interactive command-line interfaces
    
    Examples:
    >>> # Basic yes/no validation
    >>> ans = input_conditional('Please enter yes or no.', 
    ...                        lambda x: x.lower() in {'yes','no'})
    # User will be prompted until they enter 'yes' or 'no' (case insensitive)
    
    >>> # Integer validation with custom error message
    >>> def is_valid_int(s):
    ...     try:
    ...         int(s)
    ...         return True
    ...     except:
    ...         return False
    >>> num = input_conditional('Enter a number:', 
    ...                        is_valid_int,
    ...                        lambda x: print(f'"{x}" is not a valid number!'))
    
    >>> # Range validation
    >>> age = input_conditional('Enter your age (18-120):', 
    ...                        lambda x: x.isdigit() and 18 <= int(x) <= 120,
    ...                        lambda x: print('Age must be between 18 and 120'))
    
    Args:
        question (str): The question/prompt to display to the user
        condition (callable, optional): Function that takes user input and returns bool.
                                      Defaults to always True (accepts any input)
        on_fail (callable, optional): Function called when condition fails. Takes user 
                                    input as argument. Defaults to print error message
        prompt (str, optional): The input prompt symbol. Defaults to ' > '
        
    Returns:
        str: The user's input that satisfied the condition
        
    Related Functions:
    - input_integer: Uses this function for integer validation
    - input_yes_no: Uses this function for boolean input
    - input_select: Uses this function for option selection
    - input_option: Uses this function for multiple choice
    
    Implementation Notes:
    - Continues looping until condition returns True
    - Calls on_fail function for each invalid input
    - Always returns a string (convert with int(), bool(), etc. as needed)
    - Uses Python's built-in input() function internally
    
    Tags: input, validation, interactive, console, user-input, conditional
    
    Original docstring:
    Example: ans=input_conditional('Please enter yes or no.', lambda x: x.lower() in {'yes','no'},)
    """
    assert isinstance(question,str),'The "question" should be a string'
    assert callable(condition),'"condition" should be a boolean function of the users input'
    assert callable(on_fail),'"on_fail" should be a void function of the users input'
    assert isinstance(prompt,str),'The "prompt" should be a string'

    print(question)
    while True:
        answer=input(prompt)
        if condition(answer):
            return answer
        on_fail(answer)

def input_yes_no(question):
    """
    A boolean function of the user's console input
    The user must say y, ye, yes, n or no to continue
    Example: input_yes_no('Are you feeling well today?')
    """
    return 'yes'.startswith(input_conditional(question+'\nPlease enter yes or no.', lambda x: x.lower() in {'y','ye','yes','n','no'}).lower())


def input_integer(minimum=None,maximum=None):
    """Prompts the user for an integer input with optional range validation.
    
    This function provides a convenient way to get integer input from the user
    with built-in validation and range constraints. It will continue prompting
    until a valid integer within the specified range is entered.
    
    Enhanced Documentation:
    
    Usage Patterns:
    - Get validated integer input from user
    - Enforce minimum and maximum bounds on input
    - Interactive menu selection with numbered options
    - Age, quantity, or other constrained numeric input
    
    Examples:
    >>> # Basic integer input
    >>> num = input_integer()
    # Prompts: "Please input an integer"
    # Accepts any valid integer: 42, -15, 0, etc.
    
    >>> # Integer with minimum value
    >>> age = input_integer(minimum=0)
    # Prompts: "Please input an integer that's at least 0"
    # Rejects negative numbers, accepts 0 and positive integers
    
    >>> # Integer with maximum value
    >>> percentage = input_integer(maximum=100)
    # Prompts: "Please input an integer that's at most 100" 
    # Rejects numbers > 100, accepts 100 and below
    
    >>> # Integer within range
    >>> menu_choice = input_integer(minimum=1, maximum=5)
    # Prompts: "Please input an integer between 1 and 5 (inclusive)"
    # Only accepts 1, 2, 3, 4, or 5
    
    Args:
        minimum (int, optional): Minimum allowed value (inclusive). None means no minimum
        maximum (int, optional): Maximum allowed value (inclusive). None means no maximum
        
    Returns:
        int: The validated integer entered by the user
        
    Raises:
        AssertionError: If minimum or maximum are not integers when provided
        
    Related Functions:
    - input_conditional: Base function used for validation loop
    - input_yes_no: Boolean input validation
    - input_select: Multiple choice selection
    - input_option: Option selection from list
    
    Implementation Notes:
    - Uses input_conditional internally for validation loop
    - Provides specific error messages for out-of-range values
    - Handles both integer parsing errors and range violations
    - Supports negative integers and zero
    
    Tags: input, validation, integer, range, interactive, console, numeric
    """

    if minimum is not None: assert isinstance(minimum,int)
    if maximum is not None: assert isinstance(maximum,int)

    def is_valid_integer_string(string):
        try:
            int(string)
            return True
        except Exception:
            return False

    def on_fail(string):
        if not is_valid_integer_string(string):
            print("That input isn't an integer. "+cond_question)
        elif maximum is not None and int(string)>maximum:
            print("That integer is too large. "+cond_question)
        elif minimum is not None and int(string)<minimum:
            print("That integer is too small. "+cond_question)

    if   minimum is     None and maximum is     None: 
        cond_question='Please input an integer'                                                
        return int(input_conditional(question=cond_question,condition=lambda integer:is_valid_integer_string(integer)                                   ,on_fail=on_fail))

    elif minimum is not None and maximum is     None: 
        cond_question='Please input an integer that\'s at least %i'%(minimum)                  
        return int(input_conditional(question=cond_question,condition=lambda integer:is_valid_integer_string(integer) and minimum<=int(integer)         ,on_fail=on_fail))

    elif minimum is     None and maximum is not None: 
        cond_question='Please input an integer that\'s at most %i'%(maximum)                   
        return int(input_conditional(question=cond_question,condition=lambda integer:is_valid_integer_string(integer) and          int(integer)<=maximum,on_fail=on_fail))

    elif minimum is not None and maximum is not None: 
        cond_question='Please input an integer between %i and %i (inclusive)'%(minimum,maximum)
        return int(input_conditional(question=cond_question,condition=lambda integer:is_valid_integer_string(integer) and minimum<=int(integer)<=maximum,on_fail=on_fail))

    else: assert False, 'This should be an unreachable line'

def input_default(prompt='', default=''):
    """
    Like input(), but it has a default value that you can edit
    From https://stackoverflow.com/questions/2533120/show-default-value-for-editing-on-python-input-possible
    Note: A library called PyInquirer and inquirerpy do similar things with prompt toolkit, and they're fancy shmancy!
    TODO: Add windows support
    """
    if currently_running_windows():
        return input(prompt) #I don't know how to do this on windows yet. Don't crash it though.
    import readline
    readline.set_startup_hook(lambda: readline.insert_text(default))
    try:
        return input(prompt)
    finally:
        readline.set_startup_hook()

def input_select(question='Please select an option:',options=[],stringify=repr,reverse=False):
    """
    Example: Try running 'input_select_option(options=['Hello','Goodbye','Bonjour'])'

    TODO: In order to make this truly customizable, such as adding multi-select options for fuzzy searching or fuzzy searching through paths etc, we need to make this a class that can be inherited from
    Allow the user to choose from a list of numbered options
    stringify is how we turn options into strings (options dont have to be strings)
    """

    assert len(options),'input_select: Invalid input: Cannot select from 0 options.'

    if isinstance(options,set):
        options=list(options)

    number_of_options=len(options)
    max_number_of_digits=len(str(number_of_options))

    enumerated_options=list(enumerate(options))
    if reverse:
        enumerated_options=enumerated_options[::-1]
    
    question_option_lines=[]
    for i,e in enumerated_options:
        question_option_lines.append(str(i).rjust(max_number_of_digits)+': '+stringify(e))
    question+='\n'+line_join(question_option_lines)

    def condition(user_input):
        try:
            if user_input.startswith('/'):
                return True
            if user_input in {'?','p','z','q','c'}:
                return True
            i=int(user_input)
            if i!=float(user_input):#No fractions
                return False
            return 0<=i<number_of_options
        except ValueError:#ERROR: ValueError: invalid literal for int() with base 10: '
            return False

    def display_more_options():
        string_pager("""More options:
        - Enter 'p' to use rp.string_pager() to view your choices (this is useful if all the options can't fit on your terminal's screen)")
        - Enter 'c', 'control + c' or 'control + d' to cancel this selection
        - Enter '/' followed by a search query to display all options matching that query
              For example, enter '/.png' to get a list of options that have '.png' in them
        - Enter 'q' to enter search-query mode (will let you select an option by interactively searching for it)
        - Enter 'z' to enter fuzzy search mode (will let you select an option by interactively fuzzy-searching for it)
        """)    

    def display_query_options(query):
        if not query:
            print("Entering / by itself doesn't search for anything. Try '/.png' or '/somefile' etc instead. Enter ? for more information.")
            return
        options=question
        options=strip_ansi_escapes(options)
        options=options.splitlines()
        options=[option for option in options if query.lower() in option.lower()]
        options=[_highlighted_query_results(option,query) for option in options]
        string=line_join(options)
        _maybe_display_string_in_pager(string,False)
        if not options:
            string='(No options matched your query: %s)'%repr(query)
        print(string)


    def display_options_with_pager():
        string_pager(question)

    show_question=True
    while True:
        show_question=True
        user_input=input_conditional(show_question*(question+'\n')+'Please enter an integer between 0 and '+str(number_of_options-1)+' (inclusive), or \'?\' for more options.',condition)

        if user_input=='c':
            print("(Entered 'c': cancelling selection with a KeyboardInterrupt)")
            raise KeyboardInterrupt
        if user_input=='?':
            display_more_options()
        elif user_input=='p':
            display_options_with_pager()
        elif user_input.startswith('/'):
            query=user_input[1:]
            display_query_options(query)
            show_question=False
        elif user_input=='z' or user_input=='q':
            try:
                if user_input=='z':
                    print('Using an interactive fuzzy-search to select an option:')
                elif user_input=='q':
                    print('Using an interactive search to select an option:')
                else:
                    assert False

                displayed_question_option_lines=[strip_ansi_escapes(line).replace('\n',' ').replace('\r',' ') for line in question_option_lines] # In fzf, multiline options are not allowed

                if reverse:
                    displayed_question_option_lines=displayed_question_option_lines[::-1]

                result=_iterfzf(displayed_question_option_lines,exact=user_input=='q')

                assert result!=None

                index=displayed_question_option_lines.index(result)
                option=options[index]
                print('Selected option %i:'%index,option)
                return option

            except:
                print('    (Cancelled searching through the options)')
                show_question=False
        else:
            return options[int(user_input)]

def input_select_multiple(question='Please select any number of options:',options=[],stringify=repr,reverse=True):
    """
    EXAMPLE:
       input_select_multiple("Please select some letters:",'abcdefg')
    """
    add_text=fansi('++ ','green','bold')
    sub_text=fansi('-- ','red'  ,'bold')
    done_text=fansi('(DONE)','yellow','bold')
    
    #This code is a bit messy, but it can be refactored later
    #It's all about how we're selecting indices instead of the actual objects

    indices=list(range(len(options)))
    selected=list() #List of indices
    unselected=list(range(len(options))) #List of indices
    
    def _stringify(option):
        if option is None:
            return done_text
        
        output=stringify(options[option])
        if option in selected:
            output=sub_text+output
        elif option in unselected:
            output=add_text+output
        else:
            assert False,'This should be impossible'
        
        return output
    
    while True:
        #TODO: Add support for iterfzf's built-in multiple selections
        option=input_select(question,options=[None]+selected+unselected,stringify=_stringify,reverse=reverse)
        if option is None:
            return [options[index] for index in selected]
        if option in selected:
            selected=[x for x in selected if x!=option]
        else:
            selected+=[option]
        
        unselected=[option for option in indices if option not in selected]
        
def get_youtube_video_url(url_or_id):
    """
    Gets the url of a youtube video, given either the url (in which case nothing changes) or its id
    
    Example:
         >>> get_youtube_video_url('0yeejC2YVLo')
         'https://www.youtube.com/watch?v=0yeejC2YVLo'
         >>> get_youtube_video_url('https://www.youtube.com/watch?v=0yeejC2YVLo')
         'https://www.youtube.com/watch?v=0yeejC2YVLo'
    """

    if '/' in url_or_id:
        url = url_or_id
    else:
        url = "https://www.youtube.com/watch?v="+url_or_id
    return url

def _is_youtube_video_url(url):
    return url.startswith("https://www.youtube.com/watch?v=") and is_valid_url(url)

@memoized
def get_youtube_video_transcript(url_or_id: str):
    """
    Returns the captions/subtitles for a YouTube video based on the given URL or video ID.

    NOTE: If it doesn't work, try "pip install pytubefix --upgrade"

    Parameters:
        - url_or_id (str): The YouTube video URL or video ID.

    Returns:
        - str: The transcript

    """

    assert isinstance(url_or_id, str), type(url_or_id)

    # Common logic invariant to external libraries:
    url = get_youtube_video_url(url_or_id)
    if not _is_youtube_video_url(url):
        raise ValueError("get_youtube_video_transcript: Invalid youtube id or url: "+url_or_id)

    # Logic specific to pytubefix:
    pip_import("pytubefix")
    from pytubefix import YouTube

    yt = YouTube(url)


    #Find some english captions
    for caption_language in ["a.en", 'en-US']:
        if caption_language in yt.captions:
            caption = yt.captions[caption_language]
            break

    if not 'caption' in locals():
        raise ValueError("Captions for language '{}' not found. Available languages: {}".format(caption_language, list(yt.captions.keys())))

    captions_text = caption.generate_srt_captions().splitlines()[2::4]
    captions_text = "\n".join(captions_text)

    return captions_text

#Maybe uncomment in the future
# get_youtube_video_captions = get_youtube_video_subtitles = get_youtube_video_transcript

def download_youtube_video(url_or_id: str,
                           path=None,
                           *,
                           need_video=True,
                           need_audio=True,
                           max_resolution=None,
                           min_resolution=None,
                           resolution_preference=max,
                           skip_existing=False,
                           show_progress=True,
                           overwrite=False,
                           filetype='mp4'):
    """
    Downloads a YouTube video based on the given URL or video ID. The function can selectively download video only, 
    audio only, or both depending on the parameters. All downloads are currently as mp4 or webm files.

    NOTE: If it doesn't work, try "pip install pytubefix --upgrade"

    If need_audio is True, we might only download the audio file (or might download a video with audio)
    If need_video is True, we might download a video without audio (potentially at much higher resolution)
    If both are True, it tends to only download at 360p (aka video height=360).

    Parameters:
        - url_or_id (str): The YouTube video URL or video ID.
        - path (str, optional): The destination path where the video/audio will be saved. If not provided,
                                The path will be:    ./<YouTubeVideo Title>.mp4   or   .webm
                                Where <YouTubeVideo Title> is the video title as displayed on the website
                                Right now it must be an mp4 or webm file if specified manually, according to the filetype arg!
        - need_video (bool, optional): If set to True, downloads the video track. Defaults to True.
        - need_audio (bool, optional): If set to True, downloads the audio track. If False and `need_video` is True,
                                       downloads video only without audio. Defaults to True.
                                       NOTE: Setting this to False allows for higher resolution videos! This is because 
                                           YouTube normally streams both separately, and the ones that have both tend to be smaller files. 
                                           If you need high resolution and audio, I reccomend using rp.add_audio_to_video_file and using this function twice:
                                           once for audio-only and once for video-only, and use that function to patch the audio into the video-only file.
        - max_resolution (int, optional): The maximum resolution allowed for the video. Defaults to None. Only matters if need_video
        - min_resolution (int, optional): The minimum resolution required for the video. Defaults to None. Only matters if need_video
        - resolution_preference (callable, optional): A function to determine the preferred resolution from available options. Only matters if need_video
                                                      (Different youtube videos have different resolutoin options)
                                                      It will be passed a sorted list of integers, such as [144, 240, 360, 720, 1080] etc
                                                      These correspond to 144p, 240p, 360p etc - and refer to the minimum(height, width) of a video
                                                      In other words, 720P for a vertical video means the video has 720-pixel width, and 720p for a landscape video means it has 720-pixel height
                                                      Defaults to max, which selects the highest available resolution.
        - skip_existing (bool, optional): If set to True, skips downloading if the file already exists at the destination path. Defaults to False.
        - show_progress (bool, optional): If set to True, will show download progress over time. Defaults to True.
        - overwrite (bool, optional): If set to True, overwrites the existing file at the destination path. Defaults to False.
                                      If False, and the output path already exists, it will create a new non-conflicting path
                                      like some_video_copy.mp4 or some_video_copy2.mp4 etc
        - filetype (str, optional): Specifies the type of video file we will attempt to download. Can be either 'mp4' or 'webm'

    Returns:
        - str: The path to the downloaded MP4 file (which might contain just video, just audio or both)

    Note:
        - Current backend library: pytubefix. This might change in the future, but the black-box specification of download_youtube_video should always remain the same.
        - [Aug 19 2024]: pytube didnt work: https://stackoverflow.com/questions/78160027/how-to-solve-http-error-400-bad-request-in-pytube
                         so, I used this (as suggested in that stack overflow post): https://github.com/JuanBindez/pytubefix
                         
    Examples:
        >>> download_youtube_video("https://www.youtube.com/watch?v=jvipPYFebWc", need_audio=True, need_video=True, show_progress=False)
        ans = /Users/ryan/Lindsey Stirling - Roundtable Rival (Official Music Video)_copy1.mp4
        >>> get_video_file_shape(ans)
        ans = (5235, 360, 640, 3)            #Asking for both video and audio resulted in a 360P video.
        >>> download_youtube_video("https://www.youtube.com/watch?v=jvipPYFebWc", need_audio=True, need_video=False, show_progress=False)
        ans = /Users/ryan/Lindsey Stirling - Roundtable Rival (Official Music Video)_copy2.mp4
        >>> get_video_file_shape(ans)
        ERROR: KeyError: 'video_fps'         #This is an audio-only MP4 file
        >>> download_youtube_video("https://www.youtube.com/watch?v=jvipPYFebWc", need_audio=False, need_video=True, show_progress=False)
        ans = /Users/ryan/Lindsey Stirling - Roundtable Rival (Official Music Video)_copy3.mp4
        >>> get_video_file_shape(ans)
        ans = (5234, 1080, 1920, 3)          #Now at 1080P!
        >>> download_youtube_video("jvipPYFebWc", need_audio=False, need_video=True, show_progress=False) #Same thing as the above

    Example:
        >>> #Get the highest quality video along with the highest quality audio
        ... #It appears to me that the highest quality YouTube MP4 files are either one or the other,
        ... #The only video files with audio directly downloadable were 360p and not MP4 files
        ... #So, if we download them separately and combine them we get the highest quality possible
        ... #Note: QuickTime might not be able to play it - but other players like IINA or VLC can
        ... url = "https://www.youtube.com/watch?v=kxGrd7mlB5M"
        ... audio_file = download_youtube_video(url, need_audio=True, need_video=False)
        ... video_file = download_youtube_video(url, need_audio=False, need_video=True)
        ... output_file = add_audio_to_video_file(video_file, audio_file)
        ... print('Please play',output_file)

    """

    #Common logic invariant to external libraries:
    url = get_youtube_video_url(url_or_id)
    assert need_video or need_audio, 'Cannot have no audio and no video'
    assert filetype in ['mp4', 'webm'], 'filetype='+str(filetype)+' but should be either "mp4" or "webm"'
    if path is None:
        path = get_youtube_video_title(url)
    path = with_file_extension(path, filetype)
    if path_exists(path):
        if skip_existing:
            return path
        elif overwrite:
            delete_file(path)
        else:
            path=get_unique_copy_path(path)
    assert get_file_extension(path) == filetype 

    #Logic that's specific to pytubefix:
    pip_import('pytubefix')
    from pytubefix import YouTube
    from pytubefix.cli import on_progress
    yt = YouTube(url, on_progress_callback = on_progress if show_progress else None)
    ys = yt.streams
    if need_audio:
        ys = ys.filter(progressive=True)
        print(ys)
    if need_video:
        ys = ys.filter(mime_type='video/'+filetype)
        print(ys)
        #
        #Allow us to choose the resolution of the video we load - we might want to avoid loading giant videos for example
        resolutions=set(int(x.resolution[:-1])for x in ys if x.resolution if not None) #Like {360, 1080, 720, 360, 144}
        original_resolutions = sorted(resolutions)
        if min_resolution is not None:resolutions={x for x in resolutions if x>=min_resolution}
        if max_resolution is not None:resolutions={x for x in resolutions if x<=max_resolution}
        resolutions=sorted(resolutions) #An increasing list of unique integers 
        assert len(resolutions), 'No resolutions fit between given min_resolution=%s and max_resolution=%s. Available resolutions for %s are: %s'%(min_resolution, max_resolution, url, original_resolutions)
        preferred_resolution=resolution_preference(resolutions) #Choosing the best resoltion
        best_resolution=str(max(resolutions))+'p' # Like "720p"
        #
        ys = ys.filter(resolution=best_resolution).get_highest_resolution(progressive=False)
        print(ys)
    else:
        ys = yt.streams.filter(mime_type='audio/'+filetype).get_audio_only()

    assert ys is not None, 'Could not find a youtube video that satisfied the given contsraints (url=%s, need_audio=%s, need_video=%s, max_resolution=%s, min_resolution=%s, filetype=%s). Try relaxing a contstraint, such as not requiring audio, not requiring video or using a different filetype etc.'%(repr(url),need_audio, need_video, max_resolution, min_resolution, repr(filetype)) + '\nAvailable Options:\n'+'\n'.join('    '+str(x) for x in yt.streams)
    out_path = ys.download(output_path=get_parent_directory(path),filename=get_file_name(path))
        
    #Common logic invariant to external libraries:
    assert file_exists(out_path)
    assert get_file_extension(out_path)==filetype, 'For simplicity I would like this function to only return mp4 or webm files. Is this possible?'+ out_path
    return out_path


@memoized
def _get_youtube_video_data_via_embeddify(url):
    """
    See https://pypi.org/project/embeddify/
    Uses a specification called 'oembed', which lets us get info such as title/author etc without an api key (it's perfectly legal, and an intended use-case by google)
    EXAMPLE:
        >>> _get_youtube_video_data_via_embeddify('https://www.youtube.com/watch?v=2wii8hfNkzE')
       ans = {'title': 'Day9] Daily #596   Rigged Games Funday Monday P1', 'width': 560, 'version': '1.0', 'type': 'video', 'height': 315, 'provider_url': 'https://www.youtube.com/', 'author_name': 'Day9TV', 'thumbnail_url': 'https://i.ytimg.com/vi/2wii8hfNkzE/hqdefault.jpg', 'author_url': 'https://www.youtube.com/user/day9tv', 'provider_name': 'YouTube', 'thumbnail_width': 480, 'thumbnail_height': 360, 'html': '<iframe width="560" height="315" src="https://www.youtube.com/embed/2wii8hfNkzE?feature=oembed" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>'}
    """
    assert isinstance(url,str), 'Need url string, but got type '+str(type(url))
    assert is_valid_url(url), 'Not a valid url: '+repr(url)
    pip_import('embeddify')
    from embeddify import Embedder
    embedder = Embedder()
    result = embedder(url)
    return result.data
    
def get_youtube_video_title(url_or_id):
    """
    Returns the title of a youtube video, given either its url or video id

    Example:
        >>> get_youtube_video_title('aHjpOzsQ9YI')
        'Lindsey Stirling - Crystallize (Dubstep Violin Original Song)'
    """
    url = get_youtube_video_url(url_or_id)
    return _get_youtube_video_data_via_embeddify(url)['title']


_get_youtube_video_thumbnail_cache={}
def get_youtube_video_thumbnail(url_or_id,*,use_cache=False,output='image'):
    """
    Returns the thumbnail of a youtube video, either as a url or an image
    EXAMPLE:
        >>> display_image(get_youtube_video_thumbnail('https://www.youtube.com/watch?v=rWwfyzX2OeM'))
    """
    assert isinstance(output,str),'get_youtube_video_thumbnail: output arg should be a string but got type '+str(type(output))
    assert output in ['image','url'], 'get_youtube_video_thumbnail: output arg should be either "url" or "image" but it was '+str(output)
    video_url = get_youtube_video_url(url_or_id)
    
    if use_cache:
        if video_url not in  _get_youtube_video_thumbnail_cache:
            _get_youtube_video_thumbnail_cache[video_url] = get_youtube_video_thumbnail(video_url, use_cache=False)
        return _get_youtube_video_thumbnail_cache[video_url]
    
    thumbnail_url = _get_youtube_video_data_via_embeddify(video_url)['thumbnail_url']
    
    thumbnail_image = load_image(thumbnail_url, use_cache=use_cache)
    return thumbnail_image


def _as_tiktok_url(string: str) -> str:
    """
    Normalize any TikTok string form into a canonical URL.

    This function extracts the username and video ID from different possible
    TikTok formats and returns a normalized URL in the form:

        https://www.tiktok.com/@<username>/video/<video_id>?is_copy_url=1&is_from_webapp=v1

    Accepted input formats include:
    - A bare TikTok path:
        "@islanduniverse.3d/video/7539708490512616734"
    - A TikTok URL without query parameters:
        "https://www.tiktok.com/@islanduniverse.3d/video/7539708490512616734"
    - A TikTok URL with arbitrary query parameters:
        "https://www.tiktok.com/@islanduniverse.3d/video/7539708490512616734?is_copy_url=1&is_from_webapp=v1"

    In all cases, the return value is normalized to include the standard query:
        ?is_copy_url=1&is_from_webapp=v1

    Args:
        string (str): TikTok video string in one of the accepted formats.

    Returns:
        str: Canonical TikTok video URL.

    Raises:
        ValueError: If the input string does not contain a valid TikTok pattern.

    Example:
        >>> _as_tiktok_url("@islanduniverse.3d/video/7539708490512616734")
        "https://www.tiktok.com/@islanduniverse.3d/video/7539708490512616734?is_copy_url=1&is_from_webapp=v1"
    """
    import re
    # Extract username and video ID
    m = re.search(r"@([\w\.\-]+)/video/(\d+)", string)
    if not m:
        raise ValueError("Could not parse TikTok string: "+repr(string))

    username, video_id = m.groups()
    return "https://www.tiktok.com/@"+str(username)+"/video/"+str(video_id)+"?is_copy_url=1&is_from_webapp=v1"

def download_tiktok_video(url: str, path=None):
    """
    Given a url, returns a path to an mp4 file
    Downloads a tiktok video   
    
    url:
        URL or ID of the tiktok video. Equivalent examples:
            1. @islanduniverse.3d/video/7539708490512616734
            2. https://www.tiktok.com/@islanduniverse.3d/video/7539708490512616734
            3. https://www.tiktok.com/@islanduniverse.3d/video/7539708490512616734?is_copy_url=1&is_from_webapp=v1
        All of the above are equivalent inputs

    EXAMPLE:
        >>> download_tiktok_video('https://www.tiktok.com/@islanduniverse.3d/video/7484653502833364270?is_copy_url=1&is_from_webapp=v1')
        /Users/ryan/@islanduniverse.3d_video_7484653502833364270.mp4
    
    """
    pip_import("pyktok")
    import pyktok as pyk
    
    url=_as_tiktok_url(url)

    with TemporarilySuppressConsoleOutput():
        #It prints stuff at the end
        #It's not a progress indicator
        result = pyk.save_tiktok(
            url,
            metadata_fn=temporary_file_path("csv"),
            save_video=True,
            browser_name=None,
            return_fns=True,
        )
    
    video_path   =result['video_fn']
    metadata_path=result['metadata_fn']

    # #TODO: If we want metadata in the future, play around with this
    # #Disabled for now because the function signature is simple when it just returns a string
    # metadata = load_csv(metadata_path)
    # metadata = as_easydict({x: y.item() for x, y in dict(metadata).items()})
    # metadata.video_path = video_path
    
    video_path = get_absolute_path(video_path)
    if path is not None:
        video_path = move_file(video_path, path)
        
    return video_path


def _moviepy_VideoFileClip(path, *args, **kwargs):
    """ Moviepy 2 has breaking changes! They moved a class. See https://zulko.github.io/moviepy/getting_started/updating_to_v2.html """
    pip_import('moviepy')

    try:
        from moviepy.editor import VideoFileClip  # MoviePy v1 import

        return VideoFileClip(path, *args, **kwargs)

    except ImportError:
        from moviepy import VideoFileClip  # MoviePy v2 import

        try:
            suppress_console_output() #It spams the console
            return VideoFileClip(path, *args, **kwargs)
        finally:
            restore_console_output()

def _get_video_file_duration_via_moviepy(path)->float:
    """
    Returns the duration of a video file, in seconds
    https://stackoverflow.com/questions/3844430/how-to-get-the-duration-of-a-video-in-python
    """
    return _moviepy_VideoFileClip(path).duration

_get_video_file_duration_cache={}
def get_video_file_duration(path,use_cache=True):
    """ Returns a float, representing the total video length in seconds """
    path=get_absolute_path(path) #This is important for caching. 
    if use_cache and path in _get_video_file_duration_cache:
        return _get_video_file_duration_cache[path]
    out=_get_video_file_duration_via_moviepy(path)
    _get_video_file_duration_cache[path]=out
    return out

_get_video_file_framerate_cache={}
def _get_video_file_framerate_via_moviepy(path, use_cache=True):
    """ Given a (str) path to a video file, returns a number (framerate) """
    path = get_absolute_path(path) #Important for caching
    if use_cache and path in _get_video_file_framerate_cache:
        return _get_video_file_framerate_cache[path]

    with _moviepy_VideoFileClip(path) as video:
        framerate = video.fps

    _get_video_file_framerate_cache[path] = framerate
    return framerate

def _get_video_file_framerate_via_ffprobe(path, use_cache=True):
    """ 
    Slower than _get_video_file_framerate_via_moviepy but no extra python dependencies
    Given a (str) path to a video file, returns a number (framerate)
    """
    import subprocess
    import json

    if not 'ffprobe' in get_system_commands():    
        _ensure_ffmpeg_installed()
        raise RuntimeError('rp.get_video_file_framerate: Please install ffmpeg! Unable to find the ffprobe command')

    path = get_absolute_path(path)  # Important for caching
    if use_cache and path in _get_video_file_framerate_cache:
        return _get_video_file_framerate_cache[path]

    # Use ffprobe to get the video framerate
    command = [
        "ffprobe",
        "-v", "quiet",
        "-select_streams", "v:0",
        "-show_entries", "stream=avg_frame_rate",
        "-of", "json",
        path
    ]
    output = subprocess.check_output(command).decode("utf-8")
    data = json.loads(output)

    # Parse the framerate from the ffprobe output
    framerate_str = data["streams"][0]["avg_frame_rate"]
    numerator, denominator = map(int, framerate_str.split("/"))
    framerate = numerator / denominator

    _get_video_file_framerate_cache[path] = framerate
    return framerate

def get_video_file_framerate(path, use_cache=True):
    """ Given a (str) path to a video file, returns a number (framerate) """
    try:
        pip_import('moviepy')

        #Ning had some error where the following line failed, but Yuancheng's didnt...
        return _get_video_file_framerate_via_moviepy(path, use_cache)

    except Exception:
        return _get_video_file_framerate_via_ffprobe(path, use_cache)


#UNCOMMENT ONCE I USE SMART_OPEN SOMEWHERE
# def _smart_open(path, mode="rb"):
#     #TODO: Use this in load_image, load_text_file, etc - it can read from zip files and stream from AWS etc 
#     #This function is currently unused! It failed to stream MP4 files to opencv or moviepy
#     pip_import("smart_open")
#     import smart_open
#     path=smart_open.smart_open(path, mode)

def _load_video_stream(location, start_frame=0):
    #I don't know how to stream videos directly from the web. So instead we'll download it as a temp file and stram it from there. Not ideal but better than nothing.
    with _MaybeTemporarilyDownloadVideo(location) as path:
        
        cv2=pip_import('cv2')
        assert file_exists(path),'load_video error: path '+repr(path)+' does not point to a file that exists'#Opencv will silently fail if this breaks
        cv_stream=cv2.VideoCapture(path)
        
        if start_frame:
            # Set the frame position to start_frame
            # This is always faster than iterating though all the frames! Even so, it might still be slow for big videos.
            # Needs to find the nearest iframe and decode from there.
            cv_stream.set(cv2.CAP_PROP_POS_FRAMES, start_frame)
        
        while True:
            not_done,frame=cv_stream.read()
            if not not_done:
                return
            yield cv_bgr_rgb_swap(frame)

def load_video_stream(path, *, start_frame=0, with_length=True, frame_transform=None):
    """
    Much faster than load_video, which loads all the frames into a numpy array. This means load_video has to iterate through all the frames before you can even use the first frame.
    load_video_stream is a generator, meaning to get the next frame you use python's builtin 'next' function
    Returns a generator that iterates through the frame images

    It can load videos from URL's as well, but right now it does so by downloading the video as a temp file and then deleting it

    If possible, this generator will also have a length! Useful for tqdm etc..td
    Don't rely on that though, it might not always work? Depends on 'moviepy'

    If with_length, will attempt to give this iterator a __len__. If it fails, it won't error - the output simply won't have a __len__ attribute.
    with_length will make it a tiny bit slower, as calling get_video_file_num_frames is not instant.

    EXAMPLE:
        display_video(load_video_stream('https://www.shutterstock.com/shutterstock/videos/10884623/preview/stock-footage--k-pastel-pixel-animation-background-seamless-loop.webm'))
    EXAMPLE:
        for frame in load_video_stream("/Users/Ryan/Desktop/media.io_Silicon Valley - Gavin - Animals Compilation copy.mp4"):display_image(frame)
    EXAMPLE:
        for frame in load_video_stream(download_youtube_video('https://www.youtube.com/watch?v=cAy4zULKFDU')):display_image(frame)  #Monty python clip
    """
    assert isinstance(start_frame, int) and start_frame >= 0, "rp.load_video_stream: start_frame must be a non-negative integer, got {}".format(start_frame)
    assert isinstance(path, str), "rp.load_video_stream: path must be a string, got {}".format(type(path).__name__)

    frame_iterator = _load_video_stream(path, start_frame=start_frame)

    if frame_transform is not None:
        frame_iterator = map(frame_transform, frame_iterator)

    if with_length:
        try:
            num_frames = get_video_file_num_frames(path)
            num_frames = max(0, num_frames - start_frame)

            frame_iterator = IteratorWithLen(frame_iterator, num_frames)
        except Exception:
            pass

    return frame_iterator

def load_video_streams(
    *paths,
    start_frame=0,
    with_length=True,
    frame_transform=None,
    transpose=False,

    show_progress=True,
    num_threads=0, #Sometimes segfaulted when I did it this way...
    strict=True,
    lazy=False,
    buffer_limit=None
):
    """ Plural of load_video_stream. If transpose==True, returns a single iterator that returns tuples of frames """

    paths = detuple(paths)

    if not is_iterable(start_frame):
        #Allow broadcasting start_frame
        start_frame = [start_frame] * len(paths)

    assert len(start_frame)==len(paths), 'Must specify start_frame for each video'

    def load(i):
        return load_video_stream(paths[i], start_frame=start_frame[i], with_length=with_length, frame_transform=frame_transform)

    streams = gather_args_call(load_files, load, range(len(paths)))
        
    if transpose:
        lengths = [(len(x) if hasattr(x, '__len__') else None) for x in streams]
        
        streams = zip(*streams)

        if all(isinstance(l, int) for l in lengths):
            length = min(lengths) #This is the way we handle it right now, might insert blank frames later

            streams = IteratorWithLen(streams, length)

    return streams


_load_video_cache = {}
def load_video(path, *, start_frame=0, length=None, show_progress=True, use_cache=False, frame_transform=None):
    """
    This function does not take into account framerates or audio. It just returns a numpy array full of images.
    It's slower to call than load_video_stream, but it can be cached using use_cache (which would actually make it faster, if applicable)

    Args:
        path (str): The path or URL of the video file to load.
        start_frame (int, optional): The frame number to start loading from. Defaults to 0.
        length (int, optional): The maximum number of frames to load. If None, all frames are loaded.
        show_progress (bool, optional): Whether to display progress messages during loading. Defaults to True.
                                        Setting this to False can result in a small performance boost, as it won't 
                                        check the length of the video (which might take a small bit of time)
        use_cache (bool, optional): Whether to cache the loaded video for faster subsequent loading. Defaults to False.
                                    
        frame_transform (callable, optional): If specified, transforms each frame with this function.

    Returns:
        numpy.ndarray: A NumPy array containing the loaded video frames in THWC form.

    Notes:
        - This function does not consider framerates or audio. It only returns a NumPy array of frame images.
        - If `use_cache` is True and the video has been previously loaded, the cached result will be returned.
        - The function uses `load_video_stream` internally to load the video frames.

    Examples:
        >>> video = load_video("path/to/video.mp4")
        >>> video.shape
        (num_frames, height, width, 3)

        >>> video = load_video("path/to/video.mp4", start_frame=100, length=50)
        >>> video.shape
        (50, height, width, 3)

        >>> video = load_video('https://cdn.osxdaily.com/wp-content/uploads/2013/07/dancing-banana.gif')
        #Video has transparency! RGBA! Also, it used PIL to load it.

        >>> video = load_video('https://videos.pexels.com/video-files/5229792/5229792-uhd_2560_1440_24fps.mp4',use_cache=True)

    """
    assert length is None or (isinstance(length, int) and length >= 0), "rp.load_video: length must be None or a non-negative integer, got {}".format(length)
    assert isinstance(start_frame, int) and start_frame >= 0, "rp.load_video: start_frame must be a non-negative integer, got {}".format(start_frame)
    assert isinstance(path, str), "rp.load_video: path must be a string, got {}".format(type(path).__name__)

    progress_prefix = "\rload_video: path=" + repr(path) + ": "
    
    if path_exists(path):
        path = get_absolute_path(path)  # This is important for caching.
    
    cache_id = (path, start_frame, length, frame_transform)
    if use_cache and cache_id in _load_video_cache:
        return _load_video_cache[cache_id]

    if is_valid_url(path):
        #Handle file caching when loading videos from URL's
        url = path
        cache_path = get_cache_file_path(url)
        if use_cache:
            download_url(url, cache_path, skip_existing=True)
            path = cache_path
        else:
            delete_file(cache_path, strict=False)
    
    if not start_frame and length is None and not show_progress and is_image_file(path):
        #Uses PIL for GIF's and animated PNG's
        pil_video = _load_animated_image_via_pil(path, use_cache=False) #Can support alpha GIF's. This func handles caching.
        stream = iter(pil_video)
    else:
        #All other videos such as MP4's use CV2
        stream = load_video_stream(path, start_frame = start_frame, with_length=show_progress, frame_transform=frame_transform)

    out = []
    
    for i, frame in enumerate(stream):
        if length is not None and i>= length:
            break
        if show_progress:
            if hasattr(stream, "__len__"):

                #This is the number of frames that will be returned
                output_length = len(stream)
                if length is not None:
                    output_length = min(length, len(stream)) 

                progress_message = "Loaded frame %i of %i..." % (i + 1, output_length)
            else:
                progress_message = "Loaded frame %i..." % (i+1)
            print(end=progress_prefix + progress_message)
        out.append(frame)
    
    if show_progress:
        print(end=progress_prefix + 'done loading frames, creating numpy array...')
    
    out = np.asarray(out)
    
    if show_progress:
        print(end='done.\r\n')
    
    if use_cache:
        _load_video_cache[cache_id] = out
    
    return out


def load_videos(
    *paths,
    start_frame=0,
    length=None,
    frame_transform=None,
    show_progress=True,
    use_cache=False,
    num_threads=None,
    strict=True,
    lazy=False,
    buffer_limit=None
):
    """
    Plural of load_video
    Loads many videos.

    Args:
        See load_video for documentation on the paths, start_frame, length and use_cache args. Also see rp_iglob's docstring for paths.
        See load_files for documentation on the num_threads, show_progress, strict, lazy and buffer_limit args
    """
    #I don't know if there's much advantage in doing this in parallel...but we'll try...

    if show_progress in ['eta', True]: show_progress='eta:rp.load_videos'

    paths = detuple(paths)
    # paths = rp_iglob(paths)

    def load(path):
        return load_video(
            path,
            start_frame=start_frame,
            length=length,
            show_progress=False,
            use_cache=use_cache,
            frame_transform=frame_transform,
        )

    return gather_args_call(load_files, load, paths)

def load_video_via_decord(path, indices=None, *, as_dict=False):
    """
    Load video frames with decord.
    Often faster than using rp.load_video, especially if only a subset of frames are needed

    Args:
        path: Video path (or URL).
        indices:
            - None: all frames
            - int: evenly sample that many frames
            - slice: slice of frames
            - iterable: explicit frame indices
        as_dict: If True, return {'video': frames, 'indices': resolved_indices, 'num_frames': int}.
                 Note: resolved_indices may differ from the input if an int or None was given.
                 Note: num_frames reflects the number of frames in the full video file

    Returns:
        ndarray (T, H, W, C) or dict if as_dict=True.
        
    """
    pip_import("decord")
    import decord

    if not file_exists(path) and is_valid_url(path):
        url = path
        with TemporarilyDownloadUrl(url) as path:
            return load_video_via_decord(url)

    reader = decord.VideoReader(path)
    num_frames = len(reader)

    if indices is None:
        indices=range(num_frames)
    elif isinstance(indices, int):
        indices = resize_list(range(num_frames), indices)
    elif isinstance(indices, slice):
        indices = range(num_frames)[indices]
    else:
        assert is_iterable(indices)
        indices = list(indices)

    with decord.bridge._BridgeScope("native"):
        # This scope ensures it always returns the same type
        # This function should be agnostic to backend, meaning it might not always use decord in the future
        # So, let's KISS
        video = reader.get_batch(indices)
        video = video.asnumpy()
        
    if as_dict:
        return gather_vars("video", "indices","num_frames")

    return video


def save_video_avi(frames,path:str=None,framerate:int=30):
    """
    Saves the frames of the video to an .avi file
    """

    pip_import('cv2')
    import cv2
    
    if path is None: path=_get_default_video_path('avi')
    if not has_file_extension(path) or get_file_extension(path).lower()!='avi': path+='.avi'
        
    frames=as_numpy_array(frames)
    
    height=get_image_height(frames[0])
    width =get_image_width (frames[0])
    
    out = cv2.VideoWriter(path,
                      cv2.VideoWriter_fourcc(*'XVID'),
                      framerate,
                      (width,height),
                      #isColor=True
                      )
                      
    for frame in frames:
        frame=as_rgb_image(frame)
        frame=as_byte_image(frame)
        frame=cv_bgr_rgb_swap(frame)
        out.write(frame)

    path = rp.get_absolute_path(path)
        
    return path

def _get_default_video_path(extension='mp4'):
    return get_unique_copy_path('video.'+extension)

_named_video_bitrates = {
    "low"    : 100000,
    "medium" : 1000000,
    "high"   : 10000000,
    "max"    : 10000000000,
}
_named_video_qualities = {
    "low"    : 10,
    "medium" : 50,
    "high"   : 95,
    "max"    : 100,
}
def _as_video_bitrate(video_bitrate:str) -> int:
    """ As a bitrate """
    if video_bitrate in _named_video_bitrates:
        video_bitrate = _named_video_bitrates[video_bitrate]
    return video_bitrate
def _as_video_quality(video_quality:str) -> int:
    """ As a percent """
    if video_quality in _named_video_qualities:
        video_quality = _named_video_qualities[video_quality]
    if video_quality > 100:
        video_quality = 100
    return video_quality

class VideoWriterMP4:
    """
    A class for writing MP4 videos frame by frame using ffmpeg.
    
    Creates MP4 videos by writing individual frames sequentially. Handles automatic
    dimension adjustment to ensure compatibility with libx264 codec requirements.
    
    Args:
        path: Output file path (defaults to auto-generated, adds .mp4 if needed)
        framerate: Video framerate in fps (default: 60)  
        video_bitrate: Quality setting - 'low'/'medium'/'high' or int (default: 'medium')
        height/width: Force specific dimensions (default: auto from first frame)
        show_progress: Show ffmpeg progress output (default: True)
        
    Example:
        >>> writer = VideoWriterMP4('output.mp4', framerate=30)
        >>> writer.write_frame(image1)
        >>> writer.write_frame(image2) 
        >>> writer.finish()
    """
    #Todo: If this ever gets fucky, try https://github.com/imageio/imageio-ffmpeg - it looks pretty good!

    def __init__(self, path=None, framerate=60, video_bitrate='medium', height=None, width=None, show_progress=True):
        # Originally from: https://github.com/kkroening/ffmpeg-python/issues/246

        _ensure_ffmpeg_installed()

        self.show_progress=show_progress

        if path is None: path=_get_default_video_path()
        if not has_file_extension(path) or get_file_extension(path).lower()!='mp4': path+='.mp4'

        rp.pip_import('ffmpeg', 'ffmpeg-python')

        video_bitrate = _as_video_bitrate(video_bitrate)

        assert path.endswith('.mp4')
        assert isinstance(video_bitrate,int)

        vcodec='libx264' #This used to be an argument, but I don't know if I'll ever change it

        self.path          = get_absolute_path(path)
        self.vcodec        = vcodec
        self.framerate     = framerate
        self.video_bitrate = video_bitrate

        self.started  = False
        self.finished = False
    
        self.height=height
        self.width=width
        
    def write_frame(self, frame):
        import ffmpeg

        assert is_image(frame) or isinstance(frame,str)
        
        if isinstance(frame,str):
            frame = load_image(frame)

        assert not self.finished

        assert not is_a_folder(self.path)
        make_parent_directory(self.path)

        if not self.started:
            self.started = True

            height = get_image_height(frame) if self.height is None else self.height
            width  = get_image_width (frame) if self.width  is None else self.width 

            #Make sure the height and width are even. If it isn't, ffmpeg will throw a fit:
            #     ...   [libx264 @ 0x55e695b389c0] width not divisible by 2 (611x550)    ...
            if height%2:
                height-=1
            if width%2:
                width-=1

            self.process = (
                ffmpeg.input(
                    "pipe:",
                    format="rawvideo",
                    pix_fmt="rgb24",
                    s="{}x{}".format(width, height),
                    r=self.framerate,
                    **(dict(loglevel="quiet") if not self.show_progress else dict())
                )
                .output(
                    self.path,
                    pix_fmt="yuv420p",
                    vcodec=self.vcodec,
                    video_bitrate=self.video_bitrate,
                    # Interstingly, this is not where the framerate should go, based on my experiments...
                )
                .overwrite_output()
                .run_async(pipe_stdin=True)
            )

            self.height = height
            self.width  = width


        #Prepare the frame for the writer...
        frame=rp.crop_image   (frame, self.height, self.width, copy=False)
        frame=rp.as_rgb_image (frame, copy=False)
        frame=rp.as_byte_image(frame, copy=False)

        self.process.stdin.write(frame.tobytes())


    def finish(self):
        assert self.started
        self.process.stdin.close()
        self.process.wait()
        self.finished=True
        

def _cv_save_video_mp4(
    video,
    path,
    *,
    framerate,
    show_progress
):
    pip_import("cv2")
    import cv2

    video = as_rgb_images(video)
    video = as_byte_images(video)
    video = as_numpy_array(video)

    if path is None:
        path = get_unique_copy_path('video.mp4')

    if not has_file_extension(path):
        path = with_file_extension(path, 'mp4')

    fourcc = cv2.VideoWriter_fourcc(*"mp4v")
    _, h, w, c = video.shape
    video_writer = cv2.VideoWriter(path, fourcc, fps=framerate, frameSize=(w, h))

    video_iter = video
    if show_progress:
        video_iter = eta(video_iter, title='rp.r.'+get_current_function_name())

    for frame in video_iter:
        frame = cv_rgb_bgr_swap(frame)
        video_writer.write(img)

    return path

def _cv_save_video_mp4(
    video,
    path,
    *,
    framerate,
    show_progress,
    quality
):
    """ 
    Uses quality instead of bitrate because of how opencv works: https://docs.opencv.org/3.4/d4/d15/group__videoio__flags__base.html#gga41c5cfa7859ae542b71b1d33bbd4d2b4ace3320e146f4f95f3d58b32b0e1237b1 
    NOTE: This quality parameter...doesn't seem to do anything. So it will be hidden in save_video_mp4's docstring for now.
    """
    pip_import("cv2")
    import cv2

    video = as_rgb_images(video)
    video = as_byte_images(video)
    video = as_numpy_array(video)

    quality = _as_video_quality(quality)

    if path is None:
        path = get_unique_copy_path('video.mp4')

    if not has_file_extension(path):
        path = with_file_extension(path, 'mp4')

    if not folder_exists(get_parent_folder(path)):
        make_parent_directory(path)

    fourcc = cv2.VideoWriter_fourcc(*"mp4v")
    _, h, w, c = video.shape
    video_writer = cv2.VideoWriter(path, fourcc, framerate, (w, h))

    # Set quality if provided (in percent)
    if quality is not None:
        video_writer.set(cv2.VIDEOWRITER_PROP_QUALITY, quality)

    video_iter = video
    if show_progress:
        video_iter = eta(video_iter, title='rp.r.'+get_current_function_name())

    for frame in video_iter:
        frame = cv_rgb_bgr_swap(frame)
        video_writer.write(frame)  # Fixed typo: 'img' -> 'frame'

    video_writer.release()  # Ensure proper file closure

    return path

_save_video_mp4_default_backend = 'ffmpeg'

def set_save_video_mp4_default_backend(backend):
    assert backend in ('ffmpeg', 'cv2'), backend
    global _save_video_mp4_default_backend
    _save_video_mp4_default_backend = backend

def save_video_mp4(frames, path=None, framerate=60, *, video_bitrate='high', height=None, width=None, show_progress=True, backend=None):
    """
    frames: a list of images as defined by rp.is_image(). Saves an .mp4 file at the path
        - frames can also contain strings, if those strings are image file paths
        - frames can also be a glob or a folder of images, and if so they will be sorted by number
    Note that frames can also be a generator, as opposed to a numpy array.
    This can let you save larger videos that would otherwise make your computer run out of memory.

    frames: the video
    path: the path to save the video to. Defaults to 'video.mp4'
    video_bitrate: controls the quality of the output. If your backend is opencv, this parameter has no effect!
    height, width: If frames have various sizes, and are given as a generator, use this to set the height and width or else it will use the first frame's height and width
    show_progress: Whether to show the saving progress
    backend: Defaults to 'ffmpeg'. Can also be 'cv2' if you can't install 'ffmpeg' for some reason

    If you can't install ffmpeg, please set the backend to 'cv2'
    If you need this to be the default, call rp.r.set_save_video_mp4_default_backend('cv2') instead of 'ffmpeg', the default
    
    EXAMPLE BITRATES (used for the Sunkist soda example):
     10^5: 100000    : ( 345KB) is decent, and very compressed. It starts out a bit mushy though
     10^6: 1000000   : ( 3.3MB) I believe this is close to ffmpeg's default rate. It looks okay, but it does look a tiny bit mushy
     10^7: 10000000  : (32.7MB)
     10^8: 100000000 : (93.0MB)
     10^9: 1000000000: (93.0MB) It seems to be the maximum size
    """

    if backend is None:
        backend = _save_video_mp4_default_backend

    if backend=='ffmpeg':
        try:
            _ensure_ffmpeg_installed()
        except Exception as e:
            raise RuntimeError("save_video_mp4: Can't use backend=='ffmpeg' because ffmpeg is not installed. Consider setting backend=='cv2' instead? Or, if you can't change this argument, you can try running rp.r.set_save_video_mp4_default_backend('cv2')") from e

    assert backend in ('ffmpeg', 'cv2'), backend
    assert not isinstance(frames, str), 'The first argument should be the sequence of video frames, not the path!'

    if backend=='cv2':
        return _cv_save_video_mp4(
            frames,
            path,
            framerate=framerate,
            show_progress=show_progress,
            quality=video_bitrate, #Included for future updates of CV2...but right now it's inert. This parameter has no effect on the saved videos.
        )

    height = None if height is None else height
    width  = None if width  is None else width 
    
    if hasattr(frames,'__len__') and not isinstance(frames, str) and all(map(is_image, frames)):
        # If we're not fed a generator without a predefined number of frames,
        # Calculate the max height and width in the video and use that to be the height and width
        max_height, max_width = get_max_image_dimensions(frames)
        if height is None: height=max_height
        if width  is None: width =max_width 
    
    # YES, this is faster, but it also means more memory...probably not the best thing to do right?
    # #Speed improvement - batch-convert to uint8
    # if is_numpy_array(frames) or isinstance(frames, list) or is_torch_tensor(frames):
    #     frames = as_numpy_images(frames, copy=False)
    #     frames = as_byte_images(frames, copy=False)
    #     frames = as_rgb_images(frames, copy=False)

    writer = VideoWriterMP4(path, framerate, video_bitrate=video_bitrate, height=height, width=width, show_progress=show_progress)

    #Make frames speficiable as a glob, folder path, list of images, or list of image paths
    def load_frame(frame):
        #This is used to make image loading parallel...
        if isinstance(frame, str):
            return load_image(frame)
        else:
            return frame
    if isinstance(frames, str):
        if is_a_folder(frames):
            frames = get_all_image_files(frames, sort_by='number')
        else:
            frames = glob.glob(frames)
            frames = sorted(frames)
            frames = sorted(frames, key=len)
    loaded_frames = load_files(load_frame, frames, lazy=True)

    try:
        for frame in frames:
            writer.write_frame(frame)
    finally:
        writer.finish()
        
    return writer.path


def save_video_webm(frames, path=None, framerate=60, *, video_quality='high', height=None, width=None, show_progress=True):
    """
    Save video frames to WebM format with alpha channel support using VP9 codec.

    Uses imageio-ffmpeg with libvpx-vp9 codec and yuva420p pixel format to preserve
    transparency in video frames. This is the recommended format for saving videos
    with alpha channels.

    Args:
        frames: List of images as defined by rp.is_image(), or numpy array video
                Supports RGBA images - alpha channel will be preserved
                Can also be a generator for memory-efficient saving
        path: Output file path (defaults to auto-generated 'video.webm')
              Adds .webm extension if not present
        framerate: Video framerate in fps (default: 60)
        video_quality: Quality setting for VP9 codec (default: 'high')
                      - 'low': CRF 40 (smaller files, lower quality)
                      - 'medium': CRF 30 (balanced)
                      - 'high': CRF 20 (larger files, higher quality)
                      - 'max': CRF 10 (lossless-like quality)
                      - Or integer 0-63 (lower = better quality, larger file)
        height: Force specific height (default: auto from first frame)
        width: Force specific width (default: auto from first frame)
        show_progress: Whether to show encoding progress (default: True)

    Returns:
        str: Absolute path to the saved WebM file

    EXAMPLE (with alpha channel):
        >>> frames = [random_image(100, 100, mode='rgba') for _ in range(30)]
        ... path = save_video_webm(frames, 'transparent.webm')
        ... print(path)

    EXAMPLE (quality comparison):
        >>> video = [random_image(256, 256, mode='rgba') for _ in range(10)]
        ... save_video_webm(video, 'low_quality.webm', video_quality='low')
        ... save_video_webm(video, 'high_quality.webm', video_quality='high')

    See Also:
        - save_video_mp4: For videos without alpha (smaller files)
        - save_animated_webp: For animated WebP with alpha
        - save_video_gif: For animated GIFs
    """
    pip_import('imageio_ffmpeg', 'imageio-ffmpeg')
    from imageio_ffmpeg import write_frames

    _ensure_ffmpeg_installed()

    if path is None:
        path = _get_default_video_path('webm')

    if not has_file_extension(path) or get_file_extension(path).lower() != 'webm':
        path += '.webm'

    make_parent_directory(path)
    path = get_absolute_path(path)

    # Convert quality parameter to CRF value (0-63, lower is better)
    if video_quality in _named_video_qualities:
        # Map our quality scale (0-100) to CRF scale (63-0)
        quality_percent = _named_video_qualities[video_quality]
        crf = int(63 - (quality_percent / 100) * 53)  # Maps 0-100 to 63-10
    elif isinstance(video_quality, int):
        crf = max(0, min(63, video_quality))
    else:
        crf = 20  # Default to high quality

    # Determine dimensions from frames if not specified
    if height is None or width is None:
        if hasattr(frames, '__len__') and not isinstance(frames, str) and all(map(is_image, frames)):
            max_height, max_width = get_max_image_dimensions(frames)
            if height is None:
                height = max_height
            if width is None:
                width = max_width
        else:
            # Will determine from first frame during iteration
            first_frame = None
            if hasattr(frames, '__iter__'):
                frames_iter = iter(frames)
                first_frame = next(frames_iter)
                height = get_image_height(first_frame) if height is None else height
                width = get_image_width(first_frame) if width is None else width
                # Recreate frames with first frame prepended
                import itertools
                frames = itertools.chain([first_frame], frames_iter)

    # Ensure dimensions are even (required by libvpx-vp9)
    if height % 2:
        height -= 1
    if width % 2:
        width -= 1

    # Configure ffmpeg parameters for VP9 with alpha
    output_params = [
        '-crf', str(crf),
        '-b:v', '0',  # Use CRF mode (constant quality)
        '-auto-alt-ref', '0',  # Prevent transparency encoding errors
    ]

    if not show_progress:
        output_params.extend(['-loglevel', 'quiet'])

    # Create video writer with VP9 alpha support
    writer = write_frames(
        path,
        (width, height),
        pix_fmt_in='rgba',      # Input: RGBA frames
        pix_fmt_out='yuva420p',  # Output: YUV with alpha channel
        codec='libvpx-vp9',      # VP9 codec
        fps=framerate,
        output_params=output_params
    )

    writer.send(None)  # Seed the generator

    try:
        frame_iter = frames
        if show_progress and hasattr(frames, '__len__'):
            frame_iter = eta(frames, title='rp.r.save_video_webm')

        for frame in frame_iter:
            if isinstance(frame, str):
                frame = load_image(frame)

            # Convert to RGBA byte image
            frame = crop_image(frame, height, width, copy=False)
            frame = as_rgba_image(frame, copy=False)
            frame = as_byte_image(frame, copy=False)

            # Send frame bytes to writer
            writer.send(frame.tobytes())

    finally:
        writer.close()

    return path


save_animated_webm = save_video_webm


def save_video_gif_via_pil(video, path=None, *, framerate=30):
    """
    Save a video to a GIF with given path and framerate.
    Returns the path of the new GIF.

    TODO: Support higher quality dithering via adapting VideoWriterMP4 and convert_to_gif_via_ffmpeg together somehow
    """

    if isinstance(video, str):
        assert is_video_file(video) or ends_with_any(video, '.gif', '.png')
        video=load_video_stream(video)

    if path is None:
        path = get_unique_copy_path('video.gif')
    path = with_file_extension(path ,'gif')

    # Number of millis between frames
    frame_duration_millis = round(1 / framerate * 1000)

    frames = map(as_pil_image, video)
    frame_one = next(frames)

    rp.make_directory(rp.get_path_parent(path))

    frame_one.save(
        path,
        format="GIF",
        append_images=frames,
        save_all=True,
        duration=frame_duration_millis,
        loop=0,
    )

    path = get_absolute_path(path)

    return path

save_animated_gif = save_video_gif = save_animated_gif_via_pil = save_video_gif_via_pil

#COMMENTED OUT UNTIL I CAN GUARANTEE EVERY FRAME IN THE INPUT IS INCLUDED IN THE OUTPUT REGARDLESS OF FRAMERATE
# def save_animated_gif_via_ffmpeg(video, path=None, *, silent=False, custom_palette=True, framerate=30):
#     """
#     TODO: This is currently jank; dont use temp files and avoid MP4 artifacts.
#     This saves higher quality GIF's than save_animated_gif_via_pil because it uses dithering
#     """
#
#     if path is None:
#         path = get_unique_copy_path('video.gif')
#     path = with_file_extension(path ,'gif')
#
#     temp_mp4=temporary_file_path('mp4')
#     try:
#         if isinstance(video, str):
#             mp4_path = video
#         else:
#             mp4_path = save_video_mp4(video, temp_mp4, silent=silent)
#
#         gif_path = convert_to_gif_via_ffmpeg(mp4_path, path, silent=silent, custom_palette=custom_palette, framerate=framerate)
#     finally:
#         if path_exists(temp_mp4):
#             delete_file(temp_mp4)
#
#     return gif_path



def convert_to_gif_via_ffmpeg(
    video_path,
    output_path=None,
    *,
    framerate=None,
    custom_palette=True,
    show_progress=True
):
    """
    Converts a video file to a GIF using FFmpeg.
    It does a really good job - with dithering! If you set custom_palette=False, the colors will be worse but it will be sometimes more than 2x smaller.
    In any case it will still use dithering! Unlike the save_animated_gif_via_pil function - which, unless pre-dithered, will not dither your images

    TODO: Investigate the framerate arg. It sometimes skips frames! And doesn't always seem to give the framerate I ask of it.

    Args:
        video_path (str): The path to the input video file.
        output_path (str, optional): The path where the output GIF will be saved. If not provided, 
            a unique output path will be generated based on the input video's path with the '.gif' extension.
        framerate (int, optional): The desired framerate for the GIF. Default is None, meaning it will inherit the input video file's framerate.
        custom_palette (bool, optional): Whether to use a custom palette for higher quality but larger file size.
            If True, a temporary palette file will be generated and used for the GIF conversion.
            If False, the GIF will be converted without using a custom palette, resulting in a smaller file size
            but potentially lower quality. Default is True.
        show_progress (bool, optional): Whether to suppress FFmpeg output. If False, FFmpeg will run in quiet mode.
            Default is False.

    Returns:
        str: The path to the generated GIF file.

    EXAMPLE:
        >>> video_file = download_url_to_cache("https://www.shutterstock.com/shutterstock/videos/1094782389/preview/stock-footage-cute-black-and-white-border-collie-dog-touch-owner-hand-by-paw-give-high-five-to-woman-attractive.webm")
        ... print(convert_to_gif_via_ffmpeg(video_file,'doggy.gif'))  #18MB
        ... print(convert_to_gif_via_ffmpeg(video_file,'doggy_without_custom_palette.gif',custom_palette=False)) #6MB - smaller but visible dithering artifacts

    Learned how to do this from here: https://steemit.com/programming/@makerhacks/how-to-get-higher-quality-gifs-with-ffmpeg

    Note: FFMPEG might choose the frame delays on a per-frame basis, making it jerky if viewed at a low framerate! You can fix this with 
        rp.save_animated_gif(rp.convert_to_gif_via_ffmpeg(video), framerate=12)
        Because save_animated_gif will preserve dithering (it just doesn't make dithering)

    """

    import os
    import subprocess
    
    assert isinstance(video_path,str), type(video_path)
    assert number_of_lines(video_path)==1, number_of_lines(video_path)
    assert path_exists(video_path), 'r._convert_to_gif_via_ffmpeg: Input file does not exist: '+str(video_path)[:1000]
    assert framerate is None or isinstance(framerate, int) and framerate>=1, framerate

    _ensure_ffmpeg_installed()

    if output_path is None:
        output_path = with_file_extension(video_path, "gif", replace=True)
        output_path = get_unique_copy_path(output_path)

    make_parent_folder(output_path)

    input_framerate = get_video_file_framerate(video_path)

    maybe_silent = ["-loglevel", "quiet"] if not show_progress else []
    maybe_framerate = ["-r", str(input_framerate)]

    if custom_palette:
        # Generate a temporary palette file
        palette_file = temporary_file_path("png")
        try:
            # Generate the palette using FFmpeg
            subprocess.call(["ffmpeg", "-y", "-i", video_path, "-vf", "palettegen", palette_file] + maybe_silent)
            # Convert the video to GIF using the generated palette
            subprocess.call(["ffmpeg", "-y", "-i", video_path, "-i", palette_file, "-lavfi", "paletteuse", ]+maybe_framerate+[output_path] + maybe_silent)
        finally:
            # Remove the temporary palette file
            if os.path.exists(palette_file):
                os.remove(palette_file)
    else:
        # Convert the video to GIF without using a custom palette
        subprocess.call(["ffmpeg", "-y", "-i", video_path, ]+maybe_framerate+[ output_path] + maybe_silent)

    if framerate is not None:
        #As stated in the note, when framerates are slow the per-frame delays become more noticeable and makes the video appear jerky
        #This is a slow fix...but it does in fact fix the problem! TODO: Directly modify the delays in the GIF instead - that should be faster
        if show_progress:
            print("Setting the framerate to %i..."%framerate)
            temp_path = temporary_file_path('gif')
            move_file(output_path, temp_path)
            try:
                output_path = save_animated_gif(temp_path, output_path, framerate=framerate)
            finally:
                delete_file(temp_path)

    return output_path

def convert_to_gifs_via_ffmpeg(
    video_paths,
    output_paths=None,
    *,
    framerate=None,
    custom_palette=True,
    
    show_progress=True,
    num_threads=None
):
    """ Plural of convert_to_gif_via_ffmpeg. Arguments are broadcastable. """
    def run(bundle):
        return convert_to_gif_via_ffmpeg(
            video_path=bundle.video_paths,
            output_path=bundle.output_paths,
            framerate=bundle.framerate,
            custom_palette=custom_palette,
            show_progress=False,
        )

    video_paths = rp_glob(video_paths)
    
    bundles = broadcast_kwargs(
        gather_vars(
            "video_paths",
            "output_paths",
            "framerate",
            "custom_palette",
        )
    )

    if show_progress is True:
        show_progress='eta:rp.convert_to_gif_via_ffmpeg'

    return load_files(
        run,
        bundles,
        show_progress=show_progress,
        num_threads=num_threads,
    )

def save_video(images, path, *, framerate=60):
    """
    #TODO: add options for sound and framerate. Possibly quality options but probably not (that should be delegated to a function meant for a specific format)
    #Save a series of images into a video.
    #Note that the file extension used in path decides the kind of video that will be exported.
    #For example, save_video(images,'video.mp4') saves an mp4 file whilst save_video(images,'video.avi') saves an avi file
    #Use save_video(images,'video.webm') for videos with alpha channels (transparency)
    """
    assert not isinstance(images, str), 'The first argument should be the sequence of video frames, not the path!'

    assert get_file_extension(path) in 'mp4 avi gif png webp webm'.split(), 'This function currently supports .mp4, .avi, .png, .webp, .webm and .gif files'

    if path.endswith('.mp4') : return save_video_mp4(images,path,framerate=framerate, show_progress=False)
    if path.endswith('.avi') : return save_video_avi(images,path,framerate=framerate)
    if path.endswith('.png') : return save_video_png(images,path,framerate=framerate)
    if path.endswith('.gif') : return save_video_gif(images,path,framerate=framerate)
    if path.endswith('.webp'): return save_video_webp(images,path,framerate=framerate)
    if path.endswith('.webm'): return save_video_webm(images,path,framerate=framerate)


    assert False, 'Below this line mightn not work. Until further notice, please specify .avi or .mp4 in the path argument'



    if not has_file_extension(path):
        try:
            return save_video_mp4(path,images)
            return save_video_avi(images,path,framerate=framerate)
        except Exception:
            pass #No big deal, we'll try sk-video. Although, that's more annoying because it means we need FFMPEG instead of just needing OpenCV


    #Make sure all frames have ranges between 0 and 255, and that they're RGB. Otherwise we might get weird results when we save the video
    images=[as_byte_image(as_rgb_image(frame)) for frame in images]

    pip_import('skvideo.io','sk-video')
    import skvideo.io#pip install sk-video:
    #NOTE: Quicktime on my mac can't play these. Idk why. But the video outputs are NOT broken. VLC video player CAN play them without error.
    skvideo.io.vwrite(path,images,inputdict={'-r':str(framerate)})
    return path


def encode_video_to_bytes(video,filetype:str='.mp4',framerate=60):
    """Encode video to bytes without saving to disk.
    See also: decode_video_from_bytes"""
    video_file=temporary_file_path(filetype)
    
    try:
        save_video(video,video_file,framerate=framerate)
        return file_to_bytes(video_file)
    except:
        raise
    finally:
        if file_exists(video_file):
            delete_file(video_file)

def decode_video_from_bytes(encoded_video:bytes, filetype:str='.avi'):
    """Decode bytes back to video array.
    See also: encode_video_to_bytes"""
    video_file = temporary_file_path(filetype)
    try:
        bytes_to_file(encoded_video, video_file)
        return load_video(video_file)
    finally:
        if file_exists(video_file):
            delete_file(video_file)

decode_bytes_to_video = decode_video_from_bytes  # Alias for consistency

def add_audio_to_video_file(video_path, audio_path, output_path=None):
    """
    Add audio to a video file without recompressing the video.

    This function uses FFmpeg to add audio from an audio file to a video file
    without recompressing the video stream. The audio is cut off at the end of the
    video's duration.

    Parameters:
        - video_path (str): The path to the input video file.
        - audio_path (str): The path to the input audio file.
        - output_path (str): The path where the output video file with added audio will be saved.

    Supported Video Formats:
        - MP4 (.mp4)
        - MOV (.mov)
        - AVI (.avi)
        - MKV (.mkv)
        - WebM (.webm)
        - FLV (.flv)
        - WMV (.wmv)
        - MPEG (.mpeg, .mpg)
        - OGV (.ogv)

    Supported Audio Formats:
        - MP3 (.mp3)
        - WAV (.wav)
        - AAC (.aac)
        - FLAC (.flac)
        - OGG (.ogg)
        - M4A (.m4a)
        - WMA (.wma)

    Returns:
        - str: The path to the output video file with added audio.

    Note: FFmpeg must be installed and accessible from the command line for this function to work.
    """
    import subprocess
    
    _ensure_ffmpeg_installed()

    if output_path is None:
        output_path = get_unique_copy_path(
            with_file_name(
                video_path,
                with_file_extension(
                    get_file_name(video_path, include_file_extension=False) + "_with_audio",
                    get_file_extension(video_path),
                ),
            )
        )

    fansi_print(audio_path,'yellow')
    fansi_print(video_path,'yellow')
    fansi_print(output_path,'yellow')

    ffmpeg_cmd = [
        "ffmpeg",
        "-i", video_path,
        "-i", audio_path,
        "-c:v", "copy",
        "-c:a", "aac",
        "-map", "0:v:0",
        "-map", "1:a:0",
        "-shortest",
        output_path
    ]

    try:
        subprocess.run(ffmpeg_cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
        fansi_print("Audio added to video successfully: "+output_path,'green')
        return output_path
    except subprocess.CalledProcessError as e:
        fansi_print("Error occurred during audio addition:", "red")
        fansi_print("Command: " + " ".join(ffmpeg_cmd), "red")
        fansi_print("Error output: " + e.stderr.decode("utf-8"), "red")
        return None


def change_video_file_framerate(video_path, new_framerate, output_path=None):
    """
    Change the framerate of a video without recompressing or changing the audio.
    This function uses FFmpeg to change the framerate of a video file without
    recompressing the video stream or modifying the audio.
    Parameters:
        - video_path (str): The path to the input video file.
        - new_framerate (float): The new framerate for the video (e.g., 25, 30, 60).
        - output_path (str): The path where the output video file with the new framerate will be saved.
    Returns:
        - str: The path to the output video file with the new framerate.
    Note: FFmpeg must be installed and accessible from the command line for this function to work.
    References:
        https://superuser.com/questions/1088382/change-framerate-in-ffmpeg-without-reencoding

    Notes:
        Confirmed to work with the following filetypes. Might work with more! Didn't test them all yet.
            MP4, GIF
    """
    import subprocess
    
    _ensure_ffmpeg_installed()
    
    if output_path is None:
        output_path = get_unique_copy_path(
            with_file_name(
                video_path,
                with_file_extension(
                    get_file_name(video_path, include_file_extension=False)
                    + "_"
                    + str(new_framerate)
                    + "_fps",
                    get_file_extension(video_path),
                ),
            )
        )
        
    input_framerate = get_video_file_framerate(video_path)
    itsscale = input_framerate / new_framerate
    
    ffmpeg_cmd = [
        "ffmpeg",
        "-itsscale", str(itsscale),
        "-i", video_path,
        "-codec", "copy",
        output_path
    ]
    try:
        subprocess.run(ffmpeg_cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
        fansi_print("Video framerate changed successfully: "+output_path, 'green')
        return output_path
    except subprocess.CalledProcessError as e:
        fansi_print("Error occurred during framerate change:", "red")
        fansi_print("Command: " + " ".join(ffmpeg_cmd), "red")
        fansi_print("Error output: " + e.stderr.decode("utf-8"), "red")
        return None

def change_video_file_framerates(video_paths, new_framerate):
    return [change_video_file_framerate(video_path, new_framerate) for video_path in video_paths]


def concat_mp4_files(*input_files, output_file=None):
    """
    Concatenate multiple MP4 files with zero degradation (no recompression).

    Args:
        input_files (list): List of input MP4 file paths
        output_file (str): Path for the output concatenated MP4 file

    Returns:
        str: The path to the concatenated file if successful.

    Raises:
        ValueError: If no input files are provided.
        FileNotFoundError: If any input file does not exist.
        subprocess.CalledProcessError: If FFmpeg fails during concatenation.
        Exception: For any other unexpected errors.
    """
    import subprocess
    import os
    import tempfile

    input_files = detuple(input_files)

    if not input_files:
        raise ValueError("concat_mp4_files: No input files provided.")

    if isinstance(input_files, str):
        input_files = input_files.strip().splitlines()
        
    if output_file is None:
        output_file = with_file_name(input_files[0], "concatenated_videos.mp4")
        output_file = rp.get_unique_copy_path(output_file)

    with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False) as temp_file:
        temp_filename = temp_file.name
        for file_path in input_files:
            if not os.path.exists(file_path):
                os.unlink(temp_filename)
                raise FileNotFoundError("concat_mp4_files: File not found: "+file_path)
            escaped_path = file_path.replace("'", "'\\''")
            temp_file.write("file '{}'\n".format(escaped_path))

    try:
        cmd = [
            'ffmpeg',
            '-f', 'concat',
            '-safe', '0',
            '-i', temp_filename,
            '-c', 'copy',
            '-map_metadata', '0',
            '-movflags', '+faststart',
            output_file
        ]
        result = subprocess.run(cmd, capture_output=True, text=True, check=True)
        return output_file

    except subprocess.CalledProcessError as e:
        raise subprocess.CalledProcessError(e.returncode, e.cmd, output=e.output, stderr=e.stderr,
                                            msg="concat_mp4_files: FFmpeg concatenation failed.") from e

    except Exception as e:
        raise Exception("concat_mp4_files: An unexpected error occurred.") from e

    finally:
        if os.path.exists(temp_filename):
            os.unlink(temp_filename)


def directory_exists(path):
    """
    Checks if a directory exists at the given path.
    EXAMPLE:
        >>> directory_exists('/tmp') == True
        >>> directory_exists('/nonexistent') == False
    Aliases: folder_exists, is_a_folder, is_a_directory
    """
    if not isinstance(path,str): return False
    return os.path.isdir(path)
folder_exists=directory_exists
is_a_folder=is_a_directory=directory_exists#Synonyms, you might want one more than another depending o nthe context. Sometimes we might want to know if it exists, and others we might allready know the path exists and want to see what kind of path it is. It's just to make it more readable, that's all. Less need for comments like these lol.

def is_empty_folder(path:str):
    """
    Checks if a folder exists and is empty (contains no files or folders).
    Returns False if directory doesn't exist.
    EXAMPLE:
        >>> is_empty_folder('/tmp/empty_dir')     == True
        >>> is_empty_folder('/tmp/dir_with_files') == False
        >>> is_empty_folder('/nonexistent')       == False
    Aliases: is_empty_directory
    """
    if not isinstance(path,str): return False
    return is_a_folder(path) and list(get_all_paths(path,include_folders=True,include_files=True,recursive=False))==[]
is_empty_directory=is_empty_folder
    

def file_exists(path):
    """Check if a path points to an existing file.
    
    Enhanced Documentation:
    This is a fundamental file system utility used extensively throughout RP for safe file operations.
    Unlike os.path.isfile(), this function gracefully handles non-string inputs by returning False
    instead of raising an exception. It's commonly used as a safety check before file operations.
    
    Usage patterns in codebase:
    - Safety checks before file operations (most common usage)
    - Conditional file loading with fallbacks
    - Cache validation and file-based feature detection
    - Used by 75+ functions as a prerequisite check
    
    Args:
        path: File path to check. Can be any type, but only strings are valid paths.
            Non-string inputs safely return False.
    
    Returns:
        bool: True if path is a string pointing to an existing file, False otherwise.
              Returns False for directories, non-existent paths, or non-string inputs.
    
    Examples:
        >>> # Test with existing file
        >>> file_exists('/bin/bash')  # On Unix systems
        True
        >>> file_exists(__file__)
        True
        >>> 
        >>> # Test with non-existent path
        >>> file_exists('/nonexistent/path')
        False
        >>> 
        >>> # Test with non-string input (graceful handling)
        >>> file_exists(123)
        False
        >>> file_exists(None)
        False
        
    Related Functions:
        - path_exists(): Checks for both files and directories
        - directory_exists(): Checks specifically for directories
        - is_a_file: Alias for file_exists()
        
    Tags: filesystem, validation, safety-check, file-operations
    """
    if not isinstance(path,str): return False
    return os.path.isfile(path)
is_a_file=file_exists

def path_exists(path):
    """Check if a path points to an existing file or directory.
    
    Enhanced Documentation:
    This is a comprehensive path validation function that checks for both files and directories.
    It's the most general path existence check in RP, combining file_exists() and directory_exists()
    functionality. Like file_exists(), it gracefully handles non-string inputs.
    
    Usage patterns in codebase:
    - General path validation before operations that work with both files and directories
    - Preprocessing for file/directory operations where the type doesn't matter
    - Used in copy operations, symlink creation, and archive handling
    - Common in conditional logic for path-based features
    
    Args:
        path: File or directory path to check. Can be any type, but only strings are valid paths.
            Non-string inputs safely return False.
    
    Returns:
        bool: True if path is a string pointing to an existing file or directory, False otherwise.
              Returns False for non-existent paths or non-string inputs.
    
    Examples:
        >>> # Test with existing file
        >>> path_exists('/bin/bash')  # File
        True
        >>> 
        >>> # Test with existing directory  
        >>> path_exists('/tmp')  # Directory
        True
        >>> 
        >>> # Test with non-existent path
        >>> path_exists('/nonexistent/path')
        False
        >>> 
        >>> # Test with non-string input (graceful handling)
        >>> path_exists(None)
        False
        
    Related Functions:
        - file_exists(): Checks specifically for files only
        - directory_exists(): Checks specifically for directories only
        - Implementation uses: file_exists(path) or directory_exists(path)
        
    Tags: filesystem, validation, path-checking, file-operations
    """
    if not isinstance(path,str): return False
    return file_exists(path) or directory_exists(path)
# is_a_path=path_exists #Can be confused with complex vector paths etc, and also this isn't that descriptive...don't want code to be dependent on this synonym...

def rename_path(path,new_name,*,keep_extension=False):
    """
    EXAMPLE:
       rename_path("apple/bananna/cherry.jpg","coconut.png")
           is equivalent to (in bash)
       mv .apple/bananna/cherry.jpg apple/bananna/coconut.png
    """

    if keep_extension and has_file_extension(path):
        new_path = with_file_name(path, new_name, keep_extension=keep_extension)
    else:
        new_path=os.path.join(get_path_parent(path),new_name)

    os.rename(path,new_path)
    return new_path

rename_file=rename_path#Synonyms that might make more sense to read in their context than rename_path
rename_folder=rename_path
rename_directory=rename_path

def move_path(from_path,to_path):
    """
    Like the 'mv' command
    Move a folder or file into a given directory if to_path is a directory,
    otherwise just rename the path

    Enhanced Documentation:
    
    Moves (or renames) a file or directory from one location to another.
    Automatically creates parent directories if needed. Handles both moving
    into directories and renaming files/directories.
    
    Parameters:
        from_path (str): Source path of file or directory to move
        to_path (str): Destination path or directory
    
    Returns:
        str: Final path where the item was moved
    
    Usage Examples:
        >>> # Move file to different directory
        >>> move_path("data.txt", "backup/")
        >>> # Result: data.txt is now at backup/data.txt
        
        >>> # Rename a file
        >>> move_path("old_name.txt", "new_name.txt")
        >>> # Result: file is renamed
        
        >>> # Move directory
        >>> move_path("temp_folder", "archive/old_temp")
        >>> # Result: temp_folder becomes archive/old_temp
    
    Related Functions:
        - rename_path(): Just rename without directory logic
        - copy_path(): Copy instead of move
        - make_parent_directory(): Creates dirs automatically
    
    Tags: move, rename, filesystem, mv, directories, files
    """
    
    make_parent_directory(to_path)#Make sure it has somewhere to go. If the destination folder doesn't already exist, create it.
    if is_a_directory(to_path):
        new_path=path_join(to_path,get_file_name(from_path))
    else:
        new_path=to_path
    
    import shutil
    shutil.move(from_path,new_path)
    # os.rename(from_path,new_path) # This doesn't work across harddrives: OSError: [Errno 18] Invalid cross-device link: '/home/ryan/Downloads/previous_exams_548.zip' -> './previous_exams_548.zip'
    return new_path

move_file=move_directory=move_folder=move_path#Synonyms that might make more sense to read in their context than rename_path

def swap_paths(path_a,path_b):
    """
    Moves path_a to path_b and vice versa
    This is an atomic operation that will be undone upon erroring
    (So if one of the paths has a permission error, don't worry - nothing should change!)
    """

    temp_suffix='_swap%i'
    temp_path_a=get_unique_copy_path(path_a,suffix=temp_suffix)
    temp_path_b=get_unique_copy_path(path_b,suffix=temp_suffix)
    
    import shutil
    try:
        shutil.move(path_a,temp_path_a)
        shutil.move(path_b,temp_path_b)
        #If there's a permission error, we would have hit it by now
        #The next two moves should be ok
        try:
            shutil.move(temp_path_a,path_b)
            shutil.move(temp_path_b,path_a)
        except:
            #Perhaps some race condition from another process changed permissions mid-swap?
            print('rp.swap_paths: Something weird happened I didnt account for!')
            raise
    except:
        #Try to put them both back - don't swap if there's an error!
        #This operation is meant to be atomic.
        try:
            try:
                shutil.move(temp_path_a,path_a)
            finally:
                shutil.move(temp_path_b,path_b)
        except:
            #Don't raise this permission error - raise the original
            pass
        raise

def delete_file(path,*,permanent=True,strict=True):
    """
    Deletes a file at a given path.
    
    Args:
        path (str): Path to the file.
        permanent (bool, optional): If True, delete file permanently. Otherwise, move to trash. Defaults to True.

    permanent exists for safety reasons. It can be False in case you make a stupid mistake like deleting this file. When false, it will send your files to the trash bin on your system (Mac,Windows,Linux, etc)
    By default, though, permanent=True, becuase when it's not it can cause your hard-drive to fill up without you expecting it (you don't normally expect to keep files when calling a function called delete file, which doesn't actually free your hard-drive when permanent=False)

    References:
        - http://stackoverflow.com/questions/3628517/how-can-i-move-file-into-recycle-bin-trash-on-different-platforms-using-pyqt4
        - https://pypi.python.org/pypi/Send2Trash
        - pip3 install Send2Trash

    Note:
        This function does not follow symlinks. If you want to delete the target of a symlink, 
        you can resolve the symlink first using a helper function like `read_symlink(path)` 
        and then call this function.

    Enhanced Documentation:
    - Usage patterns: Temporary file cleanup, cache management, file processing cleanup
    - Related functions: delete_folder() for directories, delete_files() for multiple files, file_exists() to check first
    - RP integration: Used by 31+ functions for cleanup operations and temporary file management
    - Performance: Fast native OS calls, send2trash dependency only loaded when needed
    - Comparison: vs os.remove() - safer with trash option; vs delete_folder() - files only; vs delete_files() - single file only

    Parameters:
        path (str): Absolute or relative path to the file to delete
        permanent (bool): If True, permanently delete. If False, move to trash/recycle bin. Defaults to True.
        strict (bool): If True, raise error if file doesn't exist. If False, silently ignore. Defaults to True.

    Returns:
        None: File is deleted or moved to trash

    Examples:
        >>> delete_file("temp.txt")  # Permanently delete file
        >>> delete_file("backup.zip", permanent=False)  # Move to trash for safety
        >>> delete_file("might_not_exist.txt", strict=False)  # Don't error if missing
        >>> # Safe cleanup pattern:
        >>> if file_exists("temp_data.json"):
        ...     delete_file("temp_data.json", permanent=False)  # Use trash for safety
        >>> # Cleanup after processing:
        >>> delete_file(temp_file_path, strict=False)  # Ignore if already cleaned up

    Tags: file-management, delete, cleanup, trash, safety, filesystem
    """
    import os
    
    if strict:
        assert os.path.exists(path),"r.delete_file: There is no file to delete. The path you specified, '" + path + "', does not exist!"  # This is to avoid the otherwise cryptic errors you would get later on with this method
        assert file_exists(path),"r.delete_file: The path you selected exists, but is not a file: %s"%path

    try:
        if permanent:
            os.remove(path)
        else:
            pip_import('send2trash')
            import send2trash  # This is much safer. By default, we move files to the trash bin. That way we can't accidentally delete our whole directory for good ;)
            send2trash.send2trash(path)  # This is MUCH safer than when delete_permanently is turned on. This will have the same effect as deleting it in finder/explorer: it will send your file to the trash bin instead of immediately deleting it forever.
    except FileNotFoundError:
        if strict:
            raise



def delete_folder(path, *, recursive=True, permanent=True, strict=True):
    """
    Will recursively delete a folder and all of its contents
     permanent exists for safety reasons. It can be False in case you make a stupid mistake like deleting this file. When false, it will send your files to the trash bin on your system (Mac,Windows,Linux, etc)
     By default, though, permanent=True, becuase when it's not it can cause your hard-drive to fill up without you expecting it (you don't normally expect to keep files when calling a function called delete file, which doesn't actually free your hard-drive when permanent=False)
     http://stackoverflow.com/questions/3628517/how-can-i-move-file-into-recycle-bin-trash-on-different-platforms-using-pyqt4
     https://pypi.python.org/pypi/Send2Trash
     pip3 install Send2Trash
    """
    import shutil

    if folder_exists(path):
        if permanent:
            if not recursive:
                assert is_empty_folder(path),'delete_folder: Cannot delete folder because its not empty and recursive==False. Folder: '+repr(path)
            shutil.rmtree(path)
        else:
            pip_import('send2trash')
            import send2trash  
            send2trash.send2trash(path)  

    elif strict:
        assert path_exists(path),"r.delete_folder: There is no folder to delete. The path you specified, '" + path + "', does not exist!"  
        assert folder_exists(path), "r.delete_folder: The path you selected exists, but is not a folder: %s"%path
        assert False, "unreachable line"


def delete_symlink(path):
    """
    Deletes a symbolic link. Raises ValueError if path is not a symlink.
    EXAMPLE:
        >>> delete_symlink('/tmp/mylink')  # Removes symlink only, not target
    """
    if not is_symlink(path): raise ValueError("Not a symlink: " + str(path))
    delete_file(path)

delete_directory=delete_folder

def delete_path(path,*,permanent=True):
    """
    permanent exists for safety reasons. It can be False in case you make a stupid mistake like deleting this file. When false, it will send your files to the trash bin on your system (Mac,Windows,Linux, etc)
    By default, though, permanent=True, becuase when it's not it can cause your hard-drive to fill up without you expecting it (you don't normally expect to keep files when calling a function called delete file, which doesn't actually free your hard-drive when permanent=False)
    http://stackoverflow.com/questions/3628517/how-can-i-move-file-into-recycle-bin-trash-on-different-platforms-using-pyqt4
    https://pypi.python.org/pypi/Send2Trash
    pip3 install Send2Trash
    """
    assert os.path.exists(path),"r.delete_path: There is no folder or file to delete. The path you specified, '" + path + "', does not exist!"  
    if is_a_file(path):
        delete_file(path,permanent=permanent)
    elif is_a_folder(path):
        delete_folder(path,permanent=permanent)
    else:
        assert False, "This should be impossible...it appears that path %s exists but is neither a file nor a folder."%path

def _delete_paths_helper(*paths,permanent=True,delete_function=delete_path,strict,show_progress,num_threads=0):
    """
    Warning: Multithreaded doesn't always work. Sometimes it does, but for robustness the default is num_threads=0
    EXAMPLE:  delete_paths( 'a.jpg','b.jpg' )
    EXAMPLE:  delete_paths(['a.jpg','b.jpg'])
    EXAMPLE:  delete_paths(('a.jpg','b.jpg'))
    """
    paths=detuple(paths)

    if isinstance(paths,str):
        paths=[paths] #if we gave a single path as an argument, turn it into a list so we can iterate over it...
                
    def delfunc(path):
        return delete_function(path,permanent=permanent)

    show_progress=show_progress and 'eta:Deleting Paths'

    if num_threads == 0:
        output = []
        for path in paths:
            if isinstance(path,str):
                try:
                    output.append(path)
                except Exception:
                    if strict:
                        raise
                    else:
                        if strict is None:
                            output.append(None)

    else:
        output = load_files(
            delfunc,
            paths,
            strict=strict,
            show_progress=show_progress,
            num_threads=num_threads,
            # num_threads=0,#Debugging - it deadlocked idk why
        )

    return output


def delete_paths(*paths,permanent=True,strict=True,show_progress=False):
    """Delete multiple file/folder paths. See delete_path for details.
    
    Args: *paths, permanent=True, strict=True, show_progress=False
    Returns: Result of deletion operations
    """
    return _delete_paths_helper(*paths,permanent=permanent,delete_function=delete_path,strict=strict,show_progress=show_progress)

def delete_files(*paths,permanent=True,strict=True,show_progress=False):
    """Delete multiple files. See delete_file for details.
    
    Args: *paths, permanent=True, strict=True, show_progress=False
    Returns: Result of deletion operations
    """
    return _delete_paths_helper(*paths,permanent=permanent,delete_function=delete_file,strict=strict,show_progress=show_progress)

def delete_folders(*paths,permanent=True,strict=True,show_progress=False):
    """Delete multiple folders recursively. See delete_folder for details.
    
    Args: *paths, permanent=True, strict=True, show_progress=False  
    Returns: Result of deletion operations
    """
    return _delete_paths_helper(*paths,permanent=permanent,delete_function=delete_folders,strict=strict,show_progress=show_progress)
delete_directories=delete_folders

def copy_path(from_path,to_path,*,extract=False):
    """
    #Chooses between copy_directory and copy_file, whichever makes more sense.
    #If extract is True, it will copy only the contents of the folder to the destination, as opposed to copying the actual folder itself.
    #Works with both files and directories. If given a directory, it will be copied recursively.
    Should return the path of the newly copied file
    """
    assert path_exists(from_path),'Cannot copy from from_path='+repr(from_path)+' because that path does not exist'

    #The logic of what happens when to_path is a directory should be handled by copy_directory and copy_file respectively

    if is_a_directory(from_path):
        return copy_directory(from_path,to_path,extract=extract)
    else:
        return copy_file(from_path,to_path)

def copy_to_folder(from_path,to_path):
    """
    Copy a file or directory to a folder, keeping the same file name
    For example, copy_to_folder('/docs/text.txt','some/folder/path') will create a new file whose path is 'some/folder/path/text.txt'
    This can be nicer than copy_path, because then we don't have to rewrite the file name twice.
    This function also works with folders, and copies them recursively.
    """
    assert is_a_folder(to_path),'to_path must be a folder, but '+repr(to_path)+' either does not exist or is not a folder'
    dest=path_join(to_path,get_path_name(from_path))
    return copy_path(from_path,dest)

copy_to_directory=copy_to_folder

#def copy_directory(from_path,to_path,*,extract=False):
#    """
#    #Recursively copy a directory.
#    #If extract is True, it will copy only the contents of the folder to the destination, as opposed to copying the actual folder itself.
#    #Note: the default extract=False must not change. Future versions of rp must respect this.
#    """
#    assert path_exists   (from_path),'rp.copy_directory error: Cant copy from path '+repr(from_path)+' because that path does not exist'
#    assert is_a_directory(from_path),'rp.copy_directory error: from_path='+repr(from_path)+' is not a directory, and this function is specifically meant to copy directories.'
#    if not extract:
#        #We want to make a NEW folder at to_path because extract is False
#        #We will extract into this path
#        if is_a_folder(to_path):
#            to_path=path_join(to_path,get_directory_name(from_path))
#            if path_exists(to_path):
#                #This will throw an error - but I'll make the message different depending on whether its a file or folder.
#                assert not file_exists(to_path),'rp.copy_directory error: Cant copy a directory into a file. from_path='+repr(from_path)+' is a directory and to_path='+repr(to_path)+' is a file.'
#                assert not folder_exists(to_path),'rp.copy_directory error: Will not overwrite existing folder (extract is False). from_path='+repr(from_path)+' is a directory and to_path='+repr(to_path)+' is also already a directory.'
#                assert False, 'Internal assertion - we should have thrown an error by now'
#        else:
#            assert not file_exists(to_path),'rp.copy_directory error: Cant copy a directory into a file. from_path='+repr(from_path)+' is a directory and to_path='+repr(to_path)+' is a file.'
#    else:
#        assert not file_exists(to_path),'rp.copy_directory error: Cant copy a directory into a file. from_path='+repr(from_path)+' is a directory and to_path='+repr(to_path)+' is a file.'
#    make_directory(to_path) #Make sure the destination path can exist...
#    #Do the actual copying - extract into to_path
#    from importlib import reload
#    import distutils.dir_util
#    reload(distutils.dir_util)#I don't know why, but when I copied a folder, deleted it, then told it to copy again, it broke with "ERROR: distutils.errors.DistutilsFileError: could not create 'TestJam/Wampo/prompt_toolkit/filters/.DS_Store': No such file or directory" and reloading the module appeared to fix it. This reloading doesn't appear to damage performance, I clocked it at 0.0003399848937988281 seconds
#    distutils.dir_util.copy_tree(from_path,to_path)#Copy the directory's contents recursively...
#    return to_path


def copy_directory(from_path, to_path, *, extract=False, follow_symlinks=False):
    """
    Recursively copy a directory.
    
    If extract is True, it will copy only the contents of the folder to the destination, as opposed 
    to copying the actual folder itself. If false, it must create a new folder, just like `cp -r`. 
    
    If follow_symlinks is True, symlinks will be followed, and their contents will be copied;
    otherwise, the symlinks themselves will be copied. This is like `cp -L` aka `cp --dereference`
    
    Args:
    from_path (str): The source directory path.
    to_path (str): The destination directory path.
    extract (bool, optional): Whether to extract the contents of the folder. Defaults to False.
    follow_symlinks (bool, optional): Whether to follow and copy the contents of symlinks. 
                                      Defaults to False.
    
    Returns:
    str: The path to the copied directory.

    """
    # Note: the default extract=False and follow_symlinks=False must not change. Future versions of rp must respect this.
    # TODO: add argument ignore (iterable, optional): A set of paths we don't copy. Can include folders and supports globbing.
    #       also possible ignore as a function that takes in a path and returns a bool
    #       please note that shutil.copytree supports this functionality! Gpt4 is a good starting point lolol

    # Input assertions
    assert path_exists   (from_path),'rp.copy_directory error: Cant copy from path '+repr(from_path)+' because that path does not exist'
    assert is_a_directory(from_path),'rp.copy_directory error: from_path='+repr(from_path)+' is not a directory, and this function is specifically meant to copy directories.'

    if not extract:
        #We want to make a NEW folder at to_path because extract is False
        #We will extract into this path
        if is_a_folder(to_path):
            to_path=path_join(to_path,get_directory_name(from_path))
            if path_exists(to_path):
                #This will throw an error - but I'll make the message different depending on whether its a file or folder.
                assert not file_exists(to_path),'rp.copy_directory error: Cant copy a directory into a file. from_path='+repr(from_path)+' is a directory and to_path='+repr(to_path)+' is a file.'
                assert not folder_exists(to_path),'rp.copy_directory error: Will not overwrite existing folder (extract is False). from_path='+repr(from_path)+' is a directory and to_path='+repr(to_path)+' is also already a directory.'
                assert False, 'Internal assertion - we should have thrown an error by now'
        else:
            assert not file_exists(to_path),'rp.copy_directory error: Cant copy a directory into a file. from_path='+repr(from_path)+' is a directory and to_path='+repr(to_path)+' is a file.'
    else:
        assert not file_exists(to_path),'rp.copy_directory error: Cant copy a directory into a file. from_path='+repr(from_path)+' is a directory and to_path='+repr(to_path)+' is a file.'

    make_directory(to_path) #Make sure the destination path can exist...
    
    #This was in the old version of copy_directory...I'm not sure why we need it?
    # from importlib import reload
    # import distutils.dir_util
    # reload(distutils.dir_util)#I don't know why, but when I copied a folder, deleted it, then told it to copy again, it broke with "ERROR: distutils.errors.DistutilsFileError: could not create 'TestJam/Wampo/prompt_toolkit/filters/.DS_Store': No such file or directory" and reloading the module appeared to fix it. This reloading doesn't appear to damage performance, I clocked it at 0.0003399848937988281 seconds

    import shutil
    #Do the actual copying - extract into to_path
    shutil.copytree(from_path, to_path, symlinks=follow_symlinks, dirs_exist_ok=True)

    return to_path


copy_folder=copy_directory

_home_directory=None
def get_home_directory():
    """
    Returns the ~ directory - aka the user's home directory.
    Works cross-platform.
    """
    import os

    global _home_directory

    if _home_directory is None:
        if 'HOME' in os.environ:
            _home_directory = os.environ['HOME']
        elif 'USERPROFILE' in os.environ:  # For Windows
            _home_directory = os.environ['USERPROFILE']
        else:
            _home_directory = os.path.expanduser("~")  # Often works, but can be unreliable in some edge cases.

    return _home_directory



# def copy_file(from_path,to_path):
#     "Copy a single file from from_path to to_path, where to_path is either a folder or a file that will be overridden"
#     assert file_exists(from_path),'copy_file copies a file from from_path to to_path, but from_path='+repr(from_path)+' is not a file'
#     # assert path_exists(to_path),'to_path must be either a directory or a file that will be overwritten, but to_path='+repr(to_path)+' does not exist' # <--- This seems silly. If this assertion still seems silly in the year 2022...get rid of it forever lol
#     import shutil
#     if is_a_directory(to_path):
#         to_path=path_join(to_path,get_file_name(from_path))
#     shutil.copyfile(from_path,to_path)
#     return to_path


def copy_file(src, dst, show_progress=False, resume=False):
    """
    Atomic, resumable file copy with verification.

    - Atomic: destination only appears when complete (uses temp file + rename)
    - Resumable: interrupted copies can resume from where they left off (if resume=True)
    - Verified: checks source file size and modification time before resuming
    - If dst is a directory, the file is copied inside it.
    - Preserves permissions (like shutil.copy).
    - Always returns the path to the new file.
    - show_progress=True displays clean progress with ETA and transfer rate.
    - resume=False forces restart from beginning (deletes partial file)

    Resume safety:
    - Compares source mtime vs partial file mtime
    - Only resumes if source hasn't been modified after partial file created
    - Also checks that partial size < source size
    - If source changed, restarts from beginning
    - Partial files kept on error to allow resume on next call
    """
    import os
    import shutil

    buffer_size = 1024 * 1024  # 1MB chunks

    def check_can_resume():
        """Returns byte offset to start copying from (0 = start fresh)"""
        if not resume:
            if os.path.exists(temp_dst):
                os.remove(temp_dst)
            return 0

        if not os.path.exists(temp_dst):
            return 0

        try:
            partial_stat = os.stat(temp_dst)
            partial_size = partial_stat.st_size
        except OSError:
            return 0

        # Source modified after partial created? Restart
        if src_mtime > partial_stat.st_mtime:
            os.remove(temp_dst)
            return 0

        # Partial >= source size? Something wrong, restart
        if partial_size >= total_size:
            os.remove(temp_dst)
            return 0

        return partial_size

    def do_copy(start_offset):
        """Copy file from start_offset to end"""
        file_mode = 'ab' if start_offset > 0 else 'wb'

        if not show_progress:
            with open(src, 'rb') as fsrc, open(temp_dst, file_mode) as fdst:
                fsrc.seek(start_offset)
                shutil.copyfileobj(fsrc, fdst, buffer_size)
            return

        progress = _eta_bytes(total_size, title="Copying "+os.path.basename(src), start_n=start_offset)
        with open(src, 'rb') as fsrc, open(temp_dst, file_mode) as fdst:
            fsrc.seek(start_offset)
            bytes_copied = start_offset
            for buf in iter(lambda: fsrc.read(buffer_size), b""):
                fdst.write(buf)
                bytes_copied += len(buf)
                progress(bytes_copied)

    # Main logic
    if os.path.isdir(dst):
        dst = os.path.join(dst, os.path.basename(src))

    dst_dir = get_parent_folder(dst)
    dst_name = get_path_name(dst)
    temp_dst = path_join(dst_dir, dst_name+'.rp_copy_file_partial')

    src_stat = os.stat(src)
    total_size = src_stat.st_size
    src_mtime = src_stat.st_mtime

    start_offset = check_can_resume()

    do_copy(start_offset)

    shutil.copymode(src, temp_dst)
    os.replace(temp_dst, dst)

    return dst


def copy_paths(
    from_paths,
    to_paths,
    *,
    extract=False,
    skip_existing=False,
    follow_symlinks=False,  # This is the default behaiour in bash and zsh. In python though, shutil.copy defaults follow_symlinks to True, meaning it would copy the contents of a symlink - not the symlink itself.
    num_threads: int = None,
    show_progress=False,
    strict=True,
    lazy=False
):
    "A fast, parallelized version of copy_path"
    # You can glob the from_paths however youd like. See rp_iglob's documentation for more details!
    from_paths = rp_glob(from_paths)

    # Right now, we only support to_paths as a folder or an explicit list of destinations (1 per source)
    if isinstance(to_paths, str):
        # A single folder
        if path_exists(to_paths):
            assert is_a_folder(to_paths)
        else:
            make_folder(to_paths)
        to_paths = [path_join(to_paths, get_path_name(path)) for path in from_paths]
        assert len(set(to_paths)) == len(
            set(from_paths)
        ), "Must be a 1-to-1 mapping from sources to destinations"
    else:
        # A list of destinations
        assert is_iterable(to_paths)
        assert len(to_paths) == len(
            from_paths
        ), "Must be a 1-to-1 mapping from sources to destinations"
        assert all(isinstance(x, str) for x in to_paths)

    def do_copy(pair):
        from_path, to_path = pair
        if skip_existing and path_exists(to_path):
            return
        return copy_path(from_path, to_path, extract=extract)

    if show_progress in ['eta',True]: show_progress='eta:Copying paths'

    pairs = list(zip(from_paths, to_paths))
    return load_files(
        do_copy,
        pairs,
        num_threads=num_threads,
        show_progress=show_progress,
        strict=strict,
        lazy=lazy,
    )

_url_prefixes = 'https:// http:// s3:// sftp:// ftp:// file:// ws://'.split()

def get_path_parent(path_or_url:str, levels=1):
    """
    Retrieve the parent directory or URL of the given path or URL.
    
    Examples:
        >>> get_path_parent('oaijsd/odjf/aoijf/sdojif.ojf')      -> 'oaijsd/odjf/aoijf'
        >>> get_path_parent('/')                                 -> '/'
        >>> get_path_parent('/apsokd')                           -> '/'
        >>> get_path_parent('/apsokd.asd')                       -> '/'
        >>> get_path_parent('/aps/asda/sokd.asd')                -> '/aps/asda'
        >>> get_path_parent('http://www.example.com/path/to')    -> 'http://www.example.com/path'
        >>> get_path_parent('http://www.example.com/?query')     -> 'http://www.example.com/'
        # >>> get_path_parent('http://www.example.com/')           -> 'http://www.example.com/'
        >>> get_path_parent('s3://bucket/key/to/object')         -> 's3://bucket/key/to'

    Examples:
        >>> #With paths:
        >>> path =                        '/opt/homebrew/lib/python3.10/site-packages/rp'
        >>> get_path_parent(path,0  )  ->  /opt/homebrew/lib/python3.10/site-packages/rp
        >>> get_path_parent(path    )  ->  /opt/homebrew/lib/python3.10/site-packages
        >>> get_path_parent(path,1  )  ->  /opt/homebrew/lib/python3.10/site-packages
        >>> get_path_parent(path,2  )  ->  /opt/homebrew/lib/python3.10
        >>> get_path_parent(path,3  )  ->  /opt/homebrew/lib
        >>> get_path_parent(path,5  )  ->  /opt
        >>> get_path_parent(path,6  )  ->  /
        >>> get_path_parent(path,7  )  ->  /
        >>> get_path_parent(path,700)  ->  /

        >>> #With URLs:
        >>> path =                         'https://huggingface.co/datasets/OneOverZero/MAGICK/commit/abcdefg'
        >>> get_path_parent(path,0  )  ->   https://huggingface.co/datasets/OneOverZero/MAGICK/commit/abcdefg
        >>> get_path_parent(path    )  ->   https://huggingface.co/datasets/OneOverZero/MAGICK/commit
        >>> get_path_parent(path,1  )  ->   https://huggingface.co/datasets/OneOverZero/MAGICK/commit
        >>> get_path_parent(path,2  )  ->   https://huggingface.co/datasets/OneOverZero/MAGICK
        >>> get_path_parent(path,3  )  ->   https://huggingface.co/datasets/OneOverZero
        >>> get_path_parent(path,4  )  ->   https://huggingface.co/datasets
        >>> get_path_parent(path,5  )  ->   https://huggingface.co/
        >>> get_path_parent(path,6  )  ->   https://huggingface.co/
        >>> get_path_parent(path,7  )  ->   https://huggingface.co/
        >>> get_path_parent(path,700)  ->   https://huggingface.co/
    """


    assert levels>=0
    if levels==0:
        return path_or_url
    if levels>1:
        for level in range(levels):
            path_or_url = get_path_parent(path_or_url)
        return path_or_url
    
    # Parse the input
    from urllib.parse import urlparse, urlunparse
    parsed = urlparse(path_or_url)
    
    # Check if it's a URL (based on the presence of a scheme)
    # WARNING: This caused confusion with a simple URI like mail: which doesn't have a :// in it - when I had a filename on my filesystem like "file:thing.txt" 
    # if parsed.scheme and \
    #    not (currently_running_windows() and parsed.scheme.isalpha() and len(parsed.scheme)==1): #make sure "c:\\things\\stuff" isn't interpereted as a network path...
    #     path_parent = '/'.join(parsed.path.rstrip('/').split('/')[:-1]) or '/'
    #     return urlunparse((parsed.scheme, parsed.netloc, path_parent, parsed.params, parsed.query, parsed.fragment))

    #Handle URL's
    if starts_with_any(path_or_url, _url_prefixes):
        prefix = starts_with_any(path_or_url, _url_prefixes, return_match=True)
        suffix = path_or_url[len(prefix):]
        suffix = '/'.join(suffix.split('/')[:-1])
        return prefix + suffix

    else:  # It's a filesystem path
        import pathlib
        return str(pathlib.Path(path_or_url).parent)

get_file_folder=get_path_parent#Synonyms that might make more sense to read in their context than get_path_parent
get_file_directory=get_path_parent
get_parent_directory=get_parent_folder=get_path_parent

# def with_parent_folder(path, parent_folder):
#     return path_join(parent_folder, get_path_name(path))

def get_paths_parents(*paths_or_urls, levels=1):
    "Plural of get_path_parent"
    return [get_path_parent(path, levels) for path in detuple(paths_or_urls)]

def make_directory(path):
    """
    Will make a directory if it doesn't allready exist. If it does already exist, it won't throw an error.
    However, it will throw an error if the specified path is impossible to make without deleting some file.
    Can make nested paths that don't exist yet. You don't have to manually create every level.
    For example, let's say you don't have Jumble, Fizz, or Buzz on your computer. make_directory('Jumble/Fizz/Buzz') will create three directories nested inside of each other

    Enhanced Documentation:
    
    Creates a directory at the specified path, including all necessary parent directories.
    Safe to call on existing directories (won't raise errors). Uses Python's os.makedirs
    with exist_ok=True for robust directory creation.
    
    Parameters:
        path (str): Directory path to create. Can be relative or absolute.
                   Supports nested paths that don't exist yet.
    
    Returns:
        str: The created directory path (same as input parameter).
    
    Usage Examples:
        >>> # Create simple directory
        >>> result = make_directory("my_folder")
        >>> print(f"Created: {result}")  # Created: my_folder
        
        >>> # Create nested directories
        >>> nested = make_directory("project/data/raw")
        >>> print(f"Created nested: {nested}")  # Created nested: project/data/raw
        
        >>> # Safe to call on existing directory
        >>> make_directory("existing_folder")  # No error even if it exists
        
        >>> # Create with absolute path
        >>> abs_path = make_directory("/tmp/test_folder")
        >>> print(f"Absolute path: {abs_path}")  # /tmp/test_folder
        
        >>> # Chain with other operations
        >>> data_dir = make_directory("analysis/results")
        >>> file_path = path_join(data_dir, "output.txt")
    
    Related Functions:
        - make_parent_directory(): Create parent directory of a file path
        - directory_exists(): Check if directory already exists
        - take_directory(): Create and change to directory (like ZSH's take command)
        - make_directories(): Plural version for multiple directories
        - copy_directory(): Copy entire directory structures
        
    Notes:
        - Uses os.makedirs with exist_ok=True for safety
        - Creates all intermediate directories automatically
        - Cross-platform compatible (Windows, macOS, Linux)
        - Will raise OSError if path conflicts with existing file
        - Returns the input path for easy chaining
        
    Aliases:
        - make_folder(): Same functionality, different name
        
    Tags: directories, filesystem, creation, nested, safe, cross-platform
    """
    try:
        if not directory_exists(path):
            os.makedirs(path,exist_ok=True)
        return path
    except OSError:
        from pathlib import Path
        cursor=Path(path)
        need_to_make=[]#Parent directories we would need to make in order to make the full path specified
        while not cursor.exists():
            need_to_make.append(cursor)
            cursor=cursor.parent
        while cursor != cursor.parent:
            assert cursor.is_dir(),'make_directory: failed to make directory at path '+repr(path)+' because '+repr(cursor)+' is the path of an existing file that is not a directory'
            cursor=cursor.parent
        for directory in reversed(need_to_make):
            try:
                directory.mkdir()
            except FileExistsError:
                if is_a_folder(directory):
                    break
                else:
                    raise
        return str(path)
make_folder=make_directory

def make_parent_directory(path):
    """
    Enhanced Documentation:
    
    Creates the parent directory of the specified file or directory path.
    Useful when you need to ensure a file's parent directory exists before
    creating the file itself. Automatically creates all intermediate directories.
    
    Parameters:
        path (str): File or directory path whose parent directory should be created.
                   Can be relative or absolute path.
    
    Returns:
        str: Path to the created parent directory.
    
    Usage Examples:
        >>> # Ensure parent directory exists before saving file
        >>> file_path = "data/processed/results.txt"
        >>> parent_dir = make_parent_directory(file_path)
        >>> print(f"Parent created: {parent_dir}")  # Parent created: data/processed
        >>> # Now safe to create the file
        >>> with open(file_path, 'w') as f:
        ...     f.write("Results data")
        
        >>> # For nested file paths
        >>> deep_file = "project/logs/2024/january/debug.log"
        >>> make_parent_directory(deep_file)
        >>> # Creates: project/logs/2024/january/ directory
        
        >>> # Works with absolute paths
        >>> abs_file = "/tmp/analysis/output.csv"
        >>> parent = make_parent_directory(abs_file)
        >>> print(f"Created: {parent}")  # Created: /tmp/analysis
        
        >>> # Chain with file operations
        >>> csv_path = "reports/monthly/sales.csv"
        >>> make_parent_directory(csv_path)
        >>> bytes_to_file(b"header,data\\n1,2", csv_path)
    
    Related Functions:
        - make_directory(): Create a directory directly
        - get_parent_directory(): Get parent directory path without creating it
        - bytes_to_file(): Automatically calls this when needed
        - string_to_text_file(): Save text files (parent dirs auto-created)
        - save_image(): Save images (parent dirs auto-created)
        
    Notes:
        - Extracts parent directory path using get_parent_directory()
        - Delegates actual creation to make_directory() for consistency
        - Safe to call multiple times on same path
        - Creates all intermediate directories in the path
        - Cross-platform compatible
        
    Aliases:
        - make_parent_folder(): Same functionality, different name
        
    Tags: directories, parent, filesystem, creation, files, preparation
    """
    return make_directory(get_parent_directory(path))
make_parent_folder=make_parent_directory

def take_directory(path):
    """Creates a directory (if it doesn't exist) and changes to it in one operation.
    
    This function combines mkdir and cd functionality, inspired by ZSH's 'take' command.
    It ensures the directory exists and then navigates to it, creating parent directories
    as needed.
    
    Enhanced Documentation:
    
    Usage Patterns:
    - Quickly create and navigate to new project directories  
    - Set up directory structures and immediately start working in them
    - Create temporary working spaces for file operations
    - Initialize directory hierarchies with immediate navigation
    
    Examples:
    >>> import os
    >>> print("Before:", os.getcwd())
    Before: /home/user
    
    >>> # Create and enter new directory
    >>> result = take_directory("/tmp/my_project")
    >>> print("After take_directory:", os.getcwd())
    After take_directory: /tmp/my_project
    >>> print("Returned path:", result)
    Returned path: /tmp/my_project
    
    >>> # Create nested directory structure
    >>> take_directory("docs/api/v1")
    >>> print("Nested structure:", os.getcwd())
    Nested structure: /tmp/my_project/docs/api/v1
    
    >>> # Works with existing directories too
    >>> take_directory("/tmp")  # Just navigates if exists
    >>> print("Existing directory:", os.getcwd()) 
    Existing directory: /tmp
    
    Args:
        path (str or Path-like): The directory path to create and change to.
                                Can be absolute or relative. Parent directories
                                will be created as needed.
        
    Returns:
        str: The absolute path of the directory that was created/navigated to
        
    Raises:
        PermissionError: If insufficient permissions to create directory or navigate
        OSError: For other filesystem errors during creation or navigation
        
    Related Functions:
    - make_directory: Creates directory without changing to it
    - set_current_directory: Changes directory without creating it
    - make_directories: Creates multiple directories
    - get_current_directory: Gets current working directory
    
    Implementation Notes:
    - First calls make_directory() to ensure directory exists
    - Then calls set_current_directory() to navigate to it
    - Creates parent directories recursively if they don't exist
    - Returns the path that was actually created (may be normalized)
    - Equivalent to: mkdir -p path && cd path
    
    Original docstring:
    ZSH's take command combined mkdir with cd
    
    Tags: directory, mkdir, navigation, create, filesystem, take, zsh
    """
    path=make_directory(path)
    set_current_directory(path)
    return path

def make_directories(*paths):
    """
    Create multiple directories. Plural version of make_directory.
    
    Enhanced Documentation:
    Creates all specified directories, including any necessary parent directories.
    Accepts either multiple path arguments or a single list of paths.
    Does not error if directories already exist.
    
    Usage patterns:
    - Setting up directory structure for projects
    - Batch creation of output folders
    - Ensuring required paths exist before file operations
    
    Parameters:
        *paths: Either multiple directory path strings, or a single iterable of paths
    
    Returns:
        None: Directories are created as side effect
        
    Examples:
        >>> make_directories('/tmp/test1', '/tmp/test2')  # Multiple args
        >>> make_directories(['/tmp/test3', '/tmp/test4'])  # Single list
        >>> make_directories('/tmp/nested/deep/path')  # Creates all levels
        
    Related functions: make_directory, make_folders (alias), delete_paths
    Tags: directory, filesystem, batch, mkdir, folders
    """

    #Normalize to list of paths
    paths=detuple(paths)
    if isinstance(paths, str):
        paths=[paths]
    else:
        paths=list(paths)

    for path in paths:
        make_directory(path)

    return paths
make_folders=make_directories

def delete_all_paths_in_directory(directory,*,permanent=True,include_files=True,include_folders=True,recursive=False):
    """ Deletes all paths under a directory """
    assert directory_exists(directory)
    return delete_paths(get_all_paths(directory,include_folders=include_folders,include_files=include_files),permanent=permanent)
delete_all_paths_in_folder=delete_all_paths_in_directory

def delete_all_files_in_directory(directory,*,recursive=False,permanent=True):
    """ Ignores all folders, just deletes files """
    assert directory_exists(directory),'No such directory exists: '+repr(directory)
    return delete_all_paths_in_directory(directory,permanent=permanent,recursive=recursive,include_folders=False,include_files=True)
delete_all_files_in_folder=delete_all_files_in_directory

def path_join(*paths,show_progress=False,lazy=False):
    """
    Joins given paths, which can be a combination of strings and non-string iterables (like lists, tuples).
    An extension of os.path.join (wherever os.path.join works, so will this)
    
    Arguments:
    *paths -- A combination of string(s) and/or iterable(s) representing paths

    Returns:
    A single string if all inputs are strings, or a list of strings if any input is a non-string iterable.
    Raises ValueError if there is a length mismatch among non-string iterable arguments.

    Examples:
        path_join('a', 'b')
            returns 'a/b'
        path_join('a', ['b', 'c'])
            returns ['a/b', 'a/c']
        path_join(('a', 'b'), 'c')
            returns ['a/c', 'b/c']
        path_join(['a', 'b'], ('c', 'd'))
            returns ['a/c', 'a/d', 'b/c', 'b/d']
        path_join(['a', 'b'], 'z', ['c', 'd'])
            returns ['a/z/c', 'a/z/d', 'b/z/c', 'b/z/d']
        path_join(['a', 'b'], ['c'])
            raises ValueError: Length mismatch among iterable arguments

    Special case:
        If the input is simply a list of strings (just one argument), it will join those
        path_join(['a', 'b'])
            returns 'a/b'
        path_join(['a', ['b', 'c']])
            returns ['a/b', 'a/c']

    Note:
        All inputs must be iterable. Strings are considered as iterables.

    https://chat.openai.com/share/45864fd0-669a-40bb-9cb0-4d717c3a7e4c


    TODO: The current way it handles [['a','b'],['c','d']] --> 4 paths is so useless! No reason to make the lengths match - its taking the cartesian product and we end up with 4 paths! The idea was suppost to be that they would be in sync.......and broadcast..............this is garbage behaviour and needs to be fixed. THAT IS, unless, we WANT the product?? Like ['folder1','folder2'],['page.json','image.png'] --> ['folder1/page.json','folder1/image.png','folder2/page.json','folder2/image.png']

    """
    import os

    def is_non_str_iterable(x):
        return is_iterable(x) and not isinstance(x,str)

    if len(paths)==1 and is_non_str_iterable(paths[0]):
        #Special case: If the input is a single iterable, treat it like functions that use detuple
        paths=paths[0]
        paths=tuple(paths)

    assert sum(is_non_str_iterable(x) for x in paths)<=1, 'rp.path_join: TODO: Decide how this function handles multiple iterables. Cartesian product or broadcasting?'

    if any(is_non_str_iterable(p) for p in paths):
        iterables = [p for p in paths if is_non_str_iterable(p)]
        iterables = [list(i) for i in iterables] #Make sure they have len
        if len(set(map(len, iterables))) != 1:
            raise ValueError("Length mismatch among iterable arguments")
        for iterable in iterables:
            assert all(isinstance(x,str) for x in iterable), 'Received a list that isn\'t all strings, please see r.path_join\'s docstring. Bad list: '+str(iterable)


        path_factors = [p if is_non_str_iterable(p) else [p] for p in paths]
        path_product = itertools.product(*path_factors)

        if show_progress:
            #Add length if we can
            if all(map(has_len,path_factors)):
                total_length = math.prod(map(len,path_factors))
                path_product = eta(path_product, 'rp.path_join', length = total_length)
            else:
                print("rp.path_join: show_progress disabled because not all path factors have lengths and eta needs a length right now (Jun 10 2025)...")

        output = (os.path.join(*path_tuple) for path_tuple in path_product)
    
        if not lazy:
            output = list(output)

        return output

    else:
        #The simple case: we just join strings
        return os.path.join(*paths)

joined_paths=path_join#Synonyms for whatever comes into my head at the moment when using the rp terminal


def path_split(path):
    """
    EXAMPLE:
        >>> path_split('https://claude.ai/chat/6b1ff843-1c6c-4e34-8c9f-21bd83bc0315')
        ans = ['https://', 'claude.ai', 'chat', '6b1ff843-1c6c-4e34-8c9f-21bd83bc0315']
        >>> path_join(ans)
        ans = https://claude.ai/chat/6b1ff843-1c6c-4e34-8c9f-21bd83bc0315
        >>> path_split('/opt/homebrew/lib/python3.10/site-packages/rp')
        ans = ['/', 'opt', 'homebrew', 'lib', 'python3.10', 'site-packages', 'rp']
        >>> path_join(ans)
        ans = /opt/homebrew/lib/python3.10/site-packages/rp
    """
    if starts_with_any(path, _url_prefixes):
        #Its a URL
        prefix = starts_with_any(path, _url_prefixes, return_match=True)
        return [prefix]+path[len(prefix):].split('/')

    assert isinstance(path,str)
    sep='\\' if currently_running_windows() else '/'
    out=path.split(sep)
    if path.startswith('/') and sep=='/':
        out=['/']+out
    out=[x for x in out if x] #If // was anywhere in the path, it will have an '' in the split
    return out


def get_unique_copy_path(path: str, *, suffix: str = "_copy%i") -> str:
    """
    Generates a new file path that does not conflict with any existing files by appending a suffix to the file name. The function does not create the file itself, just provides the path.

    TLDR: Lets you create file paths like
        hello.txt
        hello_copy.txt
        hello_copy1.txt
        hello_copy2.txt
        hello_copy3.txt
        hello_copy4.txt
        ...

    Parameters
    ----------
    path : str
        The original file path.
    suffix : str, optional
        The suffix to append to the file name (default is "_copy%i"). Must contain exactly one "%i", which is replaced with the number of the existing copies of the file.

    Returns
    -------
    str
        The new, non-conflicting file path.

    Note
    ----
    The exact naming scheme of this function is subject to change. Don't rely on it too much - its only official goal is to give unique paths.

    Examples
    --------
    >>> get_unique_copy_path("/home/user/hello.txt")
    "/home/user/hello_copy.txt"

    If "/home/user/hello_copy.txt" already exists:
    >>> get_unique_copy_path("/home/user/hello.txt")
    "/home/user/hello_copy1.txt"

    If "/home/user/hello_copy.txt" and "/home/user/hello_copy1.txt" already exists:
    >>> get_unique_copy_path("/home/user/hello.txt")
    "/home/user/hello_copy2.txt"

    You can also provide a custom suffix:
    >>> get_unique_copy_path("/home/user/hello.txt", "_backup%i")
    "/home/user/hello_backup.txt"
    
    Enhanced Documentation:
    Generates a unique file path by adding incremental suffixes to avoid naming
    conflicts with existing files. Essential for safe file operations where
    you don't want to overwrite existing files. The function preserves the
    file extension and adds the suffix to the base filename.
    
    Working Examples:
        >>> import tempfile, os
        >>> with tempfile.TemporaryDirectory() as tmpdir:
        ...     test_file = os.path.join(tmpdir, 'test.txt')
        ...     with open(test_file, 'w') as f: f.write('content')
        ...     unique_path = get_unique_copy_path(test_file)
        ...     '_copy' in os.path.basename(unique_path) and unique_path != test_file
        True
        
    Related Functions:
        - copy_path(): Actually copy files to unique paths
        - path_exists(): Check if paths exist
        - with_file_extension(): Modify file extensions
        - apply_suffix_to_path(): Internal helper for suffix application
        
    Tags: file, path, unique, copy, safety, filesystem

    If "/home/user/hello_backup.txt" already exists:
    >>> get_unique_copy_path("/home/user/hello.txt", "_backup%i")
    "/home/user/hello_backup1.txt"
    """

    assert isinstance(path, str), type(path)
    assert isinstance(suffix, str), type(suffix)

    # assert suffix.count("%i") == 1 #This error was written wrong in the case we do %03i etc - let the string formatter handle it
    try:
        suffix%0    
    except:
        raise ValueError("The suffix should be formattable with an int, such as '_copy%i'. But you gave "+repr(suffix))


    def apply_suffix_to_name(name, suffix, num_copies):
        assert num_copies >= 0
        if num_copies == 0:
            #THIS PART IS MESSY. ALL OF THIS IS SUBJECT TO CHANGE.
            # new_suffix = suffix.replace("%i", "")   <--- This doesn't work for %05i, just %i
            unguessable_int = random_int(10**30)
            new_suffix = suffix%unguessable_int
            new_suffix = new_suffix.replace(str(unguessable_int),'')
        else:
            new_suffix = suffix % num_copies
        return name + new_suffix

    def apply_suffix_to_path(path, suffix, num_copies):
        folder = get_parent_folder(path)
        file = get_file_name(path)
        name = strip_file_extension(file)
        extension = get_file_extension(path)
        new_name = apply_suffix_to_name(name, suffix, num_copies)
        new_file = with_file_extension(new_name, extension)
        new_path = path_join(folder, new_file)
        return new_path

    original_path = path

    num_copies = 0
    while path_exists(path):
        path = apply_suffix_to_path(original_path, suffix, num_copies=num_copies)
        num_copies += 1

    return path

_get_cutscene_frame_numbers_cache={}
def get_cutscene_frame_numbers(video,*,use_cache=False,include_start=False,include_end=False):
    """
    Returns frame numbers where scene cuts occur.

    By default returns only actual scene cuts (where new scenes begin after a transition).
    Frame 0 is NOT a cut (it's the video start), and len(video) is NOT a cut (it's beyond the end).

    Example with 81-frame video: Actual cuts at frames 38, 66
        - include_start=False, include_end=False (default, pure cuts): [38, 66]
        - include_start=False, include_end=True  (with end marker): [38, 66, 81]
        - include_start=True,  include_end=False (scene starts): [0, 38, 66]
        - include_start=True,  include_end=True  (all bounds): [0, 38, 66, 81]

    Scenes are:
        - Scene 0: frames 0-37 (38 frames)
        - Scene 1: frames 38-65 (28 frames)
        - Scene 2: frames 66-80 (15 frames)

    Args:
        video: Either a path to a video file (str) or a video array (numpy THWC, torch TCHW, or list of images)
        use_cache: If True, cache results for both file paths and arrays (uses handy_hash for arrays)
        include_start: If True, prepend 0 (first frame of first scene, not an actual cut)
        include_end: If True, append len(video) (end marker for slicing, not an actual cut)

    Returns:
        list of int: Frame numbers based on include_start/include_end settings

    pip install scenedetect
    Code from https://pyscenedetect-manual.readthedocs.io/en/latest/api/scene_manager.html#scenemanager-example
    EXAMPLE:
        >>> video_path=download_youtube_video('https://www.youtube.com/watch?v=K5qACexzwOI')
        ... cutscene_frames_numbers=get_cutscene_frame_numbers(video_path)
        ... for i,frame in enumerate(load_video_stream(video_path)):
        ...     if i in cutscene_frames_numbers:
        ...         input('Hit enter to continue')
        ...     cv_imshow(frame)
        ...     sleep(1/30)
    """
    pip_import('cv2')#Needed for scenedetect
    pip_import('scenedetect')
    from scenedetect import open_video, SceneManager, ContentDetector

    # Determine cache key and convert video to canonical form
    is_path = isinstance(video, str)
    if is_path:
        if not path_exists(video):
            raise OSError('Path does not exist: ' + repr(video))
        if not file_exists(video):
            raise OSError('Path is not a file: ' + repr(video))
        cache_key = get_absolute_path(video)
    else:
        video = as_numpy_video(video)
        if use_cache:
            cache_key = handy_hash(video)

    # Check cache
    if use_cache and cache_key in _get_cutscene_frame_numbers_cache:
        return _get_cutscene_frame_numbers_cache[cache_key]

    # Process video - get actual cuts only
    if is_path:
        video_stream = open_video(cache_key)
        scene_manager = SceneManager()
        scene_manager.add_detector(ContentDetector())
        scene_manager.detect_scenes(video=video_stream)
        # Extract actual cuts (exclude start/end that SceneManager adds)
        scene_list = scene_manager.get_scene_list()
        cuts = [x[1].frame_num for x in scene_list]
        video_length = scene_list[-1][1].frame_num if scene_list else 0
    else:
        detector = ContentDetector()
        cuts = []
        for frame_num in range(len(video)):
            frame_bgr = cv_rgb_bgr_swap(as_byte_image(video[frame_num]), copy=False)
            cuts.extend(detector.process_frame(frame_num, frame_bgr))
        cuts.extend(detector.post_process(len(video)))
        cuts = sorted(cuts)
        video_length = len(video)

    # Add start/end markers based on parameters
    output = []
    if include_start:
        output.append(0)
    output.extend(cuts)
    if include_end and (not output or output[-1] != video_length):
        output.append(video_length)

    if use_cache:
        _get_cutscene_frame_numbers_cache[cache_key] = output
    return output

def get_cutscene_slices(video, *, use_cache=False):
    """
    Returns slices for each scene in a video, suitable for indexing video arrays.

    Each slice represents one continuous scene. The slices partition the entire video
    with no gaps or overlaps. Use these to extract individual scenes: video[slice].

    Note: Always calls get_cutscene_frame_numbers with include_start=True and include_end=True
    internally to get proper slice boundaries, regardless of default settings.

    Example: If actual cuts are at frames 38, 66 in an 81-frame video, returns:
        [slice(0, 38), slice(38, 66), slice(66, 81)]
    Which extracts:
        - Scene 0: video[0:38]   (frames 0-37, 38 frames total)
        - Scene 1: video[38:66]  (frames 38-65, 28 frames total)
        - Scene 2: video[66:81]  (frames 66-80, 15 frames total)

    Args:
        video: Either a path to a video file (str) or a video array (numpy THWC, torch TCHW, or list of images)
        use_cache: If True, cache results for both file paths and arrays

    Returns:
        list of slice: Slices for each scene, suitable for indexing video arrays
    """
    frame_numbers = get_cutscene_frame_numbers(video, use_cache=use_cache, include_start=True, include_end=True)
    slices = [slice(x, y) for x, y in zip(frame_numbers[:-1], frame_numbers[1:])]
    return slices

    

def remove_duplicate_frames(video, *, lazy=False, show_progress=False, as_indices=False):
    """
    Remove duplicate frames from a video represented as a NumPy array in THWC format
    or from a generator of frames.

    Parameters:
    - video: np.ndarray of shape (T, H, W, C) or generator of frames
    - lazy: bool, if True, returns a generator yielding frames one by one,
            if False, returns the frames as a NumPy array.
    - show_progress: If True, will show the ETA for calculation
    - as_indices: If True, returns a series of integers indicating the frame numbers
                  (as opposed to returning the frames themselves)

    Returns:
    - unique_video: generator of frames (if lazy=True)
                    np.ndarray of shape (T_unique, H, W, C) (if lazy=False)
                    OR, a generator/numpy array of indices
                    
    https://chatgpt.com/share/66f333ff-3418-8006-bdc1-f0636ad5907c
    """

    pip_import("numpy")
    pip_import("cv2")
    pip_import("skimage")

    import numpy as np
    import cv2
    from skimage.metrics import structural_similarity as ssim

    # Threshold for SSIM similarity
    ssim_threshold = 0.99  # Adjust ssim_threshold as needed

    def preprocess_frame(frame):
        frame = as_byte_image(frame)
        if not is_grayscale_image(frame):
            frame = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
        frame = resize_image_to_fit(
            frame,
            height=256,
            width=256,
            allow_growth=False,
            interp="area",
        )
        return frame

    def sim_score(frame_a, frame_b):
        # Compute SSIM between two frames
        score, _ = ssim(frame_a, frame_b, full=True)
        return score

    def helper():
        video_iter = enumerate(iter(video))

        try:
            index, frame = next(video_iter)
            frame_a = preprocess_frame(frame)
        except StopIteration:
            return  # Empty video

        # Always yield the first frame
        if as_indices: yield index
        else         : yield frame
        frame_b = frame_a

        for index, frame in video_iter:
            frame_a = preprocess_frame(frame)
            score = sim_score(frame_a, frame_b)
            if score < ssim_threshold:

                # New unique frame
                if as_indices: yield index
                else         : yield frame
                frame_b = frame_a

            else:
                # Duplicate frame detected, skip
                pass

    if show_progress:
        video=eta(video, title='rp.remove_duplicate_frames')

    output = helper()
    if not lazy:
        output = np.array(list(output))
    return output


def send_text_message(message,number):
    """
    number is a phone number. Can be an int or a string
    Once this no longer works (which it eventually won't, because it's running on a free trial), replace the credentials with your own twilio trial account
    OR create a fallback that doesnt use twilio
    EXAMPLE:
        send_text_message('Hello, World!',15436781234)
    CODE FROM: https://www.twilio.com/docs/sms/quickstart/python#install-python-and-the-twilio-helper-library
    """
    account_sid = 'AC35ef9db2c1104ea2764964cf0ddb7ebb'
    auth_token  = '0543decd5b2eb41e82393e8015c92f48'
    from_number = '16313052383'
    pip_import('twilio')
    from twilio.rest import Client
    number=str(number)
    client = Client(account_sid, auth_token)
    message = client.messages \
                .create(
                     body=message,
                     from_='+'+str(from_number),
                     to='+'+number
                 )
    
def shift_image(image,x=0,y=0,*,allow_growth=True):
    """
    Shifts an image on the x and y axes, in image coordinates

    As y increases the image moves down
    As x increases the image moves to the right
    

    EXAMPLE: Random Sticker Placement

        sticker=cv_resize_image(crop_image_zeros(load_image('https://www.freeiconspng.com/download/49400')),1/2)
        background=load_image('https://i.natgeofe.com/n/c9107b46-78b1-4394-988d-53927646c72b/1095_3x2.jpg')

        scale=.25
        sticker=cv_resize_image(sticker,scale)
        background=cv_resize_image(background,scale)

        composition=background

        for _ in range(1000):
            sticker_height,sticker_width=get_image_dimensions(sticker)
            background_height,background_width=get_image_dimensions(background)

            #Two types of random shifts - comment one out to see the other    

            #First type of shift
            #shift_x=random_int(-sticker_width,background_width+sticker_width)
            #shift_y=random_int(-sticker_height,background_height+sticker_height)

            #Second type of shift: within the background - don't let it go outside
            shift_x=random_int(0,background_width-sticker_width)
            shift_y=random_int(0,background_height-sticker_height)

            shifted_sticker=shift_image(sticker,shift_x,shift_y)
            composition=blend_images(composition,shifted_sticker)
            composition=crop_image(composition,background_height,background_width)
            display_image(composition)
            
            print(_)
            
    EXAMPLE: Moving in a circular pattern
    
        image=load_image('https://upload.wikimedia.org/wikipedia/en/7/7d/Lenna_%28test_image%29.png')
        image=cv_resize_image(image,(128,128))
        for _ in range(5):
            for angle in range(360):
                angle/=360
                angle*=tau
                x=np.cos(angle)+1-.3
                y=np.sin(angle)+1-.3
                x*=100
                y*=100
                s=image
                s=as_rgba_image(s)
                s=crop_image(s,300,300)
                s=shift_image(s,x,y,allow_growth=False)
                display_alpha_image(s)
    """
    image=as_float_image(image)
    image=as_rgba_image(image)

    assert is_image(image)
    x=round(x)
    y=round(y)
    
    height,width=get_image_dimensions(image)
    
    null_color=(0,0,0,0)
    null_image=uniform_float_color_image(height,width,null_color)

    if x<=-width or y<=-height or not allow_growth and (x>=width or y>=height):
        image = null_image
    else:
        if x>0 or y>0:
            image=bordered_image_solid_color(image,null_color,thickness=0,left=max(x,0),top=max(y,0))
        if x<0 or y<0:
            image=image[max(-y,0):,max(-x,0):]
            
    if not allow_growth:
        image = crop_image(image,height,width)
    
    return image



def roll_image(image, dx=0, dy=0, interp='nearest'):
    """
    Shifts/rolls an image by the specified displacement in x and y directions.

    NOTE:
        It uses image coordinates, so a positive dx moves the image to the right
        But a positive dy moves the image down!
    
    This function supports different interpolation methods for non-integer shifts:
    - 'nearest': Uses the nearest integer shift
    - 'bilinear': Uses bilinear interpolation for sub-pixel accuracy
    - 'round': Rounds displacement to nearest integer
    - 'floor': Floors displacement to integer
    - 'ceil': Ceils displacement to integer
    
    Parameters
    ----------
    image : numpy.ndarray
        Input image in HWC format (Height x Width x Channels)
    dx : float
        Displacement in x direction (positive moves right)
    dy : float
        Displacement in y direction (positive moves down)
    interp : str, optional
        Interpolation method ('nearest', 'bilinear', 'round', 'floor', 'ceil')
        Default is 'nearest'
        
    Returns
    -------
    numpy.ndarray
        Shifted image with same shape as input. If using bilinear, output will be floating point.
        
    EXAMPLE:

        #Circular Movement Demo
        >>> import numpy as np
        ... import math
        ... 
        ... # Load the puppy image
        ... image = load_image('https://images.unsplash.com/photo-1507146426996-ef05306b995a?fm=jpg&q=60&w=3000&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxzZWFyY2h8Mnx8cHVwcHl8ZW58MHx8MHx8fDA%3D',use_cache=True)
        ... image=resize_image_to_fit(image,512,512)
        ... 
        ... image=as_float_image(image)
        ... # Create 40 frames of circular motion
        ... radius = 50
        ... num_frames = 40
        ... 
        ... # Generate circular pattern using parametric equations
        ... t = np.linspace(0, 2*np.pi, num_frames, endpoint=False)
        ... dx = radius * np.cos(t)
        ... dy = radius * np.sin(t)
        ... 
        ... # Display each frame
        ... for x, y in zip(dx, dy):
        ...     rolled_image = roll_image(image, dx=x, dy=y, interp='bilinear')
        ...     rp.display_image(rolled_image)

        #Interpolation vs Rounding Demo
        >>> def get_animation():
        ...     image = load_image("https://picsum.photos/seed/picsum/536/354", use_cache=True)
        ...     image = resize_image_to_fit(image, 100, 100)
        ...     for x in range(200):
        ...         x /= 5  # Fractional shifting
        ...         interps = ["bilinear", "round"]
        ...         yield (
        ...             cv_resize_image(
        ...                 labeled_image(
        ...                     vertically_concatenated_images(
        ...                         labeled_images(
        ...                             [roll_image(image, dx=x, interp=interp) for interp in interps],
        ...                             interps,
        ...                         )
        ...                     ),
        ...                     f"X Shift = {x}",
        ...                 ),
        ...                 4,
        ...                 interp="nearest",
        ...             )
        ...         )
        ... display_video(get_animation(),loop=True)

    """

    import numpy as np
    import math

    if interp == 'bilinear':
        image = as_float_image(image)
    
        # Get weights for a single pixel shift (0,0)
        X, Y, W = get_bilinear_weights(np.array([dx]), np.array([dy]))
        
        # X and Y contain the 4 integer coordinates for each point
        # W contains the corresponding weights
        # Reshape from (4,1) to (4,) for easy indexing
        X = X.reshape(-1)
        Y = Y.reshape(-1)
        W = W.reshape(-1)
        
        # Call roll_image recursively for each of the 4 corner points
        # and combine them using the weights
        result = (
            W[0] * roll_image(image, dx=X[0], dy=Y[0], interp='nearest') +
            W[1] * roll_image(image, dx=X[1], dy=Y[1], interp='nearest') +
            W[2] * roll_image(image, dx=X[2], dy=Y[2], interp='nearest') +
            W[3] * roll_image(image, dx=X[3], dy=Y[3], interp='nearest')
        )
        
        return result
    
    elif interp == 'round':
        dx = round(dx)
        dy = round(dy)
    elif interp == 'floor' or interp=='nearest': 
        dx = int(dx)
        dy = int(dy)
    elif interp == 'ceil':
        dx = int(math.ceil(dx))
        dy = int(math.ceil(dy))
    else:
        raise ValueError("rp.roll_image: Invalid interp="+str(interp))

    image = as_numpy_image(image)  # Assume the image is now a float tensor in HWC form
    image = np.roll(image, dy, 0)
    image = np.roll(image, dx, 1)
    return image

def crop_image(image, height: int = None, width: int = None, origin=None, copy=False):
    """
    Returns a cropped image to the specified width and height
    If either hieght or width aren't specified (and left as None), their size will be untouched
        (This means you can crop an image only by height, for example, without having to manually specify its width)
    It crops the image, keeping the top left corner the same
    If the specified width or height are larger than the original image,
    this function will pad out the remainder with blank black pixels
    
    EXAMPLE:
        puppy=load_image('https://encrypted-tbn0.gstatic.com/images?q=tbn%3AANd9GcSwzqzyaeWqxfQiCnOqpnd1V27Wr8MOaZtfGQ&usqp=CAU')
        for _ in range(500):
            cv_imshow(crop_image(puppy,_,_))    
    
    EXAMPLE:
        ans=load_image('https://todaysveterinarypractice.com/wp-content/uploads/sites/4/2018/06/T1807F02Fig01.jpg')
        for _ in range(3):
            for theta in np.linspace(0,pi):
                display_image(crop_image(ans,100+1000*np.cos(theta)**2,100+1000*np.sin(theta)**2,'center'))
                sleep(1/60)

    """

    if origin is None: origin = 'top left'

    assert is_image(image)
    image = as_numpy_image(image, copy=False)
    image_width  = get_image_width (image)
    image_height = get_image_height(image)
    assert (image_height, image_width) == image.shape[:2]

    if height is None:height=image_height
    if width  is None:width =image_width
    height=int(height)
    width =int(width)
    assert height>=0 and width>=0, 'Images can\'t have a negative height or width'

    if height==image_height and width==image_width:
        #Shortcut - optimization
        if copy:
            return image+0
        else:
            return image

    if not any(get_image_dimensions(image)):
        #Handle cropping images that have height or width==0, allowing for zero-padding
        if is_grayscale_image(image): return np.zeros((height,width  )).astype(image.dtype)
        if is_rgb_image      (image): return np.zeros((height,width,3)).astype(image.dtype)
        if is_rgba_image     (image): return np.zeros((height,width,4)).astype(image.dtype)

    return crop_tensor(image, crop_shape=(height, width), origin=origin, copy=copy)



def crop_images(images, height:int = None, width:int=None, origin='top left', *, show_progress=False, lazy=False):
    """Batch crop multiple images to specified dimensions. See crop_image for single images."""
    output = (crop_image(image, height=height, width=width, origin=origin) for image in images)

    if show_progress:
        output = eta(output, 'rp.crop_images', length=len(images))
    if not lazy:
        output = list(output)
    return output

def crop_videos(videos, height:int = None, width:int=None, origin='top left', *, show_progress=False, lazy=False, lazy_frames=False):
    """Crop multiple videos to specified dimensions. See crop_image for details.

    Args: videos, height=None, width=None, origin='top left', show_progress=False, lazy=False, lazy_frames=False
    Returns: Generator of cropped video frames
    """
    output = (crop_images(video, height=height, width=width, origin=origin, lazy=lazy_frames) for video in videos)

    if show_progress:
        output = eta(output, 'rp.crop_images', length=len(videos))
    if not lazy:
        output = list(output)
    return output

def crop_videos_to_min_size(videos, origin='top left', *, show_progress=False, lazy=False, lazy_frames=False):
    height, width = get_min_video_dimensions(videos)
    return gather_args_call(crop_videos)

def crop_videos_to_max_size(videos, origin='top left', *, show_progress=False, lazy=False, lazy_frames=False):
    height, width = get_max_video_dimensions(videos)
    return gather_args_call(crop_videos)

def crop_image_zeros(image, *, output="image", mask=None):
    """
    Given some big image that is surrounded by black, or 0-alpha transparency, crop out that excess region
    Crops out black or fully transparent (0-alpha) regions from the borders of an image.
    
    This function removes excess black borders or regions of zero-alpha transparency around an image, reducing it to only the visible content. It can return either the cropped image or the bounding coordinates of the cropped area.

    Parameters:
        image : ndarray
            The input image to crop, which can be grayscale, RGB, or RGBA.
        output : str, optional
            Specifies the type of output to return. If 'image' (default), returns the cropped image.
            If 'bounds', returns a tuple of bounding box coordinates (top, bottom, left, right).
        mask : image, optional
            Syntactic sugar - less verbose than getting bounds via output='bounds' then applying crop to another image
            If not None, use this image to determine the crop bounds instead of the other image
            Useful for if you want to pass a mask etc

    Returns:
        cropped_image : ndarray
            The cropped image, returned if output is 'image'.
        bounds : tuple of int
            A tuple (top, bottom, left, right) defining the bounding box coordinates, returned if output is 'bounds'.

    Examples:
        >>> import numpy as np
        >>> image = np.array([[0, 0, 0, 0],
                              [0, 255, 255, 0],
                              [0, 255, 255, 0],
                              [0, 0, 0, 0]], dtype=np.uint8)
        >>> cropped_image = crop_image_zeros(image, output='image')
        >>> print(cropped_image)
        [[255 255]
         [255 255]]
         
        >>> bounds = crop_image_zeros(image, output='bounds')
        >>> print(bounds)
        (1, 3, 1, 3)  # Top, Bottom, Left, Right
    """
    
    if mask is None: mask = image

    assert output in 'image bounds'.split(),'output is a string indicating what the output type is - it can be either image or bounds but you gave type '+str(type(output))+' '+str(output)
    assert is_image(image),'Error: input image is not an image as defined by rp.is_image()'
    assert is_image(mask),'Error: input mask is not an image as defined by rp.is_image()'

    if   is_grayscale_image(mask): points=np.argwhere(mask)                # Crop out the black regions
    elif is_rgb_image      (mask): points=np.argwhere(np.any(mask,axis=2)) # Crop out the black regions
    elif is_rgba_image     (mask): points=np.argwhere(mask[:,:,3])         # Crop to where there's alpha
    else: assert False,'crop_image_zeros cannot handle this image type and this function needs to be updated'

    if not len(points): points=[[0,0],[0,0]]

    top,left     = np.min(points,axis=0)
    bottom,right = np.max(points,axis=0)+1

    if output=='image':
        cropped=image[top:bottom,left:right]
        return cropped
    elif output=='bounds':
        return top,bottom,left,right
    else:
        assert False, 'Unreachable'

def crop_tensor_zeros(tensor, *, output="tensor", mask=None):
    """
    Crops out zero-valued regions from the borders of a tensor.

    This function removes excess zero-valued borders from a tensor, reducing it to only
    the non-zero content. It works with tensors of any number of dimensions (1D, 2D, 3D, etc.)
    and supports both numpy arrays and torch tensors. It can return either the cropped tensor
    or the bounding coordinates of the cropped region.

    Parameters:
        tensor : ndarray or torch.Tensor
            The input tensor to crop. Can be any shape and any number of dimensions.
        output : str, optional
            Specifies the type of output to return. If 'tensor' (default), returns the cropped tensor.
            If 'bounds', returns a tuple of bounding box coordinates as slice objects.
        mask : tensor, optional
            Syntactic sugar - less verbose than getting bounds via output='bounds' then applying crop to another tensor.
            If not None, use this tensor to determine the crop bounds instead of the input tensor.
            Useful for if you want to pass a mask etc.

    Returns:
        cropped_tensor : ndarray or torch.Tensor
            The cropped tensor, returned if output is 'tensor'.
        bounds : tuple of slice
            A tuple of slice objects defining the bounding box, returned if output is 'bounds'.
            Can be used directly for indexing: tensor[bounds]

    Examples:
        >>> import numpy as np
        >>> tensor = np.array([[0, 0, 0, 0],
        ...                    [0, 5, 3, 0],
        ...                    [0, 2, 7, 0],
        ...                    [0, 0, 0, 0]])
        >>> cropped = crop_tensor_zeros(tensor, output='tensor')
        >>> print(cropped)
        [[5 3]
         [2 7]]

        >>> bounds = crop_tensor_zeros(tensor, output='bounds')
        >>> print(bounds)
        (slice(1, 3, None), slice(1, 3, None))
        >>> print(tensor[bounds])
        [[5 3]
         [2 7]]

        >>> tensor_3d = np.zeros((5, 5, 5))
        >>> tensor_3d[1:3, 1:3, 1:3] = 1
        >>> cropped_3d = crop_tensor_zeros(tensor_3d)
        >>> print(cropped_3d.shape)
        (2, 2, 2)
    """

    if mask is None: mask = tensor

    assert output in 'tensor bounds'.split(),'output is a string indicating what the output type is - it can be either tensor or bounds but you gave type '+str(type(output))+' '+str(output)

    # Find all non-zero indices
    if is_numpy_array(mask):
        points = np.argwhere(mask)
    elif is_torch_tensor(mask):
        points = mask.nonzero()
        if hasattr(points, 'cpu'):
            points = points.cpu().numpy()
        else:
            points = np.array(points)
    else:
        assert False,'Error: input tensor must be a numpy array or torch tensor but got type '+str(type(mask))

    # Handle empty tensor (all zeros)
    if not len(points):
        # Return minimal slice at origin
        bounds = tuple(slice(0, 1) for _ in range(tensor.ndim))
    else:
        # Compute bounding box across all dimensions
        mins = np.min(points, axis=0)
        maxs = np.max(points, axis=0) + 1
        bounds = tuple(slice(int(mins[i]), int(maxs[i])) for i in range(len(mins)))

    if output == 'tensor':
        cropped = tensor[bounds]
        return cropped
    elif output == 'bounds':
        return bounds
    else:
        assert False, 'Unreachable'

# def crop_images_zeros(*images, output="image", mask=None):
#     if len(images)==1:
#         only = images[0]
#         if is_image(only):
#             images = [only]
#         else:
#             images = [*only]

def crop_image_white(image,*,output='image'):
    """
    Crops out white or fully opaque white regions from the borders of an image.

    Enhanced Documentation:
    This function removes excess white borders from an image, reducing it to only the non-white content.
    It can handle grayscale, RGB, and RGBA images, and works with both byte (uint8) and float images.
    For byte images, white is defined as 255 for all channels. For float images, white is defined as 1.0.

    This function returns either the cropped image or the bounding coordinates of the non-white area,
    depending on the output parameter. It complements crop_image_zeros for use cases with white
    backgrounds instead of black backgrounds.

    Internally, this function creates a mask where white pixels become zero, then delegates to
    crop_image_zeros to find the bounding box, avoiding code duplication.

    Parameters:
        image : ndarray
            The input image to crop, which can be grayscale, RGB, or RGBA.
        output : str, optional
            Specifies the type of output to return. If 'image' (default), returns the cropped image.
            If 'bounds', returns a tuple of bounding box coordinates (top, bottom, left, right).

    Returns:
        cropped_image : ndarray
            The cropped image, returned if output is 'image'.
        bounds : tuple of int
            A tuple (top, bottom, left, right) defining the bounding box coordinates, returned if output is 'bounds'.

    Examples:
        >>> import numpy as np
        >>> # Create image with white borders
        >>> image = np.array([[255, 255, 255, 255],
        ...                   [255,  50,  50, 255],
        ...                   [255,  50,  50, 255],
        ...                   [255, 255, 255, 255]], dtype=np.uint8)
        >>> cropped = crop_image_white(image, output='image')
        >>> print(cropped)
        [[50 50]
         [50 50]]

        >>> # Get bounds instead of image
        >>> bounds = crop_image_white(image, output='bounds')
        >>> print(bounds)
        (1, 3, 1, 3)

        >>> # Works with RGB images
        >>> rgb_img = np.ones((10, 10, 3), dtype=np.uint8) * 255
        >>> rgb_img[2:8, 2:8] = [100, 100, 100]
        >>> cropped = crop_image_white(rgb_img)
        >>> cropped.shape
        (6, 6, 3)

    Related: crop_image_zeros, crop_image, as_byte_image, is_image

    Note: This function complements crop_image_zeros for use cases with white backgrounds instead of black.

    Tags: image, crop, white, borders, trim, processing
    """
    assert output in 'image bounds'.split(),'output is a string indicating what the output type is - it can be either image or bounds but you gave type '+str(type(output))+' '+str(output)
    assert is_image(image),'Error: input is not an image as defined by rp.is_image()'

    return crop_image_zeros(image, mask=inverted_image(image), output=output)

def cv_contour_to_segment(contour):
    """
    TODO: provide a visual example
    The way OpenCV extracts single-pixel-wide non-looping contours is to treat those contours as loops, where the second half of the points are just the first-half of the points mirrored
    If we want to find the start/end points of a segment, we need to find the two points of symmetry (which are visually obvious, but its not a given that they exist such as if we have a T-junction)
    We do this by autocorrelating the contour with it's mirrored self (AKA the reverse of the contour) to find the shift of the reverse that best matches the original contour. This is equivalent to auto-convolving the contour with itself. We will get two minima, but we only need one. The second symmetry point should be exactly half-way down that contour.
    For example: a contour created by opencv's find contours might look like [[1,1],[[2,2]],[[3,3]],[[4,4]],[[3,3]],[[2,2]]] but obviously we just want [[[1,1]],[[2,2]],[[3,3]],[[4,4]]] and to discard the duplicates. That's what cv_contour_to_segment does.
    This function can be used to get the starting and end points of the segment, which is a bit of a tricky problem.
    The output of a contour given to this function should have half the original length.
    WARNING: This function doesn't check to see if the contour you gave it is actually a segment; be careful! You can usually check to see if a contour is a segment (if using cv_find_contours) by seeing if contour.is_solid_white is True (AKA if the contour doesn't enclose any non-white,area)
    """
    contour=as_complex_vector(contour)
    if len(contour)<=3:
        return contour.copy()
    similarities=circ_conv(contour,contour.conj()).real
    i=np.argmax(similarities)
    return np.roll(contour,(len(contour)-i)//2)[:len(contour)//2]

def whiten_points_covariance(points):
    """
    Whiten the covariance matrix of a list of n-dimensional points, and return a list of new points.
    Also works with 2d-contours represented as indicated by is_points_array, is_complex_vector, and is_cv_contour
    EXAMPLE CODE:
        points=np.random.randn(1000,2)
        points[:,0]*=10#Stretch the distribution along the y-axis
        points=apply_affine(points,rotation_affine_2d(70))#Rotate the stretched distrubition
        ans=input("Displaying points before whitening...(press enter to continue)")
        scatter_plot(points)
        whitened=whiten_points_covariance(points)
        print('Displaying points after whitening (note how it looks like a unit normal distribution now)')
        scatter_plot(whitened)
    """
    pip_import('sklearn')
    from sklearn.decomposition import PCA
    # contour=as_numpy_array(points)
    if is_complex_vector(points) or is_cv_contour(points):
        points=as_points_array(points)#Support for making contours invariant to stretching in a given direction by normalizing their covariance (idea from Zebra 2019 internship).
    assert len(points.shape)==2,'Input should be a matrix, aka a list of points. But your input has shape '+str(points.shape)
    pca = PCA(whiten=True)
    whitened = pca.fit_transform(points)
    return whitened

def visible_string_ljust(string,width,fillchar=' '):
    """
    Trying to be as much like str.ljust as possible, with a small tweak:
    str.ljust doesn't ignore ansi escape sequences, nor does it take into account unicode character widths.
    The small tweak is that this function does (as best as it can).
    This function works with fansi well, but str.ljust does not.
    """
    delta_width=max(0,width-visible_string_length(string))
    return string+fillchar*delta_width

def visible_string_rjust(string,width,fillchar=' '):
    """
    Trying to be as much like str.rjust as possible, with a small tweak:
    str.rjust doesn't ignore ansi escape sequences, nor does it take into account unicode character widths.
    The small tweak is that this function does (as best as it can).
    This function works with fansi well, but str.rjust does not.
    """
    delta_width=max(0,width-visible_string_length(string))
    return fillchar*delta_width+string

def visible_string_center(string,width,fillchar=' '):
    """
    Trying to be as much like str.center as possible, with a small tweak:
    str.center doesn't ignore ansi escape sequences, nor does it take into account unicode character widths.
    The small tweak is that this function does (as best as it can).
    This function works with fansi well, but str.center does not.
    NOTE (NOT an example): Justification for why I round torwards the left when centering:
         >>> 'a'.center(1,'+')
        ans = a
         >>> 'a'.center(2,'+')
        ans = a+
         >>> 'a'.center(3,'+')
        ans = +a+
         >>> 'a'.center(4,'+')
        ans = +a++
         >>> 'a'.center(5,'+')
        ans = ++a++
    """
    delta_width=max(0,width-visible_string_length(string))
    delta_left =delta_width//2
    delta_right=delta_width-delta_left
    return fillchar*delta_left+string+fillchar*delta_right


def make_string_rectangular(string,align='left',fillchar=' '):
    """
    EXAMPLES:
     >>> s='The mathematician\nPlotting his past relations\n"ex" and "why" axis'
     >>> make_string_rectangular(s,'right',fillchar='-')
       ...MAKES...
         ----------The mathematician
         Plotting his past relations
         --------"ex" and "why" axis
     >>> make_string_rectangular(s,'left',fillchar='-')
       ...MAKES...
         The mathematician----------
         Plotting his past relations
         "ex" and "why" axis--------
     >>> make_string_rectangular(s,'center',fillchar='-')
       ...MAKES...
         ans = -----The mathematician-----
         Plotting his past relations
         ----"ex" and "why" axis----
    """
    align_methods={'left':visible_string_ljust,
                  'right':visible_string_rjust,
                 'center':visible_string_center}
    assert len(fillchar)==1,'fillchar should be a length 1 string, but got fillchar='+repr(fillchar)
    assert align in align_methods,'String alignment must be left, right or center, but got align='+repr(align)
    align_method=align_methods[align]
    lines=line_split(string)
    width=string_width(string)
    return line_join(align_method(line,width,fillchar) for line in lines)
def string_is_rectangular(string):
    "Returns true if all lines of the string have the same length"
    lines=line_split(string)
    max_line_length=string_width(string)
    return all(len(line)==max_line_length for line in lines)

def horizontally_concatenated_strings(*strings,rectangularize=False,fillchar=' '):
    """
    The fillchar parameter only matters if rectangularize is True
    EXAMPLE:
     >>> horizontally_concatenated_strings('Why\nHello\nThere!','My\nName\nIs\nBob','Pleased\nTo\nMeet\nYou!',rectangularize=False)
       ...MAKES...
          WhyMyPleased
          HelloNameTo
          There!IsMeet
          BobYou!
    EXAMPLE:
     >>> print(horizontally_concatenated_strings('Why\nHello\nThere!','My\nName\nIs\nBob','Pleased\nTo\nMeet\nYou!',rectangularize=True))
     Why   My  Pleased
     Hello NameTo
     There!Is  Meet
     Bob       You!
    EXAMPLE:
     >>> print(horizontally_concatenated_strings('a','b\nb','c\nc\nc',rectangularize=True))
     abc
      bc
       c
    """
    strings=delist(detuple(strings))
    for string in strings:
        assert isinstance(string,str),'Type '+repr(type(string))+' is not a string, and cannot be concatenated with this function'
    if rectangularize and strings:
        return string_transpose(vertically_concatenated_strings(*map(string_transpose,strings)))
    lines=[]
    for string in strings:
        for index,line in enumerate(line_split(string)):
            if index==len(lines):
                lines.append(line)
            else:
                lines[index]+=line
        if rectangularize:
            lines=line_split(make_string_rectangular(line_join(lines),align='left',fillchar=fillchar))
    return line_join(lines)
def vertically_concatenated_strings(*strings):
    """
    Pretty obvious what this does tbh, I dont see good reason for documenation here
    """
    strings=delist(detuple(strings))
    return line_join(strings)

def wrap_string_to_width(string,width):
    """
    TODO: Make this work with visible_string_length so that unicode chars/ansi codes are supported
    EXAMPLE:
     >>> wrap_string_to_width('Hello\nWorld!',2)
        ans = He
        ll
        o
        Wo
        rl
        d!
    """
    assert width>=0,'Cannot have negative width'
    lines=[]
    for line in line_split(string):
        lines+=split_into_sublists(line,width,strict=False,keep_remainder=True)
    return line_join(''.join(line)for line in lines)

def bordered_string(string,*,
                    weight=1,width     =None,     height=None,     left=None,     right=None,     bottom=None,     top=None,
                    fill=' ',width_fill=None,height_fill=None,left_fill=None,right_fill=None,bottom_fill=None,top_fill=None,
                    bottom_right_fill=None,bottom_left_fill=None,top_right_fill=None,top_left_fill=None):
    """
    NOTE: 99% of the time you should be using a rectangular string, as you can tell with string_is_rectangular
    These examples showcase the usage of a NON-rectangular string to show why, but the rest is hopefully intuitive
    EXAMPLES:
     >>> bordered_string('Hello\nWorld!',fill='-',weight=3)
    ans = -----------
    -----------
    -----------
    ---Hello---
    ---World!---
    ------------
    ------------
    ------------
    >>> bordered_string('Hello\nWorld!',fill='-',weight=3,top=0)
    ans = ---Hello---
    ---World!---
    ------------
    ------------
    ------------
    >>> bordered_string('Hello\nWorld!',fill='-',weight=3,right=3)
    ans = -----------
    -----------
    -----------
    ---Hello---
    ---World!---
    ------------
    ------------
    ------------
    >>> bordered_string('Hello\nWorld!',fill='-',weight=3,right=1)
    ans = ---------
    ---------
    ---------
    ---Hello-
    ---World!-
    ----------
    ----------
    ----------
    >>> bordered_string('Hello\nWorld!',fill='-',bottom_fill='+',weight=3,right=1)
    ans = ---------
    ---------
    ---------
    ---Hello-
    ---World!-
    ++++++++++
    ++++++++++
    ++++++++++
    >>> bordered_string('Hello\nWorld!',fill='-',bottom_fill='+',weight=3,right=1,bottom_right_fill='O')
    ans = ---------
    ---------
    ---------
    ---Hello-
    ---World!-
    +++++++++O
    +++++++++O
    +++++++++O
    >>> print(bordered_string('Hello\nWorld!',width_fill='│',height_fill='─',top_right_fill='┐',top_left_fill='┌',bottom_left_fill='└',bottom_right_fill='┘'))
    ┌─────┐
    │Hello│
    │World!│
    └──────┘
    >>> print(bordered_string(make_string_rectangular('Hello\nWorld!'),width_fill='│',height_fill='─',top_right_fill='┐',top_left_fill='┌',bottom_left_fill='└',bottom_right_fill='┘'))
    ┌──────┐
    │Hello │
    │World!│
    └──────┘
    """
    width =weight if width  is None else width
    height=weight if height is None else height
    top   =height if top    is None else top
    bottom=height if bottom is None else bottom
    left  =width  if left   is None else left
    right =width  if right  is None else right
    #
    width_fill =fill        if width_fill  is None else width_fill
    height_fill=fill        if height_fill is None else height_fill
    top_fill   =height_fill if top_fill    is None else top_fill
    bottom_fill=height_fill if bottom_fill is None else bottom_fill
    left_fill  =width_fill  if left_fill   is None else left_fill
    right_fill =width_fill  if right_fill  is None else right_fill
    #
    bottom_right_fill = bottom_right_fill if bottom_right_fill is not None else bottom_fill
    bottom_left_fill  = bottom_left_fill  if bottom_left_fill  is not None else bottom_fill
    top_right_fill    = top_right_fill    if top_right_fill    is not None else top_fill
    top_left_fill     = top_left_fill     if top_left_fill     is not None else top_fill
    #
    lines=line_split(string)

    lines =[top_fill   *string_width(lines[ 0])]*top+lines
    lines+=[bottom_fill*string_width(lines[-1])]*bottom

    for index,line in list(enumerate(lines))[top:-bottom]:
        lines[index]=left_fill*left+line+right_fill*right

    #Corner fills:
    for index in range(top   ):                         lines[index]=top_left_fill   *left + lines[index] + top_right_fill   *right
    for index in range(bottom):index=len(lines)-index-1;lines[index]=bottom_left_fill*left + lines[index] + bottom_right_fill*right

    return line_join(lines)

def simple_boxed_string(string,align='center',chars='│─┐┌└┘'):
    """
    EXAMPLE:
        >>> s="I don't have any kids\n\nBut I like making dad jokes\n\nI am a faux pa"
        >>> print(simple_boxed_string(s,'center'))
        ┌───────────────────────────┐
        │   I don't have any kids   │
        │                           │
        │But I like making dad jokes│
        │                           │
        │       I am a faux pa      │
        └───────────────────────────┘
    EXAMPLE That demonstrates not only this function but several other console-string functions in rp at once:
        def griddify(string_lists):
            def uniform_boxify(strings,height,width):
                strings=list(map(str                    ,strings))
                strings=list(map(make_string_rectangular,strings))
                strings=[pad_string_to_dims(string,height=height,width=width)for string in strings]
                return strings
            strings=list_flatten(string_lists)
            widths =list(map(string_width ,strings))
            heights=list(map(string_height,strings))
            max_height=max(heights)
            max_width =max(widths )
            string_lists=[uniform_boxify(string_list,max_height,max_width) for string_list in string_lists]
            rows=[horizontally_concatenated_strings(string_list)for string_list in string_lists]
            grid=vertically_concatenated_strings(rows)
            return grid
        strings=[[fansi(wrap_string_to_width(random_namespace_hash(randint(10,30)),5),random_element(['green','blue','yellow','red','magenta','gray','cyan']),per_line=True)for _ in range(5)]for _ in range(10)]
        print(simple_boxed_string(griddify(strings)))
    """
    return bordered_string(make_string_rectangular(string,align=align),
        width_fill       =chars[0],
        height_fill      =chars[1],
        top_right_fill   =chars[2],
        top_left_fill    =chars[3],
        bottom_left_fill =chars[4],
        bottom_right_fill=chars[5])

try:
    _strip_ansi_escapes=re.compile(r'(\x9B|\x1B\[)[0-?]*[ -\/]*[@-~]')
except Exception:pass
def strip_ansi_escapes(string):
    """
    Undoes anything fansi might do to a string
    Code from https://www.tutorialspoint.com/How-can-I-remove-the-ANSI-escape-sequences-from-a-string-in-python#targetText=You%20can%20use%20regexes%20to,%5B%40-~%5D'.
    EXAMPLE:
        assert strip_ansi_escapes(fansi("A",'red'))=='A'
    """
    try:
        return _strip_ansi_escapes.sub('',string)
    except NameError:
        assert False,"Failed to import re upon booting rp"



def visible_string_length(string):
    """
    Give the visible string length when printed into a terminal.
    Ignores ansi escape seqences and zero-width characters
    """
    try:string=strip_ansi_escapes(string)
    except AssertionError:pass
    try:
        from wcwidth import wcswidth
        out=string.count('\n')
        for line in line_split(string):
            visible_line_length=wcswidth(line)
            assert visible_line_length!=-1#It means something went wrong
            out+=visible_line_length
        return out
    except (ImportError,AssertionError):
        return len(line)#A fallback in-case wcswidth doesn't work. This will give the wrong answer on things like fansi-output, but it's probably better than crashing because not all code needs fansi

def string_width(string):
    """
    Enhanced Documentation:
    Calculates the maximum display width across all lines in a multi-line string.
    Uses visible_string_length to handle ANSI escape sequences and Unicode 
    characters correctly for accurate terminal display width calculation.
    
    Args:
        string (str): Input string (can be single or multi-line)
        
    Returns:
        int: Maximum visible width of any line (0 for empty string)
        
    Examples:
        >>> string_width('hello')
        5
        >>> string_width('hello\nworld')
        5
        >>> string_width('short\nverylongline\nmid')
        12
        >>> string_width('')
        0
        
    Related Functions:
        - string_height(): Get number of lines (alias for number_of_lines)
        - line_split(): Split string into lines
        - visible_string_length(): Calculate visible length of single line
        - number_of_lines(): Count lines in string
        
    Tags: string, text, width, display, terminal, unicode
    """
    return max(map(visible_string_length,line_split(string)),default=0)

def string_height(string):
    return number_of_lines(string)


def _pad_string_height(string, height, origin=None):
    """
    Pad a string with the specified extra height at the given origin.

    Args:
        string (str): The input string to be padded.
        height (int): The extra height (number of lines) to pad the string.
        origin (str, optional): The origin of the padding. Must be one of 'top', 'bottom', or 'center'.
                                If not provided, the string is returned unchanged.

    Returns:
        str: The padded string.

    Raises:
        ValueError: If the input string is not a string or height is negative.
    """
    if not isinstance(string, str):
        raise ValueError("Input string must be a string.")
    if not isinstance(height, int) or height < 0:
        raise ValueError("Height must be a non-negative integer.")
    
    if origin == 'top':
        return '\n' * height + string
    elif origin == 'bottom':
        return string + '\n' * height
    elif origin == 'center':
        top_pad = height // 2
        bottom_pad = height - top_pad
        return '\n' * top_pad + string + '\n' * bottom_pad
    else:
        return string

def pad_to_same_number_of_lines(*strings, origin='top'):
    """
    Pad multiple strings to have the same number of lines with the specified origin.

    Args:
        *strings (str): Variable number of input strings to be padded.
        origin (str, optional): The origin of the padding. Must be one of 'top', 'bottom', or 'center'.
                                Defaults to 'top'.

    Returns:
        list: A list of padded strings, all having the same number of lines.

    Raises:
        ValueError: If any of the input strings is not a string or origin is not one of 'top', 'bottom', or 'center'.
        
    EXAMPLE:
        >>> for origin in ["top", "center", "bottom"]:
        ...     print("-" * 10,'origin='+origin,'-'*10)
        ...     print(
        ...         horizontally_concatenated_strings(
        ...             pad_to_same_number_of_lines(
        ...                 "Hello\nWorld", "My", "Name\nIs\nClara", origin=origin
        ...             ),
        ...             rectangularize=True,
        ...         )
        ...     )
        ... #OUTPUT:
        ... #---------- origin=top ----------
        ... #       Name
        ... #Hello  Is
        ... #WorldMyClara
        ... #---------- origin=center ----------
        ... #Hello  Name
        ... #WorldMyIs
        ... #       Clara
        ... #---------- origin=bottom ----------
        ... #HelloMyName
        ... #World  Is
        ... #       Clara
    """
    strings = detuple(strings)
    if not all(isinstance(x, str) for x in strings):
        raise ValueError("All input strings must be strings.")
    if origin not in ['top', 'bottom', 'center']:
        raise ValueError("Origin must be one of 'top', 'bottom', or 'center'.")
    
    num_lines = [number_of_lines(s) for s in strings]
    max_num_lines = max(num_lines)
    new_lines = [_pad_string_height(s, max_num_lines - n, origin) for s, n in zip(strings, num_lines)]
    
    return new_lines

pad_strings_to_max_height = pad_to_same_number_of_lines


def pad_string_to_dims(string,*,height,width,fill=' '):
    assert string_width (string)<=width
    assert string_height(string)<=height
    delta_width =width -string_width (string)
    delta_height=height-string_height(string)
    top   =delta_height//2
    left  =delta_width //2
    bottom=delta_height-top
    right =delta_width -left
    return bordered_string(string,fill=fill,left=left,right=right,top=top,bottom=bottom)

def prime_number_generator():
    """
    Returns a generator that returns the sequence of primes
    If you have numba, it will run very very very fast

    TODO: Add caching and optional starting points. Add an is_prime function
    """
    p=[]
    try:
        #The fast, numba-accelerated version
        import numba
        @numba.jit()
        def primes():
            i=2
            while True:
                f=True
                for x in p:
                    if not i%x:
                        f=False
                        break
                if f:
                    p.append(i)
                    yield i
                i+=1
    except ImportError:
        #The slower, pure-python version
        def primes():
            p=[]
            i=2
            while True:
                if all(i%x for x in p):
                    yield i
                i+=1
    return primes()

def edit_distance(string_from, string_to):
    """
    Returns the levenshtein_distance between two strings

    There are faster implementations. I just took this from stackoverflow. This might be improved (use libraries that implement this in c) in the future if I feel the need for better performance
    CODE FROM:  https://stackoverflow.com/questions/2460177/edit-distance-in-python
    """
    s1,s2=string_from,string_to
    m=len(s1)+1
    n=len(s2)+1
    tbl = {}
    for i in range(m): tbl[i,0]=i
    for j in range(n): tbl[0,j]=j
    for i in range(1, m):
        for j in range(1, n):
            cost = 0 if s1[i-1] == s2[j-1] else 1
            tbl[i,j] = min(tbl[i, j-1]+1, tbl[i-1, j]+1, tbl[i-1, j-1]+cost)
    return tbl[i,j]
levenshtein_distance=edit_distance#Synonyms, for now...

def edit_image_in_terminal(image):
    """
    Silly (but really fun) function that launches mspaint on an image in the terminal
    Not very practical but fun
    This function isn't written well at the moment - if the saved image > 500kb it breaks becuase of a built-in parameter in textual_paint
    You can change that variable in their source code
    """
    pip_import('textual_paint','textual-paint')
    assert currently_in_a_tty()
    assert isinstance(image,str) or is_image(image)
    if isinstance(image,str):
        assert is_image_file(image)
        image=load_image(image)
        os.system('textual-paint '+repr(video_path))
        edited=load_image(path)
        return edited
    else:
        image=resize_image_to_fit(image,512,512, allow_growth=False) #If it's too big it will be super duper slow!
        path=temporary_file_path('png')
        try:
            save_image(image,path)
        finally:
            delete_file(path)
    return edited

    
    
class Timeout:  
    """
    TODO: Make this work on windows (I won't think it will work on anything but UNIX because of the signal handling)
    https://stackoverflow.com/questions/2281850/timeout-function-if-it-takes-too-long-to-finish
    Use this function to prevent a block of code from taking too long to run, else throw a TimeoutError
    EXAMPLE:
      with timeout(seconds=3):
          time.sleep(4)
    """
    def __init__(self, seconds=1, error_message='Timeout'):
        self.seconds = seconds
        self.error_message = error_message
    def handle_timeout(self, signum, frame):
        raise TimeoutError(self.error_message)
    def __enter__(self):
        import signal
        signal.signal(signal.SIGALRM, self.handle_timeout)
        signal.alarm(self.seconds)
    def __exit__(self, type, value, traceback):                                                                                  
        import signal                                                                                                            
        signal.alarm(0)
                                                                                                                                 
class TimeoutError(Exception):
    pass
def timeout(*timeout_args,**timeout_kwargs):
    """
    A timeout decorator that uses the Timeout class
    To see documentation for this function's arguments, see the Timeout class's documentation

    """
    # region Argument Validation
    assert not (len(timeout_args)==1 and callable(timeout_args[0])),'\'timeout\' is a decorator function. To use it as a decorator you must first pass it arguments.\nExample:\n\tGood:\n\t\t@timeout(1)\n\t\tdef f():pass\n\tBad:\n\t\t@timeout\n\t\tdef f():pass'
    try:
        with Timeout(*timeout_args,**timeout_kwargs):
            pass#This is just here to throw errors ahead of time in-case the user passes invalid arguments to the Timeout constructor
    except TypeError:
        assert False,'Bad arguments given to timeout, which were then passed to the Timeout class and gave a TypeError. Please see \'Timeout\'s arguments for more details, and use those same arguments in this decorator.'
    #endregion

    def wrapper(function):
        def wrapped(*args,**kwargs):
            with Timeout(*timeout_args,**timeout_kwargs):
                return function(*args,**kwargs)
        return wrapped
    return wrapper

#region English stuff (like plural to singular vice versa / words to numbers and vice versa etc etc)

_inflect_engine=None
def _get_inflect_engine():
    #Documentation: https://pypi.org/project/inflect/
    pip_import('inflect')
    import inflect
    global _inflect_engine
    if _inflect_engine is None:
        _inflect_engine=inflect.engine()
        _inflect_engine.classical()#Makes the difference between 'indexes' and 'indices'
    return _inflect_engine

def is_plural_noun(noun):
    """ Return True if the given noun is in plural form, and False otherwise """
    return not is_singular_noun(noun)

def is_singular_noun(noun):
    """ Return True if the given noun is in singular form, and False otherwise """
    return not bool(_get_inflect_engine().singular_noun(noun))#When a noun is singular already, and we pass it into the inflect library's singular_noun function, it returns False

def is_singular_noun_of(singular_word,plural_word):
    """ Returns true if singular_word is the signular-form of plural_word """
    return _get_inflect_engine().compare_nouns(singular_word,plural_word) and is_singular_noun(singular_word)

def is_plural_noun_of(plural_word,singular_word):
    """ Returns true if plural_word is the plural-form of singular_word """
    return _get_inflect_engine().compare_nouns(plural_word,singular_word) and is_plural_noun(plural_word)

def plural_noun(noun,force=False):
    """
    Returns the plural form of a singular word
    If force is true, it will not check to see if this noun is allready plural.
    If force is true, we guarentee that the result is different from the  orignal
    Right now this function uses a library called 'inflect'. "pip install inflect"
    EXAMPLE:
        plural_noun("house")              -> "houses"
        plural_noun("houses",force=False) -> "houses"
        plural_noun("houses",force=True)  -> "housess"
        plural_noun("mouse")              -> "mice"
        plural_noun("die")                -> "dice"
        plural_noun("goose")              -> "geese"
        plural_noun("sheep")              -> "sheep"
        plural_noun("doggy")              -> "doggies"
        plural_noun("qwerty")             -> "qwerties" #Even works for made-up words
        plural_noun("spinach")            -> "spinaches"
        plural_noun("person")             -> "people"
        plural_noun("the_thing")          -> "the_things"
        plural_noun('wrq_qijjz_puppy')    -> "wrq_qijjz_puppies" #it's robust enough to handle conjoined variable names with multiple words in them'
        plural_noun("index")              -> "indices" #Actually, indices and indexes are both gramattically correct. This is controlled by the line with '.classical()' in _get_inflect_engine
        plural_noun("octopus")            -> "octopuses"  #lol not "octoputties", which is the TECHNICALLY CORRECT name...
    
    
    EXAMPLE:
        #The 'force' option defaults to False to prevent this from happening:
        plural_noun("houses",force=True)  -> "housess"
        #If force is True, it can keep growing the word indefinately if we keep applying this function to it
    """
    if not force and is_plural_noun(noun):
        return noun
    return _get_inflect_engine().plural_noun(noun)

def singular_noun(noun):
    """
    Returns the singular form of a plural word
    EXAMPLE:
        singular_noun('houses')            -> 'house'
        singular_noun('mice')              -> 'mouse'
        singular_noun('sheep')             -> 'sheep'
        singular_noun('dice')              -> 'die'
        singular_noun('doggies')           -> 'doggy'
        singular_noun('qwerties')          -> 'qwerty'
        singular_noun('spinaches')         -> 'spinach'
        singular_noun('the_things')        -> 'the_thing'
        singular_noun('wrq_qijjz_puppies') -> 'wrq_qijjz_puppy'
        singular_noun('indexes')           -> 'index'
        singular_noun('octopuses')         -> 'octopus'
        singular_noun('houseses')          -> 'housese'
        singular_noun('geese')             -> 'goose'
        singular_noun('people')            -> 'person'
    """
    return _get_inflect_engine().singular_noun(noun) or noun#If the noun is allready singular; leave it alone. If noun is allready singular, inflect will return "False" because it fails to turn your noun into singular form, causing this function to return the orignal word instead.

def number_to_words(number):
    """
    Returns the english representation of a number (can be an integer, negative, or even floating point. But can NOT be a complex number right now, because it will mess that up)
        number_to_words(0)           -> 'zero'
        number_to_words(1)           -> 'one'
        number_to_words(2)           -> 'two'
        number_to_words(3)           -> 'three'
        number_to_words(4)           -> 'four'
        number_to_words(5)           -> 'five'
        number_to_words(10)          -> 'ten'
        number_to_words(15)          -> 'fifteen'
        number_to_words(20)          -> 'twenty'
        number_to_words(25)          -> 'twenty-five'
        number_to_words(50)          -> 'fifty'
        number_to_words(78)          -> 'seventy-eight'
        number_to_words(92)          -> 'ninety-two'
        number_to_words(101)         -> 'one hundred and one'
        number_to_words(1000)        -> 'one thousand'
        number_to_words(1238)        -> 'one thousand, two hundred and thirty-eight'
        number_to_words(3498)        -> 'three thousand, four hundred and ninety-eight'
        number_to_words(12398)       -> 'twelve thousand, three hundred and ninety-eight'
        number_to_words(12938123)    -> 'twelve million, nine hundred and thirty-eight thousand, one hundred and twenty-three'
        number_to_words(-1)          -> 'negative one'
        number_to_words(-2)          -> 'negative two'
        number_to_words(-3)          -> 'negative three'
        number_to_words(-4)          -> 'negative four'
        number_to_words(-5)          -> 'negative five'
        number_to_words(-123)        -> 'negative one hundred and twenty-three'
        number_to_words(-123.4)      -> 'negative one hundred and twenty-three point four'
        number_to_words(1.1)         -> 'one point one'
        number_to_words(0.2)         -> 'zero point two'
        number_to_words(0.333333333) -> 'zero point three three three three three three three three three'
        number_to_words(0.25)        -> 'zero point two five'
    """
    return _get_inflect_engine().number_to_words(number).replace('minus','negative')

def words_to_number(string):
    """
    I did my best to make this the inverse of number_to_words, and it works for most cases
    Returns either an int or a float, depending on the context
    Can handle decimal points and negative numbers, but NOT complex numbers
    EXAMPLES:
        words_to_number("sixty-five"                                     ) = 65
        words_to_number("negative twenty-one"                            ) = -21
        words_to_number("thirty-eight"                                   ) = 38
        words_to_number("negative thirty-five"                           ) = -35
        words_to_number("five hundred and ninety-three"                  ) = 593
        words_to_number("negative thirty-six"                            ) = -36
        words_to_number("twenty-nine"                                    ) = 29
        words_to_number("six hundred and five"                           ) = 605
        words_to_number("two hundred and thirty-four"                    ) = 234
        words_to_number("negative twenty-six"                            ) = -26
        words_to_number("thirty"                                         ) = 30
        words_to_number("one thousand and seven"                         ) = 1007
        words_to_number("one thousand, two hundred and six"              ) = 1206
        words_to_number("one thousand, seven hundred and twenty-seven"   ) = 1727
        words_to_number("ninety-eight"                                   ) = 98
        words_to_number("zero point six one"                             ) = 0.61
        words_to_number("point 5"                                        ) = 0.5
        words_to_number("zero point five"                                ) = 0.5
        words_to_number("one over 2"                                     ) = 0.5
        words_to_number("one out of 2"                                   ) = 0.5
        words_to_number("one of two"                                     ) = 0.5
        words_to_number("1 / 2"                                          ) = 0.5
        words_to_number("zero point zero five"                           ) = 0.5
        words_to_number("zero point five one"                            ) = 0.51
        words_to_number("five point 4"                                   ) = 5.4
        words_to_number("five point six three"                           ) = 5.63
        words_to_number("0.6"                                            ) = 0.6
        words_to_number("0.6234"                                         ) = 0.6234
        words_to_number("five point six two"                             ) = 5.62
        words_to_number("negative 4"                                     ) = -4
        words_to_number("negative 4 point 4"                             ) = -4.4
        words_to_number("negative zero point 4"                          ) = -0.4
        words_to_number("negative zero point 4 five 6"                   ) = -0.456
        words_to_number("one . 2 3 4 5"                                  ) = 1.2345
        words_to_number("negative one . 2 3 4 five"                      ) = -1.2345
        words_to_number("negative 2 one . 2 3 4 five"                    ) = -1.2345
        words_to_number("negative 2 four one . 2 3 4 five"               ) = -5.2345
        words_to_number("negative four one  . 2 3 4 five"                ) = -5.2345
        words_to_number("negative four one one . 2 3 4 five"             ) = -5.2345
        words_to_number("negative twelve . 2 3 4 five"                   ) = -12.2345
        words_to_number("negative          2     one   . 2 34   five    ") = -1.2345
        words_to_number("negative 2 one . 2 3 4 five"                    ) = -1.2345
        words_to_number("543 point 2 2 2"                                ) = 543.222
        words_to_number("  minus 543 point 2 2 2"                        ) = -543.222
        words_to_number(" minus point 5"                                 ) = -0.5
        words_to_number("   minus     zero out      of 10   "            ) = 0
        words_to_number("   negative  zero out      of 10   "            ) = 0
        words_to_number("      -      zero out      of 10   "            ) = 0
        words_to_number("324.234"                                        ) = 324.234
        words_to_number("-23.234"                                        ) = -23.234
        words_to_number("235"                                            ) = 235
        words_to_number("-1"                                             ) = -1
        words_to_number("-.0"                                            ) = -0.0
        words_to_number("-0.1"                                           ) = -0.1
    """
    assert isinstance(string,str),'word2number error: please input a string. You gave me a '+repr(type(string))

    try:
        #Try the fast way first
        return float(string)
    except Exception:
        #oh well lol at least we tried
        pass

    pip_import('word2number')
    from word2number import w2n #pip install word2number


    negative=False
    string=string.strip()
    string=string.replace('minus ','-')
    string=string.replace('negative ','-')
    if string.startswith('-'):
        #This word2number library can't natively handle 'negative 5' without crashing
        negative=True
        string=string[1:].strip()

    string=string.replace('/'           ,' over ')
    string=string.replace(' out of '    ,' over ')#For the next few lines that handle fractions...
    string=string.replace(' of '        ,' over ')
    string=string.replace(' divided by ',' over ')
    if ' over ' in string:
        assert string.count(' over ')==1,'word2number error: Cant have a fraction with two denominators, but was given '+repr(string)
        numerator,denominator=string.split(' over ')
        out = words_to_number(numerator)/words_to_number(denominator)
        if int(out)==out:
            return int(out)#'five out of 1' should return an integer, not a float
        return out if not negative else -out

    string=string.replace('.','point ')#Because words_to_number('234.324') should also work for the next few lines...
    if 'point ' in string:
        assert string.count('point ')==1,'word2number error: Cant have more than one decimal point in a number but string said \'point\' twice in '+repr(string)
        before_decimal,after_decimal=string.split('point ')
        before_decimal='0' if not before_decimal.strip() else before_decimal#In case we get 'point five' -> .5
        after_decimal=''.join(str(words_to_number(x)) for x in after_decimal.split()).strip()#after_decimal looks like "five one six two"...space separated digits
        out = float(str(words_to_number(before_decimal))+'.'+str(words_to_number(after_decimal)))
        return out if not negative else -out

    string=string.replace(',',' ')#Because without this, 'one thousand, one hundred and fifty five' returns the wrong answer
    out=w2n.word_to_num(string)

    return out if not negative else -out

#endregion

def _get_parts_of_speech_via_nltk(word):
    """
    Given a word, return the parts of speech (adjectives, nouns, verbs etc) that this word belongs to.
    Code from: https://stackoverflow.com/questions/35462747/how-to-check-a-word-if-it-is-adjective-or-verb-using-python-nltk
    EXAMPLES:
         >>> _get_parts_of_speech_via_nltk('dog')
        ans = {'n'}
         >>> _get_parts_of_speech_via_nltk('tail')
        ans = {'v', 'n'}
         >>> _get_parts_of_speech_via_nltk('run')
        ans = {'v', 'n'}
         >>> _get_parts_of_speech_via_nltk('pretty')
        ans = {'s'}
         >>> _get_parts_of_speech_via_nltk('the')
        ans = set()
         >>> _get_parts_of_speech_via_nltk('aosidfj')
        ans = set()
    """

    pip_import('nltk')
    _make_sure_nltk_has_wordnet_installed()
    from nltk.corpus import wordnet as wn
    pos_l = set()
    for tmp in wn.synsets(word):
        if tmp.name().split('.')[0] == word:
            pos_l.add(tmp.pos())
    return pos_l

def _nltk_wordnet_is_installed()->bool:
    pip_import('nltk')
    import nltk
    try:
        nltk.data.find("corpora/wordnet")
        return True
    except LookupError:
        return False

def _make_sure_nltk_has_wordnet_installed():
    #If wordnet isn't installed, this function will install it. It avoids nltk's 'Resource wordnet not found.' error.
    pip_import('nltk')
    import nltk
    if not _nltk_wordnet_is_installed():
        fansi_print("rp: Detected that while you do have nltk installed, you do not have wordnet downloaded. Downloading wordnet...",'yellow','bold')
        nltk.download('wordnet')
        fansi_print("...done!",'yellow','bold')

def is_a_verb(word:str)->bool:
    #Returns true if the given word is an english verb, false otherwise
    return 'v' in _get_parts_of_speech_via_nltk(word)

def is_an_adjective(word:str)->bool:
    #Returns true if the given word is an english adjective, false otherwise
    return 's' in _get_parts_of_speech_via_nltk(word)

def is_a_noun(word:str)->bool:
    """
    Returns true if the given word is an english noun, false otherwise
    Please note that this function is far from fool-proof. is_a_noun('poop')==False, for example (which is wrong), even though is_an_english_word('poop')==True
    However, for most english words, this will work properly.
    """
    return 'n' in _get_parts_of_speech_via_nltk(word)

@memoized
def get_all_english_words():
    """
    Apparently, both Linux and Mac have a file that contains every english word! 
    See https://stackoverflow.com/questions/3788870/how-to-check-if-a-word-is-an-english-word-with-python/3789057
    TODO: Possibly might make all of these words lower case, haven't decided yet...
    """
    if currently_running_unix():
        return set(line_split(text_file_to_string("/usr/share/dict/words")))
    else:
        assert False,'Sorry, currently only unix supports rp.all_english_words()'

@memoized
def _get_all_english_words_lowercase():
    return line_join(get_all_english_words()).lower()

def is_an_english_word(word):
    "This function is not case sensitive"
    return word.lower() in _get_all_english_words_lowercase()

def split_sentences(text, language='english'):
    """
    Splits the input text into sentences based on the specified language.

    This function uses the Punkt tokenizer, which is an unsupervised machine learning
    algorithm for sentence tokenization developed by Kiss and Strunk. It effectively
    segments text into sentences based on punctuation and capitalization cues. The Punkt
    tokenizer is part of the Natural Language Toolkit (nltk) library in Python.

    The function is designed to work with multiple languages:
        'english'
        'spanish'
        'french'
        'italian'
        'german'
        'russian'
        'portuguese'
        'greek'
        'estonian'
        'dutch'
        'slovene'
        'swedish'
        'czech'
        'finnish'
        'malayalam'
        'norwegian'
        'danish'
        'turkish'
        'polish'

    To see the full list of supported languages and their corresponding codes, run the following code snippet:
        print(rp.r._get_punkt_languages())

    Parameters:
        text (str): The input text to be split into sentences.
        language (str): The langauge to be split

    Returns:
        list: A list of sentences extracted from the input text.

    Example:
        text = "Hello! This is an example of splitting sentences. How are you doing today?"
        sentences = split_sentences(text)
        print(sentences)
        # Output: ['Hello!', 'This is an example of splitting sentences.', 'How are you doing today?']

    Alternative Libraries:
        An alternative library for sentence tokenization is spaCy, which provides a more comprehensive
        natural language processing pipeline and supports a wide range of languages. If you need more advanced
        NLP capabilities or prefer a different tokenizer, consider using the spaCy library.

    Written with the partial aid of GPT4: https://sharegpt.com/c/ialPWcW
    """

    #Imports and downloads
    pip_import("nltk")
    _ensure_punkt_downloaded()
    import nltk
    from nltk.tokenize import sent_tokenize

    
    #Input assertions
    assert isinstance(text, str)
    assert language in _get_punkt_languages(), 'Language %s is not supported. Please choose from: %s'%(language, _get_punkt_languages())

    return sent_tokenize(text)

@memoized
def _ensure_punkt_downloaded():
    # Ensure nltk is installed and punkt is downloaded
    pip_import("nltk")
    import nltk
    try:
        nltk.find('tokenizers/punkt')
    except LookupError:
        nltk.download('punkt')

@memoized
def _get_punkt_languages():
    """
    Gets a list of languages supported by nltk's punkt (sentence splitter)
    Current languages as of writing:
        greek
        english
        estonian
        dutch
        portuguese
        slovene
        spanish
        swedish
        french
        czech
        finnish
        malayalam
        russian
        norwegian
        german
        danish
        turkish
        italian
        polish
    """
    pip_import('nltk')
    _ensure_punkt_downloaded()

    import os
    from nltk.data import find
    
    output=[]
    
    punkt_path = str(find('tokenizers/punkt'))
    language_dirs = os.listdir(punkt_path)
    
    for lang_dir in language_dirs:
        if lang_dir.endswith('.pickle'):
            output.append(lang_dir.split('.')[0])
    
    return output

def connected_to_internet():
    """
    Return True if we're online, else False
    Code from: https://stackoverflow.com/questions/20913411/test-if-an-internet-connection-is-present-in-python/21460844
    """
    import socket
    try:
        # connect to the host -- tells us if the host is actually
        # reachable
        url_to_try = 'www.google.com'

        if 'XM_XID' in os.environ:
            #If we're in Google's internal XCloud, we should check a non-google URL
            url_to_try = 'https://www.microsoft.com'

        socket.create_connection((url_to_try, 80))
        return True
    except OSError:
        pass
    return False

def _string_pager_via_pypager(string):
    """ Uses prompt-toolkit. But this can break if you have the wrong prompt toolkit version.  Also it fails on ANSI escape sequences. """
    pip_import('pypager')
    string=str(string)
    from pypager.source import StringSource
    from pypager.pager import Pager
    p = Pager()
    p.add_source(StringSource(text=string,lexer=None))
    p.run()

def _string_pager_via_click(string):
    """ A pure-python alternative to less """
    click=pip_import('click')
    click.echo_via_pager(string)

def _string_pager_via_less(string):
   """
   Pipes a string into less, respecting ANSI escape sequences for coloring.

   Args:
       string: The string to pipe into less.

   Raises:
       FileNotFoundError: If the 'less' command is not found.
       Exception: For other errors during the process.

   EXAMPLE:
     >>> colored_text = '\\033[31mRed Text\\033[0m'
     >>> _string_pager_via_less(colored_text)
     # (less pager will open displaying 'Red Text' in red)
   """
   import subprocess
   import os

   try:
       process = subprocess.Popen(
           ['less', '-r'],  # -r option to interpret raw control characters (ANSI escapes)
           stdin=subprocess.PIPE,
           stderr=subprocess.PIPE
       )
       process.communicate(string.encode('utf-8')) # Encode string to bytes for stdin

   except FileNotFoundError:
       raise FileNotFoundError("less command not found. Please make sure less is installed.")

_default_string_pager = _string_pager_via_click
def string_pager(string):
    """
    Uses a python-based pager, similar to the program 'less', where you can scroll and search through things
    What is a pager? See: https://en.wikipedia.org/wiki/Terminal_pager
    Useful for displaying gigantic outputs without printing the whole thing
    """
    string=str(string)

    try:
        return _default_string_pager(string)
    except FileNotFoundError:
        #If we're on windows, and we tried to use _string_pager_via_less
        return _string_pager_via_click(string)

    return _string_pager_via_pypager(string)#We're not using this one right now, because it uses prompt toolkit and might break if we have the wrong version installed. It also fails on ansi escape colorings.



#region Mouse functions

#TODO: Build full keylogger functionality with pynput, including the ability to record entire mouse position/mouse click/keypress sequences and play them back again (for easy, full automation)

_pynput_mouse_controller=None
def _get_pynput_mouse_controller():
    """
    Private function to get or create a cached pynput mouse controller instance.

    Enhanced Documentation:
    - Usage patterns: Internal singleton for mouse operations, lazy initialization
    - Related functions: All mouse_* functions (get_mouse_position, mouse_left_click, etc.)
    - RP integration: Used by 11+ mouse control functions to avoid repeated initialization
    - Performance: Caches controller instance globally to avoid recreating expensive objects
    - Comparison: vs creating new Controller() - much faster; vs direct pynput - adds lazy loading

    Parameters:
        None

    Returns:
        pynput.mouse.Controller: Cached mouse controller instance for mouse operations

    Examples:
        # Internal usage only - not meant for direct calling:
        # controller = _get_pynput_mouse_controller()
        # controller.position = (100, 200)  # Move mouse
        # Instead use public functions:
        >>> set_mouse_position(100, 200)  # Uses this internally
        >>> mouse_left_click()  # Uses this internally
        >>> get_mouse_position()  # Uses this internally

    Tags: private, mouse, controller, singleton, lazy-loading, pynput
    """
    global _pynput_mouse_controller
    if not _pynput_mouse_controller:
        pynput=pip_import('pynput')
        _pynput_mouse_controller=pynput.mouse.Controller()
    return _pynput_mouse_controller

def get_mouse_position():
    """
    Return (x,y) coordinates representing the position of the mouse cursor. (0,0) is the top left corner of the screen.
    x is horizontal movement, y is vertical movement. More y is further down, more x is further right.
    EXAMPLE: while True:print(get_mouse_position()) #Move your mouse around and watch the numbers change
    """
    return _get_pynput_mouse_controller().position

def get_mouse_x():
    return get_mouse_position()[0]

def get_mouse_y():
    return get_mouse_position()[1]

def set_mouse_position(*position):
    """
    EXAMPLES:
        set_mouse_position( 23,40 ) #you can specify the coordinates as separate x,y arguments
        set_mouse_position(*get_mouse_position())
    
        set_mouse_position((23,40)) #you can also specify the coordinates as a single tuple
        set_mouse_position( get_mouse_position())
    """
    position=detuple(position)
    assert len(position)==2 and all(map(is_number,position)),'Invalid input: expected (x,y) pair but got position='+repr(position)
    x,y=position#I'm being explicit here for readability
    _get_pynput_mouse_controller().position=x,y

def record_mouse_positions(duration=1,rate=60):
    """
    #Record the mouse position for (duration) seconds, taking (rate) samples per second
    """
    assert rate>0
    assert duration>=0
    out=[]
    for _ in range(int(duration*rate)):
        out.append(get_mouse_position())
        sleep(1/rate)
    return out

def playback_mouse_positions(positions,rate=60):
    """
    #Play back a list of mouse positions at (rate) positions per second
    #EXAMPLE: playback_mouse_positions(record_mouse_positions(10)) #Move the mouse around for 10 seconds, then watch it play back again
    """
    assert is_iterable(positions)
    assert rate>0
    for position in positions:
        set_mouse_position(position)
        sleep(1/rate)

def mouse_left_click():
    """
    Trigger the mouse's left click button
    """
    pynput=pip_import('pynput')
    _get_pynput_mouse_controller().click(pynput.mouse.Button.left)

def mouse_right_click():
    """
    Trigger the mouse's right click button
    """
    pynput=pip_import('pynput')
    _get_pynput_mouse_controller().click(pynput.mouse.Button.right)

def mouse_middle_click():
    """
    Trigger the mouse's middle click button
    """
    pynput=pip_import('pynput')
    _get_pynput_mouse_controller().click(pynput.mouse.Button.middle)

def mouse_left_press():
    """
    Press the mouse's left button
    EXAMPLE: mouse_left_press();sleep(1);mouse_left_release()
    """
    pynput=pip_import('pynput')
    _get_pynput_mouse_controller().press(pynput.mouse.Button.left)

def mouse_right_press():
    """
    Press the mouse's right button
    EXAMPLE: mouse_right_press();sleep(1);mouse_right_release()
    """
    pynput=pip_import('pynput')
    _get_pynput_mouse_controller().press(pynput.mouse.Button.right)

def mouse_middle_press():
    """
    Press the mouse's middle button
    EXAMPLE: mouse_middle_press();sleep(1);mouse_middle_release()
    """
    pynput=pip_import('pynput')
    _get_pynput_mouse_controller().press(pynput.mouse.Button.middle)

def mouse_left_release():
    """
    Release the mouse's left button
    EXAMPLE: mouse_left_release();sleep(1);mouse_left_release()
    """
    pynput=pip_import('pynput')
    _get_pynput_mouse_controller().release(pynput.mouse.Button.left)

def mouse_right_release():
    """
    Release the mouse's right button
    EXAMPLE: mouse_right_release();sleep(1);mouse_right_release()
    """
    pynput=pip_import('pynput')
    _get_pynput_mouse_controller().release(pynput.mouse.Button.right)

def mouse_middle_release():
    """
    Release the mouse's middle button
    EXAMPLE: mouse_middle_release();sleep(1);mouse_middle_release()
    """
    pynput=pip_import('pynput')
    _get_pynput_mouse_controller().release(pynput.mouse.Button.middle)

#endregion

def get_monitor_resolution():
    """
    Returns the resolution of the primarty monitor as (height, width)
    """
    pip_import('screeninfo')
    from screeninfo import get_monitors
    for m in get_monitors():
        if m.is_primary:
            return m.height, m.width
    assert False, 'There should be at least one primary monitor.'

def get_number_of_monitors():
    """
    Returns an int: the number of monitors attached to this computer.
    """
    pip_import('screeninfo')
    from screeninfo import get_monitors
    return len(get_monitors())


# def captured_stdout_as_string(callable):
#THIS IS COMMENTED OUT. IT WORKS TECHNICALLY, BUT ITS WAY TOO MESSY. It would be better to make it into a generator that yields each character then returns the output of callable.
#     #Takes a callable, returns a string containing the stdout of that callable
#     #EXAMPLE: captured_stdout_as_string(lambda:print("Hello, World!"))  --->  'Hello, World!\n'
#     from contextlib import redirect_stdout
#     import io
#     f = io.StringIO()
#     with redirect_stdout(f):
#         callable()
#     s = f.getvalue()
#     return s

def unicode_loading_bar(n,chars='▏▎▍▌▋▊▉█'):
    """
    EXAMPLE 1: for _ in range(200):print(end='\r'+unicode_loading_bar(_));sleep(.05)
    EXAMPLE 2:
        for _ in range(1500):
            sleep(1/30)#30fps
            x=_/1000#_ is between 0 and 1
            x**=2#Frequency increases over time
            x*=tau
            x*=10
            x=np.sin(x)
            x+=1#Make it all positive
            x*=20
            x*=8
            print(unicode_loading_bar(x))
    THE ABOVE EXAMPLE PRINTS SOMETHING LIKE THIS:
     ▊
     █▊
     ███▏
     ████▌
     ██████▏
     ███████▊
     █████████▍
     ██████████▋
     ███████████▋
     ████████████▎
     ████████████▌
     ████████████▍
     ███████████▊
     ██████████▊
     █████████▍
     ███████▉
     ██████▏
     ████▋
     ███
     █▊
     ▊
     ▎
     ▏
     ▍
     █
     ██
     ███▎
    """

    assert is_number(n),'Input assumption'
    assert n>=0,'Input assumption that n>=0 but n='+repr(n)
    assert isinstance(chars,str),'Input assumption'
    assert len(chars)>=1,'Input assumption'
    n=int(n)
    n=max(0,n)#Clip off negative numbers
    size=len(chars)
    output =n//size*chars[-1]
    output+=chars[n%size]
    return output

def get_box_char_bar_graph(values, min_val=None, max_val=None, num_lines=1):
    """
    Generate a bar graph using box characters based on the provided values.

    Args:
        values (list): A list of numeric values to be plotted in the bar graph.
        min_val (float, optional): The minimum value for scaling the graph. If not provided, the minimum value from the input values will be used.
        max_val (float, optional): The maximum value for scaling the graph. If not provided, the maximum value from the input values will be used.
        num_lines (int, optional): The number of lines (height) of the bar graph. Default is 1.

    Returns:
        str: A string representation of the bar graph using box characters.


    EXAMPLE:
        >>> values = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1]
        ... print(get_box_char_bar_graph(values, num_lines=1))
        ... #PRINTS OUT THIS:
        ... #  ▁▂▃▄▅▆▇█▇▆▅▄▃▂▁
    

    EXAMPLE:
        >>> for theta in range(360*100):
        ...     theta*=tau/360
        ...     _erase_terminal_line()
        ...     print(get_box_char_bar_graph(np.sin(np.linspace(0, theta * 1.2**tau, 36)) + 1))
        ...     sleep(1/360)
        ... #PRINTS SOMETHING LIKE THIS:
        ... #▃▄▅▅▆▆▇▇▇▇█▇▇▇▇▆▆▅▅▄▄▃▂▂▁▁         ▁
        ... #▃▅▆▇▇█▇▇▆▅▄▂▁     ▁▂▃▅▆▇▇▇▇▇▆▅▄▂▁
        ... #▄▅▇▇▇▆▅▃▁   ▁▃▅▆▇▇▇▅▄▂   ▁▂▄▆▇█▇▆▄▂▁
        ... #▃▆▇▇▆▄▁  ▁▃▆▇▇▆▄▁  ▁▃▆▇▇▆▄▁  ▁▃▆▇█▆▄
        ... #▃▆█▆▄▁ ▁▃▆▇▆▄▁ ▁▃▆▇▆▄▁ ▁▃▆▇▆▄▁ ▁▃▆▇▆
        ... #▄▇▇▅▁ ▁▅▇▇▄  ▂▆█▆▂  ▃▇▇▅▁ ▁▅▇▇▄  ▂▆▇
        ... #▄▇▇▃ ▁▅▇▆▂ ▂▆▇▅▁ ▃▇▇▄  ▄▇▆▂ ▁▅█▆▁ ▂▆
        ... #▄█▆▁ ▃▇▆▁ ▃▇▆▁ ▃▇▆▁ ▃▇▆▁ ▃▇▆▁ ▃▇▆▁ ▃    


    EXAMPLE:
        >>> def color_demo(duration=20):
        ... 
        ...     def animate_sine_wave(frequency, amplitude, phase, num_points, num_lines):
        ...         values = [amplitude * math.sin(2 * math.pi * frequency * x / num_points + phase) for x in range(num_points)]
        ...         graph = get_box_char_bar_graph(values, min_val=-1, max_val=1, num_lines=num_lines)
        ...         return graph
        ... 
        ... 
        ...     start_time = time.time()
        ...     end_time = start_time + duration
        ... 
        ...     colors = ["red", "green", "yellow", "blue"]
        ... 
        ...     while time.time() < end_time:
        ...         frequency = (math.sin(time.time() - start_time) + 1) * 2 + 0.5
        ...         amplitude = (math.cos(time.time() - start_time) + 1) * 0.5 + 0.5
        ...         phase = (time.time() - start_time) * 0.5
        ... 
        ... 
        ...         num_points=120
        ...         num_lines=40
        ...         concatenated_graphs = vertically_concatenated_strings(
        ...             horizontally_concatenated_strings([fansi(animate_sine_wave(frequency, amplitude, phase, num_points=num_points, num_lines=num_lines), c1, None, c2) for c1, c2 in [["green", "blue"], ["red", "yellow"]]]),
        ...             horizontally_concatenated_strings([fansi(animate_sine_wave(frequency, amplitude, phase, num_points=num_points, num_lines=num_lines), c1, None, c2) for c1, c2 in [["black", "gray"], ["cyan", "magenta"]]]),
        ...         )
        ... 
        ...         os.system("cls" if os.name == "nt" else "clear")
        ...         print(concatenated_graphs)
        ...         time.sleep(1/60)
        ... 
        ... color_demo()
        ... #WILL PRINT SOMETHING LIKE THIS:
        ... #                                            ▁▃▅▆▇██████▇▅▄▂
        ... #                                         ▁▄▆████████████████▅▃
        ... #                                       ▂▆█████████████████████▇▄▁
        ... #                                     ▃▇██████████████████████████▅▁
        ... #                                   ▂▇██████████████████████████████▅
        ... #                                 ▁▆██████████████████████████████████▄
        ... #                                ▄█████████████████████████████████████▇▂
        ... #                              ▂▇████████████████████████████████████████▅
        ... #                             ▅████████████████████████████████████████████▂
        ... #                           ▂▇██████████████████████████████████████████████▅
        ... #                          ▄█████████████████████████████████████████████████▇▂
        ... #                        ▁▇████████████████████████████████████████████████████▄
        ... #                       ▃███████████████████████████████████████████████████████▆
        ... #                      ▅██████████████████████████████████████████████████████████▃
        ... #                    ▂█████████████████████████████████████████████████████████████▅
        ... #                   ▄███████████████████████████████████████████████████████████████▇▂
        ... #                 ▁▇██████████████████████████████████████████████████████████████████▄
        ... #                ▄█████████████████████████████████████████████████████████████████████▇▁
        ... #              ▁▆████████████████████████████████████████████████████████████████████████▄
        ... #             ▄███████████████████████████████████████████████████████████████████████████▇▂
        ... #           ▂▇██████████████████████████████████████████████████████████████████████████████▅
        ... #         ▂▆██████████████████████████████████████████████████████████████████████████████████▄
        ... #       ▁▅██████████████████████████████████████████████████████████████████████████████████████▃
        ... #     ▂▆██████████████████████████████████████████████████████████████████████████████████████████▄▁
        ... #  ▁▄▇██████████████████████████████████████████████████████████████████████████████████████████████▅▃                 ▂▅█
        ... # ▇████████████████████████████████████████████████████████████████████████████████████████████████████▆▄▂▁        ▂▄▅████
        ... # ██████████████████████████████████████████████████████████████████████████████████████████████████████████▇▆▆▆▇▇████████
        ... # ████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████
        ... # ████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████
        ... # ████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████
        ... # ████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████
        ... # ████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████
        ... # ████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████
        
    """
    import math
    import os
    import time

    if not len(values):
        return ""

    if min_val is None:
        min_val = min(values)
    if max_val is None:
        max_val = max(values)

    box_chars = " ▁▂▃▄▅▆▇█"
    num_boxes = len(box_chars) - 1

    lines = []
    for value in values:
        if value <= min_val:
            lines.append(" " * num_lines)
        elif value >= max_val:
            lines.append("█" * num_lines)
        else:
            normalized_value = (value - min_val) / (max_val - min_val)
            full_boxes = int(normalized_value * num_lines)
            remainder = normalized_value * num_lines - full_boxes
            lines.append(" " * (num_lines - full_boxes - 1) + box_chars[int(remainder * num_boxes)] + "█" * full_boxes)

    return string_transpose("\n".join(lines))


def get_scope(frames_back=0):
    """
    Get the scope of n levels up from the current stack frame
    Useful as a substitute for using globals(), locals() etc: you can specify exactly how many functions up you want to go
    This function lets you do pretty crazy things that seem totally illegal to python scoping rules...
    EXAMPLE:
       | --> hello='bonjour'
       |   2 def f():
       |   3     hello='hola'
       |   4     def g():
       |   5         hello='world'
       |   6         print(get_scope(0)['hello'])
       |   7         print(get_scope(1)['hello'])
       |   8         print(get_scope(2)['hello'])
       |   9     g()
       |  10 f()
       |world
       |hola
       |bonjour
    A useful application of this function is for letting pseudo_terminal infer the locals() and globals() when embedding it without having to pass them manually through arguments. I got this idea from iPython's embed implementation, and thought it was pretty genius.
    """
    scope='locals' #Until I find a reason to make this variable, this won't be exposed as an argument.

    assert isinstance(frames_back,int),'frames_back must be an integer (fractions dont make any sense; you cant go up a fractional number of frames)'
    assert frames_back>=0,'frames_back cannot be negative'
    assert scope in {'locals','globals'},"scope must be either 'locals' or 'globals', but you gave scope="+repr(scope)

    import inspect

    frame=inspect.currentframe()
    frame=frame.f_back#Don't ever return the scope for this function; that's totally useless
    for _ in range(frames_back):

        frame=frame.f_back

    return {'locals':frame.f_locals, 'globals':frame.f_globals}[scope]


def _get_visible_scope(frames_back=0):
    """
    Treat the output as read-only! Will include all variables that can be seen from that point.

    Should be an improved form of get_scope, but to avoid breaking other code (just in case), I'm making this a private helper for rp internals.
    
    Get the scope of n levels up from the current stack frame,
    including all globals, locals, and variables from enclosing scopes.
    
    Parameters:
    frames_back (int): How many frames to go up in the stack. 0 means the calling function's frame.
    
    Returns:
    dict: A dictionary with all variables from globals, locals, and enclosing scopes.

    EXAMPLE:
        >>> import rp
        ... y=456
        ... def f():
        ...     x=123
        ...     def g():
        ...         print(rp.repr_kwargs_dict(rp.r._get_visible_scope()))
        ...     g()
        ... f()
        dict(
             __name__        = '__main__',
             __doc__         = None,
             __package__     = None,
             __loader__      = <_frozen_importlib_external.SourceFileLoader object at 0x104e90530>,
             __spec__        = None,
             __annotations__ = {},
             __builtins__    = <module 'builtins' (built-in)>,
             __file__        = '/Users/burgert/CleanCode/Sandbox/test.py',
             __cached__      = None,
             rp              = <module 'rp' from '/Users/burgert/miniconda3/lib/python3.12/site-packages/rp/__init__.py'>,
             y               = 456,
             f               = <function f at 0x104e6e340>,
             x               = 123,
             g               = <function f.<locals>.g at 0x104ef5760>,
        )

    """
    assert isinstance(frames_back, int), 'frames_back must be an integer'
    assert frames_back >= 0, 'frames_back cannot be negative'

    import inspect

    frame = inspect.currentframe()
    frame = frame.f_back  # Don't return the scope for this function
    for _ in range(frames_back):
        frame = frame.f_back
        if frame is None:
            break

    # Create result dict with both globals and locals
    result = {}
    result.update(frame.f_globals)
    result.update(frame.f_locals)
    
    # Check for variables in enclosing scopes
    current = frame
    # Handle freevars (variables from enclosing scopes)
    if hasattr(current, 'f_code') and hasattr(current.f_code, 'co_freevars'):
        # Get a list of all outer frames
        frames = []
        outer = current.f_back
        while outer:
            frames.append(outer)
            outer = outer.f_back
        
        # Process each frame from innermost to outermost
        for outer_frame in frames:
            if outer_frame.f_locals:
                # Check each local in the outer frame
                for name, value in outer_frame.f_locals.items():
                    # Add it if it's not already in our result
                    if name not in result:
                        result[name] = value
    
    return result


_all_module_names=set()
def get_all_importable_module_names(use_cache=True):
    """
    Returns a set of all known names that you can use 'import <name>' on
    """
    while True:
        try:

            #Usually this works on the first try
            if use_cache and _all_module_names:
                return _all_module_names
            import pkgutil
            for _,name,_ in pkgutil.iter_modules():
                _all_module_names.add(name)
            for name in sys.builtin_module_names:
                _all_module_names.add(name)
            for module in sys.modules.values():
                #Things like numpy.random don't always show up easily, but they do when they're already imported
                if hasattr(module, '__name__'):
                    name = module.__name__
                    _all_module_names.add(name)
            return _all_module_names

        except RuntimeError:
            # RuntimeError: dictionary changed size during iteration
            # Happens if you do module autocompletion *immediately* after starting pterm
            pass


def get_module_path_from_name(module_name):
    """
    Gets the file path of a module or package without importing it, given the module's name.
    
    For packages (directories with __init__.py), it returns the path to the __init__.py file.
    For single-file modules (.py files), it returns the path to that .py file.

    EXAMPLES:
        >>> get_module_path_from_name('rp')
        ans = /Library/Frameworks/Python.framework/Versions/3.5/lib/python3.5/site-packages/rp/__init__.py
        >>> get_module_path_from_name('prompt_toolkit')
        ans = /Library/Frameworks/Python.framework/Versions/3.5/lib/python3.5/site-packages/prompt_toolkit/__init__.py
        >>> get_module_path_from_name('numpy')
        ans = /Library/Frameworks/Python.framework/Versions/3.5/lib/python3.5/site-packages/numpy/__init__.py
        >>> get_module_path_from_name('six')
        ans = /Library/Frameworks/Python.framework/Versions/3.5/lib/python3.5/site-packages/six.py
        >>> get_module_path_from_name('rp.libs.stdin_python_highlighter')
        ans = /Users/burgert/miniconda3/lib/python3.12/site-packages/rp/libs/stdin_python_highlighter.py
    """
    import importlib.util
    import os

    spec = importlib.util.find_spec(module_name)
    if spec is None:
        return None

    # If it's a package, spec.submodule_search_locations will be a list of paths (usually just one).
    # The convention is that the main file for a package is its __init__.py.
    if spec.submodule_search_locations:
        # We need to construct the path to __init__.py within that directory
        parent_dir = spec.submodule_search_locations[0]
        init_file = os.path.join(parent_dir, '__init__.py')
        
        if file_exists(init_file):
            return init_file
        else:
            return parent_dir
        

    # If it's a single-file module, spec.origin will be the full path to the .py file.
    if spec.origin:
        return spec.origin

    return None


def get_module_path(module):
    """
    Returns the file path of a given python module
    """
    if isinstance(module,str):
        return get_module_path_from_name(module)

    if not is_a_module(module):
        if hasattr(module,'__module__'):
            #This will work for functions too
            module=module.__module__
        elif hasattr(type(module),'__module__'):
            module=type(module).__module__

    #Yes, we need that twice...
    if isinstance(module,str):
        return get_module_path_from_name(module)

    import inspect
    assert inspect.ismodule(module),'get_module_path error: The input you gave is not a module type. You gave input of type '+repr(type(module))

    try:
        return inspect.getfile(module)
    except TypeError:
        # ERROR: TypeError: <module 'rp.git.tapnet' (namespace) from ['/usr/local/google/home/burgert/.local/lib/python3.12/site-packages/rp/git/tapnet']> is a built-in module
        return module.__path__[0]

def get_module_paths(*modules):
    "Plural of get_module_path"
    return [get_module_path(x) for x in detuple(modules)]

def is_a_module(object):
    import builtins
    return type(object)==type(builtins)

def date_to_epoch_seconds(datetime_obj) -> float:
    """Converts a datetime object to seconds since the Unix epoch; aka seconds since January 1st, 1970 at 00:00:00 UTC"""
    from datetime import datetime as dt_class
    
    epoch = dt_class.utcfromtimestamp(0)
    delta = datetime_obj - epoch
    return delta.total_seconds()

def date_to_epoch_millis(datetime_obj) -> int:
    """Converts a datetime object to milliseconds since the Unix epoch; aka millis since January 1st, 1970 at 00:00:00 UTC"""
    return int(date_to_epoch_seconds(datetime_obj) * 1000)

def epoch_seconds_to_date(epoch_seconds: float) -> 'datetime':
    """Converts epoch time in seconds to a datetime object; returns a datetime representing the time since January 1st, 1970 at 00:00:00 UTC"""
    from datetime import datetime, timedelta
    
    epoch = datetime.utcfromtimestamp(0)
    return epoch + timedelta(seconds=epoch_seconds)

def epoch_millis_to_date(epoch_millis: int) -> 'datetime':
    """Converts epoch time in milliseconds to a datetime object; returns a datetime representing the time since January 1st, 1970 at 00:00:00 UTC"""
    epoch_seconds = epoch_millis / 1000.0
    return epoch_seconds_to_date(epoch_seconds)

def get_current_date():
    """
    This is annoying to type...so I added this function as a bit of sugar.
    """
    import datetime
    return datetime.datetime.now()

def string_to_date(string):
    """
    Given a date represented as a string, turn it into a datetime object and return it
    It can handle many different formats - it's very flexible!

    Now uses the modern 'dateparser' library instead of the old 'timestring' library
    for better timezone handling, midnight parsing, and relative date support.

    EXAMPLES:

      >>> print(string_to_date('monday, aug 15th 2015 at 8:40 pm'))       # --> datetime.datetime(2015, 8, 15, 20, 40)
      >>> print(format_date(string_to_date('now')))               # --> Tue Aug 22, 2023 at 1:55:59PM
      >>> print(format_date(string_to_date('7pm')))               # --> Tue Aug 22, 2023 at 7:00:00PM
      >>> print(format_date(string_to_date('7am')))               # --> Tue Aug 22, 2023 at 7:00:00AM
      >>> print(format_date(string_to_date('tomorrow 7am')))          # --> Wed Aug 23, 2023 at 7:00:00AM
      >>> print(format_date(string_to_date('jan 18 1975')))           # --> Sat Jan 18, 1975 at 12:00:00AM
      >>> print(format_date(string_to_date('jan 18 1975 at 4:30pm')))      # --> Sat Jan 18, 1975 at 4:30:35PM
      >>> print(format_date(string_to_date('Sat Jan 18, 1975 at 4:30:35PM')))  # --> Sat Jan 18, 1975 at 4:30:35AM
      >>> print(format_date(string_to_date('2023-08-28 00:00:00')))       # --> Mon Aug 28, 2023 at 12:00:00AM
      >>> print(format_date(string_to_date('now pst')))             # --> Mon Jun 23, 2025 at 8:40:54AM PST

    """

    # CHANGE OF LIBRARY AS OF JUN 23 2025:
    # We used to use the 'timestring' library but it a dead repo
    # It also handled timezones incorrectly - which is to say not at all
    # It did handle '5 seconds' which dateparser cannot though
    # However, it also got many wrong - like condidently returning an incorrect answer for '3 hours after 6pm'
    # New library will error on dates like that
    # Old library: http://github.com/codecov/timestring
    # Where I found old library: https://stackoverflow.com/questions/466345/converting-string-into-datetime

    pip_import("dateparser")
    pip_import("pytz")

    import dateparser
    import pytz

    settings = dict(
        PREFER_DATES_FROM="current_period",
        PREFER_DAY_OF_MONTH="first",
        RETURN_AS_TIMEZONE_AWARE=True,
        LANGUAGE_DETECTION_CONFIDENCE_THRESHOLD=1.0,
    )

    result = dateparser.parse(string, settings=settings)

    if result is None:
        raise ValueError("rp.string_to_date: Cannot parse " + repr(string))

    # If the parsed date is naive (no timezone was detected in the string),
    # then we attach the current user's timezone.
    if result.tzinfo is None:
        local_tz = get_current_timezone(form='tzinfo')
        result = local_tz.localize(result)

    return result


def open_file_with_default_application(path):
    """
    Open a file or folder with the OS's default application
    EXAMPLE: open_file_with_default_application('.') #Should open up a file browser with the current directory
    Currently works on Mac, Windows, and Linux.
    https://stackoverflow.com/questions/434597/open-document-with-default-os-application-in-python-both-in-windows-and-mac-os
    """
    import subprocess, os, platform
    if platform.system() == 'Darwin':       # macOS
        subprocess.call(('open', path))
    elif platform.system() == 'Windows':    # Windows
        os.startfile(path)
    else:                                   # linux variants
        subprocess.call(('xdg-open', path))

#DEPRECATED In favor of indentify
# def indent(text:str,indent:str='    '):
#     return line_join(indent+line for line in line_split(text))



def mean(*x):
    """
    A super basic mean calculator
    Works with any datatype that supports + and /

    EXAMPLES:
     >>> mean(1,2,3)
     >>> mean(1,2,3)
    ans = 2.0
     >>> mean(123)
    ans = 123.0
     >>> mean([1,5,2,4])
    ans = 3.0
    """
    x=detuple(x)
    return sum(x)/len(x)

def median(*x):
    """
    EXAMPLES:
     >>> median(1,1,1,5,9,9,9)
    ans = 5
     >>> median([1,1,1,5,9,9,9])
    ans = 5
     >>> median(['a','b','c','d','e'])
    ans = c
    """
    x=detuple(x)
    from statistics import median
    return median(x)

def norm_cdf(x,mean=0,std=1):
    """
    normal cumulative distribution function
    Given a value x, calculate the z-score and return the cumulative normal distribution of that z score
    Note: this function can also take numpy vector inputs and return vector outputs!
    EXAMPLE: bar_graph(norm_pdf(np.linspace(-3,3)))
    """
    z=(x-mean)/std#Calculate the z-score
    pip_import('scipy')
    from scipy.stats import norm
    return norm.cdf(z)
        

def norm_pdf(x,mean=0,std=1):
    """
    normal probability density function
    Given a value x, calculate the z-score and return the normal distribution density of that z score
    Note: this function can also take numpy vector inputs and return vector outputs!
    EXAMPLE: bar_graph(norm_pdf(np.linspace(-3,3)))
    """
    z=(x-mean)/std#Calculate the z-score
    pip_import('scipy')
    from scipy.stats import norm
    return norm.pdf(z)

def inverse_norm_cdf(p,mean=0,std=1):
    """
    inverse normal cumulative distribution function
    The inverse of the norm_pdf function (given a probability, find the x-value that made it)
    Note: this function can also take numpy vector inputs and return vector outputs!
    EXAMPLE: bar_graph(inverse_norm_cdf(np.linspace(0,1,1000)))
    """
    pip_import('scipy')
    from scipy.stats import norm
    z=norm.ppf(p)#The z-score. https://bytes.com/topic/python/answers/478142-scipy-numpy-inverse-cumulative-normal
    x=z*std+mean
    return x

def s3_list_objects(s3url, *, recursive=True, include_metadata=False, lazy=False, show_progress=False):
    """
    Generator function to list S3 objects at a given S3 URL.

    Parameters:
    - s3url (str): The S3 URL in the format s3://bucket-name/path/to/prefix
    - recursive (bool): If True, list all objects under the prefix recursively.
                        If False, list only the objects directly under the prefix.
    - include_metadata (bool): If True, yield EasyDict objects with metadata.
    - lazy (bool): If True, return a generator that yields objects on-demand.
                   If False, return a list of all objects.
    - show_progress (bool): If True, print each object key or prefix as it's yielded.

    Returns:
    - If lazy=True and include_metadata=False:
        - Generator[str]: A generator that yields the key of each object as a string.
    - If lazy=True and include_metadata=True:
        - Generator[EasyDict]: A generator that yields an EasyDict for each object with the following keys:
            - key (str): The object key.
            - last_modified (datetime): The last modified timestamp of the object.
            - etag (str): The ETag of the object.
            - size (int): The size of the object in bytes.
            - storage_class (str): The storage class of the object.
            - prefix (str): The prefix (for common prefixes if not recursive).
    - If lazy=False and include_metadata=False:
        - List[str]: A list of object keys as strings.
    - If lazy=False and include_metadata=True:
        - List[EasyDict]: A list of EasyDicts with the same keys as described above.

    If show_progress=True, each object key or prefix will be printed as it's yielded,
    in addition to being yielded or returned in the list.
    """

    pip_import("boto3")
    pip_import("urllib")
    pip_import("easydict")

    import boto3
    from urllib.parse import urlparse
    from easydict import EasyDict
    
    def helper():
    
        # Initialize the S3 client
        s3_client = boto3.client('s3')

        # Parse the S3 URL to extract bucket name and prefix
        parsed_url = urlparse(s3url)
        if parsed_url.scheme != 's3':
            raise ValueError("The URL must start with 's3://'")

        bucket_name = parsed_url.netloc
        prefix = parsed_url.path.lstrip('/')

        # Set up pagination to handle large result sets
        paginator = s3_client.get_paginator('list_objects_v2')

        # If not recursive, use delimiter to prevent listing subfolders
        pagination_params = {
            'Bucket': bucket_name,
            'Prefix': prefix,
        }

        if not recursive:
            pagination_params['Delimiter'] = '/'

        # Paginate through the objects
        for page in paginator.paginate(**pagination_params):

            # List objects
            if 'Contents' in page:
                for obj in page['Contents']:
                    if include_metadata:
                        # Map the S3 object dictionary to an EasyDict with friendly attribute names
                        metadata = EasyDict({
                            'key': obj.get('Key'),
                            'last_modified': obj.get('LastModified'),
                            'etag': obj.get('ETag'),
                            'size': obj.get('Size'),
                            'storage_class': obj.get('StorageClass'),
                        })
                        yield metadata
                    else:
                        yield obj['Key']

            # List common prefixes (subdirectories) if not recursive
            if not recursive and 'CommonPrefixes' in page:
                for cp in page['CommonPrefixes']:
                    if include_metadata:
                        # For directories, we can provide minimal metadata
                        metadata = EasyDict({
                            'prefix': cp.get('Prefix'),
                        })
                        yield metadata
                    else:
                        yield cp['Prefix']

    def printed_generator(iterator):
        for value in iterator:

            if isinstance(value, str):
                print(value)
            else:
                print(value.key)

            yield value

    output = helper()

    if show_progress:
        output = printed_generator(output)

    if not lazy:
        output = list(output)

    return output


def is_s3_url(url):
    "Returns true if the given string is an Amazon S3 URL"
    return isinstance(url,str) and url.startswith('s3://')

def is_gs_url(url):
    "Returns true if the given string is an Google Cloud Storage URL"
    return isinstance(url,str) and url.startswith('gs://')

def is_google_drive_url(url):
    """
    Check if a URL is a Google Drive URL.

    Supports formats:
    - https://drive.google.com/file/d/FILE_ID/view
    - https://drive.google.com/uc?id=FILE_ID
    - https://drive.google.com/open?id=FILE_ID

    >>> is_google_drive_url("https://drive.google.com/file/d/1ABC123def456/view")
    True
    >>> is_google_drive_url("https://drive.google.com/uc?id=1ABC123def456")
    True
    >>> is_google_drive_url("https://example.com/file.txt")
    False
    """
    if not isinstance(url, str):
        return False

    import re
    patterns = [
        r'https://drive\.google\.com/file/d/[a-zA-Z0-9_-]+',
        r'https://drive\.google\.com/uc\?id=[a-zA-Z0-9_-]+',
        r'https://drive\.google\.com/open\?id=[a-zA-Z0-9_-]+',
        r'https://docs.google.com/document/d.*',
    ]

    for pattern in patterns:
        if re.match(pattern, url):
            return True
    return False

#COMMENTED NOT BECAUSE IT DOESNT WORK (IT DOES), BUT BECAUSE WE NO LONGER NEED IT
# def get_google_drive_file_id(url):
#     """
#     Get the file ID from a Google Drive URL.
#
#     >>> get_google_drive_file_id("https://drive.google.com/file/d/1ABC123def456/view")
#     '1ABC123def456'
#     >>> get_google_drive_file_id("https://drive.google.com/uc?id=1XYZ789abc")
#     '1XYZ789abc'
#     """
#     import re
#     patterns = [
#         r'https://drive\.google\.com/file/d/([a-zA-Z0-9_-]+)',
#         r'https://drive\.google\.com/uc\?id=([a-zA-Z0-9_-]+)',
#         r'https://drive\.google\.com/open\?id=([a-zA-Z0-9_-]+)',
#     ]
#
#     for pattern in patterns:
#         match = re.match(pattern, url)
#         if match:
#             return match.group(1)
#
#     raise ValueError("Not a valid Google Drive URL or could not extract file ID from: {}".format(url))

def download_url(url, path=None, *, skip_existing=False, show_progress=False, timeout=None):
    """
    Download files from URLs with multi-protocol support and automatic path handling.

    Supports HTTP/HTTPS, AWS S3, Google Cloud Storage, Google Drive, Google Colossus (/cns paths),
    and YouTube video downloads. Automatically chooses appropriate download method
    based on URL format and creates parent directories as needed.
    
    Enhanced Documentation:
    - Usage patterns: File downloads, dataset retrieval, asset fetching, batch processing with download_urls()
    - Related functions: download_urls() for batch downloads, download_url_to_cache() for caching, TemporarilyDownloadUrl() for context management  
    - Performance: Streams large files in 1MB chunks, progress tracking available, concurrent downloads via download_urls()
    - Multiplexing: Dispatches to specialized backends - requests for HTTP, AWS CLI for S3, gsutil for GCS, youtube-dl for videos
    
    Parameters:
        url (str): URL to download. Supports HTTP/HTTPS, s3://, gs://, /cns/, and YouTube video URLs.
        path (str|None): Destination path. If None, uses URL filename in current directory. If directory, downloads into it with URL filename.
        skip_existing (bool): Skip download if file already exists at path. Defaults to False.
        show_progress (bool): Show download progress bar using tqdm. Defaults to False.  
        timeout (int|float|None): Request timeout in seconds. Applied to HTTP requests, S3/GCS CLI calls.
    
    Returns:
        str: Path to the downloaded file. Always returns the final file location, whether newly downloaded or existing.
    
    Protocol Support:
        - HTTP/HTTPS: Uses requests with streaming and chunked download
        - AWS S3 (s3://): Uses AWS CLI with quiet/verbose options  
        - Google Cloud Storage (gs://): Uses gsutil with recursive copy
        - Google Drive (drive.google.com): Uses gdown for authenticated downloads
        - Google Colossus (/cns/): Uses internal fileutil for Google systems
        - YouTube: Delegates to download_youtube_video() with format selection
    
    Examples:
        >>> # Basic HTTP download
        >>> path = download_url('https://httpbin.org/uuid')
        './uuid'
        
        >>> # Download to specific path  
        >>> path = download_url('https://httpbin.org/uuid', 'my_file.json')
        'my_file.json'
        
        >>> # Download to directory
        >>> path = download_url('https://httpbin.org/uuid', './downloads/')  
        './downloads/uuid'
        
        >>> # Skip if file exists
        >>> path = download_url('https://httpbin.org/uuid', 'existing.json', skip_existing=True)
        'existing.json'
        
        >>> # AWS S3 download (requires aws cli)
        >>> path = download_url('s3://bucket/file.txt', show_progress=True, timeout=30)
        
        >>> # Google Cloud Storage (requires gsutil)  
        >>> path = download_url('gs://bucket/file.txt')
        
        >>> # YouTube video download
        >>> path = download_url('https://www.youtube.com/watch?v=dQw4w9WgXcQ')
    
    Original docstring: Works with both HTTP, Aws S3 and Google Cloud Storage urls, as well as /cns paths accessed via Google's internal fileutil. Download a file from a url and return the path it downloaded to. If no path is specified, it will choose one for you and return it (as a string). If path exists and it is a folder, it will download to the url's filename in that folder.
    Original example: open_file_with_default_application(download_url('https://i.imgur.com/qSmVyCo.jpg'))#Show a picture of a cat
    
    Tags: download, http, s3, gcs, youtube, streaming, multiplexing, file-handling
    """
    assert isinstance(url,str),'url should be a string, but got type '+repr(type(url))
    assert path is None or isinstance(path,str),'path should be either None or a string, but got type '+repr(type(path))
    assert timeout is None or isinstance(timeout, (int, float)), 'timeout should be a number or None, but got type ' + repr(type(timeout))

    if is_a_folder(path):
        dst_root = path
        dst_path = path_join(dst_root, get_file_name(url))

    else:
        dst_path= './' + get_file_name(url) if path is None else path
        dst_root = get_path_parent(dst_path)

        #Create the parent directory of the destination if it doesn't already exist
        if not path_exists(dst_root):
            make_directory(dst_root)


    if skip_existing and path_exists(dst_path):
        #Don't download anything - skip it if it already exists
        return dst_path

    if is_s3_url(url):
        import subprocess

        try:
            aws_args = ['aws', 's3', 'cp', url, dst_path] 
            if not show_progress:
                aws_args+=['--quiet']
            if timeout:
                aws_args.extend(['--cli-connect-timeout', str(timeout)]) # Add timeout for s3 cli
                aws_args.extend(['--cli-read-timeout', str(timeout)]) # Add timeout for s3 cli
            subprocess.check_call(aws_args)
        except subprocess.TimeoutExpired:
            raise TimeoutError("rp.download_url timed out downloading s3 url %s to path %s"%(url,dst_path))  # Removed after {timeout} seconds as timeout is optional
        except subprocess.CalledProcessError as e:
            raise Exception("rp.download_url failed to download s3 url %s to path %s: "%(url,dst_path) + str(e))

        return dst_path
    
    elif is_gs_url(url):

        import subprocess

        try:
            kwargs = dict()
            gs_args = ['gsutil', 'cp', '-r', url, dst_path]
            if timeout:
                gs_args = ['timeout', str(timeout)] + gs_args
            if not show_progress:
                kwargs.update(dict(stderr=subprocess.DEVNULL))
            subprocess.check_call(gs_args,**kwargs)
        except subprocess.TimeoutExpired:
            raise TimeoutError("rp.download_url timed out downloading Google Cloud Storage url %s to path %s"%(url,dst_path))
        except subprocess.CalledProcessError as e:
            raise Exception("rp.download_url failed to download Google Cloud Storage url %s to path %s: "%(url,dst_path) + str(e))

        return dst_path
    
    elif url.startswith('/cns/') and system_command_exists('fileutil', use_cache=True) and not folder_exists('/cns'):
        #We're on an internal Google computer and want to download a file from colossus

        import subprocess

        try:
            kwargs = dict()
            fileutil_args = ['fileutil', 'cp', '--R', '--f']
            if timeout:
                fileutil_args+=['--fileutil_timeout',str(timeout)] #Not a guarantee - in the doc it says its aspirational
                fileutil_args = ['timeout', str(timeout*2)] + fileutil_args #So we kill it if it takes 2x as long as the deadline
            fileutil_args+=[url,dst_path]
            if not show_progress:
                kwargs.update(dict(stderr=subprocess.DEVNULL))
            subprocess.check_call(fileutil_args,**kwargs)
        except subprocess.TimeoutExpired:
            raise TimeoutError("rp.download_url timed out downloading Google Colossus url %s to path %s"%(url,dst_path))
        except subprocess.CalledProcessError as e:
            raise Exception("rp.download_url failed to download Google Colossus url %s to path %s: "%(url,dst_path) + str(e))

        return dst_path

    elif is_google_drive_url(url):
        #EXAMPLE:
        #    >>> download_url('https://docs.google.com/document/d/1LEYMqILsRe0EajUlpC-V_xZ9AcWlkRUKM2poCjP5Ixs/edit?usp=drive_link')
        #    ans = ./My actual resume.docx

        pip_import('gdown')
        import gdown

        if path is None:
            #We can't use the default dst_path as it would take the file name from the URL which is often junk
            #It needs to end with "/" to treat it as a folder
            dst_path = dst_root + "/"

        output_path = gdown.download(url=url, output=dst_path, fuzzy=True, quiet=not show_progress)

        return output_path

    elif _is_youtube_video_url(url):
        return download_youtube_video(url, dst_path, skip_existing=skip_existing, show_progress=show_progress, timeout=timeout) # Pass timeout to youtube download

    elif is_valid_url(url):
        pip_import('requests')
        import requests

        try:
            request_kwargs = {'stream': True}
            if timeout:
                request_kwargs['timeout'] = timeout
            response = requests.get(url, **request_kwargs)
            response.raise_for_status()  # Raise HTTPError for bad responses (4xx or 5xx)
        except requests.exceptions.Timeout:
            raise TimeoutError("rp.download_url timed out downloading url {}".format(url)) # Removed after {timeout} seconds as timeout is optional
        except requests.exceptions.RequestException as e:
             raise Exception("rp.download_url failed to download url {}: {}".format(url, e))

        total_size = int(response.headers.get('content-length', 0))
        chunk_size = 1024**2  # 1 MB per iteration
        with open(dst_path, 'wb') as file:
            iterator = response.iter_content(chunk_size)
            
            if show_progress:
                pip_import('tqdm')
                from tqdm import tqdm
                iterator=tqdm(iterator, total=total_size // chunk_size, unit='MB')
            
            for data in iterator:
                file.write(data)

        return dst_path

    else:
        raise ValueError("Invalid URL: "+str(url)[:1000])

def download_urls(
    *urls,
    url_to_path=lambda url:get_file_name(url),
    skip_existing=False,
    strict=True,
    num_threads=None,
    show_progress=False,
    buffer_limit=None,
    lazy=False,
    timeout=None
):
    """
    Plural of download_url
    Tune the num_threads and buffer_limit for optimal downloads to avoid too many concurrent downloads
    """
    urls=detuple(urls)
    load_file = lambda url: download_url(
        url = url,
        path = url_to_path(url),
        skip_existing = skip_existing,
        timeout=timeout,
    )
    if show_progress in ['eta',True]: show_progress='eta:Downloading URLs'

    return load_files(
        load_file,
        urls,
        show_progress=show_progress,
        strict=strict,
        num_threads=num_threads,
        lazy=lazy,
        buffer_limit=buffer_limit,
    )

class TemporarilyDownloadUrl:
    """
    Downloads a file from a URL to a temporary or specified path,
    automatically deleting the file upon exiting the context.

    Example:
        >>> with TemporarilyDownloadUrl('http://example.com/file.pdf') as filepath:
        ...     # Use 'filepath' here; file will be deleted automatically when done.
    """
    def __init__(self, url:str, path=None):
        self.url = url
        self.path = temporary_file_path(get_file_extension(url)) if path is None else path

    def __enter__(self):
        return download_url(self.url, self.path)
        
    def __exit__(self, *_):
        try:
            os.remove(self.path)
        except FileNotFoundError:
            pass

class _MaybeTemporarilyDownloadVideo:
    def __init__(self, url_or_path:str, ):

        self.url_or_path = url_or_path

        if is_valid_url(url_or_path) and not file_exists(url_or_path):
            self.temp_path = temporary_file_path(get_file_extension(url_or_path))

    def __enter__(self):
        if hasattr(self, 'temp_path'):
            #Download something if we need to
            if _is_youtube_video_url(self.url_or_path):
                #A youtube video download
                out = self.temp_path = download_youtube_video(
                    self.url_or_path,
                    need_video=True,
                    need_audio=False,
                )
            else:
                #A regular download
                out = self.temp_path = download_url(self.url_or_path, self.temp_path)
            assert out==self.temp_path
        else:
            #Nothing needs to be downloaded
            out=self.url_or_path
        return out
        
    def __exit__(self, *_):
        if hasattr(self, 'temp_path'):
            try:
                os.remove(self.temp_path)
            except FileNotFoundError:
                pass


def download_url_to_cache(url, cache_dir=None, skip_existing=True, hash_func=None, show_progress=False, timeout=None):
    """
    Like download_url, except you only specify the output diectory - the filename will be chosen for you based on hashing the url.
    Downloads a file from a specified URL into a caching directory, creating a filename based on a hash of the URL. 

    Args:
        url (str): The URL of the file to be downloaded. It can also be a file path, to cache things from NFS etc.
        cache_dir (str, optional): The directory to store the cached files. If None, uses the system's temporary directory.
        skip_existing (bool): If True, skips downloading files that already exist in the cache directory.
        hash_func (func, optional)
        show_progress (bool, optional)

    Returns:
        str: The full path to the downloaded or existing cached file.

    Notes:
        - Use `get_cache_file_path` to retrieve the path to a cached file without downloading it.
    """
    assert isinstance(url, str)

    cache_path = get_cache_file_path(url, cache_dir=cache_dir, hash_func=hash_func)

    if is_valid_url(url):
        return download_url(
            url,
            cache_path,
            skip_existing=skip_existing,
            show_progress=show_progress,
            timeout=timeout,
        )
    elif file_exists(url):
        #Can also be useful for getting things off NFS for faster loading
        if not skip_existing or not file_exists(cache_path):
            # import shutil
            # shutil.copy(
            #     url,
            #     cache_path,
            # )
            copy_file(url, cache_path, show_progress=show_progress)
        return cache_path
    elif folder_exists(url):
        if not skip_existing or not folder_exists(cache_path):
            copy_directory(url, cache_path, extract=False, follow_symlinks=False)
        return cache_path
    else:
        raise ValueError("rp.download_url_to_cache: url=%s is neither a valid url nor an existing path"%url)

download_to_cache = download_file_to_cache = download_url_to_cache

def download_urls_to_cache(
    *urls,
    cache_dir=None,
    skip_existing=True,
    hash_func=None,

    lazy=False,
    num_threads=None,
    buffer_limit=None,
    strict=True,
    show_progress=False,
    timeout=None
):
    """ Plural of rp.download_url_to_cache """
    urls = detuple(urls)

    if show_progress in ['eta', True]: show_progress='eta:rp.download_urls_to_cache'

    def download(url):
        return download_url_to_cache(
            url,
            cache_dir=cache_dir,
            skip_existing=skip_existing,
            hash_func=hash_func,
            timeout=timeout,
        )

    return load_files(
        download,
        urls,
        lazy=lazy,
        buffer_limit=buffer_limit,
        show_progress=show_progress,
        strict=strict,
    )

download_files_to_cache = download_urls_to_cache

def get_cache_file_path(url, *, cache_dir=None, file_extension=None, hash_func=None):
    r"""
    Computes a cache file path for the provided input
    It is a pure function, and uses no system calls - making it fast
    By default, uses your system's temporarily file path as your cache directory (as opposed to any network storage)

    Parameters:
        url (str or any): Typically a url. Input based on which the hash for the file name is generated.
            It can also be any object compatible with rp.object_to_bytes - doesn't stictly have to be a url.
        cache_dir (str, optional): Directory for storing cached files. Defaults to system's temporary directory.
        file_extension (str, optional): The file extension of the output path. If none, is inferred automatically.
        hash_func (callable, optional): Function to compute hash of `url`. Uses SHA-256 with a prefix by default.

    Returns:
        str: Full path to the cache file, incorporating directory, hashed file name, and extension.

    Example:
        >>> get_cache_file_path("example_data", cache_dir="/tmp", file_extension=".txt")
        '/tmp/rp_cache_<hashcode>.txt'

        >>> #Load the image from a url, caching it to your harddrive for extra speed - persistently between python processes
        >>> image = load_image(download_url_to_cache('https://picsum.photos/seed/picsum/536/354'))

    Examples (hash_func):
        >>> print(download_url_to_cache('https://picsum.photos/seed/picsum/536/354'))
        /var/folders/pm/461ntwb12b7bbqcjw1s6t0kw0000gn/T/rp_cache_d4a556ad5dd6bb4c43fc2d2eb08194d9b888fb7eb3e58b5473e7a46059b4217f
        >>> print(download_url_to_cache('https://picsum.photos/seed/picsum/536/354',hash_func=get_file_name))
        /var/folders/pm/461ntwb12b7bbqcjw1s6t0kw0000gn/T/354
        >>> print(download_url_to_cache('https://picsum.photos/seed/picsum/536/354',hash_func=get_parent_folder))
        /var/folders/pm/461ntwb12b7bbqcjw1s6t0kw0000gn/T/https://picsum.photos/seed/picsum/536


        >>> print(download_url_to_cache('https://picsum.photos/seed/picsum/536/354',hash_func=identity))
        /var/folders/pm/461ntwb12b7bbqcjw1s6t0kw0000gn/T/https://picsum.photos/seed/picsum/536/354
        >>> assert file_exists('/var/folders/pm/461ntwb12b7bbqcjw1s6t0kw0000gn/T/https://picsum.photos/seed/picsum/536/354')

        >>> #NOTE: The /'s in the url created a nested path, it's not flat! That might be ok for your needs though.
        >>> #So, the next version might be more palatable...
        >>> print(download_url_to_cache('https://picsum.photos/seed/picsum/536/354',hash_func=lambda url:url.replace("/","\\")))
        /var/folders/pm/461ntwb12b7bbqcjw1s6t0kw0000gn/T/https:\\picsum.photos\seed\picsum\536\354

    Original purpose: To make download_url_to_cache from download_url via
        >>> download_url(url, get_cache_file_path(url), skip_existing=True)
    """

    #Input validation
    assert cache_dir is None or isinstance(cache_dir, str)
    assert file_extension is None or isinstance(file_extension, str)

    #Get and verify the cache_dir
    if cache_dir is None:
        cache_dir=tempfile.gettempdir()
    assert isinstance(cache_dir, str), type(cache_dir)

    #Get an appropriate file extension if possible
    if file_extension is None:
        if isinstance(url, str):
            file_extension = get_file_extension(url)
        else:
            #I don't know what it should be, so leave it empty for now.
            #Maybe it's supposed to be a directory?
            #Future: Infer the extension from x's type in this case:
            #    in the future, we might have support for x as .pth or .npy or .png etc
            file_extension = ''
    assert isinstance(file_extension, str)


    if hash_func is None:
        prefix = 'rp_cache_'
        hashcode = get_sha256_hash(url.encode() if isinstance(url, str) else object_to_bytes(url))
        hashcode = hashcode[:16] #This should be more than long enough
        filename = prefix+str(hashcode)
    else:
        hashcode = hash_func(url)
        filename = str(hashcode)

    filename = with_file_extension(filename, file_extension)

    output = path_join(cache_dir, filename)
    return output

def get_cache_file_paths(urls, *, cache_dir=None, file_extension=None, hash_func=None, lazy=False, show_progress=False):
    """Plural of get_cache_file_path, supporting a `lazy` option"""
    func = gather_args_bind(get_cache_file_path)
    if show_progress is True: show_progress='eta:'+get_current_function_name()
    out = load_files(func, urls, lazy=lazy, num_threads=0, show_progress=show_progress)
    return out

def debug(level=0):
    """
    Launch a debugger at 'level' frames up from the frame where you call this function.
    Try to launch rp_ptpdb, but if we can't for whatever reason fallback to regular ol' pdb.
    This doesn't use pudb, because pudb doesn't work on windows. Meanwhile, ptpdb runs on anything that can run prompt toolkit...making it a clear winner here.
    """
    try:
        from rp.rp_ptpython.rp_ptpdb import set_trace_shallow
        # text_to_speech("CHUGGA CHUGGA MUGGA WUGGA")
        return set_trace_shallow(level=1+level)
    except:
        raise
        from pdb import set_trace
        # text_to_speech("OK AND THE OLD ONE")
        return set_trace()


def _tensorify(x):
    """
    Convert input to tensor-like format (NumPy array or torch tensor).

    Private utility function that ensures input is either a numpy array or torch tensor.
    Passes through existing tensors/arrays, converts other types to numpy arrays.
    Used by matrix functions for consistent input handling.

    Examples:
        >>> _tensorify([1, 2, 3])  # List to numpy
        array([1, 2, 3])
        >>> import numpy as np
        >>> arr = np.array([4, 5, 6])
        >>> _tensorify(arr) is arr  # Pass through existing numpy arrays
        True

    Tags: private, conversion, tensor, numpy
    """
    if not is_torch_tensor(x) and not is_numpy_array(x):
        x = as_numpy_array(x)
    return x

def is_a_matrix(matrix):
    matrix = _tensorify(matrix)
    return len(matrix.shape)==2

def is_a_square_matrix(matrix):
    matrix = _tensorify(matrix)
    return is_a_matrix(matrix) and matrix.shape[0]==matrix.shape[1]

def square_matrix_size(matrix):
    """
    Square matrices are of shape (N,N) where N is some integer
    This function returns N
    Lets you not have to choose between matrix.shape[0] and matrix.shape[1], which are both equivalent.
    """
    matrix = _tensorify(matrix)
    assert is_a_square_matrix(matrix)
    return matrix.shape[0]

_prime_factors_cache={}
def prime_factors(number):
    """
    EXAMPLES:
         >>> prime_factors(23)
        ans = [23]
         >>> prime_factors(24)
        ans = [2, 2, 2, 3]
         >>> prime_factors(12)
        ans = [2, 2, 3]
         >>> prime_factors(720)
        ans = [2, 2, 2, 2, 3, 3, 5]
         >>> prime_factors(10)
        ans = [2, 5]
         >>> prime_factors(11)
        ans = [11]
    """
    assert number>=1,'number must be a positive integer'
    assert int(number)==number,'number must be a positive integer'
    if number in _prime_factors_cache:
        return _prime_factors_cache[number]
    original_number=number
    out=[]
    try:
        pip_import('sympy')
        from sympy import primefactors
        primes=primefactors(number)#Much faster if available
    except Exception:
        primes=prime_number_generator()#Still works, but this algorithm is slower than sympy's
    for prime in primes:
        while not number%prime:
            number//=prime
            out.append(prime)
        if prime>number:
            _prime_factors_cache[original_number]=out
            return out

def set_os_volume(percent):
    """ Set your operating system's volume """
    assert is_number(percent),'Volume percent should be a number, but got type '+repr(type(percent))
    assert 0<=percent<=100,'Volume percent should be between 0 and 100, but got volume = '+repr(percent)
    if currently_running_mac():
        pip_import('osascript').osascript('set volume output volume '+str(int(percent)))
    else:
        assert False,'Sorry, currently only MacOS is supported for setting the volume. This might change in the future.'

def fuzzy_string_match(string,target,*,case_sensitive=True):
    """
     >>> fuzzy_string_match('apha','alpha')
    ans = True
     >>> fuzzy_string_match('alpha','alpha')
    ans = True
     >>> fuzzy_string_match('aa','alpha')
    ans = True
     >>> fuzzy_string_match('aqa','alpha')
    ans = False
     >>> fuzzy_string_match('e','alpha')
    ans = False
     >>> fuzzy_string_match('h','alpha')
    ans = False
    """
    if not case_sensitive:
        string=string.lower()
        target=target.lower()

    import re
    pattern='.*'.join(re.escape(char) for char in string)
    pattern='.*'+pattern+'.*'
    return bool(re.fullmatch(pattern,target))

def get_english_synonyms_via_nltk(word):
    """
    This thing is really crappy...but also really funny xD
    This thing belongs in death of the mind honestly...
    Try get_bad_english_synonyms('spade') and it won't list shovel...
    Try get_bad_english_synonyms('cat') and it lists 'vomit', 'spew'...
    Try get_bad_english_synonyms('cheese') and it lists 'vomit', {'cheeseflower', 'Malva_sylvestris', 'cheese', 'tall_mallow', 'high_mallow'}
    https://www.geeksforgeeks.org/get-synonymsantonyms-nltk-wordnet-python/
    """
    pip_import('nltk')
    from nltk.corpus import wordnet 
    synsets = wordnet.synsets(word)
    if not synsets:
        return set()
    synonym_sets = [{y.name() for y in x.lemmas()} for x in synsets]#Sets of synonyms for different definitions. This function ignores that. For example, given word=='dog', 'hot-dog' and 'canine' would be in different synonym_sets. But for the sake of simplicity, this function will return them all as one big set. If you want something more sophisticated, just use the nltk library or modify this function
    synonyms = set.union(*synonym_sets)
    return synonyms


@memoized
def _datamuse_words_request(query,word):
    """ Uses https://www.datamuse.com/api/ """
    pip_import('requests')
    import requests,json
    response=requests.get('https://api.datamuse.com/words?'+query+'='+word)
    content=response.content.decode()
    content=json.loads(content)
    return [x['word'] for x in content]


def get_english_synonyms_via_datamuse(word):
    """
    EXAMPLE: get_english_synonyms_via_datamuse('food')
    Uses https://www.datamuse.com/api/
    """
    return _datamuse_words_request('rel_syn',word)

def get_english_related_words_via_datamuse(word):
    """
    EXAMPLE: get_english_synonyms_via_datamuse('food')
    Uses https://www.datamuse.com/api/
    """
    return _datamuse_words_request('ml',word)

def get_english_antonyms_via_datamuse(word):
    """
    EXAMPLE: get_english_synonyms_via_datamuse('good')
    Uses https://www.datamuse.com/api/
    """
    return _datamuse_words_request('rel_ant',word)

def get_english_rhymes_via_datamuse(word):
    """
    EXAMPLE: get_english_synonyms_via_datamuse('breath')#poppy: what rhymes with breath?
    Uses https://www.datamuse.com/api/
    """
    return _datamuse_words_request('rel_rhy',word)

def get_english_synonyms(word):
    """ Attempts to return all english synonyms for a given word string, returns a list of strings """
    try:
        return get_english_synonyms_via_datamuse(word)
    except Exception:
        return get_english_synonyms_via_nltk(word)

@memoized
def fibonacci(n):
    """ Given an int, returns the fibbonacci sequence at that index """
    assert n>=0
    if n<71:
        #Although this method is beautiful, it's not accurate past n=70 (due to floating point precision)
        #www.desmos.com/calculator/6q1csqqoqo
        #Calculate fibbonacci in constant time
        φ=.5+.5*5**.5#The golden ratio
        return round((φ**n-φ**(-n)*(-1)**n)/5**.5)
    else:
        #The less cool, but more accurate way
        #This method takes O(n) time (but it doesn't really matter for almost all realistic cases)
        a=0
        b=1
        for _ in range(n):
            a,b=b,a+b
        return a

@memoized
def inverse_fibonacci(n):
    """
    Runs in constant time
    inverse_fibonacci(fibonacci(3415))==3415
    inverse_fibonacci(fibonacci(1234))==1234
    inverse_fibonacci(fibonacci(9471))==9471
    inverse_fibonacci(fibonacci(  x ))==x for all non-negative integer x
    https://stackoverflow.com/questions/5162780/an-inverse-fibonacci-algorithm
    TODO: Make accurate past n=70, similar to how def fibonacci(n) was made (split into two cases)
    """
    φ=.5+.5*5**.5#The golden ratio
    from math import log as ln
    return int(ln(n*5**.5+.5)/ln(φ))

def graham_scan(path):
    """
    This function is intentionally unoptimized to match my personal intuition of the algorithm most closely
    (Might change in the future if this is a bottleneck)
    
    Enhanced Documentation:
    
    Computes the convex hull of a 2D point set using the Graham scan algorithm.
    This implementation uses complex numbers for cleaner geometric computations
    and prioritizes algorithmic clarity over performance optimization.
    
    Args:
        path (array-like): Collection of 2D points as tuples, lists, or complex numbers.
                          Can be [(x1,y1), (x2,y2), ...] or complex numbers [z1, z2, ...]
                          
    Returns:
        numpy.ndarray: Complex number array representing convex hull vertices
                      in counter-clockwise order. Shape: (n_vertices,)
                      Empty array if input is empty.
    
    Examples:
        >>> # Square with interior point
        >>> points = [(0, 0), (1, 0), (1, 1), (0, 1), (0.5, 0.5)]
        >>> hull = graham_scan(points)
        >>> len(hull)  # Should be 4 vertices (square corners)
        4
        >>> hull.dtype
        dtype('complex128')
        
        >>> # Triangle
        >>> triangle = [(0, 0), (2, 0), (1, 2)]
        >>> hull = graham_scan(triangle)
        >>> len(hull)
        3
    
    Algorithm Details:
        1. Find bottom-left point as reference
        2. Sort remaining points by polar angle from reference
        3. Use stack-based approach to maintain convex hull invariant
        4. Remove collinear points and interior vertices
    
    Related Functions:
        - convex_hull(): Alias for this function
        - as_complex_vector(): Converts points to complex representation
        - is_counter_clockwise(): Used for orientation testing
    
    Usage Patterns:
        - Computational geometry applications
        - Finding minimum bounding polygon
        - Collision detection preprocessing
        - Image processing contour simplification
    
    Performance Notes:
        - Time complexity: O(n log n) due to sorting step
        - Space complexity: O(n) for point storage
        - Uses complex arithmetic for elegant geometric operations
    
    Tags: computational-geometry, convex-hull, graham-scan, algorithms, 2d-geometry
    """

    #Complex numbers make math nicer
    points=as_complex_vector(path)

    #Empty set edge case
    if len(points)==0:
        return as_numpy_array([])

    #Remove any duplicate points
    points=np.unique(points)

    #Edge cases where we aren't able to make a polygon: the original set of points is either a point or a line
    if len(points)<=2:
        return points

    #Find point with lowest y coordinate. On ties, choose the leftmost point
    bottom_left_point=min(points,key=lambda point:(point.imag,point.real))

    #Make bottom_left_point the center. We'll undo this shift at the end by adding bottom_left_point to the hull
    points=points-bottom_left_point

    #Remove the center from the pointset (because it doesn't have an angle) and add it to the hull
    points=points[np.where(points!=0)]
    hull=[0+0j]

    #Sort the points by decreasing angle (decreasing angle because I'm used to it's visualization)
    #During ties, choose the closer point first
    points=sorted(points,key=lambda point:(-np.angle(point),np.abs(point)))

    #Add the first edge to the hull (now we have a line)
    hull.append(points.pop(0))

    #The meat of the graham scan algorithm
    while points:
        point=points.pop(0)
        while True:
            edge =hull[-2:]
            if not is_counter_clockwise([*edge,point]):
                hull.append(point)
                break
            else:
                hull.pop()

    #Un-shift the points on the hull
    hull=as_numpy_array(hull)+bottom_left_point

    return hull

    #TEST CODE (run repeatedly):
    #   points=randints_complex(20,10)
    #   scatter_plot(points,dot_size=10)
    #   convex_hull=graham_scan(points)
    #   display_polygon(convex_hull,alpha=.25)

def convex_hull(points):
    """
    Only 2d convex hulls are supported at the moment, sorry...
    """
    return graham_scan(points)

def _point_on_edge(point,edge):
    """ Return true if a point is on an edge, including the edge's endpoints """
    point,=as_complex_vector([point])
    a,b=edge
    return loop_direction_2d([point,*edge])==0 and point.real==median([point.real,a.real,b.real]) and \
                                                   point.imag==median([point.imag,a.imag,b.imag])
def _edges_intersect(edge_a,edge_b):
    edge_a=as_complex_vector(edge_a)
    edge_b=as_complex_vector(edge_b)
    assert len(edge_a==2)
    assert len(edge_b==2)

    if _point_on_edge(edge_a[0],edge_b) or \
       _point_on_edge(edge_a[1],edge_b) or \
       _point_on_edge(edge_b[0],edge_a) or \
       _point_on_edge(edge_b[1],edge_a):
       return True

    return is_clockwise([edge_a[0],*edge_b])!= \
           is_clockwise([edge_a[1],*edge_b])and\
           is_clockwise([edge_b[0],*edge_a])!= \
           is_clockwise([edge_b[1],*edge_a])

    #TEST CODE (run this over and over again):
    #    while True:
    #        edge_a=randints_complex(2,4)
    #        edge_b=randints_complex(2,4)
    #        if not _edges_intersect(edge_a,edge_b):#Depending on whether you're looking for false positives or false negatives, negate this or don't negate this
    #            break
    #    print(_edges_intersect(edge_a,edge_b))
    #    plot_clear()
    #    plot_polygon(edge_a)
    #    plot_polygon(edge_b)
    #    plot_update()

def paths_intersect(path_a,path_b)->bool:
    """
    Does NOT assume the paths are loops
    O(n^2) naive algorithm. Should be full-proof.
    """

    #Make sure we have valid paths
    path_a=as_complex_vector(path_a)
    path_b=as_complex_vector(path_b)

    #If one of the paths has no points, there are no intersections
    if not len(path_a) or not len(path_b):
        return False

    #If a path only has one point, turn that into a segment with duplicate end-points to work nicely with the rest of the code
    if len(path_a)==1:path_a=[path_a[0]]*2
    if len(path_b)==1:path_b=[path_b[0]]*2

    #Check every edge in path_a to every edge in path_b for intersections
    edges_a=map(list,zip(path_a[:-1],path_a[1:]))
    edges_b=map(list,zip(path_b[:-1],path_b[1:]))
    for edge_a in edges_a:
        for edge_b in edges_b:
            if _edges_intersect(edge_a,edge_b):
                #We found an intersection
                return True

    #No intersections were found
    return False

def _edge_intersection_positions(edge_a,edge_b):
    """ Will return a list of either 0, 1 or 2 points (2 points is a special edge case where one line shares part of its segment with another line collinearly) """
    a0,a1=edge_a=as_complex_vector(edge_a)
    b0,b1=edge_b=as_complex_vector(edge_b)

    output=[]
    if _point_on_edge(a0,edge_b):output.append(a0)
    if _point_on_edge(a1,edge_b):output.append(a1)
    if _point_on_edge(b0,edge_a):output.append(b0)
    if _point_on_edge(b1,edge_a):output.append(b1)
    if not output and _edges_intersect(edge_a,edge_b):#If we already detected an intersection, we don't need to run these calculations (which might even divide by 0)
        # https://stackoverflow.com/questions/20677795/how-do-i-compute-the-intersection-point-of-two-lines
        px= ( (a0.real*a1.imag-a0.imag*a1.real)*(b0.real-b1.real)-(a0.real-a1.real)*(b0.real*b1.imag-b0.imag*b1.real) ) / ( (a0.real-a1.real)*(b0.imag-b1.imag)-(a0.imag-a1.imag)*(b0.real-b1.real) ) 
        py= ( (a0.real*a1.imag-a0.imag*a1.real)*(b0.imag-b1.imag)-(a0.imag-a1.imag)*(b0.real*b1.imag-b0.imag*b1.real) ) / ( (a0.real-a1.real)*(b0.imag-b1.imag)-(a0.imag-a1.imag)*(b0.real-b1.real) )
        output.append(px+py*1j)

    return np.unique(output)

    #TEST CODE (run repeatedly):
    #   while True:
    #       edge_a=randints_complex(2,4)
    #       edge_b=randints_complex(2,4)
    #       if _edges_intersect(edge_a,edge_b):#Depending on whether you're looking for false positives or false negatives, negate this or don't negate this
    #           break
    #   print(_edges_intersect(edge_a,edge_b))
    #   plot_clear()
    #   intersection=_edge_intersection_positions(edge_a,edge_b)
    #   plot_path(edge_a,color='blue' ,alpha=.5,linestyle='--')
    #   plot_path(edge_b,color='green',alpha=.5,linestyle='-')
    #   plot_path(intersection,marker='o',linestyle='dotted',color='black',alpha=.5)
    #   plot_update()

def path_intersections(path_a,path_b):
    """
    TODO: Let this function take varargs paths and return all intersections
    Returns a list of points where the two paths intersect, including edge cases (the paths intersect tangentially at a vertex, or overlap etc - it handles all of those correctly)
    """
    path_a=as_points_array(path_a)
    path_b=as_points_array(path_b)
    output=[]
    edges_a=list(map(list,zip(path_a[:-1],path_a[1:])))
    edges_b=list(map(list,zip(path_b[:-1],path_b[1:])))
    for edge_a in edges_a:
        for edge_b in edges_b:
            if _edges_intersect(edge_a,edge_b):
                output.extend(_edge_intersection_positions(edge_a,edge_b))
    output=as_complex_vector(output)
    return np.unique(output)

def path_intersects_point(path,point)->bool:
    """ Return true if a 2d point "point" lies along a path of 2d points "path" """
    return paths_intersect([point],path)


def reduce_wrap(func=None, *, detuple=False):
    """
    Decorator that extends a binary (two-argument) function to accept variable arguments.
    The function should be associative and commutative for this to work correctly.

    Args:
        func: A function that takes exactly two arguments and returns a result
              of the same type.
        detuple: If True, allows passing either multiple args OR a single iterable.

    Returns:
        A decorated function that accepts any number of arguments and applies the binary
        function in a pairwise fashion using functools.reduce.

    Examples:
        @reduce_wrap
        def add(a, b):
            return a + b

        add(1, 2)          # Returns 3
        add(1, 2, 3, 4)    # Returns 10
        add(5)             # Returns 5

        @reduce_wrap(detuple=True)
        def multiply(a, b):
            return a * b

        multiply(2, 3, 4)      # Returns 24
        multiply([2, 3, 4])    # Returns 24
    """
    import functools

    def decorator(f):
        @functools.wraps(f)
        def wrapper(*args):
            if not args:
                raise TypeError(f.__name__ + "() requires at least one argument")

            if detuple:
                args = globals()['detuple'](args)
                # After detuple, check what we have
                try:
                    arg_len = len(args)
                except TypeError:
                    # Not iterable (e.g., a single scalar after detuple), return as-is
                    return args
                # Strings should be treated as atomic, not as iterables of characters
                if isinstance(args, str):
                    return args
                # If it's a tuple, it must have had >1 element originally (detuple doesn't unwrap those)
                # If it's another iterable (list, etc), check length
                if isinstance(args, tuple) or arg_len > 1:
                    return functools.reduce(f, args)
                elif arg_len == 1:
                    return args[0]
                else:
                    return args
            else:
                if len(args) == 1:
                    return args[0]
                return functools.reduce(f, args)

        wrapper.__doc__ = (wrapper.__doc__ or "") + "\n\nThis function accepts variable arguments (*args)."
        return wrapper

    # Support both @reduce_wrap and @reduce_wrap(detuple=True)
    if func is None:
        return decorator
    else:
        return decorator(func)

@reduce_wrap(detuple=True)
def longest_common_prefix(a,b):
    """
    Written by Ryan Burgert, 2020. Written for efficiency's sake.
    Works for strings, lists and tuples (and possibly other datatypes, but not numpy arrays)
    This implementation is two orders of magnitude faster than anything I could find on the web/stack overflow/etc, especially for strings
    It has complexity O(len(output of this function)), and a very good time constant (because it doesn't directly iterate through every element in a python loop)
    On my computer, this function was able to compare two strings of length 1,000,000 in 0.00454 second. Here's the test I used: string='a'*10**7;tic();longest_common_prefix(string,string);ptoc() [[[tic() starts a timer, ptoc() prints out the elapsed time]]]
    
    EXAMPLES:
       longest_common_prefix('abcderty','abcdefoaisjd')                --> abcde
       longest_common_prefix('abcderty','abcsdefoa')                   --> abc
       longest_common_prefix('abcderty','asbcsdefoa')                  --> a
       longest_common_prefix('abcderty','aasbcsdefoa')                 --> a
       longest_common_prefix('aaaabdcderty','aasbcsdefoa')             --> aa
       longest_common_prefix(list('aaaabdcderty'),list('aasbcsdefoa')) --> ['a', 'a']
    """
    
    len_a=len(a)
    len_b=len(b)
    out_max=min(len_a,len_b)
    s=0#Start index
    i=1#Length of proposed additional match
    while s+i<out_max and a[s:s+i]==b[s:s+i]:
        s+=i
        i*=2
    while i:
        if a[s:s+i]==b[s:s+i]:
            s+=i
        i//=2
    assert a[:s]==b[:s]
    return a[:s]


@reduce_wrap(detuple=True)
def longest_common_suffix(a,b):
    """
    This funcion is analagous to longest_common_prefix. See it for more documentation.
    EXAMPLES:
       longest_common_suffix('12345abcd','876323abcd')        --> abcd
       longest_common_suffix('adofoieabcd','29348psaabcd')    --> abcd
       longest_common_suffix('adofoieabcd','29348psaabqcd')   --> cd
       longest_common_suffix([1,2,3,4,5],[7,6,3,4,5])         --> [3, 4, 5]
       longest_common_suffix([1,2,3,4,5],[7,3,3,4,3,6,3,4,5]) --> [3, 4, 5]
    """

    out=longest_common_prefix(a[::-1],b[::-1])[::-1]
    if isinstance(a,str) and not isinstance(b,str):
        out=''.join(out)
    return out


@reduce_wrap(detuple=True)
def longest_common_substring(a,b):
    """
    https://pypi.org/project/pylcs/
    Doesn't seem to be super efficient...would be better if it just returned the first index; then I could use longest_common_prefix on it
    TODO: Add a pure-python fallback in-case pylcs fails (its implemented in C++)
    """
    pip_import('pylcs')
    import pylcs
    res = pylcs.lcs_string_idx(a, b)
    return ''.join([b[i] for i in res if i != -1])

def input_keypress(handle_keyboard_interrupt=False):#handle_keyboard_interrupt=False): <---- TODO: Implement handle_keyboard_interrupt correctly! right now it doesn't work...
    """
    If handle_keyboard_interrupt is True, when you press control+c, it will return the control+c character instead of throwing a KeyboardInterrupt
    Blocks the code until you press some key on the keyboard
    Returns the characters sent to a terminal after you press that key
    Original code from https://stackoverflow.com/questions/983354/how-do-i-make-python-wait-for-a-pressed-key
    Note that when arrow keys are pressed, for example, more than one character might be sent - and it might vary depending on your terminal.
    EXAMPLE: for _ in range(10): print(repr(input_keypress()))
    EXAMPLE: Piano:
        print("Press keys qwertyui to play music!")
        major_scale=[0,2,4,5,7,9,11,12]
        while True:
            index='qwertyui'.index(input_keypress())#Intentionally, this will break the loop if we press a wrong key
            print(index)
            play_chord(major_scale[index],t=.25)

    Waits for a single keypress on stdin.

    This is a silly function to call if you need to do it a lot because it has
    to store stdin's current setup, setup stdin for reading single keystrokes
    then read the single keystroke then revert stdin back after reading the
    keystroke.

    Returns a tuple of characters of the key that was pressed - on Linux, 
    pressing keys like up arrow results in a sequence of characters. Returns 
    ('\x03',) on KeyboardInterrupt which can happen when a signal gets
    handled.

    """
    import termios, fcntl, sys, os
    fd = sys.stdin.fileno()
    # save old state
    flags_save = fcntl.fcntl(fd, fcntl.F_GETFL)
    attrs_save = termios.tcgetattr(fd)
    # make raw - the way to do this comes from the termios(3) man page.
    attrs = list(attrs_save) # copy the stored version to update
    # iflag
    attrs[0] &= ~(termios.IGNBRK | termios.BRKINT | termios.PARMRK
                  | termios.ISTRIP | termios.INLCR | termios. IGNCR
                  | termios.ICRNL | termios.IXON )
    # oflag
    attrs[1] &= ~termios.OPOST
    # cflag
    attrs[2] &= ~(termios.CSIZE | termios. PARENB)
    attrs[2] |= termios.CS8
    # lflag
    attrs[3] &= ~(termios.ECHONL | termios.ECHO | termios.ICANON
                  | termios.ISIG | termios.IEXTEN)
    termios.tcsetattr(fd, termios.TCSANOW, attrs)
    # turn off non-blocking
    fcntl.fcntl(fd, fcntl.F_SETFL, flags_save & ~os.O_NONBLOCK)
    # read a single keystroke
    ret = []
    try:
        ret.append(sys.stdin.read(1)) # returns a single character
        fcntl.fcntl(fd, fcntl.F_SETFL, flags_save | os.O_NONBLOCK)
        c = sys.stdin.read(1) # returns a single character
        while len(c) > 0:
            ret.append(c)
            c = sys.stdin.read(1)
    except KeyboardInterrupt:
        # if handle_keyboard_interrupt:
        #     ret.append('\x03')
        # else:
            raise
    finally:
        # restore old state
        termios.tcsetattr(fd, termios.TCSAFLUSH, attrs_save)
        fcntl.fcntl(fd, fcntl.F_SETFL, flags_save)
    output= ''.join(tuple(ret))
    if output=='\x03' and not handle_keyboard_interrupt:
        raise KeyboardInterrupt
    return output

def input_select_path(root=None,
                      *,
                      sort_by='name',
                      reverse=True,
                      message:str=None,
                      include_folders=True,
                      include_files=True,
                      file_extension_filter:str=None)->str:
    """
    Asks the user to select a file or folder
    If reverse. put option 0 on the bottom instead of the top (might make it easier to read)
    If include_files=True, allows the user to select a file
    If include_folders=True, allows the user to select a folder
    'message', if not None, will be displayed above the prompt
    
    Enhanced Documentation:
    Interactive file/folder selection with customizable filtering and display options.
    Provides a numbered menu of filesystem items for user selection. Supports
    recursion into subdirectories and specialized filtering.
    
    Args:
        root (str, optional): Directory to browse. Defaults to current directory.
        sort_by (str): Sort method - 'name', 'size', 'date', 'number' etc.
        reverse (bool): If True, option 0 appears at bottom (easier to read)
        message (str, optional): Custom message displayed above the prompt
        include_folders (bool): If True, show directories in selection
        include_files (bool): If True, show files in selection  
        file_extension_filter (str, optional): Space-separated extensions to show
        
    Returns:
        str: Absolute path to selected file or folder
        
    Examples:
        >>> from rp.r import input_select_path
        >>> # Basic file selection
        >>> selected = input_select_path(message="Choose a file:")
        >>> print(f"Selected: {selected}")
        >>> # Only show Python files
        >>> py_file = input_select_path(
        ...     file_extension_filter="py",
        ...     include_folders=False,
        ...     message="Select Python file:"
        ... )
        >>> # Only show directories
        >>> folder = input_select_path(
        ...     include_files=False,
        ...     message="Select folder:"
        ... )
        
    Interactive Features:
        - Numbered selection menu with colored output
        - Support for navigating subdirectories
        - Configurable sorting and display order
        - File extension filtering
        - Custom prompt messages
        
    Related Functions:
        - input_select_file(): File-only selection variant
        - input_select_folder(): Folder-only selection variant  
        - input_select(): General-purpose selection utility
        - get_all_paths(): Underlying path discovery mechanism
        
    Tags: interactive, file-selection, user-input, filesystem-navigation
    """
    
    assert include_files or include_folders, 'Both include_files and include_folders are False, which means the user can\'t select anything!'

    assert root is None or is_a_folder(root)
    if root is None:
        root=get_current_directory()

    folders=get_all_paths(root,sort_by=sort_by,include_files=False,include_folders=True )
    files  =get_all_paths(root,sort_by=sort_by,include_files= True,include_folders=False)


    parent =get_parent_directory(root)
    paths  =[parent]+folders
    
    if include_folders:
        #Include the option to select a folder
        paths=[None]+paths
        
    if include_files:
        paths+=files
    
    def format_path(path:str):
        if path is None:
            return fansi(root,'green','bold')
        if is_a_folder(path):
            if path==parent:
                path='..'
            path=get_folder_name(path)
            return fansi(path,'cyan','bold')
        else:
            path=get_file_name(path)
            if not file_extension_filter or get_file_extension(path) in file_extension_filter.split():
                path=fansi(path,'yellow','bold')
            else:
                path=fansi(path,'red')
            return path

    header_lines=[]
    
    if message is not None:
        header_lines.append(message)
        
    print('Current Folder:',fansi(get_absolute_path(root),'green','bold'))
    
    if include_files and not include_folders:
        header_lines.append('Please select a %s:'%fansi('file','yellow','bold'))
    elif include_folders and not include_files:
        header_lines.append('Please select a %s to navigate into it, or %s to select the current folder:'%(fansi('folder','cyan','bold'),fansi('0','green','bold')))
    elif include_files and include_folders:
        header_lines.append('Please select a %s or %s to navigate into, or %s to select the current folder:'%(fansi('file','yellow','bold'),fansi('folder','cyan','bold'),fansi('0','green','bold')))

    header=line_join(header_lines)

    selected=input_select(header,options=paths,stringify=format_path,reverse=reverse)

    if selected is None:
        return root

    if is_a_folder(selected):
        try:
            return input_select_path(selected,sort_by=sort_by,reverse=reverse,message=message,include_files=include_files,include_folders=include_folders,file_extension_filter=file_extension_filter)
        except PermissionError as error:
            print(fansi('ERROR: ','red','bold')+fansi(error,'red'))
            return input_select_path(root    ,sort_by=sort_by,reverse=reverse)
            
    print('You selected the following file: '+fansi(selected,'cyan','bold')) 

    return selected

def input_select_folder(root=None,sort_by='name',reverse=True,message=None,file_extension_filter=None)->str:
    return input_select_path(root=root,sort_by=sort_by,reverse=reverse,include_folders=True,include_files=False,message=message,file_extension_filter=file_extension_filter)

def input_select_file(root=None,sort_by='name',reverse=True,message=None,file_extension_filter=None)->str:
    return input_select_path(root=root,sort_by=sort_by,reverse=reverse,include_folders=False,include_files=True,message=message,file_extension_filter=file_extension_filter)


def input_select_serial_device_id(*defaults)->str:
    """
    I use this to select arduinos when I want to connect to one with a serial port
    After this, I generally use serial.Serial(device_id,baudrate=9600).read() etc
    EXAMPLES:
        print(input_select_serial_device_id())
        print(input_select_serial_device_id("/dev/cu.SLAB_USBtoUART")) #Won't prompt you if "/dev/cu.SLAB_USBtoUART" is a valid option
    """

    pip_import('serial')#Required dependency
    import serial.tools.list_ports
    ports     =list(serial.tools.list_ports.comports())
    device_ids={port.device for port in ports}

    for default in defaults:
        #Allow us to automatically select a port again if we specify it when calling this function
        assert isinstance(default,str),'The default device ids should be strings, like "/dev/cu.SLAB_USBtoUART" or "/dev/cu.Bluetooth-Incoming-Port" etc'
        if default in device_ids:
            return default

    refresh_option='(Refresh Ports)'#Select this to refresh the port list

    def option_to_string(option):
        if isinstance(option,str):
            #the option is a string
            return fansi(option,'yellow')
        else:
            #the option is a port
            return fansi(option.device,'cyan')+'\t'+fansi("Description: "+repr(option.description),'blue')

    selected_option=input_select(question="Please choose a port:",options=[refresh_option,*ports],stringify=option_to_string)

    if selected_option is refresh_option:
        #the selected_option is a string
        return input_select_serial_device_id(*defaults)
    else:
        #the selected_option is a port
        return selected_option.device

def temporary_file_path(file_extension:str=''):
    """
    Returns the path of a temporary, writeable file
    (No more pesky "don't have permission to write" errors)
    https://stackoverflow.com/questions/23212435/permission-denied-to-write-to-my-temporary-file

    Enhanced Documentation:
    
    Creates a unique temporary file path that can be written to without permission issues.
    The file is automatically created in the system's temporary directory with proper write permissions.
    Optionally adds a file extension for format identification.
    
    Parameters:
        file_extension (str, optional): File extension to add to the path. Can include or omit the dot.
                                      Examples: '.txt', 'csv', '.png'. Defaults to empty string.
    
    Returns:
        str: Absolute path to a unique temporary file that can be written to.
             Format: /tmp/tmpXXXXXXXX[.extension] (Unix) or C:\\Users\\...\\Temp\\tmpXXXX[.ext] (Windows)
    
    Usage Examples:
        >>> # Basic usage - get a temp file path
        >>> temp_path = temporary_file_path()
        >>> print(temp_path)  # /var/folders/.../tmpXXXXXXXX
        
        >>> # With file extension (dot included)
        >>> csv_path = temporary_file_path('.csv')
        >>> print(csv_path)  # /var/folders/.../tmpXXXXXXXX.csv
        
        >>> # With file extension (dot omitted)
        >>> json_path = temporary_file_path('json')
        >>> print(json_path)  # /var/folders/.../tmpXXXXXXXX.json
        
        >>> # Use with file operations
        >>> temp_file = temporary_file_path('.txt')
        >>> with open(temp_file, 'w') as f:
        ...     f.write("Hello World!")
    
    Related Functions:
        - bytes_to_file(): Save bytes data to a file (uses this for default path)
        - string_to_text_file(): Save text to a file
        - delete_file(): Clean up temporary files when done
        
    Notes:
        - File is guaranteed to be unique and writable
        - Actual file is not created, only the path is returned
        - Files are placed in system temp directory (automatically cleaned by OS)
        - Cross-platform compatible (works on Windows, macOS, Linux)
        
    Tags: files, temporary, path, filesystem, io, cross-platform
    """
    import tempfile
    f = tempfile.NamedTemporaryFile(mode='w') # open file
    temp = f.name  
    if file_extension:
        if not file_extension.startswith('.'):
            file_extension='.'+file_extension
        temp += file_extension
    return temp

def make_temporary_folders(n:int=1)->list:
    """ Create N temp folders and return a list of str, which are the paths """
    return make_folders(temporary_file_path() for _ in range(n))
make_temporary_directories=make_temporary_folders

@memoized
def python_2_to_3(code:str)->str:
    """
    Turns python2 code into python3 code
    EXAMPLE: python_2_to_3("print raw_input('>>>')") --> "print(input('>>>'))"
    """
    pip_import('lib2to3','2to3')#Make sure this is installed
    assert isinstance(code,str),'code should be a string but got type '+repr(type(code))
    # from rp import r
    # temp_file_path=__file__+'.python_2_to_3_temp.py'
    temp_file_path=temporary_file_path()#get_absolute_path(temp_file_path)
    string_to_text_file(temp_file_path,code)
    import sys
    from subprocess import Popen, PIPE, STDOUT
    p = Popen([sys.executable,'-m','lib2to3','-w',temp_file_path], stdout=PIPE, stdin=PIPE, stderr=PIPE)
    stdout_data = p.communicate(input=code.encode())[0]
    return text_file_to_string(temp_file_path)
    return (stdout_data.decode())

def strip_python_docstrings(code: str) -> str:
    """
    This function removes all docstrings from functions and classes in the input Python code. 
    The code is first parsed into an abstract syntax tree (AST), and then each function and class in the tree 
    is searched for docstrings, which are removed. The resulting code is then returned as a string.

    Example:
    code = '''def foo():
        "This is a docstring."
        print("Hello World!")
    '''
    stripped_code = strip_docstrings(code)
    print(stripped_code) # "def foo():\n    print("Hello World!")\n"

    :param code: A string of Python code.
    :return: A string of Python code with all docstrings removed.
    """
    # print( 'strip_python_docstrings: I think this function is wrong. ChatGPT screwed up. Please rewrite this function by hand. Reference https://stackoverflow.com/questions/1769332/script-to-remove-python-comments-docstrings to start.')
    pip_import('astor')

    import ast
    import astor

    tree = ast.parse(code)
    for node in ast.walk(tree):
        if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
            node.body = [n for n in node.body if not isinstance(n, ast.Expr) or not isinstance(n.value, ast.Str)]
    return astor.to_source(tree)

def strip_python_comments(code:str):
    """
    Takes a string, and returns a string
    Removes all python #comments from code with a scalpel (not touching anything else)
    Todo: Add an option to delete entire lines of comments (when a line is literally just a comment)
    Todo: Add option to delete multiline strings that just serve as comments
    """
    return ''.join(token for token in split_python_tokens(code) if not token.startswith('#'))

def strip_trailing_whitespace(string):
    """
    Takes a string, and returns a string
    Returns a new string, with all trailing whitespace removed from the end of every line. Doesn't change the number of lines.
    Useful for refactoring code to get rid of trailing whitespace.
    """
    return '\n'.join([line.rstrip() for line in string.splitlines()])

def strip_all_lines(s:str):
    return '\n'.join([x.lstrip().rstrip()for x in s.split('\n')])

def indentify(s:str,indent='\t'):
    if isinstance(indent, int):
        indent=' '*indent
    return '\n'.join(indent + x for x in s.split('\n'))

def unindent(string, indent=" "):
    """Removes common leading indentation from a multi-line string. Similar to textwrap.dedent - but allows you to specify your own indent characters."""
    if not string:
        return string

    def count_leading(line, char):
        return len(line) - len(line.lstrip(char))

    lines = string.splitlines()

    levels = [count_leading(line, indent) for line in lines if line.strip(indent)]
    indent_level = min(levels)

    new_lines = [line[indent_level * len(indent) :] for line in lines]

    return line_join(new_lines)

# def delete_empty_lines(string,strip_whitespace=False):
#     """
#     Takes a string, and returns a string
#     Removes all lines of length 0 from the string and returns the result
#     If strip_whitespace is True, it will also delete lines that have nothing but whitespace
#     """
#     return '\n'.join([line for line in string.splitlines() if (line.strip() if strip_whitespace else line)])

def _strip_key(key):
    """Normalize key into a strip-predicate: callable, None->not, other->__eq__."""
    if callable(key):
        return key                   # True => strip
    if key is None:
        return operator.not_         # strip falsey values
    return partial(operator.eq, key) # strip this value

def _list_lstrip_index(x, key):
    """Index of first element NOT to strip (predicate False), else len(x)."""
    pred = _strip_key(key)
    for i in range(len(x)):
        if not pred(x[i]):
            return i
    return len(x)

def _list_rstrip_index(x, key=None):
    """Index of last element NOT to strip (predicate False), else -1."""
    pred = _strip_key(key)
    for i in range(len(x))[::-1]:
        if not pred(x[i]):
            return i
    return -1

def list_lstrip(x, key=bool):
    """
    Strip from the left while predicate is True (i.e., while elements match what to strip).

    See `list_strip` for `key` semantics.

    Examples:
    >>> list_lstrip(['','hi',0])         # --> ['', 'hi', 0] (empty string is falsy)
    >>> list_lstrip([0,0,1,2])           # --> [0, 0, 1, 2] (0 is falsy)
    >>> list_lstrip(['x','y','x'], 'y')  # --> ['x','y','x']
    >>> list_lstrip([1,None,2], None)    # --> [1, None, 2]
    >>> list_lstrip(['a','b'], bool)     # --> [] (all strings are truthy)
    """
    l = _list_lstrip_index(x, key)
    return x[l:]

def list_rstrip(x, key=bool):
    """
    Strip from the right while predicate is True (i.e., while elements match what to strip).

    See `list_strip` for `key` semantics.

    Examples:
    >>> list_rstrip(['hi',0,0])          # --> ['hi', 0, 0] (0 is falsy)
    >>> list_rstrip([0,1,2,0])           # --> [0, 1, 2, 0] (0 is falsy)
    >>> list_rstrip(['a','b','a'],'a')   # --> ['a','b']
    >>> list_rstrip([1,None,2], None)    # --> [1, None, 2]
    >>> list_rstrip(['x','y'], bool)     # --> [] (all strings are truthy)
    """
    r = _list_rstrip_index(x, key)
    return x[:r+1]

def list_strip(x, key=bool):
    """
    Strip from both ends while predicate is True.

    `key` defines what to strip:
      - callable: used as-is; elements where `key(elem)` is True are stripped.
      - None: strip falsey values (like whitespace for strings).
      - other value: strip elements equal to that value.

    Examples:
    >>> list_strip(['','Hello','world',0], None)    # --> ['Hello','world']
    >>> list_strip([0,0,5,6,0], None)               # --> [5,6]
    >>> list_strip(['x','y','x','y'],'y')           # --> ['x','y','x']
    >>> list_strip([0,1,0,2,0], lambda v:v==0)      # --> [1,0,2]
    >>> list_strip([1,None,2,3], None)              # --> [1, None, 2, 3]
    >>> list_strip([None,0,False], None)            # --> []
    """
    l = _list_lstrip_index(x, key)
    r = _list_rstrip_index(x, key)
    return x[l:r+1]

def strip_empty_lines(string: str) -> str:
    r"""
    Remove leading/trailing empty lines.

    An empty line is exactly "" (length 0).
    Whitespace-only lines (e.g. "   ") are preserved.

    Examples:
    >>> strip_empty_lines("\nHello\n\n")
    'Hello'
    >>> strip_empty_lines("   \nHi\n")
    '   \nHi'
    """
    lines = string.splitlines()
    lines = list_strip(lines, key=operator.not_)
    return "\n".join(lines)


def strip_blank_lines(string: str) -> str:
    r"""
    Remove leading/trailing blank lines.

    A blank line is empty or contains only whitespace.

    Examples:
    >>> strip_blank_lines("\nHello\n\n")
    'Hello'
    >>> strip_blank_lines("   \nHi\n")
    'Hi'
    """
    lines = string.splitlines()
    lines = list_strip(lines, key=lambda s: s.strip() == "")
    return "\n".join(lines)


def delete_empty_lines(string: str, strip_whitespace: bool = False) -> str:
    r"""
    Delete all empty lines in the string.

    If strip_whitespace is True, also delete lines that contain only whitespace.

    Examples:
    >>> delete_empty_lines("A\n\nB\n   \nC")
    'A\nB\n   \nC'
    >>> delete_empty_lines("A\n\nB\n   \nC", strip_whitespace=True)
    'A\nB\nC'
    """
    return "\n".join(
        line for line in string.splitlines()
        if (line.strip() if strip_whitespace else line)
    )


def delete_blank_lines(string: str) -> str:
    r"""
    Delete all blank lines in the string.

    A blank line is empty or contains only whitespace.

    Examples:
    >>> delete_blank_lines("A\n\nB\n   \nC")
    'A\nB\nC'
    """
    return "\n".join(line for line in string.splitlines() if line.strip())


def propagate_whitespace(string):
    """
    Best used when you pass it a string whose trailing whitespace are stripped, but you can pass it any string and it will be fine.
    Used to put helpful whitespace back in code in appropriate places for indents etc.

    EXAMPLE:
        s='def f():\n    x\n \n    yield\n    def f():  \n    \n        pass\n  \n   \nprint(x)   '

        print('First, not stripping whitespace')
        q=s
        print(q.replace(' ','·'))
        print('\n–––––––––––––\n')
        #q=strip_trailing_whitespace(s)
        print(q.replace(' ','·'))
        print('\n–––––––––––––\n')
        q=propagate_whitespace(q)
        print(q.replace(' ','·')   )

        print()
        print()
        print(   )

        print('Next, with stripping whitespace')
        q=s
        print(q.replace(' ','·'))
        print('\n–––––––––––––\n')
        q=strip_trailing_whitespace(s)
        print(q.replace(' ','·'))
        print('\n–––––––––––––\n')
        q=propagate_whitespace(q)
        print(q.replace(' ','·')   )
       
        OUTPUT:
            First, not stripping whitespace
            def·f():
            ····x
            ·
            ····yield
            ····def·f():··
            ····
            ········pass
            ··
            ···
            print(x)

            –––––––––––––

            def·f():
            ····x
            ·
            ····yield
            ····def·f():··
            ····
            ········pass
            ··
            ···
            print(x)

            –––––––––––––

            def·f():
            ····x
            ····
            ····yield
            ····def·f():··
            ········
            ········pass
            ···
            ···
            print(x)



            Next, with stripping whitespace
            def·f():
            ····x
            ·
            ····yield
            ····def·f():··
            ····
            ········pass
            ··
            ···
            print(x)

            –––––––––––––

            def·f():
            ····x

            ····yield
            ····def·f():

            ········pass


            print(x)

            –––––––––––––

            def·f():
            ····x
            ····
            ····yield
            ····def·f():
            ········
            ········pass
    """


    lines = string.splitlines()
    # Reverse the list to start from the bottom
    reversed_lines = lines[::-1]

    # Initialize the amount of whitespace to propagate
    whitespace_to_propagate = ""

    for i in range(len(reversed_lines)):
        line = reversed_lines[i]
        # Strip the line to check if it's empty
        stripped_line = line.lstrip()
        do_propgate = whitespace_to_propagate.startswith(line)
        if not do_propgate:
            # Update the whitespace to propagate if the line has characters
            whitespace_to_propagate = line[:len(line) - len(stripped_line)]
        else:
            # Apply the saved whitespace to propagate if the line is empty
            reversed_lines[i] = whitespace_to_propagate

    # Reverse the lines back to the original order and join them into a single string
    return "\n".join(reversed_lines[::-1])



## /Library/Frameworks/Python.framework/Versions/3.5/lib/python3.5/site-packages/rp/.rprc
_default_rprc="""## %s
## This is the rprc file. Like .bashrc, or .vimrc, this file is run each time you boot rp from the command line.
## Even though the extension of this file is .rprc, and not .py, treat it as a python file.
## Feel free to commment/uncomment any of the lines here, or to add your own. This file is preserved when you update rp.

## Add the current directory to the path, letting us import any files in the directory we booted rp in
## For example, if we run 'rp' in a directory with 'thing.py', let us run 'import thing.py' by enabling the belowline
__import__("sys").path.append(__import__("os").getcwd())

#Should we automatically download any pip_imports?
#__import__("rp").r._pip_import_autoyes=True
#__import__("rp").r._pip_install_needs_sudo=False

## Set the terminal's cursor to the shape of a line, instead of a block. I personally prefer this, but I've commented out because I don't know if you'd like it.
#__import__('rp').set_cursor_to_bar()

## Custom command prefix shortcuts
#__import__('rp').r._add_pterm_prefix_shortcut("fu","!!fileutil")
#__import__('rp').r._add_pterm_prefix_shortcut("fp",["fansi_print('","','green bold')"])

## Custom pterm commands
#__import__('rp').r._add_pterm_command_shortcuts('''
#     CLC $r._pterm_cd("~/CleanCode")
#     RZG $os.system(f"cd {$get_path_parent($get_module_path(rp)} ; lazygit")
#''')

#Added protected folders to CDH and CDC.
#When you add a directory to this list, if any file inside it doesn't exist but the prefix also doesn't exist, it will be shown as blue when running CDH and it won't be deleted during CDH CLEAN.
#This is useful for drives that are temporarily mounted, like over SSHFS, so your history isn't wiped when you run CDC and the drive isn't mounted.
__import__("rp").cdc_protected_prefixes+=[
   # '/Users/ryan/sshfs/' 
]

## Import the rp library's whole namespace. It's not nessecary, but it exposes a lot of useful functions without
#from rp import *

"""%rprc_file_path

def _get_ryan_rprc_path():
    if not file_exists(rprc_file_path):
        string_to_text_file(rprc_file_path,_default_rprc)
    return text_file_to_string(rprc_file_path)

def _set_ryan_rprc():
    rprc_file_path=_get_ryan_rprc_path()
    string_to_text_file(rprc_file_path,text_file_to_string(rprc_file_path)+'\n'+'from rp import *')
    print("Your rprc file has been modified.")
    

def get_vim_python_executable():
    """
    Vim depends on a python executable somewhere on your computer. 
    This function returns the path to that python executable.
    """
    import subprocess

    python_code = 'import os;print(os.__file__)' # This works as expected on both Mac and Linux
    #python_code = 'import sys;print(sys.executable)' # This is the most intuitive one to use, but doesn't work correctly on Mac

    # Run Vim in headless mode without loading vimrc files and capture the output
    output = subprocess.check_output(['vim', '-T', 'dumb', '--not-a-term', '-n', '-i', 'NONE', '-u', 'NONE', '-N', '-c', 'py3 '+python_code, '-c', 'quit'], stderr=subprocess.STDOUT)
    

    # Decode the output from bytes to string
    output = output.decode('utf-8').strip()
    
    #It prints a bunch of invisible junk before the actual path
    output = output[output.find('/'):] 
    
    #Output is like /opt/homebrew/Cellar/python@3.12/3.12.2_1/Frameworks/Python.framework/Versions/3.12/lib/python3.12/os.py
    output=get_path_parent(output)
    output=get_path_parent(output)
    output=get_path_parent(output)
    output=path_join(output,'bin','python3')
    
    if not file_exists(output):
        raise RuntimeError("Faild to find vim's python executable!")

    # Return the path to the Python executable
    return output

def _vim_pip_install(package):
    """ Package can be like package=='ropevim' """
    command = get_vim_python_executable()+' -m pip install %s --break-system-packages' % package
    os.system(command)

def _set_ryan_ranger_config():
    """Configure ranger file manager with RP-optimized settings. Internal helper."""
    config = """
#<RP CONFIG START>
set preview_images true
set preview_images_method iterm2
#<RP CONFIG END>
"""
    folder = get_absolute_path("~/.config/ranger")
    make_folder(folder)
    return append_line_to_file(config, path_join(folder, "rc.conf"))

def _set_ryan_vimrc(confirm=False):
    """
    ON MAC, Ropevim Is annoying to install:
        :py3 import os; print(os.__file__)

        Prints something like

        /opt/homebrew/Cellar/python@3.12/3.12.2_1/Frameworks/Python.framework/Versions/3.12/lib/python3.12/os.py

        You need to do

        /opt/homebrew/Cellar/python@3.12/3.12.2_1/Frameworks/Python.framework/Versions/3.12/bin/python3 -m pip install ropevim  --brea
        k-system-packages

    confirm parameter options:
    - 'NO' means we install packages but do not replace vimrc
    - 'COPY' means copy vimrc file without installing packages (skip package installation, only copy ryan_vimrc.py to ~/.vimrc)
    - 'HARD LINK' means create hard link to ryan_vimrc.py without installing packages (skip package installation, create hard link from ~/.vimrc to ryan_vimrc.py)
    - 'SOFT LINK' means create symbolic link to ryan_vimrc.py without installing packages (skip package installation, create symlink from ~/.vimrc to ryan_vimrc.py)
    - True means we ask for confirmation before replacing vimrc file
    - False means we don't ask and replace the vimrc file
    Only pseudo_terminal uses this confirm argument (and it will stay that way) so it's not a big deal this is messy, it can be cleaned later. The default is to autoinstall without asking.
    """

    # Check if this is one of the new modes that skip setup phases
    skip_setup_phases = confirm in ['COPY', 'HARD LINK', 'SOFT LINK']

    # Phase 1: Python syntax fix (skip for new modes)
    if not skip_setup_phases:
        #Mar 2025: Bug in vim for highlighting f-strings. Fix suggested from here: https://stackoverflow.com/questions/70261663/vim-syntax-highlighting-of-a-python-formatted-string
        os.system(
            """
            mkdir -p "$HOME/.vim/syntax"
            curl -s https://raw.githubusercontent.com/vim/vim/21c6d8b5b6ef510c9c78b9dfb89a41146599505f/runtime/syntax/python.vim \
              > "$HOME/.vim/syntax/python.vim"
            """
        )

    # Phase 2: Package installation (skip for new modes)
    if not skip_setup_phases:
        packages = 'isort black-macchiato pyflakes removestar ropevim drawille pudb pynvim'.split()

        for package in packages:
            try:
                _vim_pip_install(package)
                if package == 'black-macchiato':
                    pip_import('macchiato', package, auto_yes=True)
                else:
                    pip_import(package, auto_yes=True)
            except Exception:
                print("Skipped pip install ..."+str(package))

    # Phase 3: Vimrc file handling
    should_handle_vimrc = False
    if skip_setup_phases:
        # For new modes, always handle vimrc (no user prompt)
        should_handle_vimrc = True
    elif not confirm=='NO' and (not confirm or input_yes_no('Would you like to add Ryan Burgert\'s vim settings to your ~/.vimrc?')):
        should_handle_vimrc = True

    if should_handle_vimrc:
        vimrc_path = get_absolute_path('~/.vimrc')
        ryan_vimrc_path = get_module_path_from_name('rp.ryan_vimrc')

        # Create backup if vimrc exists
        if file_exists(vimrc_path):
            backup_path = vimrc_path + '___rp_auto_backup___' + format_current_date()
            copy_file(vimrc_path, backup_path)
            print("Created backup: {0}".format(backup_path))

        # Handle different vimrc operations based on confirm mode
        if confirm == 'COPY':
            # Copy the ryan_vimrc.py content to ~/.vimrc
            vimrc = text_file_to_string(ryan_vimrc_path)
            string_to_text_file(vimrc_path, vimrc)
            print("Copied vimrc content from {0} to {1}".format(ryan_vimrc_path, vimrc_path))

        elif confirm == 'HARD LINK':
            # Create hard link from ~/.vimrc to ryan_vimrc.py
            import os
            if file_exists(vimrc_path):
                os.remove(vimrc_path)
            os.link(ryan_vimrc_path, vimrc_path)
            print("Created hard link from {0} to {1}".format(vimrc_path, ryan_vimrc_path))

        elif confirm == 'SOFT LINK':
            # Create symbolic link from ~/.vimrc to ryan_vimrc.py
            import os
            if file_exists(vimrc_path):
                os.remove(vimrc_path)
            os.symlink(ryan_vimrc_path, vimrc_path)
            print("Created symbolic link from {0} to {1}".format(vimrc_path, ryan_vimrc_path))

        else:
            # Default behavior: copy content
            vimrc = text_file_to_string(ryan_vimrc_path)
            string_to_text_file(vimrc_path, vimrc)

    # Phase 4: Vim plugin setup (skip for new modes)
    if not skip_setup_phases and should_handle_vimrc:
        shell_command('git clone https://github.com/VundleVim/Vundle.vim.git ~/.vim/bundle/Vundle.vim')
        os.system('vim +PluginInstall +qall')
        print("Finished setting your ~/.vimrc vim settings. Give it a try! Enter 'ans?v' without quotes to see your new ~/.vimrc file")

def _set_ryan_xonshrc():
    xonshrc_path=get_absolute_path('~/.xonshrc')
    xonfig=text_file_to_string(xonshrc_path) if file_exists(xonshrc_path) else ''
    
    ryan_xonfig='''
$PROMPT = "{BOLD_CYAN} >> {BOLD_CYAN}{cwd_base}{branch_color}{curr_branch: {}}{NO_COLOR} "
$CASE_SENSITIVE_COMPLETIONS = False
'''
    string_to_text_file(xonshrc_path,xonfig+ryan_xonfig)
    print("Your ~/.xonshrc file has been modified. Use the SHELL command to try it out!")

def _sort_imports_via_isort(code):
    pip_import('isort')
    import isort
    return isort.code(code)
sort_imports_via_isort = _sort_imports_via_isort

def clean_imports_via_unimport(code: str) -> str:
    """
    Removes dead imports

    EXAMPLE:
        >>> code = "import numpy\nprint(123)"
        >>> print(code)
        import numpy
        print(123)
        >>> print(clean_imports_via_unimport(code))
        print(123)
    """
    pip_import('unimport')
    
    import unimport
    from unimport.analyzers.main import MainAnalyzer
    from unimport.statement import Import
    import unimport.refactor

    analyzer = MainAnalyzer(source=code)
    
    # Analyze the code to find unused imports
    analyzer.traverse()
    
    # Get unused imports
    unused_imports = list(Import.get_unused_imports())
    
    # Remove unused imports from the code
    result = unimport.refactor.refactor_string(source=code, unused_imports=unused_imports)
    
    # Clean up
    analyzer.clear()
    
    return result


_ryan_tmux_conf=r'''
#Ryan Burgert's Tmux Config

#Main changes:
#   * Note: These shortcuts are all preceded by Control+B
#   - You can use the mouse to move panes around
#   - The history limit is way higher than the default
#   - Ctrl+S toggles the visibility of the status bar
#   - Let Control-HJKL (with shift key) resize panes
#   - Changing the leader (useful in nested sessions):
#       - NOTE: To activate this, uncomment the section titled CHANGING THE LEADER
#       - Alt+a sets the leader to Alt+B 
#       - Control+A sets the leader to Control+B
#   - Vim-like bindings have been added:
#       - hjkl for pane navigation
#       - When in copy move (after ctrl+b then [), use 'v' to start a selection then 'y' to yank it
#           - When this selection is yanked, it's sent to your system clipboard, which means you can paste it again somewhere else (i.e. sublime text, for example). NOTE: On Linux, please install 'sudo apt install xclip' to make this work!)
#   - Reordering tabs
#       - N and P move the current tab right and left, respectively
#   - Use capital C to create new window with same directory as current pane (as opposed to lower-case c)


#BASICS:
    set -g mouse on 

    set -g history-limit 99999
    
    #Don't let us have weird gaps between window numbers...
    #https://unix.stackexchange.com/questions/21742/renumbering-windows-in-tmux
    set-option -g renumber-windows on

    #When use :rename, let us use names as long as we want...
    set -g status-left-length 10000


#CAPABILITIES

    #YANKING:
        # Let tmux copy to the system clipboard.
        # First, please run   git clone https://github.com/tmux-plugins/tmux-yank ~/clone/path
        run-shell ~/clone/path/yank.tmux
        set -g @yank_with_mouse on
        set -g @yank_selection_mouse 'clipboard' # or 'primary' or 'secondary'

    #IMAGES:
        #Allow images to be displayed through tmux
        #https://github.com/wookayin/python-imgcat/issues/4
        set -gq allow-passthrough on

    #UNDERCURLS
        # https://ryantravitz.com/blog/2023-02-18-pull-of-the-undercurl/
        # https://gist.github.com/michenriksen/a3fd9e4104548c960696748d994309a3
        set-option -gas terminal-overrides "*:Tc" # true color support
        set-option -gas terminal-overrides "*:RGB" # true color support
        set -as terminal-overrides ',*:Smulx=\E[4::%p1%dm'  # undercurl support
        set -as terminal-overrides ',*:Setulc=\E[58::2::%p1%{65536}%/%d::%p1%{256}%/%{255}%&%d::%p1%{255}%&%d%;m'  # underscore colours - needs tmux-3.0


#BINDINGS
    #REORDER TABS
        #Reorder TMUX tabs
        #https://superuser.com/questions/343572/how-do-i-reorder-tmux-windows
        bind-key P swap-window -t -1\; select-window -t -1
        bind-key N swap-window -t +1\; select-window -t +1
    #VIM NAVIGATION
        #Allow vim-like navigation: https://stackoverflow.com/questions/30719042/tmux-using-hjkl-to-navigate-panes
        set -g status-keys vi
        setw -g mode-keys vi
        # smart pane switching with awareness of vim splits
        bind h select-pane -L
        bind j select-pane -D
        bind k select-pane -U
        bind l select-pane -R
        bind-key -T copy-mode-vi 'v' send -X begin-selection     # Begin selection in copy mode.
        bind-key -T copy-mode-vi 'C-v' send -X rectangle-toggle  # Begin selection in copy mode.
    #RESIZING HJKL
        #Let Control-HJKL (with shift key) resize panes
        bind C-J resize-pane -D 10
        bind C-K resize-pane -U 10
        bind C-H resize-pane -L 10
        bind C-L resize-pane -R 10
    #BREAK PANE:
        #Turn a pane into a tab
        bind-key b "break-pane"
    #COMPATIABILITY:
        #Let control+arrow keys work through tmux 
        #https://stackoverflow.com/questions/38133250/cannot-get-control-arrow-keys-working-in-vim-through-tmux
        set-window-option -g xterm-keys on
    #TOGGLE STATUSBAR:
        bind-key C-s set-option -g status #Ctrl+S toggles the visibility of the status bar
    #SEND/JOIN PANES:
        #Shortcut for joining panes: https://maciej.lasyk.info/2014/Nov/19/tmux-join-pane/
        #Note: You need to enter two numbers, not one. For example, 1.3 or 3.4 not just 0. You can see what's what with ^b+w or ^b+q on a given tab
        bind-key s command-prompt -p "Send pane to:"  "join-pane -t '%%'"
        bind-key S command-prompt -p "Join pane from:"  "join-pane -s '%%'"
    #RENAMING SESSION:
        bind-key ` command-prompt "rename-window %%"
    #CHANGING THE LEADER:
        # bind-key M-a set-option -g prefix M-b # Alt+a sets the leader to Alt+B
        # bind-key C-a set-option -g prefix C-b # Control+A sets the leAder to Control+B
    #PANE SYNCHRONIZATOIN:
        bind e setw synchronize-panes #e synchronizes the keyboard
    #PANE RESET
        bind C-r respawn-pane -k #Respawn a pane
    #MARKED PANE SWAP:
        bind C-m swap-pane #Swap the current pane with the marked pane

#OPTIONS:
    #ESCAPE:
        #set-option -g prefix M-b
        #NOTE: With this option, simply pressing 'escape' will take a 1 second delay. Set bindings in other programs appropriately (like in lazygit), don't use this, or just wait it out
    #AUTO-CD:
        #Stay in the same directory when splitting panes
        bind '"' split-window -c "#{pane_current_path}"
        bind % split-window -h -c "#{pane_current_path}"
        bind C new-window -c "#{pane_current_path}"  #Use capital C to create new window with same directory as current pane
        bind e setw synchronize-panes #e synchronizes the keyboard
    #COLORS:
        #Let tmux use 256 colors, instead of being limited to plain boring ascii colors
        # set -g default-terminal "tmux-256color" #Glitchy, can't reset. Breaks lazygit. Don't use.
        # set -g default-terminal "screen-256color"  # Supports: Pudb 256 colors, No mouse dragging in vim
        # set -g default-terminal "xterm"  #Supports: Truecolor, mouse dragging, Pudb 256 colors does NOT work, 
        set -g default-terminal "${TERM}" #Automatically choose: https://www.reddit.com/r/neovim/comments/11usepy/how_to_properly_set_tmux_truecolor_capability/
        #The above made mouse, truecolor, 256 color, pudb 256 color, and mouse drag work in both local AND nested tmux. WHY isn't this the default?
        #TRUECOLOR SUPPORT!!!
        #NOTE: If you're using MOSH, you won't get 24 bit color support (aka truecolor support). The code exists in the mosh github repo, but the last release was in 2015...(as of 2022 writing this, it was 7 years ago...)
        #To get 24 bit color support in mosh you have to build it yourself! I PROMISE IT'S NOT THAT BAD (it's really quick)! https://github.com/mobile-shell/mosh/wiki/Build-Instructions
        #I was referred there by https://github.com/mobile-shell/mosh/issues/649
        set-option -sa terminal-overrides ",xterm*:Tc" #This somehow enables Truecolor...idk why but it works on Macx! From https://herrbischoff.com/2020/08/how-to-enable-italics-in-tmux/

        # undercurl support : https://www.reddit.com/r/neovim/comments/179tb2r/undercurl_in_tmuxneovim/
        set -as terminal-overrides ',*:Smulx=\E[4::%p1%dm'
        # support colors for undercurl
        set -as terminal-overrides ',*:Setulc=\E[58::2::%p1%{65536}%/%d::%p1%{256}%/%{255}%&%d::%p1%{255}%&%d%;m'
    #CLIPBOARD:
        #Let tmux copy to the system clipboard. "external" is more restrictive than "on" - "on lets any program copy to terminal via OSC52. This is what we want.
        #https://github.com/tmux/tmux/wiki/Clipboard#terminal-support---tmux-inside-tmux
        # set -g set-clipboard external
        set -g set-clipboard on
    #THEME:
        #STATUSBAR:
            #POSITION:
                #set-option -g status-position top
            #COLOR:
                #Pro tip: To see all 256 colors, use rp.print_fansi_reference_table()
                #THEME: Lavender
                    #set -g status-style             bg=colour97             #Set the color of the status bar
                    #set -g pane-active-border-style bg=default,fg=colour97  #The border colors between panes
                    #set -g mode-style               bg=colour213,fg=black   #Set the color of text selection
                #THEME: Blue
                    #set -g status-style             bg=colour25             #Set the color of the status bar
                    #set -g pane-active-border-style bg=default,fg=colour39  #The border colors between panes
                    #set -g mode-style               bg=colour117,fg=black   #Set the color of text selection
                #THEME: Green
                    #set -g status-style             bg=colour22             #Set the color of the status bar
                    #set -g pane-active-border-style bg=default,fg=colour40  #The border colors between panes
                    #set -g mode-style               bg=colour114,fg=black   #Set the color of text selection
                #THEME: Orange
                    #set -g status-style             bg=colour166            #Set the color of the status bar
                    #set -g pane-active-border-style bg=default,fg=colour208 #The border colors between panes
                    #set -g mode-style               bg=colour215,fg=black   #Set the color of text selection
                #THEME: Purple
                    #set -g status-style             bg=colour54             #Set the color of the status bar
                    #set -g pane-active-border-style bg=default,fg=colour93  #The border colors between panes
                    #set -g mode-style               bg=colour141,fg=black   #Set the color of text selection
                #THEME: Red
                    #set -g status-style             bg=colour124            #Set the color of the status bar
                    #set -g pane-active-border-style bg=default,fg=colour160 #The border colors between panes
                    #set -g mode-style               bg=colour203,fg=black   #Set the color of text selection
            #TEXT: 
                ## Better status, such as AM PM time. TODO: Configure this. It's a mess right now...
                ## See https://arcolinux.com/everything-you-need-to-know-about-tmux-status-bar/
                ## set -g status-right "#[fg=cyan]%A, %d %b %Y %I:%M %p"
                ## set-option -g status-right "#[fg=green,bg=default,bright]#(tmux-mem-cpu-load) "
                ## set-option -ag status-right "#[fg=red,dim,bg=default]#(uptime | cut -f 4-5 -d ' ' | cut -f 1 -d ',') "
                ## set-option -ag status-right " #[fg=white,bg=default]%a%l:%M:%S %p#[default] #[fg=blue]%Y-%m-%d
                ## set -g status-right '#[fg=white]#(hostname)@#(host `hostname` | cut -d " " -f 4)'
                #set -g status-right '#[fg=cyan]#(hostname) : '
                #set -ag status-right "#[fg=cyan]#(uptime | cut -f 4-5 -d ' ' | cut -f 1 -d ',') "
                ## set -ag status-right "#[fg=cyan]%A, %d %b %Y %I:%M %p"
                #set -ag status-right "#[fg=cyan]: %b %d %I:%M %p"

'''
def _set_ryan_tmux_conf():
    conf_path=get_absolute_path("~/.tmux.conf")
    if not file_exists(conf_path) or input_yes_no("You already have a tmux config file ~/.tmux.conf, would you like to overwrite it?"):
        string_to_text_file(conf_path,_ryan_tmux_conf)
        shell_command('git clone https://github.com/tmux-plugins/tmux-yank ~/clone/path')
        print("Succesfully configured your tmux! Please restart tmux to see the changes.")
    try:
        os.system('tmux source-file ~/.tmux.conf') #Refresh tmux conf if possible
    except Exception:
        pass

def _run_sys_command(*command, title='SYS COMMAND'):
    """ 
    Run a system command, announcing it

    EXAMPLE:
        >>> _run_sys_command('echo hello')
        SYS COMMAND: echo hello
        hello
        ans = 0
    """

    if len(command)==1:
        command = command[0]
    else:
        command = " ".join(shlex.quote(x) for x in command)

    message = fansi(title+": ",'bold green')+fansi(command, 'green')
    print(message)
    return os.system(command)

_all_shell_rc_paths = ["~/.zshrc", "~/.bashrc"]

def _ensure_shell_has_path(*, mac=None, linux=None, windows=None, unix=None):
    """
    Add a directory to the shell PATH for the current process and future logins.

    Args:
        mac: Path to add when running on macOS.
        linux: Path to add when running on Linux.
        windows: Path to add when running on Windows (not implemented).
        unix: Fallback for both mac and linux if specific values are not given.

    Notes:
        - Modifies os.environ["PATH"] immediately for this process.
        - Appends an `export PATH=...:$PATH` line to common shell rc files
          (~/.zshrc, ~/.bashrc etc - see r._all_shell_rc_paths).
        - Rejects paths containing ":" on POSIX, since ":" is the PATH separator.
        - This affects the OS shell environment, not Python’s sys.path.
    """
    
    if mac   is None: mac   = unix
    if linux is None: linux = unix

    if mac and currently_running_mac() or linux and currently_running_linux():
        path = mac if currently_running_mac() else linux
        path = os.path.expanduser(str(path)).strip()

        if path in os.environ['PATH'].split(':'):
            return #No action needed

        path = os.path.expanduser(str(path))

        if ":" in path:
            raise ValueError("PATH entries on POSIX cannot contain ':': " + path)

        # For this current process
        os.environ["PATH"] = path + ":" + os.environ.get("PATH", "")

        # For new shell sessions
        new_line = "export PATH=" + shlex.quote(path) + ":$PATH  # Added by RP"
        append_line_to_files(new_line, _all_shell_rc_paths)
        fansi_print('r._ensure_shell_has_path: Wrote '+repr(new_line)+" to files: "+repr(rc_paths), 'green bold')
            
    elif windows and currently_running_windows():
        raise NotImplementedError

def _ensure_installed(name: str, *, windows=None, mac=None, linux=None, unix=None, force=False) -> bool:
    """ Attempts to install a program on various operating systems. Returns True if we install something. """

    assert isinstance(name,    str)
    assert isinstance(linux,   str) or linux   is None
    assert isinstance(mac,     str) or mac     is None
    assert isinstance(windows, str) or windows is None
    assert isinstance(unix,    str) or unix    is None

    if mac   is None: mac   = unix
    if linux is None: linux = unix

    if not force and (system_command_exists(name) or system_library_exists(name)):
        #Don't reinstall what we've already installed
        return False

    if   currently_running_linux()   and linux   is None: raise RuntimeError('r._ensure_installed('+repr(name)+'): Linux not supported!')
    elif currently_running_mac()     and mac     is None: raise RuntimeError('r._ensure_installed('+repr(name)+'): MacOS not supported!')
    elif currently_running_windows() and windows is None: raise RuntimeError('r._ensure_installed('+repr(name)+'): Windows not supported!')

    if   currently_running_linux()  : command = linux
    elif currently_running_mac()    : command = mac
    elif currently_running_windows(): command = windows

    if   starts_with_any(command, 'brew ' , 'yes | brew ' ): _ensure_brew_installed()
    elif starts_with_any(command, 'curl ' , 'yes | curl ' ): _ensure_curl_installed()
    elif starts_with_any(command, 'npm '  , 'yes | npm '  ): _ensure_npm_installed()
    elif starts_with_any(command, 'nvm '  , 'yes | nvm '  ): _ensure_nvm_installed() ; command = _nvm_command_string(command, remove_prefix=True)
    elif starts_with_any(command, 'go '   , 'yes | go '   ): _ensure_go_installed()
    elif starts_with_any(command, 'apt '  , 'apt-get '    ): command = 'sudo '+command
    elif starts_with_any(command, 'snap ' , 'yes | snap'  ): _ensure_snap_installed() ; command = 'sudo '+command
    elif starts_with_any(command, 'cargo ', 'yes | cargo '): _ensure_cargo_installed()
    elif starts_with_any(command, 'winget '): command = command + ' -e --accept-source-agreements'
 
    assert connected_to_internet()

    error_code =_run_sys_command(command)

    if error_code:
        raise RuntimeError('r._ensure_installed('+name+'): Failed to run system command with error code %i: %s' % (error_code, command))

    return True #We installed something

def _brew_install(x):
    _ensure_brew_installed()
    command = 'brew install '+shlex.quote(x)
    _run_sys_command(command)

def _ensure_go_installed():
    _ensure_installed(
        'go',
        mac    = 'brew install golang-go',
        linux  = 'apt install golang-go --yes',
        windows='winget install --id=GoLang.Go', # https://winstall.app/apps/GoLang.Go
    )

def _ensure_cargo_installed():
    _ensure_installed(
        'cargo',
        unix   = 'curl https://sh.rustup.rs -sSf | sh -s -- -y', #https://doc.rust-lang.org/cargo/getting-started/installation.html
        windows='echo Please Visit https://doc.rust-lang.org/cargo/getting-started/installation.html', # I haven't automated it for windows yet
    )
    _ensure_shell_has_path(unix="~/.cargo/bin")

def _ensure_brew_installed():
    assert not currently_running_windows(), 'r._ensure_brew_installed: brew isnt supported on Windows. Try WSL? See https://docs.brew.sh/Installation'
    if not system_command_exists('brew'):
        _run_sys_command('''sudo -v && NONINTERACTIVE=1 /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"''')
    _ensure_shell_has_path(linux="/home/linuxbrew/.linuxbrew/bin")
    assert system_command_exists('brew'), 'r._ensure_brew_installed: Failed to automatically install homebrew. Please see https://brew.sh'

def _ensure_wget_installed():
    _ensure_installed(
        'wget',
        mac='brew install wget',
        linux='apt install wget --yes',
        windows='winget install --id=JernejSimoncic.Wget', #https://winstall.app/apps/JernejSimoncic.Wget
    )

def _ensure_curl_installed():
    _ensure_installed(
        'curl',
        mac='brew install curl',
        linux='apt install curl --yes',
        windows='winget install --id=cURL.cURL', #https://winstall.app/apps/cURL.cURL
    )

def _ensure_rclone_installed():
    _ensure_installed(
        'rclone',
        mac='brew install rclone',
        linux='apt install rclone --yes',
        windows='winget install --id=Rclone.Rclone', #https://rclone.org/install/
    )

def _ensure_rsync_installed():
    _ensure_installed(
        'rsync',
        mac='brew install rsync',
        linux='apt install rsync --yes',
        windows='winget install --id=cwRsync.cwRsync',  # https://winstall.app/apps/cwRsync.cwRsync
    )

def _ensure_ffmpeg_installed():
    _ensure_installed(
        'ffmpeg',
        mac='brew install ffmpeg',
        linux='apt install ffmpeg --yes',
        windows='winget install --id=Gyan.FFmpeg ', #https://winstall.app/apps/Gyan.FFmpeg
    )
    if not system_command_exists("ffmpeg"):
        if   currently_running_windows(): assert False, "Please install ffmpeg! https://www.ffmpeg.org/download.html"
        elif currently_running_linux  (): assert False, "Please install ffmpeg! >>> sudo apt install ffmpeg"
        elif currently_running_mac    (): assert False, "Please install ffmpeg! >>> brew install ffmpeg #get brew at https://brew.sh"
        else:                             assert False, "Please install ffmpeg!"

def _ensure_claudecode_installed():
    _ensure_node_installed(min_version=18)
    _ensure_installed(
        'claude',
        # https://github.com/anthropics/claude-code
        unix   ='npm install -g @anthropic-ai/claude-code',
        windows='npm install -g @anthropic-ai/claude-code',
    )

def _ensure_gemini_cli_installed():
    _ensure_node_installed(min_version=18)
    _ensure_installed(
        'gemini',
        # https://github.com/google-gemini/gemini-cli
        unix   ='npm install -g @google/gemini-cli',
        windows='npm install -g @google/gemini-cli',
    )

def _ensure_opencode_installed():
    _ensure_installed(
        'opencode',
        # https://github.com/sst/opencode
        unix   ='brew install sst/tap/opencode',
        windows=None,
    )

def _ensure_snap_installed():
    _ensure_installed(
        'snap',
        mac=None,
        linux='sudo apt update ; sudo apt install snapd',
        windows=None,
    )

def _ensure_vscode_installed():
    """
    Ensure VS Code CLI is available for launching VS Code Server.
    The CLI provides the `code serve-web` command needed to run the local web server.
    """
    _ensure_installed(
        'code',
        mac='brew install --cask visual-studio-code',
        linux='curl -L "https://code.visualstudio.com/sha/download?build=stable&os=linux-deb-x64" > /tmp/vscode.deb && sudo apt install /tmp/vscode.deb --yes',
        windows='winget install --id=Microsoft.VisualStudioCode',
    )

def _ensure_nvtop_installed():
    _ensure_installed(
        'nvtop',
        mac=None,
        linux='snap install nvtop --yes',
        windows=None,
    )

def _ensure_zsh_installed():
    _ensure_installed(
        'zsh',
        mac='brew install zsh',
        linux='apt install zsh --yes',
        windows=None,
    )

def _install_oh_my_zsh():
    """ https://ohmyz.sh/#install """
    _ensure_zsh_installed()
    _ensure_curl_installed()
    _run_sys_command('sh -c "$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)"')

def _ensure_viddy_installed():
    """ https://github.com/sachaos/viddy """
    _ensure_installed(
        'viddy',
        mac='brew install viddy',
        linux='wget -O viddy.tar.gz https://github.com/sachaos/viddy/releases/download/v1.3.0/viddy-v1.3.0-linux-x86_64.tar.gz && tar xvf viddy.tar.gz && mv viddy /usr/local/bin',
        windows=None, #Please see https://github.com/sachaos/viddy
    )

def _ensure_tmux_installed():
    _ensure_installed(
        'tmux',
        mac='brew install tmux',
        linux='apt install tmux --yes',
        windows=None,
    )

def _ensure_npm_installed():
    _ensure_installed(
        'npm',
        mac='brew install npm',
        linux='apt install npm --yes',
        windows='winget install --id OpenJS.NodeJS',#https://winget.run/pkg/OpenJS/NodeJS
    )
    _ensure_node_installed() #https://github.com/anthropics/claude-code/issues/113 - taupirho's response

def _ensure_zellij_installed():
    _ensure_installed(
        'zellij',
        unix='cargo install --locked zellij',
        windows=None,
    )

def _ensure_nvm_installed():
    """Install nvm if missing and make sure it's sourced in future shells."""

    if file_exists(get_absolute_path('~/.nvm/nvm.sh')):
        #It's likely not in the path because of the way nvm works
        return

    installed_something = _ensure_installed(
        "nvm",
        unix="curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/master/install.sh | bash",
        windows="winget install --id CoreyButler.NVMforWindows",
    )

    if installed_something and currently_running_unix():
        exports = r'export NVM_DIR="$HOME/.nvm" && [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"'
        append_line_to_files(exports, _all_shell_rc_paths)

def _ensure_gcloud_installed():
    _ensure_installed(
        'gcloud',
        unix='curl -sSL https://sdk.cloud.google.com | bash',
        windows='winget install Google.CloudSDK',
    )



def _get_node_version():
    """
    Return (major, minor, patch) or raise if node is missing/broken.

    EXAMPLE:
        >>> _get_node_version()
        ans = (24, 1, 0)
        >>> !node -v
        v24.1.0

    """
    import subprocess, re

    try:
        res = subprocess.run(["node", "-v"], capture_output=True, text=True, check=True)
        version_string = (res.stdout or res.stderr).strip()
    except Exception as e:
        raise RuntimeError("r._get_node_version: Failed to run `node -v`: %s" % str(e))

    if not version_string.startswith("v"):
        raise RuntimeError(
            "r._get_node_version: Unexpected output from `node -v`: %r" % version_string
        )

    m = re.match(r"v(\d+)\.(\d+)\.(\d+)", version_string)
    if not m:
        raise RuntimeError(
            "r._get_node_version: Could not parse version string %r" % version_string
        )

    A, B, C = map(int, m.groups())
    return A, B, C

def _nvm_command_string(nvm_args, *, remove_prefix: bool = False) -> str:
    """Generate NVM command with proper sourcing."""
    if not isinstance(nvm_args, str):
        # Python 3.5 compatible version of shlex.join() (which was added in 3.8)
        nvm_args = ' '.join(shlex.quote(arg) for arg in nvm_args)
    
    if remove_prefix:
        if nvm_args.startswith('yes | nvm '):
            nvm_args = nvm_args[10:]
        elif nvm_args.startswith('nvm '):
            nvm_args = nvm_args[4:]

    return 'bash -c ' + shlex.quote('source ~/.nvm/nvm.sh && nvm ' + nvm_args)

def _nvm_install_latest_node_version():
    _ensure_nvm_installed()
    return _run_sys_command(_nvm_command_string("install node"))


def _ensure_node_installed(*,min_version:int=None):
    _ensure_nvm_installed()
    _ensure_installed(
        'node',
        unix   ='nvm install --lts',
        windows='nvm install --lts',#https://winget.run/pkg/OpenJS/NodeJS
    )

    A,B,C=_get_node_version()

    if min_version is not None and A < min_version:
        fansi_print(
            "r._ensure_node_installed(%i): node is out of date - we have version %i.%i.%i but need at least version %i"
            % (min_version, A, B, C, min_version),
            "green bold",
        )
        # _nvm_install_specific_node_version(min_version)
        _nvm_install_latest_node_version()
        rp.fansi_print("r._ensure_node_installed: Updated node version! PLEASE NOTE: You may have to restart rp from a new shell to install things with it", 'yellow yellow italic bold on dark red')

def _ensure_git_installed():
    _ensure_installed(
        'git',
        mac='brew install git',
        linux='apt install git --yes',
        windows='winget install --id=Git.Git',#https://winstall.app/apps/Git.Git
    )
    pip_import('git',auto_yes=True)

def _ensure_btop_installed():
    _ensure_installed(
        'btop',
        unix  ='brew install btop',
        windows=None,
    )

def _ensure_shfmt_installed():
    _ensure_installed(
        'shfmt',
        mac    = 'brew install shfmt',
        linux  = 'apt install shfmt',
        windows='winget install --id=mvdan.shfmt',
    )
 
def _ensure_pdftocairo_installed():
    _ensure_installed(
        'pdftocairo',
        mac    = 'brew install poppler',
        linux  = 'apt install poppler-utils',
    )
 


def _install_ollama(force=False):
    _ensure_installed(
        'ollama',
        mac='brew install ollama',
        linux='curl -fsSL https://ollama.com/install.sh | sh',
        windows='winget install --id=Ollama.Ollama', #https://winstall.app/apps/Ollama.Ollama
        force=force,
    )
    pip_import('ollama',auto_yes=True)

def _ensure_libvulkan_installed():
    _ensure_installed(
        "libvulkan.so.1",
        linux="apt install libvulkan1 mesa-vulkan-drivers",
    )

def _ensure_ollama_server_running():
    serve_processes = search_processes(" ollama serve ")
    ollama_running = bool(serve_processes)
    if not ollama_running:
        _install_ollama()
        
        session_name = tmux_get_unique_session_name("Ollama Server")
            
        yaml = tmuxp_create_session_yaml(
            {"ollama": "ollama serve"},
            session_name=session_name,
        )
        tmuxp_launch_session_from_yaml(yaml)
        
        fansi_print(
            " >> OLLAMA SERVER STARTED IN TMUX SESSION: %s << "%repr(session_name),
            "italic bold black black on green cyan",
        )

def _launch_vscode_web_server(port=None):
    """
    Launch VS Code as a local web server accessible in your browser.
    
    Args:
        port: Port to use. If None, finds next available port starting from 51235.
    """
    _ensure_vscode_installed()
    if port is None:
        port = get_next_free_port(51235)
    _run_sys_command('code serve-web --host 0.0.0.0 --without-connection-token --port '+str(port)+' --accept-server-license-terms --disable-telemetry')

#THIS FUNCTION INSTALLS IT FINE! BUT I DIDN'T FINISH MAKING IT USEFUL YET. UNCOMMENT ONCE I HAVE WRAPPERS FOR IT.
# def _install_grounded_sam_2(grounded_sam_dir: str = None, force=False):
#     """
#     TODO: I currently don't have code that uses this, I used SA2VA instead. It's in CommonSource.
#     WARNING: This function might be moved to CommonSource!
#     Installs this: https://github.com/IDEA-Research/Grounded-SAM-2
#     Downloads code, installs it
#     Downloads checkpoints too
#     If grounded_sam_dir is not specified, defaults to rp's downloads folder
#     """
#     import shutil
#
#     grounded_sam_dir = grounded_sam_dir or path_join(r._rp_downloads_folder, "grounded_sam_2")
#     
#     if folder_exists(grounded_sam_dir):
#         if force:
#             shutil.rmtree(grounded_sam_dir)
#         else:
#             return
#         
#     if not folder_exists(grounded_sam_dir):
#         git_clone("https://github.com/IDEA-Research/Grounded-SAM-2", grounded_sam_dir, depth=1)
#         
#     with SetCurrentDirectoryTemporarily(grounded_sam_dir):
#         os.environ["SAM2_BUILD_CUDA"] = "0"
#         for checkpoint_folder in ["gdino_checkpoints", "checkpoints"]:
#             with SetCurrentDirectoryTemporarily(checkpoint_folder):
#                 os.system("bash download_ckpts.sh")
#         pip_install('-e ".[notebooks]"')
#
#THIS FUNCTION INSTALLS IT FINE! BUT I DIDN'T FINISH MAKING IT USEFUL YET. UNCOMMENT ONCE I HAVE WRAPPERS FOR IT.
# def _install_pytorch_hed(git_dir: str = None, force=False):
#     """
#     https://github.com/xwjabc/hed
#     """
#     import shutil    
#
#     git_dir = git_dir or path_join(r._rp_downloads_folder, "pytorch_hed")
#     
#     if folder_exists(git_dir):
#         if force:
#             shutil.rmtree(git_dir)
#         else:
#             return git_dir
#         
#     if not folder_exists(git_dir):
#         git_clone("https://github.com/xwjabc/hed", git_dir, depth=1)
#         
#     with SetCurrentDirectoryTemporarily(git_dir):
#         _ensure_wget_installed()
#         _run_sys_command("wget https://cseweb.ucsd.edu/~weijian/static/datasets/hed/hed-data.tar")
#         _run_sys_command("tar xvf ./hed-data.tar")    
#
#     return git_dir


def _load_ryan_lazygit_config():
    _install_lazygit()

    #Get the path depending on the platform
    #https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md
    if currently_running_mac():
        path='~/Library/Application Support/lazygit/config.yml'
    elif currently_running_unix():
        path='~/.config/lazygit/config.yml'
    else:
        path=r'%LOCALAPPDATA%\lazygit\config.yml'
    
    path=get_absolute_path(path)
    make_parent_directory(path)
    
    config_lines=unindent("""
        # < RP Lazygit Config Start >
        #DEFAULTS: https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md
        keybinding:
          universal:
            quit: '<c-d>'
            quit-alt1: '<c-d>' # alternative/alias of quit
            # return: 'q' # return to previous menu, will quit if there's nowhere to return
            return: '<c-c>' # return to previous menu, will quit if there's nowhere to return
            scrollDownMain-alt2: null #NOT <c-d>
        refresher:
          refreshInterval: 60 # Save battery life...
          # fetchInterval: 60 # re-fetch interval in seconds
        # < RP Lazygit Config End >
    """).strip()

    if file_exists(path) and config_lines.strip() not in load_text_file(path):
        append_line_to_file(config_lines + "\n", path)

    return path

def _install_lazygit(force=False):
    #https://github.com/jesseduffield/lazygit/tree/master?tab=readme-ov-file#installation
    
    if 'lazygit' in get_system_commands() and not force:
        # print('lazygit is already installed. Not installing because force==False.')
        return
    with SetCurrentDirectoryTemporarily(make_directory(temporary_file_path())):
        
        if currently_running_mac():
            _brew_install('jesseduffield/lazygit/lazygit')
        elif currently_running_linux():
            _run_sys_command(
                unindent(
                r"""
                LAZYGIT_VERSION=$(curl -s "https://api.github.com/repos/jesseduffield/lazygit/releases/latest" | grep -Po '"tag_name": "v\K[^"]*')
                curl -Lo lazygit.tar.gz "https://github.com/jesseduffield/lazygit/releases/latest/download/lazygit_${LAZYGIT_VERSION}_Linux_x86_64.tar.gz"
                tar xf lazygit.tar.gz lazygit
                sudo install lazygit /usr/local/bin
                """
                )
            )
        elif currently_running_windows():
            #Untested
            _run_sys_command("""winget install -e --id=JesseDuffield.lazygit""")
        
def _ensure_filebrowser_installed():
    """https://filebrowser.org/installation"""

    system_commands = get_system_commands()

    if "filebrowser" in system_commands:
        print("r._ensure_filebrowser_installed: filebrowser already installed")
        return
    if "brew" in system_commands:
        command = "brew tap filebrowser/tap && brew install filebrowser"
    elif currently_running_windows():
        command = "iwr -useb https://raw.githubusercontent.com/filebrowser/get/master/get.ps1 | iex"
    elif currently_running_unix():
        command = "curl -fsSL https://raw.githubusercontent.com/filebrowser/get/master/get.sh | bash"
    else:
        assert False, "Unsupported OS"

    _run_sys_command(command)

def _ensure_cog_installed():
    """ 
    Cog is an open source tool that makes it easy to put a machine learning model in a Docker container.
    https://replicate.com/docs/guides/push-a-model
    """
    if not 'cog' in get_system_commands():
        _run_sys_command("""
            sudo curl -o /usr/local/bin/cog -L https://github.com/replicate/cog/releases/latest/download/cog_`uname -s`_`uname -m`
            sudo chmod +x /usr/local/bin/cog
        """)

def _run_bashtop():
    if not "bashtop" in get_system_commands():
        # Auto-install bashtop
        fansi_print(
            'rp: "bashtop" command not found - trying to install it!', "yellow", "bold"
        )
        if currently_running_linux():
            _run_sys_command("""yes | sudo add-apt-repository ppa:bashtop-monitor/bashtop
                         sudo apt update
                         sudo apt install bashtop""")
        elif currently_running_mac():
            assert False, 'TODO - install bashtop on Mac. But for real, just use bpytop - its better anyway. Use BOP'
        elif currently_running_windows():
            assert False, 'TODO'
        else:
            assert False, 'Wat?'

    #Run it!
    os.system('bashtop')

def _disable_terminal_mouse_reporting():
    """
    Disables terminal mouse reporting/tracking modes that can cause
    unwanted output like [200~[<64;106;43M when scrolling.
    
	   Originally made for when Claudecode bugs out with scrolling when in editor...
    
    This sends ANSI escape sequences to turn off various mouse tracking modes:
    - 1000: Disables normal mouse tracking (button press/release events)
    - 1002: Disables mouse tracking with drag events
    - 1003: Disables all mouse motion events (even when buttons not pressed)
    - 1004: Disables focus in/out event reporting
    - 1005: Disables UTF-8 mouse mode
    - 1006: Disables SGR mouse mode (extended coordinate reporting)
    - 1015: Disables urxvt mouse mode (another extended reporting format)
    
    The 'l' at the end means "turn off" these features (vs 'h' which turns them on).
    """
    print("\033[?1000;1002;1003;1004;1005;1006;1015l", end="", flush=True)

def _terminal_move_cursor_to_top_left():
    """
    Prints ANSI escape sequence to move cursor to the top-left position
    of the terminal.
    """
    # Move to home position (1,1)
    print("\033[H", end="", flush=True)

def _terminal_move_cursor_to_bottom_left():
    # Move to the bottom of the screen
    print("\033[9999B", end="", flush=True)

def _terminal_move_cursor_to_bottom_and_new_line():
    """
    Prints ANSI escape sequence to move cursor to the bottom row 
    and creates a new line.
    """
    _terminal_move_cursor_to_bottom_left()
    print()


_default_ai_coder_cli_command = "claude"
def _run_ai_coder_cli(code, command=None):
    """
    Given code as either a path or a string, edits that code using a cli-based coder (such as gemini-cli or claudecode).
    
    If the given code is a file path, makes a temporary directory and works there,
    creating an editme.py file. If the code is a string, it creates a temporary
    workspace with the code in editme.py. Auto-commits changes via git thread.

    If you want to switch the default command, please set something like this:
        rp.r._default_ai_coder_cli_command = "claude"
        rp.r._default_ai_coder_cli_command = "claude --dangerously-skip-permissions"
        rp.r._default_ai_coder_cli_command = "claude --dangerously-skip-permissions --append-system-prompt 'Check if editme.py exists. If it does, this is the file the user is implicitly referencing.'"
        rp.r._default_ai_coder_cli_command = "codex"
        rp.r._default_ai_coder_cli_command = "cursor-agent"
        rp.r._default_ai_coder_cli_command = "gemini"
    
    Args:
        code: A file path, directory path, or string containing code to edit
        command: The command to launch the AI coder
        
    Returns:
        EasyDict with keys:
            - code: The edited code (if editme.py exists after editing)
            - workdir: Path to the temporary working directory
    """
    import subprocess

    if command is None:
        command = _default_ai_coder_cli_command
    
    _ensure_git_installed()

    if file_exists(code):
        code = get_parent_directory(code)

    if directory_exists(code):
        with SetCurrentDirectoryTemporarily(code):
            _run_sys_command(command)
        return

    if not isinstance(code, str):
        try:
            code = get_source_code(code)
        except Exception:
            code = str(code)

    filetype = 'bash' if code.startswith('!') else 'py'
    filename = "editme." + filetype

    display_code_cell(code, title=" "+command.capitalize()+" " + filename + " ", language=filetype)
    workdir = path_join(_claudecode_folder, 'workspace_%i'%millis())
    make_directory(workdir)

    old_code = code

    print(fansi_highlight_path(workdir))
    with SetCurrentDirectoryTemporarily(workdir):
        save_text_file(code, filename)

        done = False
        
        # @run_as_new_thread
        # def commit_thread():
        #     silent = ' &> /dev/null ' * 1
        #     os.system('git init '+silent)
        #     while not done and get_current_directory()==workdir:
        #         sleep(.1)
        #         commit_message = 'Auto-Commit at '+format_current_date()
        #         os.system('git add --all '+silent+' && git commit -am '+shlex.quote(commit_message)+silent)

        import subprocess
        @run_as_new_thread
        def commit_process():
            devnull = open(os.devnull, 'w')
            subprocess.run(['git', 'init'], stdout=devnull, stderr=devnull)
            while not done and get_current_directory()==workdir:
                sleep(.1)
                commit_message = 'Auto-Commit at '+format_current_date()
                try:
                    subprocess.run(['git', 'add', '--all'], stdout=devnull, stderr=devnull)
                    subprocess.run(['git', 'commit', '-am', commit_message], stdout=devnull, stderr=devnull)
                except Exception:
                    pass
            
        try:
            #Make sure it doesnt block the above thread!
            subprocess.run(command, shell=True)
        finally:
            done=True

        if file_exists(filename):
            code = load_text_file(filename)

            if old_code.startswith('!!') and not code.startswith('!!'):
                code = '!!'+code.lstrip('!')
            elif old_code.startswith('!') and not code.startswith('!'):
                code = '!'+code

            return gather_vars("code workdir")
        else:
            return gather_vars("workdir")

def _run_claude_code(code,*,fullperm=False):
    """ See rp.r._run_ai_coder_cli.__doc__ """
    _ensure_claudecode_installed()
    command = 'claude --dangerously-skip-permissions' if fullperm else 'claude'
    return _run_ai_coder_cli(code,command)

def _run_gemini_cli(code):
    """ See rp.r._run_ai_coder_cli.__doc__ """
    _ensure_gemini_cli_installed()
    return _run_ai_coder_cli(code,'gemini')


def _configure_filebrowser():
    #https://filebrowser.org/configuration/authentication-method
    _run_sys_command('filebrowser config set --auth.method=noauth')
    
def _run_filebrowser(port=8080, root='.'):
    _ensure_filebrowser_installed()
    _configure_filebrowser()

    port=get_next_free_port(port)
    command = 'filebrowser -r '+shlex.quote(root)+' -a 0.0.0.0 -p '+str(port)+ ' --noauth'
    print('r._run_filebrowser: Running '+repr(command))

    os.system(command)

def get_port_is_taken(port: int) -> bool:
    """
    Check if a port is already in use.

    Args:
    port (int): The port number to check.

    Returns:
    bool: True if the port is in use, False otherwise.
    """
    assert isinstance(port, int)

    import socket
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
        try:
            # Try to bind to the port on all available interfaces
            s.bind(("", port))
            return False  # If bind is successful, port is free
        except socket.error:
            return True  # If bind fails, port is likely in use
        except OverflowError:
            # ERROR: OverflowError: bind(): port must be 0-65535.
            return False

def get_port_is_free(port:int) -> bool:
    """ Opposite of rp.get_port_is_taken """
    return not get_port_is_taken(port)

def _get_all_ports(port_filter,*,show_progress=False):
    """ Get all ports that satisfy some condition, return a list of integers """
    all_ports = range(65535)
    if show_progress:
        function_name = get_current_function_name(frames_back=2)
        all_ports = eta(all_ports, title=function_name)
    return [port for port in all_ports if port_filter(port)]

def get_all_free_ports(*,show_progress=False):
    """ Return a list of all free ports on this computer"""
    return gather_args_call(_get_all_ports, get_port_is_free)

def get_all_taken_ports(*,show_progress=False):
    """ Return a list of all taken ports on this computer"""
    return gather_args_call(_get_all_ports, get_port_is_taken)

def get_next_free_port(port,n=0):
    """
    Find the next free port starting from a given port.
    For example, if port=8080 and port 8080 is taken, it tries port 8081.
    And if that's taken, tries port 8082 until it finds some free, unused port.
    
    Args:
        port (int): Starting port number to check
        n (int): Number of additional free ports to skip (default: 0)
                If n=1, returns the second free port found
                If n=2, returns the third free port found, etc.
    """
    
    if n:
        for _ in range(n):
            port=get_next_free_port(port)
            port+=1

    assert isinstance(port, int)
    while get_port_is_taken(port):
        port+=1
    return port

# def get_process_using_port(port: int, *, strict = True):
#     """
#     Gets the process ID (PID) using the specified port.
#
#     Args:
#         port: The port number.
#         strict: If True, raise RuntimeError if no process is found.
#                 If None, return None if no process is found.
#
#     Returns:
#         The PID, or None if strict is None and no process is found.
#
#     Raises:
#         TypeError: If 'port' is not an int, or 'strict' is not bool or None.
#         ValueError: If 'port' is out of range.
#         RuntimeError: If strict is True and no process/PID is found.
#         psutil.Error: If there's a psutil error (rare).
#
#     EXAMPLES:
#         >>> # Assuming a process is listening on port 8080
#         >>> port = 8080
#         >>> assert get_port_is_taken(port)
#         >>> pid = get_process_using_port(port)
#         >>> print(pid)  # Output would be the PID (e.g., 1234)
#
#         >>> # If no process is listening on port 9999, with strict=True (default)
#         >>> port = 9999
#         >>> try:
#         ...   get_process_using_port(port)
#         ... except RuntimeError as e:
#         ...   print(str(e))
#         No process found using port 9999.
#
#         >>> # If no process is listening on port 9999, with strict=None
#         >>> port = 9999
#         >>> result = get_process_using_port(port, strict=None)
#         >>> print(result)
#         None
#
#     Alias: get_pid_using_port
#
#     """
#     pip_import('psutil')
#     import psutil
#
#     if not isinstance(port, int):
#         raise TypeError("Port must be an integer but got type %s"%type(port))
#     if not 0 <= port <= 65535:
#         raise ValueError("Port must be within the range 0-65535 but got %i"%port)
#     if strict not in [True, None]:
#         raise TypeError("'strict' argument must be either True or None but got %s"%strict)
#
#     for conn in psutil.net_connections():
#         if conn.laddr.port == port and conn.status == psutil.CONN_LISTEN:
#             if conn.pid:
#                 return conn.pid
#             elif strict: # Handle edge case where PID might not be available
#                 raise RuntimeError("Process found using port {0}, but PID is unavailable.".format(port))
#             else:
#                 return None
#
#     if strict:
#         raise RuntimeError("No process found using port {0}.".format(port))
#     else:
#         return None

def _listening_pids_via_lsof(port):
    """
    Helper: return a list of PIDs that are LISTENing on the given TCP port using lsof.
    """
    import subprocess
    
    if not isinstance(port, int):
        raise TypeError("Port must be an integer but got type %s" % type(port))
    if not 0 <= port <= 65535:
        raise ValueError("Port must be within the range 0-65535 but got %i" % port)

    # -i tcp:PORT filters by TCP port
    # -sTCP:LISTEN restricts to listeners (avoid ESTABLISHED, etc.)
    # -t outputs only PIDs
    cmd = ["bash", "-lc", "lsof -i tcp:{0} -sTCP:LISTEN -t".format(port)]
    proc = subprocess.run(cmd, capture_output=True, text=True)

    # If lsof isn't found or another real error occurs, surface stderr to help debugging.
    if proc.returncode not in (0, 1):  # 1 = "no output" / "not found" cases for lsof
        err = proc.stderr.strip()
        if err:
            raise RuntimeError("lsof error: {0}".format(err))

    lines = [ln.strip() for ln in proc.stdout.splitlines() if ln.strip()]
    pids = []
    for ln in lines:
        if ln.isdigit():
            pids.append(int(ln))
    return pids


def get_process_using_port(port, strict=True):
    """
    Gets one process ID (PID) listening on the specified TCP port using lsof.

    Args:
        port: The port number.
        strict: If True, raise RuntimeError if no process is found.
                If None, return None if no process is found.

    Returns:
        The PID (one of them, if multiple), or None if strict is None and no process is found.

    Raises:
        TypeError: If 'port' is not an int, or 'strict' is not bool or None.
        ValueError: If 'port' is out of range.
        RuntimeError: If strict is True and no process/PID is found, or lsof fails.
    """
    if strict not in (True, None):
        raise TypeError("'strict' argument must be either True or None but got %s" % (strict,))

    pids = _listening_pids_via_lsof(port)

    if pids:
        # Return a single representative PID (first one)
        return pids[0]

    # No processes found
    if strict:
        raise RuntimeError("No process found using port {0}.".format(port))
    return None


# Alias
get_pid_using_port = get_process_using_port


def killport(port, strict=True):
    """
    Kills any process listening on the given TCP port.

    If strict and no process uses that port, raise an error.
    Uses get_process_using_port() for the existence check, then cleans up all listeners.
    """
    # First, rely on get_process_using_port to validate existence / strict behavior
    pid = get_process_using_port(port, strict=(True if strict else None))

    if pid is None:
        # strict is False-ish path; nothing to do
        return

    # There is at least one PID; now gather *all* and kill them
    import os
    import signal
    
    all_pids = _listening_pids_via_lsof(port)

    if not all_pids:
        # Very rare race where process exited between the check and here
        if strict:
            raise ValueError("killport: No processes using port {0}".format(port))
        return

    for p in all_pids:
        try:
            os.kill(p, signal.SIGKILL)
            print("Killed process with PID: {0}".format(p))
        except ProcessLookupError:
            # Process already gone
            print("Process {0} not found (already exited).".format(p))
        except PermissionError:
            # Insufficient permissions
            print("Permission denied when trying to kill PID {0}.".format(p))


get_pid_using_port = get_process_using_port

def get_pids_using_ports(ports, *, strict=True, show_progress=False):
    """
    Plural of rp.get_process_using_port 
    EXAMPLE:
        >>> taken=get_all_taken_ports(show_progress=True)
        gather_args_call: Done! Did 65535 items in 0:00:00.918162
        >>> taken
        [22, 88, 631, 3341, 5000, 5900, 7000, 7265, 7768, 9993, 23803, 43234, 47941, 49785, 49824, 50054, 50537, 50724, 50983, 51523, 51525, 51654, 51843, 51881, 51960, 51986, 52067, 52508, 52800, 53052, 53233, 53399, 53526, 53721, 54078, 54112, 54128, 54134, 54139, 54144, 54160, 54163, 54222, 54297, 54419, 54680, 54696, 54702, 54749, 55367, 55547, 55555, 55609, 55711, 55738, 55930, 56003, 56049, 56210, 56215, 56225, 56310, 56488, 56495, 56551, 56568, 56601, 57006, 57246, 57395, 57485, 57621, 57891, 58153, 58214, 58429, 58822, 59072, 59077, 59414, 59724, 59996, 60832, 60899, 60945, 61072, 61237, 61634, 61649, 61802, 61828, 62273, 62668, 62748, 62937, 63110, 63452, 63546, 63589, 63670, 64102, 64363, 64589, 65303, 65459, 65510]
        >>> get_pids_using_ports(taken,strict=None,show_progress=True)
        get_pids_using_ports: Done! Did 106 items in 0:00:03.060600
        [None, None, None, 15587, 449, None, 449, 512, 45132, None, 502, 99876, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, 454, None, None, None, None, None, 45132, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, 45132, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None]

    """
    if show_progress:
        ports = eta(ports, title=get_current_function_name())
    return [gather_args_call(get_process_using_port, port) for port in ports]

def compress_bytes(data: bytes) -> bytes:
    """
    Compress bytes data.
    
    Args:
        data: The input bytes to compress
        
    Returns:
        Compressed bytes
    """
    import zlib
    return zlib.compress(data)

def decompress_bytes(compressed_data: bytes) -> bytes:
    """
    Decompress bytes data.
    
    Args:
        compressed_data: The compressed bytes to decompress
        
    Returns:
        Original bytes
    """
    import zlib
    return zlib.decompress(compressed_data)

def can_convert_object_to_bytes(x:object)->bool:
    """
    Returns true if we can run object_to_bytes on x without getting an error
    See object_to_bytes for more documentation
    """
    dill=pip_import('dill')
    return dill.pickles(x)#https://stackoverflow.com/questions/17872056/how-to-check-if-an-object-is-pickleable

def _dill_dumps(x):
    pip_import('dill')
    import dill
    return dill.dumps(x)

def object_to_bytes(x:object)->bytes:
    """
    Try to somehow turn x into a bytestring. 
    Right now, it supports numpy arrays, lambdas and functions, dicts lists and tuples and everything pickle can handle
    Should be able to serialize things like numpy arrays, and pickleable objects
    However it works is a black box, as long as it can be decoded by the bytes_to_object function
    """
    # assert can_convert_object_to_bytes(x),'Sorry, but we cannot serialize this object to a bytestring'
    output = _dill_dumps(x)
    output = compress_bytes(output)
    return output

def object_to_base64(x):
    return bytes_to_base64(object_to_bytes(x))

def base64_to_object(x):
    return bytes_to_object(base64_to_bytes(x))

def bytes_to_object(x:bytes)->object:
    """ Inverse of object_to_bytes, see object_to_bytes for more documentation """
    dill=pip_import('dill')
    try:
        return dill.loads(decompress_bytes(x))
    except Exception:
        return x #bytestrings allready are objects. In the event that we have an error, it might make sense just to return the original bytestring

def encode_bytes_to_image(data:bytes):
    """
    Encode binary data into a numpy array representing an RGB image.
    
    The function packs the data length as an 8-byte little-endian integer,
    pads it to 8 bytes, then converts the data into RGB pixel values.
    
    Args:
        data (bytes): Binary data to encode into image format
        
    Returns:
        numpy.ndarray: 3D array with shape (side_len, side_len, 3) representing RGB image
    """
    import numpy as np
    import math
    import struct
    
    data_len = len(data)
    length_bytes = struct.pack('<Q', data_len).ljust(8, b'\x00')
    data_with_length = length_bytes + data
    
    total_len = len(data_with_length)
    pixels_needed = math.ceil(total_len / 3)
    side_len = math.ceil(math.sqrt(pixels_needed))
    total_bytes_needed = side_len * side_len * 3
    
    padded_data = data_with_length + b'\x00' * (total_bytes_needed - total_len)
    
    image_array = np.frombuffer(padded_data, dtype=np.uint8)
    image = image_array.reshape((side_len, side_len, 3))
    
    return image

def decode_image_to_bytes(image)->bytes:
    """
    Decode a numpy array image back to the original binary data.
    
    Extracts the data length from the first 8 bytes of the flattened image,
    then returns the specified number of bytes containing the original data.
    
    Args:
        image (numpy.ndarray): 3D RGB image array to decode
        
    Returns:
        bytes: The original binary data that was encoded in the image
    """
    import numpy as np
    import struct
    
    flattened = image.flatten()
    all_bytes = flattened.tobytes()
    
    length_bytes = all_bytes[:8]
    data_length = struct.unpack('<Q', length_bytes[:8])[0]
    
    return all_bytes[8:8+data_length]


_web_clipboard_url = 'https://ryanpythonide.pythonanywhere.com'#By sqrtryan@gmail.com account

def _eta_bytes(total_bytes, *, min_interval=None, title=None, completion_verb=None, start_n=0):
    """
    ETA tracker specifically for byte transfers that formats sizes as KB/MB/GB.

    Args:
        total_bytes (int or None): Total number of bytes to process. If None, only tracks elapsed time.
        min_interval (float): Minimum seconds between display updates
        title (str): Display title for progress messages
        completion_verb (str): Verb for completion message (default: "Completed" if None)
        start_n (int): Starting byte count for resumed operations (default: 0)

    Returns:
        callable: Progress function that accepts current byte count
    """
    max_width = len("1023.9BB") # Max possible width is "1023.9XX" where XX is 2-char unit like BB, YB, etc.
    def format_bytes(n):
        return human_readable_file_size(n).rjust(max_width)

    return _eta(
        total_bytes,
        min_interval=min_interval,
        title=title,
        format_value=format_bytes,
        completion_verb=completion_verb,
        completion_unit="",
        start_n=start_n,
    )

class _WebCopyProgressTracker:
    """Tracks progress for web copy data uploads"""
    def __init__(self, data, callback, chunk_size=8192):
        self.bytes_read = 0
        self.total_size = len(data)
        self.callback = callback
        self.data = data
        self.chunk_size = chunk_size

    def update(self, bytes_count):
        self.bytes_read += bytes_count
        self.callback(self.bytes_read)
        return self.bytes_read

    def read(self, size=-1):
        if size == -1:
            size = len(self.data) - self.bytes_read

        chunk = self.data[self.bytes_read:self.bytes_read + size]
        self.update(len(chunk))
        return chunk

    def __iter__(self):
        """Yield chunks for requests library to iterate over"""
        while self.bytes_read < self.total_size:
            chunk = self.data[self.bytes_read:self.bytes_read + self.chunk_size]
            if not chunk:
                break
            self.update(len(chunk))
            yield chunk

def _web_copy(data:object, *, show_progress=False)->None:
    """ Make the request for web-copying. Can also upload arbitrary HTML pages. """
    assert connected_to_internet(),"Can't connect to the internet"
    assert isinstance(data, bytes)

    pip_import('requests')
    import requests
    from io import BytesIO
    
    if show_progress:
        display_progress = _eta_bytes(len(data), title="Web Copy", min_interval=1/60, completion_verb="Sent")

        # Create a tracker that will monitor the upload progress
        if sys.version_info >= (3, 6):
            data = _WebCopyProgressTracker(data, display_progress)
        #else: Python 3.5 doesn't handle file-like objects properly in requests
        
    response = requests.post(_web_clipboard_url, data=data)

    assert response.status_code==200,'Got bad status code that wasnt 200: '+str(response.status_code)
    return response

def web_copy(data:object, *, show_progress=False)->None:
    """ Send an object to RyanPython's server's clipboard """
    assert connected_to_internet(),"Can't connect to the internet"

    response=_web_copy(object_to_bytes(data), show_progress=show_progress)
    assert response.status_code==200,'Got bad status code that wasnt 200: '+str(response.status_code)

def web_paste(*, show_progress=False):
    """ Get an object from RyanPython's server's clipboard """
    assert connected_to_internet(),"Can't connect to the internet"
    data = curl_bytes(_web_clipboard_url, show_progress=show_progress)
    return bytes_to_object(data)

def tmux_copy(string:str):
    """ Copies a string to tmux's clipboard, assuming tmux is running and installed """
    assert isinstance(string,str),'You can only copy a string to the tmux clipboard'
    temp_file_path=temporary_file_path()
    try:
        string_to_text_file(temp_file_path,string)
        shell_command('tmux load-buffer '+temp_file_path)
    finally:
        delete_file(temp_file_path)

def tmux_paste():
    """ Returns the string from tmux's current clipboard, assuming tmux is running and installed """
    tmux_clipboard=shell_command('tmux show-buffer')
    return tmux_clipboard

def local_copy(data:object):
    """
    Works just like web_copy, but is local to one's computer
    This makes copying large python objects between processes practical
    """
    file=open(_local_obj_clipboard_path,'wb')
    file.write(object_to_bytes(data))
    file.close()

def local_paste():
    """
    Works just like web_paste, but is local to one's computer
    This makes copying large python objects between processes practical
    """
    file=open(_local_obj_clipboard_path,'rb')
    return bytes_to_object(file.read())
    
def _run_tmux_command(command):
    """
    Utility function to run a tmux command.

    Private helper function that executes tmux commands and returns their output.
    Used internally by other tmux-related functions for consistent command execution.

    Args:
        command: Shell command string or list of command arguments

    Returns:
        str: Stripped stdout from the tmux command

    Examples:
        >>> # Internal usage examples (function is private)
        >>> _run_tmux_command(['tmux', 'list-sessions'])  # doctest: +SKIP
        >>> _run_tmux_command('tmux -V')  # doctest: +SKIP

    Usage Patterns:
        - Internal helper for tmux operations
        - Consistent tmux command execution with error handling
        - Returns clean output (stripped whitespace)

    Tags: tmux, subprocess, private, helper, terminal
    """
    import subprocess

    if isinstance(command, str):
        command = shlex.split(command)

    result = subprocess.run(
        command,
        text=True,
        capture_output=True,
    )

    if result.returncode != 0:
        raise RuntimeError(
            "rp.r._run_tmux_command: tmux command failed: "
            + result.stderr.strip()
            + result.stdout.strip()
        )

    return result.stdout.strip()

# NOT WELL DEFINED. By pid or by active?
def tmux_get_active_pane_index() -> int:
    """Returns the index of the current tmux pane."""
    return int(_run_tmux_command("tmux display -p -t '{down-of}' '#{pane_index}'".split()))

def tmux_get_active_window_index() -> int:
    """Returns the index of the current tmux window."""
    return int(_run_tmux_command(["tmux", "display-message", "-p", "#{window_index}"]))

def tmux_get_active_window_name() -> str:
    """Returns the name of the current tmux window."""
    return _run_tmux_command(["tmux", "display-message", "-p", "#{window_name}"])

def tmux_get_active_session_index() -> int:
    """Returns the index of the current tmux session."""
    return int(_run_tmux_command(["tmux", "display-message", "-p", "#{session_index}"]))

def tmux_get_active_session_name() -> str:
    """
    Returns:
        str: The name of the current tmux session.

    Raises:
        AssertionError: If you run this function outside of tmux

    Examples:
        # Within a tmux session
        >>> tmux_get_active_session_name()
        'mysession'
    """
    assert running_in_tmux(), 'rp.tmux_get_active_session_name: This function must be called while running in tmux'
    return _run_tmux_command('tmux display-message -p #{session_name}'.split())



def get_process_tty(pid: int = None) -> str:
    """
    Return the controlling TTY for a given PID as a full device path
    (e.g. '/dev/pts/3' or '/dev/ttys002').

    Args:
        pid (int | None): Process ID. If None, uses get_process_id().

    Returns:
        str: Full TTY path.

    Raises:
        ValueError: If the process has no controlling TTY.
        subprocess.CalledProcessError: If ps command fails.
    """
    import subprocess
    if pid is None:
        pid = get_process_id()

    tty = subprocess.check_output(
        ["ps", "-o", "tty=", "-p", str(pid)], text=True
    ).strip()

    if not tty or tty == "?":
        raise ValueError("PID %s has no controlling TTY" % pid)

    if not tty.startswith("/"):
        tty = "/dev/" + tty
    return tty


def tmux_get_process_pane_index(pid: int= None) -> int:
    """
    Return the tmux pane index (#{pane_index}) for the given PID.

    Args:
        pid (int | None): Target process ID. If None, uses get_process_id().

    Returns:
        int: The tmux pane index.

    Raises:
        ValueError: If no matching tmux pane is found.
        subprocess.CalledProcessError: If tmux command fails.
    """
    import subprocess
    if pid is None:
        pid = get_process_id()

    tty = get_process_tty(pid)

    panes = subprocess.check_output(
        ["tmux", "list-panes", "-a", "-F", "#{pane_tty} #{pane_index}"],
        text=True
    )

    for line in panes.splitlines():
        pane_tty, pane_index = line.split()
        if pane_tty == tty:
            return int(pane_index)

    raise ValueError("No tmux pane index found for TTY %s (PID %s)" % (tty, pid))



def _get_all_tmux_windows():
    """Returns a list of all window indexes in the current tmux session."""
    window_indexes = _run_tmux_command(['tmux', 'list-windows', '-F', '#{window_index}']).split()
    return list(map(int, window_indexes))

def _tmux_close_windows(filter_condition):
    """Closes tmux windows based on a filtering condition function, private to this module."""
    current_window = tmux_get_active_window_index()
    all_windows = _get_all_tmux_windows()
    windows_to_close = [w for w in all_windows if filter_condition(w, current_window)]

    # Close each window, sorted by index to avoid issues with changing indices
    for window in sorted(windows_to_close, reverse=True):
        _run_tmux_command(['tmux', 'kill-window', '-t', str(window)])

def tmux_close_windows_to_left():
    """Closes all tmux windows to the left of the current window."""
    _tmux_close_windows(lambda w, current: w < current)

def tmux_close_windows_to_right():
    """Closes all tmux windows to the right of the current window."""
    _tmux_close_windows(lambda w, current: w > current)

def tmux_close_other_windows():
    """Closes all tmux windows except the current one."""
    _tmux_close_windows(lambda w, current: w != current)

def tmux_close_other_sessions():
    """Closes all tmux sessions except the current one."""
    current_session = tmux_get_active_session_name()
    all_sessions = tmux_get_all_session_names()
    sessions_to_close = [s for s in all_sessions if s != current_session]

    # Close each session
    for session in sessions_to_close:
        tmux_kill_session(session)

tmux_kill_other_sessions = tmux_close_other_sessions

def tmux_detach_other_clients():
    """Detaches all other clients from the current tmux session."""
    current_session = tmux_get_active_session_name()
    all_clients = _get_all_tmux_clients(current_session)
    current_client = _get_active_tmux_client_tty()
    clients_to_detach = [c for c in all_clients if c != current_client]

    # Detach each client
    for client in clients_to_detach:
        _run_tmux_command(['tmux', 'detach-client', '-t', client])

def tmux_detach_all_clients():
    """Detaches all other clients from the current tmux session."""
    os.system('tmux list-sessions -F "#{session_name}" | xargs -I % tmux detach -s %')

def _get_active_tmux_client_tty():
    """Returns the ID of the current tmux client."""
    return _run_tmux_command(['tmux', 'display-message', '-p', '#{client_tty}'])

def _get_all_tmux_clients(session):
    """Returns a list of all client IDs attached to the specified tmux session."""
    clients = _run_tmux_command(['tmux', 'list-clients', '-t', session, '-F', '#{client_tty}']).split()
    return list(clients)

def tmux_get_all_session_names():
    """
    Retrieve the names of all active tmux sessions.

    Returns:
        list: A list of strings representing the names of all active tmux sessions.
    """
    pip_import('libtmux')
    import libtmux
    
    server = libtmux.Server()
    return [session.name for session in server.list_sessions()]

tmux_list_sessions = tmux_get_all_session_names

def tmux_get_unique_session_name(name=""):
    """ Given a prefix, will return a new session name that doesn't exist already """
    existing_sessions=tmux_get_all_session_names()
    
    candidate_name = name
    index = 0
    while not candidate_name or candidate_name in existing_sessions:
        candidate_name = name + ' ' * bool(name) + str(index)
        index+=1
    
    return candidate_name

def tmux_get_active_session_name():
    """
    Returns:
        str: The name of the current tmux session.

    Raises:
        AssertionError: If you run this function outside of tmux

    Examples:
        # Within a tmux session
        >>> tmux_get_active_session_name()
        'mysession'
    """
    assert running_in_tmux(), 'rp.tmux_get_active_session_name: This function must be called while running in tmux'
    return _run_tmux_command('tmux display-message -p #{session_name}'.split())

def tmux_session_exists(session_name):
    """
    Returns True if a session exists, False otherwise.
    """
    assert isinstance(session_name, str), "Session name must be a string."

    return session_name in tmux_get_all_session_names()

def tmux_kill_session(session_name, strict=False):
    """
    Kill a specified tmux session by its name.

    Args:
        session_name (str): The name of the tmux session to kill.
        strict (bool, optional): If True, will throw an error if trying to kill a nonexistant session

    Raises:
        AssertionError: If the name is not a string.
        ValueError: If no session with the specified name exists and strict==True
        
    Example:
        >>> squelch_call(tmux_kill_session,'0') # Tries to kill session 0 if it exists
    """
    pip_import('libtmux')
    import libtmux
    
    assert isinstance(session_name, str), "Session name must be a string."

    server = libtmux.Server()
    session = server.find_where({"session_name": session_name})
    
    if session:
        session.kill_session()
        print("rp.tmux_kill_session: Session %s killed successfully."%repr(session_name))
    else:
        if strict:
            raise ValueError("rp.tmux_kill_session: No session named '{}' found.".format(session_name))

def tmux_kill_sessions(*session_names,strict=False):
    """Plural of tmux_kill_session"""
    session_names = detuple(session_names)

    #If strict, make sure all sessions exist before trying to kill any of them
    if strict:
        for name in session_names:
            if not tmux_session_exists(name):
                raise ValueError('rp.tmux_kill_sessions: Is strict and session %s doesnt exist'%repr(name))

    #Then, kill all the sessions
    for session_name in session_names:
        tmux_kill_session(session_name)


def tmux_type_in_all_panes(keystrokes: str, *, session: str = None, window: str = None):
    """
    Sends keystrokes to all panes within a specified Tmux session and window.
    If no session or window is specified, the current active session and window are used.
    If only a session is specified, it targets the default window in that session.
    If only a window is specified, it targets that window in the current session.

    Args:
        keystrokes (str): The keystrokes to send.
        session (str, optional): The target session name or ID (defaults to the current session).
        window (str, optional): The target window within the session (defaults to the current window).

    Special keys and key combinations can be sent using tmux's `send-keys` syntax.
    For example, to send `control+c`, use `"C-c"` as the `keystrokes` argument.
    Here, `"C-"` prefixes denote the Control key, followed by the key itself (`"c"` in this case).

    **Common Special Keys:**

    - **Control Key:**  Use `"C-"` prefix (e.g., `"C-c"`, `"C-v"`, `"C-a"`)
    - **Enter:** `"Enter"`
    - **Space:** `"Space"`
    - **Tab:** `"Tab"`
    - **Escape:** `"Escape"`
    - **Backspace:** `"Backspace"`
    - **Delete:** `"Delete"`
    - **Up Arrow:** `"Up"`
    - **Down Arrow:** `"Down"`
    - **Left Arrow:** `"Left"`
    - **Right Arrow:** `"Right"`

    For a comprehensive list of all supported keys, refer to the `tmux send-keys` documentation
    (e.g., by running `man tmux` in your terminal and searching for "send-keys").

    """
    import subprocess

    # Construct the target specifier
    target = ''
    if session and window:
        target = session + ':' + window
    elif session:
        target = session + ':'  # Targeting default window in specified session
    elif window:
        target = ':' + window  # Targeting specified window in the current session

    # Commands to run
    commands = [
        ["tmux", "set-window-option", "-t", target, "synchronize-panes", "on"],
        ["tmux", "send-keys", "-t", target, keystrokes],
        ["tmux", "set-window-option", "-t", target, "synchronize-panes", "off"]
    ]

    # Execute the commands
    for command in commands:
        subprocess.run(command, check=True)


def tmux_get_scrollback(with_ansi=True) -> str:
    """
    Gets the scrollback buffer of the current tmux pane.

    Returns:
        str: The scrollback buffer content.
    """
    import subprocess

    try:
        # Capture the scrollback buffer using tmux capture-pane and save to stdout
        result = subprocess.run(
            [
                "tmux",
                "capture-pane",
                *["-e"] * bool(with_ansi),
                "-p",
                "-S",
                "-",
            ],
            capture_output=True,
            text=True,
            check=True,
        )
        return result.stdout
    except subprocess.CalledProcessError as e:
        # Handle errors (e.g., not in a tmux session)
        raise RuntimeError("Failed to get tmux scrollback: " + str(e))


#Keeping this function private until it works perfectly!
def _tmux_reset_all_panes(*, session: str = None, window: str = None):
    """
    Resets all panes within a specified Tmux session and window. If no session or window is specified,
    the current active session and window are used. If only a session is specified, it targets the default window in that session.
    If only a window is specified, it targets that window in the current session.
    Args:
        session (str, optional): The target session name or ID (defaults to the current session).
        window (str, optional): The target window within the session (defaults to the current window).
    """
    import subprocess
    
    # Construct the target specifier
    target = ''
    if session and window:
        target = session + ':' + window
    elif session:
        target = session + ':'  # Targeting default window in specified session
    elif window:
        target = ':' + window  # Targeting specified window in the current session
    
    # Commands to run
    commands = [
        ["tmux", "set-window-option", "-t", target, "synchronize-panes", "on"],
        ["tmux", "send-keys", "-t", target, "clear", "Enter"],
        ["tmux", "send-keys", "-t", target, "C-l"],
        ["tmux", "set-window-option", "-t", target, "synchronize-panes", "off"]
    ]
    
    # Execute the commands
    for command in commands:
        subprocess.run(command, check=True)

def tmuxp_create_session_yaml(windows, *, session_name=None, command_before=None):
    """
    Creates a yaml file to be loaded by tmuxp. See https://github.com/tmux-python/tmuxp
    It lets you easily create such files without much overhead, pythonically.
    You provide a list of windows (or a dict of windows, if you want control over the window names).
    Each window itself is either a single command (a string) or a list of panes (str commands or lists of str commands).
    The session_name can be specified, but if it isn't it will automatically choose an unchosen session name (so it can be started without  conflicts).
    command_before can either be a string or a list of strings, and will be run in each created pane before the above specified per-pane commands
    Make use of the outputs with rp.tmuxp_launch_session_from_yaml ! (or the "tmuxp load <file.yaml>" command after saving the output to a file)


    EXAMPLE:
        >>> print(tmuxp_create_session_yaml([["echo 1", "echo 2"], "echo 3"]))

            session_name: '0'
            windows:
            - window_name: '0'
              layout: tiled
              panes:
              - echo 1
              - echo 2
            - window_name: '1'
              panes:
              - echo 3

        >>> print(tmuxp_create_session_yaml('echo 123',session_name='my_session'))

            session_name: my_session
            windows:
            - window_name: '0'
              panes:
              - echo 123

        >>> print(tmuxp_create_session_yaml({"a": "echo 123", "b": "echo 456"}, session_name="my_session"))

            session_name: my_session
            windows:
            - window_name: a
              panes:
              - echo 123
            - window_name: b
              panes:
              - echo 456

        >>> print(tmuxp_create_session_yaml([[['echo 1','echo 2','echo 3'],'echo 4','echo 5'],'echo 6'], command_before="cd ~"))

            session_name: '0'
            windows:
            - window_name: '0'
              layout: tiled
              shell_command_before: cd ~
              panes:
              - shell_command:
                - echo 1
                - echo 2
                - echo 3
              - echo 4
              - echo 5
            - window_name: '1'
              shell_command_before: cd ~
              panes:
              - echo 6

        >>> tmuxp_create_session_yaml(
        ...         [
        ...             [
        ...                 [
        ...                     "echo 1",
        ...                     "echo 2",
        ...                     "echo 3\necho w",
        ...                 ],
        ...                 "echo 4\necho x",
        ...                 "echo 5",
        ...             ],
        ...             "echo 6\necho y",
        ...         ],
        ...         command_before="cd ~\necho z",
        ...     )
        ... )
        ... #It handles multiline strings as follows:

            session_name: '0'
            windows:
            - window_name: '0'
              shell_command_before:
              - cd ~
              - echo z
              layout: tiled
              panes:
              - shell_command:
                - echo 1
                - echo 2
                - echo 3
                - echo w
              - shell_command:
                - echo 4
                - echo x
              - shell_command:
                - echo 5
            - window_name: '1'
              shell_command_before:
              - cd ~
              - echo z
              layout: tiled
              panes:
              - shell_command:
                - echo 6
              - shell_command:
                - echo y
              
        >>> ans=tmuxp_create_session_yaml([[['say Hello','say World']]])
        ... tmuxp_launch_session_from_yaml(ans,attach=False)

        >>> #Try this example from when you're *NOT* in a tmux session. It will attach you to it.
        >>> session_name=tmux_get_unique_session_name()
        ... ans=tmuxp_create_session_yaml([[['say Hello','say World']]],session_name=session_name)
        ... tmuxp_launch_session_from_yaml(ans,attach=True)
        ... #Process will be blocked until session is dead.
        ... tmux_kill_session(session_name)

        >>> #A demo of how nesting lists works
        >>> commands = ['echo a','echo b','echo c']
        >>> print(tmuxp_create_session_yaml(commands))
            session_name: '0'
            windows:
            - window_name: '0'
              panes:
              - echo a
            - window_name: '1'
              panes:
              - echo b
            - window_name: '2'
              panes:
              - echo c
        >>> print(tmuxp_create_session_yaml([commands]))
            session_name: '0'
            windows:
            - window_name: '0'
              layout: tiled
              panes:
                - echo a
                - echo b
                - echo c
        >>> print(tmuxp_create_session_yaml([[commands]]))
            session_name: '0'
            windows:
            - window_name: '0'
              panes:
              - shell_command:
                - echo a
                - echo b
                - echo c

    """
    import yaml

    # Input assertions
    assert isinstance(windows, (str, list, dict))
    assert isinstance(session_name, str) or session_name is None
    assert isinstance(command_before, (str, list)) or command_before is None

    if session_name is None:
        session_name = tmux_get_unique_session_name()

    config = {"session_name": session_name, "windows": []}

    def is_listlike(x):
        return isinstance(x, (list, tuple))

    if isinstance(windows, str):
        windows = [windows]

    if is_listlike(windows):
        windows = {str(i): e for i, e in enumerate(windows)}

    assert isinstance(windows, dict), "Internal assertion"
    assert all(isinstance(k, str) for k in windows.keys()), "All window names must be strings"
    assert all(is_listlike(v) or isinstance(v, str) for v in windows.values()), "All window commands must be strings (for single pane) or lists of strings (for multiple panes). Strings can be multiline for multiple commands. Value %s: "%windows.values() + str(windows.values())

    def process_command(command):
        if not command:
            command = []
        elif isinstance(command, str):
            command = command.splitlines()
        elif len(command) == 1:
            # Keep single-item lists as lists so we can expand multiline strings below.
            # Collapsing happens later (after expansion) in the pane loop.
            pass        
        
        #Expand any more multiline strings
        if not isinstance(command, str):
            assert is_iterable(command)
            command = list_flatten(map(str.splitlines, command))

        return command

    for window_name, pane_commands in windows.items():
        window = {
            "window_name": str(window_name),
        }

        command_before = process_command(command_before)
        if command_before:
            window["shell_command_before"] = command_before

        panes = []

        if isinstance(pane_commands, str):
            # pane_commands = pane_commands.splitlines()
            pane_commands = [pane_commands]
        assert is_listlike(pane_commands)

        for command in pane_commands:
            command = process_command(command)
            if not isinstance(command, str) and len(command)==1:
                command = command[0]
            if command:
                panes.append(command if isinstance(command, str) else {"shell_command": command})

        if len(panes)>1:
            #Only bother to add this line if we need it
            window["layout"] = "tiled"

        window["panes"] = panes


        config["windows"].append(window)

    return yaml.dump(config, default_flow_style=False, sort_keys=False, width=9999999)


def tmuxp_launch_session_from_yaml(session_yaml,*,attach=False):
    """
    Input can be a yaml string or yaml file path
    Uses the "tmuxp load <yaml file>" command to spin up a tmux session
    Good in combination with rp.tmuxp_create_session_from_yaml !
    Also to read the session name afterwards, use parse_yaml(session_yaml)['session_name']
    If attach is False, we spin up the tmux session without attaching it
    If it is True, we try to attach to it immediately. This will block the current process.

    EXAMPLE:
        >>> tmuxp_launch_session_from_yaml(
        ...     tmuxp_create_session_yaml(
        ...         [[func_call_to_shell_command(list, map(print, range(i))) for i in range(9)]]
        ...     ),
        ...     attach=True,
        ... ) 
    """
    pip_import("tmuxp")

    if file_exists(session_yaml):
        session_path = session_yaml
        session_yaml = load_text_file(session_path)
        should_delete = False
    else:
        assert isinstance(session_yaml, str), "input should be a yaml string"
        session_path = temporary_file_path("yaml")
        save_text_file(session_yaml, session_path)
        should_delete = True

    try:
        command = "tmuxp load " + shlex.quote(session_path)
        if not attach:
            #https://tmuxp.git-pull.com/cli/load.html
            #Load the session without attaching it
            command+= " -d"
            
        fansi_print("rp.tmuxp_create_session: " + command, "green", "bold")
        fansi_print(
            indentify(
                with_line_numbers(session_yaml, align=True, prefix="%i ▏"),
                indent="    ",
            ),
            "green",
        )
        os.system(command)

    finally:
        if should_delete:
            delete_file(session_path)

    ##Future: I might return something, such as the session name
    # data = parse_yaml(session_yaml)
    # assert 'session_name' in data
    # output = data['session_name']




    
#OLD, HARD-TO-READ CODE THAT DOES WORK
# def extract_code_from_ipynb(path:str=None):
#     """ Its a utility for running ipynb files in rp, by extracting their code into cells that can be run individually """
#     import json
#     if path is None:
#         path=input_select_file(file_extension_filter='ipynb')
#     assert path_exists(path),'Sorry, but '+repr(path)+' doesnt exist'
#     path=text_file_to_string(path)
#     notebook=json.loads(path)
#     code_cells=[''.join(cell['source']) for cell in notebook['cells'] if cell['cell_type']=='code'] 
#     code_cells=[code_cell for code_cell in code_cells if code_cell.strip()]
#     notebook_code='\n\n#################################\n\n'.join(code_cells)
#     notebook_code=line_join((line if not (line.startswith('%') or line.startswith('!')) else '#'+line) for line in line_split(notebook_code))
#     ans=notebook_code
#     return ans


def _extract_code_cells_from_ipynb(notebook_path=None):
    """
    Extract code cells from a Jupyter notebook as a list.

    Args:
        notebook_path (str, optional): Path to the notebook file. If None, prompts for selection.

    Returns:
        list: List of processed code cells from the notebook.
    """
    import json

    # Handle path input
    if notebook_path is None:
        notebook_path = input_select_file(file_extension_filter='ipynb')

    notebook_path = os.path.expanduser(notebook_path)

    # Validate path exists
    if not path_exists(notebook_path):
        raise ValueError("Notebook path does not exist: "+str(notebook_path))

    # Read and parse notebook
    notebook_data = load_json(notebook_path)

    # Extract code from code cells, skipping empty cells
    code_blocks = []
    for cell in notebook_data['cells']:
        if cell['cell_type'] == 'code':
            cell_code = ''.join(cell['source'])
            if cell_code.strip():
                code_blocks.append(cell_code)

    # Process code blocks - comment out magic and shell commands
    processed_blocks = []
    for block in code_blocks:
        processed_lines = []
        for line in block.split('\n'):
            if line.startswith('%') or line.startswith('!'):
                processed_lines.append('#' + line)
            else:
                processed_lines.append(line)
        processed_blocks.append('\n'.join(processed_lines))

    return processed_blocks

_ipynb_separator = '#'*50

def exec_ipynb(notebook_path:str, *, scope=None, show_code=True):
    """
    Run code from a jupyter notebook 
    TODO: Add show_text and show_markdown options too 
    """
    if scope is None:
        #Execute code in the scope of the caller
        scope=get_scope(1)

    cells = _extract_code_cells_from_ipynb(notebook_path)

    if show_code:

        #We also have a display_markdown() helper func ready to go! TODO: Please use it.
        def _announce_cell(cell_num):
            display_code_cell(
                cells[cell_num],
                title=" "
                + get_file_name(notebook_path, include_file_extension=False)
                + " : CELL #"
                + str(cell_num)
                + " ",
            )

        halt=False
        with TemporarilySetItem(scope, dict(_announce_cell=_announce_cell)):
            for cell_num,cell in enumerate(cells):
                if halt:
                    break
                _announce_cell(cell_num)

                if running_in_jupyter_notebook():
                    with _get_jupyter_output_widget():
                        #Redirect Jupyter's output into an output widget
                        #We do this so if something calls clear_output, 
                        #   it only clears the output of this 'virtual'
                        #   cell, instead of clearing all previous cells.
                        try:
                            exeval(cell, scope)
                        except:
                            #When capturing the output, it won't stop on error
                            #It will keep plowing through unless we stop it...
                            halt=True
                            raise
                else:
                    exeval(cell, scope)


    else:
        for cell in cells:
            exeval(cell, scope)

def _get_jupyter_output_widget():
    pip_import("IPython")
    pip_import("ipywidgets")

    import ipywidgets as widgets
    from IPython.display import display

    output_widget = widgets.Output()
    display(output_widget)
    return output_widget

def extract_code_from_ipynb(notebook_path=None):
    """
    Extract and concatenate code from a Jupyter notebook as a single string.

    Args:
        notebook_path (str, optional): Path to the notebook file. If None, prompts for selection.

    Returns:
        str: Concatenated code from all code cells with cell separators.
    """
    # Get processed code blocks
    code_blocks = _extract_code_cells_from_ipynb(notebook_path)

    # Join with separators
    cell_separator = '\n\n'+_ipynb_separator+'\n\n'
    return cell_separator.join(code_blocks)

@memoized
def _get_facebook_client(email,password):
    #Cache this to make it faster when sending repeated messages etc
    assert False,'This function is currently broken and will fail to log into facebook because a dependency called fbchat is no longer maintained and no longer compatiable with facebook.com\'s api. TODO: Update this function'
    pip_import('fbchat')
    import fbchat
    return fbchat.Client(email,password)

def send_facebook_message(message:str=None,my_email:str=None,my_password:str=None):
    """
    TODO: Fix this its too old to work with current facebook APIs
    """
    pip_import('fbchat')
    import fbchat as f
    e=my_email or input("Please enter your facebook account's email: ")
    p=my_password or input("Please enter your facebook account's password: ")
    me=_get_facebook_client(e,p)
    users=me.fetchAllUsers()
    users=sorted(users,key= lambda x: x.name)
    user=input_select('Please select the user you\'d like to message: ',users,stringify=lambda x:x.name)
    m=message or input("Please enter your message: ")
    user_id=user.uid
    return me.sendMessage(m,user_id)

def get_all_facebook_messages(my_email:str=None,my_password:str=None,my_name:str=None,max_number_of_messages:int=9999)->list:
    """
    TODO: Fix this its too old to work with current facebook APIs
    Returns a list of all messages between you and one of your contacts on facebook
    Uses a python package called 'fbchat' to do this
    I used this to download all messages between me and one of my friends for safekeeping (facebook makes this difficult)
    Todo: Let this import groupchat history and not just direct messages between you and someone else
    """
    pip_import('fbchat')
    import fbchat as f
    e=my_email or input("Please enter your facebook account's email: ")
    p=my_password or input("Please enter your facebook account's password: ")
    my_name=input("Please enter your name: ")
    me=_get_facebook_client(e,p)
    users=me.fetchAllUsers()
    users=sorted(users,key= lambda x: x.name)
    user=input_select('Please select another facebook user:',users,stringify=lambda x:x.name)
    user_id=user.uid
    user_name=user.name
    messages=me.fetchThreadMessages(user_id,limit=max_number_of_messages)
    messages=messages[::-1]#Make the most recent message come last, not first. This is the way it shows it in facebook messenger
    def format(a,t):
        a=str(a)
        t=str(t)
        o=a
        o+='\n'
        o+=t
        return o
    message_tuples=[(user_name if m.author==user_id else my_name, m.text) for m in messages]
    message_strings=[format(*message_tuple) for message_tuple in message_tuples]
    output='\n\n'.join(message_strings)
    print(output)
    return message_tuples

def explore_torch_module(module):
    """
    Lets you explore a pytorch module in a graphical terminal ui.
    No more wondering what a config does to a model - it runs on executing code (not static)!
    Make sure to run this in a terminal emulator! It can be over SSH, that's fine.
    It tells you which parts of a model take how many gigavytes etc, lets you explore their source code and attributes

    EXAMPLE: Explore Stable Diffusion's U-Net
        >>> #pip install rp textual textual[syntax]
        ... 
        ... import rp
        ... import torch
        ... from diffusers import StableDiffusionPipeline
        ... 
        ... pipe = StableDiffusionPipeline.from_pretrained("CompVis/stable-diffusion-v1-4", revision="fp16", torch_dtype=torch.float16)
        ... rp.explore_torch_module(pipe.unet)
        ... #Shows a TUI that looks something like this:
        ... # ╔════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════╗
        ... # ║▼ 1.6GB UNet2DConditionModel                                    │                                                                                                   ║
        ... # ║├── 23.1KB (conv_in): Conv2d(4, 320, kernel_size=(3, 3), stride=│             /opt/homebrew/lib/python3.10/site-packages/diffusers/models/attention.py              ║
        ... # ║├── (time_proj): Timesteps                                      │                                                                                                   ║
        ... # ║├── ▶ 3.9MB (time_embedding): TimestepEmbedding                 │───────────────────────────────────────────────────────────────────────────────────────────────────║
        ... # ║├── ▼ 476.1MB (down_blocks): ModuleList                         │▊▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▎║
        ... # ║│   ├── ▶ 20.1MB (0): CrossAttnDownBlock2D                      │▊   1  @maybe_allow_in_graph                                                                      ▎║
        ... # ║│   ├── ▶ 70.2MB (1): CrossAttnDownBlock2D                      │▊   2  class BasicTransformerBlock(nn.Module):                                                 ▄▄ ▎║
        ... # ║│   ├── ▼ 267.0MB (2): CrossAttnDownBlock2D                     │▊   3      r'''                                                                                   ▎║
        ... # ║│   │   ├── ▼ 132.6MB (attentions): ModuleList                  │▊   4      A basic Transformer block.                                                             ▎║
        ... # ║│   │   │   ├── ▼ 66.3MB (0): Transformer2DModel                │▊   5                                                                                             ▎║
        ... # ║│   │   │   │   ├── 5KB (norm): GroupNorm(32, 1280, eps=1e-06, a│▊   6      Parameters:                                                                            ▎║
        ... # ║│   │   │   │   ├── 3.1MB (proj_in): Conv2d(1280, 1280, kernel_s│▊   7          dim (`int`): The number of channels in the input and output.                       ▎║
        ... # ║│   │   │   │   ├── ▼ 60.0MB (transformer_blocks): ModuleList   │▊   8          num_attention_heads (`int`): The number of heads to use for multi-head             ▎║
        ... # ║│   │   │   │   │   └── ▼ 60.0MB (0): BasicTransformerBlock     │▊      attention.                                                                                 ▎║
        ... # ║│   │   │   │   │       ├── 5KB (norm1): LayerNorm((1280,), eps=│▊   9          attention_head_dim (`int`): The number of channels in each head.                   ▎║
        ... # ║│   │   │   │   │       ├── ▼ 12.5MB (attn1): Attention         │▊  10          dropout (`float`, *optional*, defaults to 0.0): The dropout probability to use.    ▎║
        ... # ║│   │   │   │   │       │   ├── 3.1MB (to_q): Linear(in_features│▊  11          cross_attention_dim (`int`, *optional*): The size of the encoder_hidden_states     ▎║
        ... # ║│   │   │   │   │       │   ├── 3.1MB (to_k): Linear(in_features│▊      vector for cross attention.                                                                ▎║
        ... # ║│   │   │   │   │       │   ├── 3.1MB (to_v): Linear(in_features│▊  12          activation_fn (`str`, *optional*, defaults to `"geglu"`): Activation function      ▎║
        ... # ║│   │   │   │   │       │   └── ▶ 3.1MB (to_out): ModuleList    │▊      to be used in feed-forward.                                                                ▎║
        ... # ║│   │   │   │   │       ├── 5KB (norm2): LayerNorm((1280,), eps=│▊  13          num_embeds_ada_norm (:                                                             ▎║
        ... # ║│   │   │   │   │       ├── ▶ 10.0MB (attn2): Attention         │▊  14              obj: `int`, *optional*): The number of diffusion steps used during             ▎║
        ... # ║│   │   │   │   │       ├── 5KB (norm3): LayerNorm((1280,), eps=│▊      training. See `Transformer2DModel`.                                                        ▎║
        ... # ║│   │   │   │   │       └── ▶ 37.5MB (ff): FeedForward          │▊  15          attention_bias (:                                                                  ▎║
        ... # ║│   │   │   │   └── 3.1MB (proj_out): Conv2d(1280, 1280, kernel_│▊  16              obj: `bool`, *optional*, defaults to `False`): Configure if the attentions     ▎║
        ... # ║│   │   │   └── ▶ 66.3MB (1): Transformer2DModel                │▊      should contain a bias parameter.                                                           ▎║
        ... # ║│   │   ├── ▶ 106.3MB (resnets): ModuleList                     │▊  17          only_cross_attention (`bool`, *optional*):                                         ▎║
        ... # ║│   │   └── ▶ 28.1MB (downsamplers): ModuleList                 │▊  18              Whether to use only cross-attention layers. In this case two cross             ▎║
        ... # ║│   └── ▶ 118.8MB (3): DownBlock2D                              │▊▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▎║
        ... # ║├── ▼ 974.3MB (up_blocks): ModuleList                           │───────────────────────────────────────────────────────────────────────────────────────────────────║
        ... # ║│   ├── ▶ 309.5MB (0): UpBlock2D                                │                                                                                                   ║
        ... # ║│   ├── ▶ 492.7MB (1): CrossAttnUpBlock2D                       │                   Attributes: down_blocks[2].attentions[0].transformer_blocks[0]                  ║
        ... # ║│   ├── ▶ 136.2MB (2): CrossAttnUpBlock2D                       │                                                                                                   ║
        ... # ║│   └── ▶ 35.9MB (3): CrossAttnUpBlock2D                        │───────────────────────────────────────────────────────────────────────────────────────────────────║
        ... # ║├── ▶ 185.1MB (mid_block): UNetMidBlock2DCrossAttn              │▼ Attributes of BasicTransformerBlock                                                              ║
        ... # ║├── 1.2KB (conv_norm_out): GroupNorm(32, 320, eps=1e-05, affine=│├── ▶ T_destination: TypeVar                                                                       ║
        ... # ║├── (conv_act): SiLU                                            │├── activation_fn: "geglu"                                                                         ║
        ... # ║└── 22.5KB (conv_out): Conv2d(320, 4, kernel_size=(3, 3), stride│├── attention_bias: False                                                                          ║
        ... # ║                                                                │├── attention_head_dim: 160                                                                        ║
        ... # ║                                                                │├── call_super_init: False                                                                         ║
        ... # ║                                                                │├── cross_attention_dim: 768                                                                     ▃▃║
        ... # ║                                                                │├── dim: 1280                                                                                      ║
        ... # ║                                                                │├── double_self_attention: False                                                                   ║
        ... # ║                                                                │├── dropout: 0.0                                                                                   ║
        ... # ║                                                                │├── dump_patches: False                                                                            ║
        ... # ║                                                                │├── ▼ forward_stats: [dict with 20 keys, dict with 20 keys, dict with 20 keys, dict with 20 keys]  ║
        ... # ╚════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════╝
    """

    # _ensure_textual_syntax_installed() # ---> pip install rp textual textual[syntax]
    # def _ensure_textual_syntax_installed():
    #     pip_import('textual')
    #     try:
    #         import textual.widgets
    #         textual.widgets._text_area.TextArea(language='python')
    #     except TypeError:
    #         #TypeError: Language.__init__() missing 1 required positional argument: 'name'
    #         if r._pip_import_autoyes or input_yes_no('Can we please "pip install textual[syntax]" to upgrade your current textual version so you can have syntax highlighting?'):
    #             _run_sys_command(shlex.quote(os.system),' pip install textual[syntax] --upgrade')
    #             fansi_print("Please restart your python instance - a new version of textual was just installed!",'green green bold underlined italic on dark dark yellow')

    try:
        # https://github.com/RyannDaGreat/torchviewer.git
        import rp.libs.pytorch_module_explorer as pme
        pme.explore_module(module)
    except TypeError:
        fansi_print(
            "Please run 'pip install textual[syntax] --upgrade' - this might resolve your issue, as it appears you have textual installed but not with syntax highlighting support, which this viewer needs",
            "green green bold underlined italic on dark dark yellow",
        )

def record_torch_module_forward_stats(module):
    """
    A context manager to wrap a call of a torch module! Records tons of stats about it
    Best to use with rp.explore_torch_module - see the self-contained example!

    EXAMPLE:
        >>> from diffusers import StableDiffusionPipeline
        ... import rp
        ... import torch
        ... 
        ... device = rp.select_torch_device()
        ... 
        ... pipe = StableDiffusionPipeline.from_pretrained("CompVis/stable-diffusion-v1-4", revision="fp16", torch_dtype=torch.float16)
        ... pipe = pipe.to(device)
        ... 
        ... # Record input/output tensor shapes, etc for all modules
        ... with rp.record_torch_module_forward_stats(pipe):
        ...     pipe('Image of Doggy', num_inference_steps=3)
        ... 
        ... # Explore the collected stats - press 'f' to see them
        ... rp.explore_torch_module(pipe)
    """
    import rp.libs.torch_hooks as th
    return th.record_module_forward_stats(module)

def visualize_pytorch_model(model,*,input_shape=None, example_input=None, supress_warnings=True):
    """
    TODO: integrate code better with _visualize_pytorch_model_via_torchviz: get rid of redundant code
    Show a graph depicting some pytorch-based neural network
     - model: should be some neural network model created in pytorch
     - input_shape: should be the shape of a single input. For example, if MNIST is the input, input_shape should be [28, 28].
          We need input_shape in order to determine the size of each layer in the network.
     - example_input: an alternative to using input_shape (particularly useful for networks that don't take torch.Tensor in their forward model)
    See https://github.com/waleedka/hiddenlayer/blob/master/demos/pytorch_graph.ipynb for a demo
    
    EXAMPLE:
        >>> import torchvision.models
        ... model = torchvision.models.vgg16()
        ... visualize_pytorch_model(model,[3,224,224])

    """

    import warnings

    with warnings.catch_warnings():
        warnings.filterwarnings('ignore') #Sometimes hiddenlayer will complain that a model is in training mode because of some ONNX exporting mismatch issue. It's probably not worth warning the user about.
            
        pip_import('hiddenlayer')  #This library is used to draw the neural network. See github.com/waleedka/hiddenlayer
        pip_import('torch'      )  #We obviously need pytorch installed to use this function
        pip_import('graphviz')
        import hiddenlayer, torch
        assert isinstance(model,torch.nn.Module)    

        assert example_input is None or input_shape is None,'Please only specify one, not both: either input_shape or example_input should be None'
        
        if input_shape is not None:
            input_shape=[1, *input_shape] #The first dimension refers to the number of samples. For simplicity's sake, we're going to use just one sample.
            model_input=torch.zeros(input_shape)
            model=model.cpu()
        else:
            model_input=example_input

        graph = hiddenlayer.build_graph(model=model, args=model_input)
        
        if running_in_ipython(): 
            #If we're in a jupyter notbook, display the graph inside it 
            from IPython.display import display
            display(graph)
        else:
            file_type = 'pdf'
            output_path = temporary_file_path(file_type)
            graph.save(path=output_path, format=file_type)
            # display_image(load_image(output_path),block=block) #We would use this line if we wanted to rasterize it. However, a PDF is probably the best option
            open_file_with_default_application(output_path)# If we're making a pdf, open it in some pdf viewer


def get_sinusoidal_positional_encodings(shape, channels, scale=10000.0):
    """
    Generate sinusoidal position encodings for transformer models.
    
    Parameters:
        shape: int or list/tuple of ints
            Length of the sequence for 1D encoding, or spatial dimensions for multi-dimensional encoding.
        channels: int or list/tuple of ints
            Dimension of the model (embedding dimension) for 1D encoding, or embedding dimensions per
            spatial dimension for multi-dimensional encoding.
        scale: float or list/tuple of floats, optional
            Scaling factor for the frequencies, by default 10000.0¹. Can be specified per dimension.
    
    Returns:
        np.ndarray
            Position encoding matrix. For 1D encoding, shape will be (shape, channels).
            For multi-dimensional encoding, shape will be (*shape, sum(channels)).
    
    Notes:
        The position encoding uses sine for even indices and cosine for odd indices
        in the embedding dimension, with frequencies decreasing as the dimension increases.
        
        For multi-dimensional inputs, positional encodings are created separately for each dimension
        and then concatenated along the channel dimension. This allows different encoding parameters
        per dimension.
        
        ¹ The value 10000 comes from the original "Attention is All You Need" paper by 
        Vaswani et al. (2017), Section 3.5 "Positional Encoding", page 6. The paper 
        states: "We use sine and cosine functions of different frequencies... 
        where pos is the position and i is the dimension." The specific value 10000 
        appears in equation (5) as a scaling factor that determines how quickly the 
        frequencies decrease across embedding dimensions. While the paper doesn't 
        explicitly justify why 10000 was chosen, it has become the standard in transformer 
        implementations. Paper link: https://arxiv.org/pdf/1706.03762.pdf
    
    EXAMPLES:

        >>> #---------------------------
        ... # VISUALIZATION EXAMPLE
        ... #---------------------------
        ... import numpy as np
        ... import matplotlib.pyplot as plt
        ... from typing import Tuple
        ...
        ... # Example 1: Generate and print a small position encoding matrix
        ... P = get_sinusoidal_positional_encodings(shape=4, channels=4, scale=100)
        ... print("Position encoding matrix (4×4):")
        ... print(P)
        ... print("Shape: %s" % str(P.shape))
        ...
        ... # Example 2: Visualize a larger position encoding matrix
        ... P_large = get_sinusoidal_positional_encodings(shape=256, channels=128)
        ... plt.figure(figsize=(10, 8))
        ... plt.imshow(P_large, cmap='viridis', aspect='auto')
        ... plt.colorbar()
        ... plt.title("Position Encodings (20×64)")
        ... plt.xlabel("Embedding Dimension")
        ... plt.ylabel("Sequence Position")
        ... plt.savefig("position_encoding_heatmap.png")
        ... plt.show()  # Uncomment to display the plot

        >>> #BASIC USAGE
        >>> encoding = get_position_encoding(shape=4, channels=6)
        >>> encoding.shape
        (4, 6)
        >>> encoding = get_position_encoding(shape=10, channels=512)
        >>> encoding.shape
        (10, 512)
        
        >>> #INPUT --> SHAPE DEMOS:
        >>> print(get_sinusoidal_positional_encodings((10,10,5),[17])            .shape)#   -->   (10, 10, 5, 51)
        >>> print(get_sinusoidal_positional_encodings((10,10,5),17)              .shape)#   -->   (10, 10, 5, 51)
        >>> print(get_sinusoidal_positional_encodings(10,17)                     .shape)#   -->   (10, 17)
        >>> print(get_sinusoidal_positional_encodings([10],17)                   .shape)#   -->   (10, 17)
        >>> print(get_sinusoidal_positional_encodings([10,20],17)                .shape)#   -->   (10, 20, 34)
        >>> print(get_sinusoidal_positional_encodings([10,20],[17,1])            .shape)#   -->   (10, 20, 18)
        >>> print(get_sinusoidal_positional_encodings([10,20],[17,1],[1])        .shape)#   -->   (10, 20, 18)
        >>> print(get_sinusoidal_positional_encodings([10,20],[17,1],[1,2])      .shape)#   -->   (10, 20, 18)
        >>> print(get_sinusoidal_positional_encodings([10,20],[17,1],[1,2,3])    .shape)#   -->   ERROR: ValueError: Error: All lists must have the same length or be scalars. Lengths provided: [2, 2, 3]
        >>> print(get_sinusoidal_positional_encodings([10,20],[17,1,4],[1,2])    .shape)#   -->   ERROR: ValueError: Error: All lists must have the same length or be scalars. Lengths provided: [2, 3, 3]
        >>> print(get_sinusoidal_positional_encodings([10,20,5],[17,1,4],[1,2,3]).shape)#   -->   (10, 20, 5, 22)
        >>> print(get_sinusoidal_positional_encodings([10],[17,1,4],[1,2,3])     .shape)#   -->   (10, 10, 10, 22)
        >>> print(get_sinusoidal_positional_encodings(10,[17,1,4],[1,2,3])       .shape)#   -->   ERROR: ValueError: channels must be a positive integer, got [17, 1, 4]

        >>> def demo_encoding_similarity(channels=256, scale=10000):
        ...     '''
        ...     This demo shows how channels and scale affect similaity values
        ...     Play around with it, generating different videos!
        ...     As channels --> Infinity, the distance function becomes super smooth
        ...     It nicely shows how the cosine similarity is equivariant to the query point
        ...     This also shows why fourier features don't use just 2 axes...the similaity is so strongly aligned with the axes...
        ...     Note: Best to run this locally on a macbook so you can preview the animation as its calculated
        ...     '''
        ...     import math
        ... 
        ...     def get_video():
        ...         encodings = get_sinusoidal_positional_encodings([512, 512], channels, scale)
        ...         encodings = encodings.astype(np.float32)
        ...         # display_video(video_with_progress_bar(full_range(encodings).transpose(2,0,1)),framerate=20,loop=True)
        ... 
        ...         coordinate = [64, 64]  # No longer...
        ... 
        ...         angles = range(360)
        ...         coordinates = [
        ...             (round(256 + 205 * math.cos(angle)), round(256 + 205 * math.sin(angle)))
        ...             for angle in [angle * 2 * math.pi / 360 for angle in angles]
        ...         ]
        ... 
        ...         for coordinate in eta(coordinates):
        ...             x, y = coordinate
        ...             encoding = encodings[y, x]
        ...             similarity = (encoding[None, None, :] * encodings).sum(-1)
        ...             similarity_image = full_range(similarity)
        ...             similarity_image = apply_colormap_to_image(similarity_image, "jet")
        ...             similarity_image = cv_draw_circle(similarity_image, x, y, rim=2)
        ...             similarity_image = labeled_image(
        ...                 similarity_image, f"Embedding Similaity: x={x} y={y}", font="R:Futura"
        ...             )
        ...             yield similarity_image
        ... 
        ...     display_video(get_video(), loop=True)
        ... 
        ... 
        ... demo_encoding_similarity()
    """
    
    #---------------------------
    # MULTIDIMENSIONAL HANDLING
    #---------------------------
    if is_iterable(shape):
        #---------------------------
        # BROADCASTING
        #---------------------------
        #Optionally us to specify channels and scale on a per-dimension basis
        if is_iterable(channels): channels = list(channels)
        if is_iterable(scale   ): scale    = list(scale   )
        shape = list(shape)
        shape, channels, scale = broadcast_lists(shape, channels, scale)
        shape = tuple(shape)
    
        #---------------------------
        # INPUT VALIDATION
        #---------------------------
        if not all(isinstance(size, int) and size > 0 for size in shape):
            raise ValueError("given shape must have all positive ints, but got %s" % shape)
        
        if not all(isinstance(chan, int) and chan > 0 for chan in channels):
            raise ValueError("given channels must all be positive ints, but got %s" % shape)

        #---------------------------
        # CALCULATE RESULT
        #---------------------------
        encodings = [get_sinusoidal_positional_encodings(shap, chan, scal) for (shap,chan,scal) in zip(shape,channels,scale)]
        
        # For a shape like (H, W), create positional encodings with shape (H, W, 2*channels)
        result = None
        for i, (enc, chan) in enumerate(zip(encodings,channels)):
            # Create the shape for reshaping: [1, 1, ..., size_i, ..., 1, channels]
            reshape_shape = [1] * len(shape) + [chan]
            reshape_shape[i] = shape[i]
            
            # Create the tile repeats: [H, W, ..., 1] with a 1 at position i
            tile_shape = list(shape)
            tile_shape[i] = 1
            
            # Reshape and tile/repeat
            reshaped = enc.reshape(reshape_shape)
            tiled = np.tile(reshaped, tile_shape + [1])
            
            # Add to result
            if result is None:
                result = tiled
            else:
                result = np.concatenate([result, tiled], axis=-1)

        assert result.shape == (*shape, sum(channels))
        
        return result

    #---------------------------
    # INPUT VALIDATION
    #---------------------------
    if not isinstance(shape, int) or shape <= 0:
        raise ValueError("shape must be a positive integer, got %s" % shape)
    
    if not isinstance(channels, int) or channels <= 0:
        raise ValueError("channels must be a positive integer, got %s" % channels)
    

    #---------------------------
    # SPECIAL CASE: ODD CHANNELS
    #---------------------------
    if channels % 2 != 0:
        # If the given number of channels is odd, calculate with one extra channel then clip it off
        position_encoding = get_sinusoidal_positional_encodings(shape, channels + 1, scale)[:, :channels]
        assert position_encoding.shape == (shape, channels)
        return position_encoding
    

    #---------------------------
    # POSITION ENCODING CALCULATION
    #---------------------------
    # Initialize position encoding matrix
    position_encoding = np.zeros((shape, channels))
    
    # Generate position encodings
    for pos in range(shape):
        for i in range(channels // 2):
            denominator = scale ** (2 * i / channels)
            position_encoding[pos, 2 * i] = np.sin(pos / denominator)
            position_encoding[pos, 2 * i + 1] = np.cos(pos / denominator)
    

    #---------------------------
    # OUTPUT VALIDATION
    #---------------------------
    assert position_encoding.shape == (shape, channels)
    
    return position_encoding


#def _visualize_pytorch_model_via_torchviz(model,*,input_shape=None, example_input=None):
#    pip_import('torch'      )
#    pip_import('torchviz'   )
#    import torch
#    import torchviz
#    assert isinstance(model,torch.nn.Module)    

#    assert example_input is None or input_shape is None,'Please only specify one, not both: either input_shape or example_input should be None'
    
#    if input_shape is not None:
#        input_shape=[1, *input_shape] #The first dimension refers to the number of samples. For simplicity's sake, we're going to use just one sample.
#        model_input=torch.zeros(input_shape)
#        model=model.cpu()
#    else:
#        model_input=example_input

#    return torchviz.make_dot(model(model_input))


#def visualize_pytorch_model_via_torchviz(model,*,input_shape=None, example_input=None):
#    #TODO: integrate code better with _visualize_pytorch_model_via_torchviz: get rid of redundant code
#    #Show a graph depicting some pytorch-based neural network
#    # - model: should be some neural network model created in pytorch
#    # - input_shape: should be the shape of a single input. For example, if MNIST is the input, input_shape should be [28, 28].
#    #      We need input_shape in order to determine the size of each layer in the network.
#    # - example_input: an alternative to using input_shape (particularly useful for networks that don't take torch.Tensor in their forward model)
#    #See https://github.com/waleedka/hiddenlayer/blob/master/demos/pytorch_graph.ipynb for a demo
#    #
#    #EXAMPLE:
#    #    import torchvision.models
#    #    model = torchvision.models.vgg16()
#    #    visualize_pytorch_model(model,[3,224,224])
#    return _visualize_pytorch_model_via_hiddenlayer(model,input_shape=input_shape,example_input=example_input)
#    return _visualize_pytorch_model_via_torchviz(model,input_shape=input_shape,example_input=example_input)

def inverted_color(color):
    """
        >>> inverted_color('#00FF00')      --->  #FF00FF               #If given color is in hex form, keep the hashtag
        >>> inverted_color('00FF00')       --->  FF00FF                #Or if given color is hex with no hashtag, dont include hashtag
        >>> inverted_color((255,0,0))      --->  (0, 255, 255)         #Works with byte colors
        >>> inverted_color((1,0,0))        --->  (254, 255, 255)       #If colors are ints, are treated as bytes
        >>> inverted_color((1.0,0,0))      --->  (0.0, 1, 1)           #Float colors treated differently
        >>> inverted_color((1.,0.,0.,0.))  --->  (0.0, 1.0, 1.0, 0.0)  #Alpha is not inverted, it is kept the same
        >>> inverted_color((1.,0.,0.,1.))  --->  (0.0, 1.0, 1.0, 1.0)  #(ditto to above)
        >>> inverted_color('green')        --->  (1.0, 0.0, 1.0, 1.0)  #If a string is given, compatiable with as_rgba_float_color, return a float color
    """
    if   is_binary_color(color): return tuple(not x for x in color[:3])+tuple(color[3:])
    elif is_byte_color(color)  : return tuple(255-x for x in color[:3])+tuple(color[3:])
    elif is_float_color(color) : return tuple(1-x   for x in color[:3])+tuple(color[3:])
    elif isinstance(color, str):
        hashtag = color.startswith('#')
        if hashtag:
            color = color[1:]

        is_hex = len(color) == 6 and set(color) <= set("0123456789abcdefABCDEF")

        output = inverted_color(
            as_rgba_float_color(color),
        )

        if is_hex:
            output = float_color_to_hex_color(output, hashtag=hashtag)

        return output
    else:
        raise TypeError('Unknown color format: '+repr(color))

def inverted_image(image, invert_alpha=False):
    """ Inverts the colors of an image. By default, it doesn't touch the alpha channel (if one exists) """
    assert is_image(image)
    image=image.copy()
    if is_rgba_image(image) and not invert_alpha:
        if is_byte_image(image):
            image[:,:,:3]=255-image[:,:,:3]
        elif is_float_image(image):
            image[:,:,:3]=1-image[:,:,:3]
        elif is_binary_image(image):
            image[:,:,:3]=~image[:,:,:3]
    else:
        if is_byte_image(image):
            image=255-image
        elif is_float_image(image):
            image=1-image
        elif is_binary_image(image):
            image=~image
    return image
invert_image = inverted_image

def inverted_images(images, invert_alpha=False):
    """
    Batch invert colors of multiple images. See inverted_image for single images.
    
    Enhanced Documentation:
    Processes multiple images at once for efficient batch color inversion operations.
    Commonly used for data augmentation, artistic effects, or creating negative-style images.
    Handles both lists of images and numpy arrays of stacked images automatically.

    Parameters:
        images (list or numpy.ndarray): Collection of images to invert
        invert_alpha (bool, optional): Whether to invert alpha channel. Defaults to False

    Returns:
        list or numpy.ndarray: Inverted images in same format as input
            - If input is numpy array: returns numpy array with same shape
            - If input is list: returns list of inverted images
            - Each image maintains original dtype and dimensions

    Examples:
        >>> import numpy as np
        >>> # Test with list of images
        >>> test_images = [np.ones((5, 5, 3), dtype=np.uint8) * 100, 
        ...                np.ones((3, 3, 3), dtype=np.uint8) * 200]
        >>> result = inverted_images(test_images)
        >>> print(type(result))
        <class 'list'>
        >>> print(len(result))
        2
        >>> print(result[0][0,0])
        [155 155 155]
        
        >>> # Test with numpy array stack
        >>> stacked = np.array(test_images)  
        >>> result_stacked = inverted_images(stacked)
        >>> print(type(result_stacked))
        <class 'numpy.ndarray'>
        
    Related Functions:
        - inverted_image(): Invert single image
        - invert_images: Alias for this function

    Tags: images, batch, inversion, plural, data-augmentation
    """
    if is_numpy_array(images):
        return as_numpy_array(gather_args_call(inverted_images, list(images)))
    return [gather_args_call(inverted_image, image) for image in images]
invert_images = inverted_images

def make_zip_file_from_folder(src_folder:str=None, dst_zip_file:str=None, *, show_progress=False)->str:
    """
    Creates a .zip file on your hard drive.
    Zip the contents of some src_folder and return the output zip file's path

    Parameters:
        - src_folder (str, optional): The folder to zip. If not provided, prompts user to select.
        - dst_zip_file (str, optional): Destination path for the zip file. If not provided, creates next to source folder.
        - show_progress (bool, optional): If True, shows compression progress. Default is False.

    Returns:
        str: Path to the created zip file
    """
    if src_folder is None:
        print("Please select a folder whose contents you'd like to zip:")
        src_folder=input_select_folder()
        
    assert is_a_folder(src_folder)
    
    tmp_path=temporary_file_path()

    try:
        if show_progress:
            # Match shutil.make_archive behavior exactly: follow symlinks, dereference them
            import zipfile
            import os

            # Collect all files (following symlinks like shutil.make_archive does)
            file_list = []
            for root, dirs, files in os.walk(src_folder):
                for file in files:
                    file_list.append(os.path.join(root, file))

            # Create zip with progress
            with zipfile.ZipFile(tmp_path+'.zip', 'w', zipfile.ZIP_DEFLATED) as zipf:
                for file_path in eta(file_list, title="rp.make_zip_file_from_folder"):
                    arcname = os.path.relpath(file_path, src_folder)
                    # Write normally - this dereferences symlinks like shutil.make_archive
                    zipf.write(file_path, arcname)
        else:
            # Golden standard behavior
            import shutil
            shutil.make_archive(tmp_path, 'zip', src_folder)
    except KeyboardInterrupt:
        delete_file(tmp_path+'.zip') #It will be corrupted. Don't waste space
        raise
    tmp_path+='.zip'

    if dst_zip_file is None:
        new_path=src_folder+'.zip'
        new_path=get_unique_copy_path(new_path) #Zip files can be large and time consuming to create. Don't overwrite them - make a copy.
        new_path=new_path[:-len('.zip')]
        new_path+='.zip'
        dst_zip_file=new_path

    move_file(tmp_path,dst_zip_file)
        
    return dst_zip_file

def extract_zip_file(zip_file_path, folder_path=None, *, treat_as=None, show_progress=False):
    """
    Extracts a zip or tar file to a specified folder. If the folder doesn't exist, it is created.

    Parameters:
        - zip_file_path (str): The path to the zip or tar file to extract.
        - folder_path (str, optional): The destination folder path for extracted contents. If not provided, 
          extracts to a new folder next to the zip file, with the same name as the file.
        - treat_as (str, optional): If specified as 'zip' or 'tar', treats the file at zip_file_path as a zip or tar file,
          useful for files like .pptx that are zip archives but have a different extension.
        - show_progress (bool, optional): If True, shows extraction progress. Default is False.

    Returns:
        The path to the folder where files were extracted.
    """

    assert treat_as in [None, 'zip', 'tar'], 'Currently treat_as only supports .zip and .tar files' #TODO: Expand this functionality to .rar files etc with pyunpack

    if folder_path is None:
        # By default, extract path/to/thing.zip to a new folder called path/to/thing
        folder_path = strip_file_extension(zip_file_path)
    
    assert isinstance(zip_file_path, str)
    assert isinstance(folder_path, str)
    make_directory(folder_path)
    assert folder_exists(folder_path)

    if get_file_extension(zip_file_path) == 'zip' or treat_as == 'zip':
        # If we're just unpacking a zip file, we don't need pyunpack
        import zipfile
        with zipfile.ZipFile(zip_file_path, 'r') as zip_ref:
            if show_progress:
                file_list = zip_ref.namelist()
                for file in eta(file_list, title="rp.extract_zip_file to "+folder_path):
                    zip_ref.extract(file, folder_path)
            else:
                zip_ref.extractall(folder_path)
    
    elif get_file_extension(zip_file_path) == 'tar' or treat_as == 'tar':
        # If it's a tar file, use tarfile to extract it
        import tarfile
        with tarfile.open(zip_file_path, 'r') as tar_ref:
            if show_progress:
                file_list = tar_ref.getnames()
                for file in eta(file_list, title="rp.extract_zip_file to "+folder_path):
                    tar_ref.extract(file, folder_path)
            else:
                tar_ref.extractall(folder_path)
    
    else:
        # If it's not a zip or tar file, don't give up. We can still unzip rar, jar, 7z, and other filetypes with the help of pyunpack
        _extract_archive_via_pyunpack(zip_file_path, folder_path)

    _maybe_unbury_folder(folder_path)  # If it created something like unzipped_folder/unzipped_folder/contents make it into unzipped_folder/contents instead

    return folder_path

unzip_to_folder=extract_zip_file

def _extract_archive_via_pyunpack(archive_path, folder_path):
    """
    This function is used to unpack more than just .zip files.
    It can unpack .rar files, .tar files, .7z files - etc!
    On linux, you might have to apt-install a package to get this function to work
    For example: 'apt install patool', 'apt install rar', etc

    SUPPORTED FILETYPES:
        7z       (.7z)
        ACE      (.ace)
        ALZIP    (.alz)
        AR       (.a)
        ARC      (.arc)
        ARJ      (.arj)
        BZIP2    (.bz2)
        CAB      (.cab)
        compress (.Z)
        CPIO     (.cpio)
        DEB      (.deb)
        DMS      (.dms)
        GZIP     (.gz)
        LRZIP    (.lrz)
        LZH      (.lha .lzh)
        LZIP     (.lz)
        LZMA     (.lzma)
        LZOP     (.lzo)
        RPM      (.rpm)
        RAR      (.rar)
        RZIP     (.rz)
        TAR      (.tar)
        XZ       (.xz)
        ZIP      (.zip .jar)
        ZOO      (.zoo)

    """

    pip_import('pyunpack')
    pip_import('patool')

    filetype=get_file_extension(archive_path)
    supported_filetypes='zip jar rar tar gz 7z deb ace alz a arc arj bz2 cab Z cpio dms lrz lha lzh lz lzma lzo rpm rz xz zoo'.lower().split()
    assert filetype.lower() in supported_filetypes, 'Sorry, but I dont know how to unpack/extract/unzip etc the given filetype .'+filetype+'\n\tSupported filetypes: '+' '.join(sorted(set(supported_filetypes)))
    
    from pyunpack import Archive
    Archive(archive_path).extractall(folder_path)

    return folder_path

def _maybe_unbury_folder(folder):
    """
    Checks if the given folder contains a single subfolder with the same name.
    If so, it moves all contents of the subfolder to the main folder 
    and then deletes the subfolder, effectively "unburying" or "flattening" the structure by one level.
    
    This is particularly useful for correcting certain zip extraction behaviors 
    that result in structures like 'zip_folder.zip --> zip_folder/zip_folder/contents' 
    rather than the expected 'zip_folder.zip --> zip_folder/contents'.

    Args:
    - folder (str): The path to the folder to check and potentially unbury.
    
    Returns:
    - unburied (bool): Returns True if we made changes, and False if nothing changed
    
    EXAMPLES:
        1. Perfect Match:
            maybe_unbury_folder("path/to/MyFolder")
            Before:
                MyFolder
                |-- MyFolder
                    |-- file1.txt
                    |-- file2.txt
            After:
                MyFolder
                |-- file1.txt
                |-- file2.txt

        2. Name Mismatch:
            maybe_unbury_folder("path/to/ParentFolder")
            Before:
                ParentFolder
                |-- ChildFolder
                    |-- doc1.doc
                    |-- pic1.jpg
            After:
                ParentFolder
                |-- ChildFolder
                    |-- doc1.doc
                    |-- pic1.jpg

        3. Multiple Contents:
            maybe_unbury_folder("path/to/OuterFolder")
            Before:
                OuterFolder
                |-- InnerFolder
                    |-- song1.mp3
                |-- notes.txt
            After:
                OuterFolder
                |-- InnerFolder
                    |-- song1.mp3
                |-- notes.txt

        4. Subfolder with File:
            maybe_unbury_folder("path/to/MainDir")
            Before:
                MainDir
                |-- file3.txt
            After:
                MainDir
                |-- file3.txt

        5. Nested Perfect Match:
            maybe_unbury_folder("path/to/TopLevel")
            Before:
                TopLevel
                |-- TopLevel
                    |-- TopLevel
                        |-- image1.png
            After (two calls):
                TopLevel
                |-- image1.png

        6. Same Name but Multiple Contents:
            maybe_unbury_folder("path/to/MixedFolder")
            Before:
                MixedFolder
                |-- MixedFolder
                    |-- video1.mp4
                |-- report.pdf
            After:
                MixedFolder
                |-- MixedFolder
                    |-- video1.mp4
                |-- report.pdf
    """
    paths=get_all_paths(folder)

    #When using "compress" option on Mac's finder GUI, it automatically puts a "__MACOSX" folder in there - it's junk.
    #We don't count this as important when considering whether to unbury. Todo: Should we delete it? Right now we're leaving it be.
    ignorable_paths = '__MACOSX'.split()
    paths = [x for x in paths if get_path_name(x) not in ignorable_paths]

    if not len(paths)==1:
        return False
    subfolder=paths[0]
    if not is_a_folder(subfolder):
        return False
    if get_folder_name(folder)==get_folder_name(subfolder):
        for path in get_all_paths(subfolder):
            move_path(path,folder)
        assert folder_is_empty(subfolder)
        delete_folder(subfolder)
        return True
    return False

def get_normal_map(bump_map):
    """
    Turn a bump map aka a height map, into a normal map
    This is used for 3d graphics, such as in video games
    EXAMPLE:
        ans=load_image('https://www.filterforge.com/filters/6422-bump.jpg')
        ans=get_normal_map(ans)
        display_image(full_range(ans))
    """
    assert is_image(bump_map)
    pip_import('snowy')#A truly delightful little image processing library!
    import snowy
    bump_map=as_grayscale_image(as_float_image(bump_map))
    bump_map=np.expand_dims(bump_map,2)#Snowy needs to have a third axis for colors
    normal_map=snowy.compute_normals(bump_map)
    return normal_map

def sobel_edges(image):
    """
    Calculates sobel edges for edge detection
    Computes it indivisually for each r,g,b channel
       - Because of this feature, this function is approximately 3x faster on grayscale images
    EXAMPLE:
        ans=load_image('https://encrypted-tbn0.gstatic.com/images?q=tbn%3AANd9GcSwzqzyaeWqxfQiCnOqpnd1V27Wr8MOaZtfGQ&usqp=CAU'))
        ans=sobel_edges(ans)
        display_image(ans)
    """
    assert is_image(image)
    pip_import('snowy')#A truly delightful little image processing library!
    import snowy

    image=as_float_image(image)
    if is_grayscale_image(image):
        image=np.expand_dims(image,2)
        output=snowy.compute_sobel(image)
        output=output.squeeze(2)
        return output

    else:
        image=as_rgb_image(image)

        red  =snowy.compute_sobel(np.expand_dims(image[:,:,0],2))
        green=snowy.compute_sobel(np.expand_dims(image[:,:,1],2))
        blue =snowy.compute_sobel(np.expand_dims(image[:,:,2],2))
    
        output=np.dstack((red,green,blue))
    return output

def currently_in_a_tty():
    """
    Returns True if we're in a TTY (aka a terminal that can run Prompt-Toolkit)
    (As opposed to, for example, running inside a jupyter notebook)
    """
    try:
        return sys.stdout.isatty()
    except Exception:
        #Perhaps the current stdout, maybe patched, breaks when we do this...
        return False

@memoized
def currently_running_desktop(*,verbose=False):
    """
    Determines if a desktop environment is currently running.

    Parameters:
    - verbose (bool): Whether to print detailed information about how the desktop environment was detected.

    Returns:
    - bool: True if a desktop environment is detected, False otherwise.
    
    Alias: rp.running_in_desktop
    
    TODO: Right now, because of the caching, verbose will only work once. 
    
    Made with GPT4: https://chat.openai.com/share/b69ee840-7dfd-4110-9f42-ddc75e88d0a9
    Relevant Stack Overflow: https://stackoverflow.com/questions/2035657/what-is-my-current-desktop-environment

    This function has not been thoroughly tested yet! If there are any bugs please fix them!
    """
    import os
    import platform
    import subprocess
    import re

    os_type = platform.system()

    if os_type == 'Linux':
        # Environment variable checks
        for env_var in ['DISPLAY', 'WAYLAND_DISPLAY', 'XDG_CURRENT_DESKTOP', 'DESKTOP_SESSION', 'KDE_FULL_SESSION', 'GNOME_DESKTOP_SESSION_ID']:
            if env_var in os.environ:
                if verbose:
                    print("Detected Desktop Environment via {} environment variable.".format(env_var))
                return True
        
        # Running process checks
        try:
            running_processes = subprocess.Popen(["ps", "axw"], stdout=subprocess.PIPE).stdout.read().decode('utf-8')
            desktop_processes = ['gnome-shell', 'plasmashell', 'xfce4-session', 'mate-session', 'xfce-mcs-manage', 'ksmserver']
            for process in desktop_processes:
                if re.search(r'\b{}\b'.format(re.escape(process)), running_processes):
                    if verbose:
                        print("Detected Desktop Environment via running process: {}.".format(process))
                    return True
        except Exception as e:
            if verbose:
                print("An error occurred while checking running processes: {}".format(e))

        if verbose:
            print("No desktop environment detected.")
        return False

    elif os_type == 'Windows':
        if 'SESSIONNAME' in os.environ and os.environ['SESSIONNAME'] == 'Console':
            if verbose:
                print("Detected desktop environment on Windows via SESSIONNAME environment variable.")
            return True
        if verbose:
            print("No desktop environment detected on Windows.")
        return False

    elif os_type == 'Darwin':
        if 'HOME' in os.environ:
            if verbose:
                print("Detected desktop environment on macOS via HOME environment variable.")
            return True
        if verbose:
            print("No desktop environment detected on macOS.")
        return False

    else:
        if verbose:
            print("Unsupported OS: {}".format(os_type))
        return False

running_in_desktop = currently_running_desktop

def _maybe_display_string_in_pager(string,with_line_numbers=True):
    #Display the string in the pager if it's too long
    import rp.rp_ptpython.r_iterm_comm as ric
    if ric.pseudo_terminal_level == 0: #Don't block terminal output if we're not in pseudo terminal! That can cause issues for pretty printing in a headless job, for example.
        return
    if currently_in_a_tty():
        # if number_of_lines_in_terminal(string) > get_terminal_height()*.75:
        if number_of_lines_in_terminal(string) > get_terminal_height()-5:#This takes into account the prompt-toolkit prompt height, which I believe is hardcoded to 7 lines from the bottom of the terminal
            ring_terminal_bell()
            display_string=_line_numbered_string(string) if with_line_numbers else string
            string=fansi("There were %i lines with %i characters in the output, which would print out for many (%i) lines in your terminal. So, we're displaying them with rp.string_pager. Press 'q' to exit, -S to toggle line wrapping, and use arrow keys to navigate (or press 'h' for more help)"%(number_of_lines(string),len(string),number_of_lines_in_terminal(string)),'blue','bold')+'\n'+display_string
            string_pager(string)
    


def _fd(query,select=False,silent=False):
    #Act like the 'fd' command 
    def highlighted(string,query):
        #Case insensitive fansi-highlighting of a query in a string
        #Example: print(highlighted('Hello, world wORld hello woRld!','world'))#All the 'world','wORld', etc's are printed green and bold
        i=string.lower().find(query.lower())
        if i==-1:return string#No matches -> no highlighting.
        s =string[:i]
        j=i+len(query)
        s+=fansi(string[i:j],'green','bold')
        s+=highlighted(string[j:],query)
        return s
    from glob import iglob, escape
    from itertools import chain
    # glob_query='*'+escape(query)+'*'
    printed_lines=[]
    def print_line(line):
        if not silent:
            print(line)
        printed_lines.append(line)
    query=query.lower()
    try:
        import itertools
        globbed=itertools.chain(glob.iglob('.**',recursive=True), glob.iglob('**',recursive=True), glob.iglob('**/.*',recursive=True))

        for result in globbed:
        # for result in chain( iglob(glob_query), iglob('*/**/'+glob_query,recursive=True)):
            if query in get_path_name(result):  
                print_line(highlighted(result,query))
    except KeyboardInterrupt:
        pass
    _maybe_display_string_in_pager(line_join(printed_lines))
    return printed_lines
    # if sys.stdout.isatty() and len(printed_lines)>get_terminal_height()*.75:
    #     ring_terminal_bell()
    #     printed_lines.insert(0,fansi("FD: There were many (%i) results, so we're displaying them with rp.string_pager. Press 'q' to exit, and use arrow keys to navigate (or press 'h' for more help)"%len(printed_lines),'blue','bold'))
        
    #     string_pager(line_join(printed_lines))

def get_image_file_dimensions(image_file_path:str):
    """
    Takes the file path of an image, and returns the image's (height, width)
    It does this without loading the entire image, so while 
       get_image_file_dimensions(image_file_path) == get_image_width,get_image_height (load_image(image_file_path))
    This method can be up to 4000 times faster.
    This method supports the following file types:
        png jpg gif tiff svg jpeg jpeg2000
    """
    assert file_exists(image_file_path)
    if get_file_extension(image_file_path)=='exr':
        return _get_openexr_image_dimensions(image_file_path)
    pip_import('imagesize')
    import imagesize
    return imagesize.get(image_file_path)[::-1]#Returns (height, width)



_video_shape_cache = {}

def get_video_file_shape(path, use_cache=True):
    """
    Returns the shape of the numpy tensor we would get with rp.load_video(path)

    Args:
        path (str): Path to the video file.
        use_cache (bool): Whether to use cached results if available.

    Returns:
        tuple: (num_frames, height, width, num_channels)
    
    NOTE: 
        If I ever find a video format best represented with num_channels==1,
        such that load_video loads a bunch of grayscale images such that
        the video shape would only have 3 dims (num_frames, height, width)
        then in the future this function might just return a tuple with vals!
    """
    path = get_absolute_path(path) #Important for caching
    if use_cache and path in _video_shape_cache:
        return _video_shape_cache[path]

    pip_import('moviepy')

    with _moviepy_VideoFileClip(path) as video:
        width, height = video.size
        num_frames = int(video.fps * video.duration)
        num_channels = 3  # Assuming RGB - I'm not aware of any other formats

    shape = (num_frames, height, width, num_channels)
    _video_shape_cache[path] = shape
    return shape

def get_video_file_num_frames(path, use_cache=True):
    """
    Returns the number of frames in the video.
    """
    shape = get_video_file_shape(path, use_cache)
    return shape[0]

def get_video_file_height(path, use_cache=True):
    """
    Returns the height of the video.
    """
    shape = get_video_file_shape(path, use_cache)
    return shape[1]

def get_video_file_width(path, use_cache=True):
    """
    Returns the width of the video.
    """
    shape = get_video_file_shape(path, use_cache)
    return shape[2]


_hsv_to_rgb_cache = None

def _hsv_to_rgb_via_numpy(hsv_image):
    """
    Convert an HSV image to RGB using numpy.
    The input HSV values are assumed to be in the range [0, 1].
    """
    h, s, v = hsv_image[:, :, 0], hsv_image[:, :, 1], hsv_image[:, :, 2]
    h%=1

    i = (h * 6).astype(int)
    f = h * 6 - i
    p = v * (1 - s)
    one_minus_f = 1 - f
    q = v - v * s * f
    t = v - v * s * one_minus_f

    i = i % 6  # To ensure values are in [0, 5]

    # Initialize an empty RGB image
    rgb_image = np.empty(hsv_image.shape, dtype=hsv_image.dtype)

    mask = i == 0
    rgb_image[mask] = v[mask], t[mask], p[mask]

    mask = i == 1
    rgb_image[mask] = q[mask], v[mask], p[mask]

    mask = i == 2
    rgb_image[mask] = p[mask], v[mask], t[mask]

    mask = i == 3
    rgb_image[mask] = p[mask], q[mask], v[mask]

    mask = i == 4
    rgb_image[mask] = t[mask], p[mask], v[mask]

    mask = i == 5
    rgb_image[mask] = v[mask], p[mask], q[mask]

    return rgb_image

def _hsv_to_rgb_via_numba(hsv_image):
    """
    Convert an HSV image to RGB using Numba for optimization.
    The input HSV values are assumed to be in the range [0, 1].
    """
    h, s, v = hsv_image[:, :, 0], hsv_image[:, :, 1], hsv_image[:, :, 2]
    h%=1

    i = (h * 6).astype(np.int32)
    f = h * 6 - i
    p = v * (1 - s)
    q = v - v * s * f
    t = v - v * s * (1 - f)

    i = i % 6

    # Initialize an empty RGB image
    rgb_image = np.empty(hsv_image.shape, dtype=hsv_image.dtype)

    for x in range(hsv_image.shape[0]):
        for y in range(hsv_image.shape[1]):
            if   i[x, y] == 0: rgb_image[x, y] = v[x, y], t[x, y], p[x, y]
            elif i[x, y] == 1: rgb_image[x, y] = q[x, y], v[x, y], p[x, y]
            elif i[x, y] == 2: rgb_image[x, y] = p[x, y], v[x, y], t[x, y]
            elif i[x, y] == 3: rgb_image[x, y] = p[x, y], q[x, y], v[x, y]
            elif i[x, y] == 4: rgb_image[x, y] = t[x, y], p[x, y], v[x, y]
            elif i[x, y] == 5: rgb_image[x, y] = v[x, y], p[x, y], q[x, y]

    return rgb_image

_rgb_to_hsv_cache = None

def _rgb_to_hsv_via_numpy(rgb_image):
    """
    Convert an RGB image to HSV using numpy.
    The input RGB values are assumed to be in the range [0, 1].
    """
    r, g, b = rgb_image[:, :, 0], rgb_image[:, :, 1], rgb_image[:, :, 2]
    
    max_val = np.maximum(r, np.maximum(g, b))
    min_val = np.minimum(r, np.minimum(g, b))
    delta = max_val - min_val
    
    v = max_val
    s = np.where(max_val != 0, delta / max_val, 0)
    
    h = np.zeros_like(r)
    
    mask = (max_val == r) & (g >= b)
    h[mask] = 60 * (g[mask] - b[mask]) / delta[mask]
    
    mask = (max_val == r) & (g < b)
    h[mask] = 60 * (g[mask] - b[mask]) / delta[mask] + 360
    
    mask = max_val == g
    h[mask] = 60 * (b[mask] - r[mask]) / delta[mask] + 120
    
    mask = max_val == b
    h[mask] = 60 * (r[mask] - g[mask]) / delta[mask] + 240
    
    h = h % 360
    h /= 360  # Normalize to [0, 1]
    
    hsv_image = np.stack([h, s, v], axis=-1)
    return hsv_image

def _rgb_to_hsv_via_numba(rgb_image):
    """
    Convert an RGB image to HSV using Numba for optimization.
    The input RGB values are assumed to be in the range [0, 1].
    """
    r, g, b = rgb_image[:, :, 0], rgb_image[:, :, 1], rgb_image[:, :, 2]
    
    h = np.zeros_like(r)
    s = np.zeros_like(r)
    v = np.zeros_like(r)
    
    for x in range(rgb_image.shape[0]):
        for y in range(rgb_image.shape[1]):
            max_val = max(r[x, y], g[x, y], b[x, y])
            min_val = min(r[x, y], g[x, y], b[x, y])
            delta = max_val - min_val
            
            v[x, y] = max_val
            s[x, y] = 0 if max_val == 0 else delta / max_val
            
            if max_val == r[x, y]:
                if g[x, y] >= b[x, y]: h[x, y] = 60 * (g[x, y] - b[x, y]) / delta       if delta != 0 else 0
                else                 : h[x, y] = 60 * (g[x, y] - b[x, y]) / delta + 360 if delta != 0 else 0
            elif max_val == g[x, y]:   h[x, y] = 60 * (b[x, y] - r[x, y]) / delta + 120
            elif max_val == b[x, y]:   h[x, y] = 60 * (r[x, y] - g[x, y]) / delta + 240
    
    h /= 360  # Normalize to [0, 1]

    # Instead of np.stack, manually create the hsv_image
    hsv_image = np.empty_like(rgb_image)
    hsv_image[:, :, 0] = h
    hsv_image[:, :, 1] = s
    hsv_image[:, :, 2] = v
    
    return hsv_image


#OLDER, SLOWER VERSION (27x slower than numba)
#def rgb_to_hsv(image):
#    #Takes an RGB image and returns an HSV image
#    #Any alpha channels will be removed
#    #EXAMPLE: Animating a rainbow doggy
#    #    i=load_image('https://www.rover.com/blog/wp-content/uploads/2018/04/ThinkstockPhotos-485251240-960x540.jpg')
#    #    for _ in range(100):
#    #        i=rgb_to_hsv(i)
#    #        i[:,:,0]+=.05
#    #        i=hsv_to_rgb(i)
#    #        display_image(i)
#    assert is_image(image)
#    image=as_float_image(image)
#    image=as_rgb_image(image)
#    pip_import('skimage')
#    import skimage.color as color
#    return color.rgb2hsv(image)

#OLDER, SLOWER VERSION (27x slower than numba)
#def hsv_to_rgb(image):
#    #Takes an RGB image and returns an HSV image
#    #Any alpha channels will be removed
#    assert is_image(image)
#    image=as_float_image(image)
#    image=as_rgb_image(image)
#    pip_import('skimage')
#    import skimage.color as color
#    return color.hsv2rgb(image)

def hsv_to_rgb(hsv_image):
    """
    Convert an HSV image to RGB.

    Install Numba to get a massive speed boost!

    Initially, the conversion was done using skimage's hsv2rgb function. However, 
    this method was found to be slow. As an optimization, this function first 
    attempts to use Numba to speed up the conversion. If Numba cannot be imported 
    or used, it falls back to a numpy-based implementation.

    The input HSV values are assumed to be in the range [0, 1].
    
    Enhanced Documentation:
    Converts HSV color space to RGB. This is the inverse operation of rgb_to_hsv,
    using the same optimized backend selection pattern. Essential for color
    manipulation workflows where you modify HSV components and convert back.
    
    Args:
        hsv_image: HSV image array with values in range [0, 1]. Can include alpha.
                   - H (Hue): [0, 1] representing [0°, 360°]
                   - S (Saturation): [0, 1] 
                   - V (Value): [0, 1]
                   
    Returns:
        numpy.ndarray: RGB image with same shape as HSV input, values in [0, 1]
        
    Examples:
        >>> import numpy as np
        >>> from rp.r import hsv_to_rgb, rgb_to_hsv
        >>> # Create HSV image with pure red hue
        >>> hsv = np.zeros((50, 50, 3))
        >>> hsv[:,:,0] = 0.0  # Red hue
        >>> hsv[:,:,1] = 1.0  # Full saturation  
        >>> hsv[:,:,2] = 1.0  # Full value
        >>> rgb = hsv_to_rgb(hsv)
        >>> # Should produce red image
        >>> np.allclose(rgb[:,:,0], 1.0)  # Red channel = 1
        True
        >>> np.allclose(rgb[:,:,1:], 0.0)  # Green, Blue = 0
        True
        
    Performance:
        - With Numba: ~10x faster than scikit-image  
        - NumPy fallback: ~2x faster than scikit-image
        
    Implementation Variants:
        - _hsv_to_rgb_via_numba(): JIT-compiled version (fastest)
        - _hsv_to_rgb_via_numpy(): Pure NumPy version (fallback)
        
    Related Functions:
        - rgb_to_hsv(): Convert RGB to HSV
        - with_image_brightness(): Modify V channel
        - with_image_hue(): Modify H channel  
        - with_image_saturation(): Modify S channel
        
    Tags: color-space, hsv, rgb, conversion, image-processing, color-manipulation
    """
    global _hsv_to_rgb_cache

    assert is_image(hsv_image)
    alpha=extract_alpha_channel(hsv_image) if is_rgba_image(hsv_image) else None #Try to preserve alpha channel
    hsv_image = as_rgb_image(as_float_image(hsv_image))

    if _hsv_to_rgb_cache is not None:
        return _hsv_to_rgb_cache(hsv_image)

    try:
        from numba import jit
        numba_version = _hsv_to_rgb_via_numba
        _hsv_to_rgb_cache = jit(nopython=True)(numba_version)
    except ImportError:
        _hsv_to_rgb_cache = _hsv_to_rgb_via_numpy

    output = _hsv_to_rgb_cache(hsv_image)

    if alpha is not None:
        output=with_alpha_channel(output,alpha,copy=False)

    return output

def rgb_to_hsv(rgb_image):
    """
    Convert an RGB image to HSV.

    Install Numba to get a massive speed boost!

    Initially, the conversion was done using skimage's rgb2hsv function. However, 
    this method was found to be slow. As an optimization, this function first 
    attempts to use Numba to speed up the conversion. If Numba cannot be imported 
    or used, it falls back to a numpy-based implementation.

    The input RGB values are assumed to be in the range [0, 1].
    
    Enhanced Documentation:
    Converts RGB color space to HSV (Hue, Saturation, Value). Uses optimized 
    implementations with automatic backend selection - Numba JIT compilation when
    available, falling back to pure NumPy. Preserves alpha channel if present.
    
    Args:
        rgb_image: RGB image array with values in range [0, 1]. Can be RGB or RGBA.
                  Input is automatically converted to RGB float format.
                  
    Returns:
        numpy.ndarray: HSV image with same shape as input RGB, values in [0, 1]
                      - H (Hue): [0, 1] representing [0°, 360°]
                      - S (Saturation): [0, 1]
                      - V (Value): [0, 1]
                      
    Examples:
        >>> import numpy as np
        >>> from rp.r import rgb_to_hsv, hsv_to_rgb
        >>> # Convert RGB to HSV
        >>> rgb = np.random.rand(100, 100, 3)
        >>> hsv = rgb_to_hsv(rgb)
        >>> hsv.shape
        (100, 100, 3)
        >>> # Verify round-trip conversion
        >>> rgb_back = hsv_to_rgb(hsv)
        >>> np.allclose(rgb, rgb_back, atol=1e-5)
        True
        
    Performance:
        - With Numba: ~10x faster than scikit-image
        - NumPy fallback: ~2x faster than scikit-image
        
    Implementation Variants:
        - _rgb_to_hsv_via_numba(): JIT-compiled version (fastest)
        - _rgb_to_hsv_via_numpy(): Pure NumPy version (fallback)
        
    Related Functions:
        - hsv_to_rgb(): Convert HSV back to RGB
        - with_image_hue(): Modify image hue
        - with_image_saturation(): Modify image saturation
        - get_image_hue/saturation/value(): Extract HSV components
        
    Tags: color-space, hsv, rgb, conversion, image-processing, color-manipulation
    """
    global _rgb_to_hsv_cache

    assert is_image(rgb_image)
    alpha=extract_alpha_channel(rgb_image) if is_rgba_image(rgb_image) else None #Try to preserve alpha channel
    rgb_image = as_rgb_image(as_float_image(rgb_image))

    if _rgb_to_hsv_cache is not None:
        return _rgb_to_hsv_cache(rgb_image)

    try:
        from numba import jit
        numba_version = _rgb_to_hsv_via_numba
        _rgb_to_hsv_cache = jit(nopython=True)(numba_version)
    except ImportError:
        _rgb_to_hsv_cache = _rgb_to_hsv_via_numpy

    output = _rgb_to_hsv_cache(rgb_image)

    if alpha is not None:
        output=with_alpha_channel(output,alpha,copy=False)

    return output

def get_image_hue(image):
    """Takes in an image as defined by rp.is_image and returns a matrix"""
    assert is_image(image)
    return rgb_to_hsv(image)[:,:,0]

def get_image_saturation(image):
    """Takes in an image as defined by rp.is_image and returns a matrix"""
    assert is_image(image)
    return rgb_to_hsv(image)[:,:,1]

def get_image_value(image):
    """Takes in an image as defined by rp.is_image and returns a matrix"""
    assert is_image(image)
    return rgb_to_hsv(image)[:,:,2]

get_image_brightness=get_image_value

def get_image_red(image):
    """Takes in an image as defined by rp.is_image and returns a matrix"""
    image=as_numpy_image(image,copy=False)
    if is_grayscale_image(image):
        image=as_rgb_image(image)
    return image[:,:,0]
        
def get_image_green(image):
    """Takes in an image as defined by rp.is_image and returns a matrix"""
    image=as_numpy_image(image,copy=False)
    if is_grayscale_image(image):
        image=as_rgb_image(image)
    return image[:,:,1]

def get_image_blue(image):
    """Takes in an image as defined by rp.is_image and returns a matrix"""
    image=as_numpy_image(image,copy=False)
    if is_grayscale_image(image):
        image=as_rgb_image(image)
    return image[:,:,2]


def _with_image_channel(image, color_img_or_value, channel_idx):
    """
    Helper function to apply a color image or value to a given channel in the main image.
    :param image: The main image
    :param color_img_or_value: Either an image of the color or a single numeric value.
    :param channel_idx: The channel index (0 for red, 1 for green, 2 for blue)
    :return: Image with modified color channel.
    """
    assert is_image(image)
    assert is_image(color_img_or_value) or is_number(color_img_or_value)
    
    if is_image(color_img_or_value):
        assert get_image_dimensions(image) == get_image_dimensions(color_img_or_value), 'Images must be the same size'
    
    if is_grayscale_image(image):
        image = as_rgb_image(image)

    image = as_float_image(image)
    
    if is_number(color_img_or_value):
        color_image = image + 0
        color_image[:] = color_img_or_value
    else:
        color_image = color_img_or_value

    color_image = as_float_image(color_image)
    color_image = as_grayscale_image(color_image)
    
    image[:, :, channel_idx] = color_image
    
    return image


def with_image_red(image, red):
    """
    Modify the red channel of the image with the given red image or value.
    Returns a float image.
    """
    return _with_image_channel(image, red, 0)

def with_image_green(image, green):
    """
    Modify the green channel of the image with the given green image or value.
    Returns a float image.
    """
    return _with_image_channel(image, green, 1)

def with_image_blue(image, blue):
    """
    Modify the blue channel of the image with the given blue image or value.
    Returns a float image.
    """
    return _with_image_channel(image, blue, 2)

def with_image_hue(image, hue):
    """Sets the image hue. The hue can either be given as an image, or as a number."""
    alpha=extract_alpha_channel(image) if is_rgba_image(image) else None

    image = as_rgb_image(image)
    image = as_float_image(image)

    if is_number(hue):
        value = hue
        hue = as_grayscale_image(image)
        hue[:] = value

    hue = as_grayscale_image(hue)
    hue = as_float_image(hue)
    hue = hue % 1

    hsv = rgb_to_hsv(image)
    hsv[:, :, 0] = hue
    rgb = hsv_to_rgb(hsv)

    if alpha is not None:
        rgba=with_alpha_channel(rgb,alpha,copy=False)
        return rgba

    return rgb

def shift_image_hue(image, shift):
    """
    EXAMPLE:
        >>> while True:
        >>> display_image(
        >>>     shift_image_hue(
        >>>         resize_image_to_fit(load_image_from_webcam(), height=256, interp="area"),
        >>>         toc() / 10,
        >>>     )
        >>> )
    """
    new_hue = blend_images(get_image_hue(image),shift,mode='add')
    return with_image_hue(image,new_hue)


def with_image_saturation(image, saturation):
    """Sets the image saturation. The saturation can either be given as an image, or as a number."""
    alpha=extract_alpha_channel(image) if is_rgba_image(image) else None

    image = as_rgb_image(image)
    image = as_float_image(image)

    if is_number(saturation):
        value = saturation
        saturation = as_grayscale_image(image)
        saturation[:] = value

    saturation = as_grayscale_image(saturation)
    saturation = as_float_image(saturation)

    hsv = rgb_to_hsv(image)
    hsv[:, :, 1] = saturation
    rgb = hsv_to_rgb(hsv)

    if alpha is not None:
        rgba=with_alpha_channel(rgb,alpha,copy=False)
        return rgba

    return rgb

def with_image_brightness(image, brightness):
    """Sets the image brightness. The brightness can either be given as an image, or as a number."""
    alpha=extract_alpha_channel(image) if is_rgba_image(image) else None

    image = as_rgb_image(image)
    image = as_float_image(image)

    if is_number(brightness):
        value = brightness
        brightness = as_grayscale_image(image)
        brightness[:] = value

    brightness = as_grayscale_image(brightness)
    brightness = as_float_image(brightness)

    hsv = rgb_to_hsv(image)
    hsv[:, :, 2] = brightness
    rgb = hsv_to_rgb(hsv)

    if alpha is not None:
        rgba=with_alpha_channel(rgb,alpha,copy=False)
        return rgba

    return rgb


def hsv_to_rgb_float_color(*hsv):
    """
    Converts a floating point HSV color to an RGB one
    hsv_to_rgb_float_color(h,s,v) ==== hsv_to_rgb_float_color((h,s,v))
    """
    import colorsys

    hsv = detuple(hsv)
    return colorsys.hsv_to_rgb(*hsv)


def rgb_to_hsv_float_color(*rgb):
    """
    Converts a floating point RGB color to an HSV one
    rgb_to_hsv_float_color(r,g,b) ==== rgb_to_hsv_float_color((r,g,b))
    """
    import colorsys

    rgb = detuple(rgb)
    return colorsys.rgb_to_hsv(*rgb)

def srgb_to_linear(image):
    """
    Converts an sRGB image array to linear RGB.
    Can make linear operations on images look better.

    The sRGB color space is a "gamma-corrected" space that approximates
    human vision. Most image files (JPG, PNG) and displays use sRGB.
    Linear RGB has a direct, linear relationship with light intensity,
    making it suitable for physically accurate rendering calculations
    like lighting and blending.

    Args:
        image: A NumPy array representing an image in sRGB color space.
                  Values are expected to be in the range [0, 1].

    Returns:
        A NumPy array of the same shape in linear RGB color space.
        
    EXAMPLE:
        (please see sister func linear_to_srgb's docstring for EXAMPLE code)
        
    """
    # The conversion formula is piecewise
    # C_linear = C_srgb / 12.92, if C_srgb <= 0.04045
    # C_linear = ((C_srgb + 0.055) / 1.055) ^ 2.4, if C_srgb > 0.04045
    if is_image(image): image=as_float_image(image,copy=False)
    return np.where(
        image <= 0.04045,
        image / 12.92,
        ((image + 0.055) / 1.055) ** 2.4
    )

def linear_to_srgb(image):
    """
    Converts a linear RGB image array to sRGB.
    Can make linear operations on images look better.    
    
    TODO: Let this func offically support more than just numpy images, since its an elementwise function.

    This is the inverse of the srgb_to_linear conversion, often called
    "gamma correction." It's the final step before displaying an image
    or saving it to a standard file format.

    Args:
        image: A NumPy array representing an image in linear RGB.
               Values are expected to be in the range [0, 1].

    Returns:
        A NumPy array of the same shape in sRGB color space.

    EXAMPLE:
        
        >>> images = as_rgb_images(
        ...     as_float_images(
        ...         resize_images_to_min_size(
        ...             crop_images_to_square(
        ...                 load_images(
        ...                     [
        ...                         "https://i.guim.co.uk/img/media/fcf3d31f6631c127a30df753728eabca4c619755/0_503_1747_1048/master/1747.jpg?width=1200&height=1200&quality=85&auto=format&fit=crop&s=42a30da05bde8462e3b968705c9e6768",
        ...                         "https://carsonridgecabins.com/wp-content/uploads/2025/04/shutterstock_67819000.jpg",
        ...                     ],
        ...                     use_cache=True,
        ...                 )
        ...             )
        ...         )
        ...     )
        ... )
        ... blend1 = blend(*images, 0.5)
        ... blend2 = linear_to_srgb(
        ...     blend(srgb_to_linear(images[0]), srgb_to_linear(images[1]), 0.5)
        ... )
        ... preview_image = horizontally_concatenated_images(
        ...     labeled_images(
        ...         [blend1, blend2],
        ...         ["Naive Blending", "Gamma-Corrected Blending"],
        ...         size=30,
        ...         font="Futura",
        ...     )
        ... )
        ... display_image(preview_image)        

    """
    # The conversion formula is the inverse of sRGB to linear
    # C_srgb = C_linear * 12.92, if C_linear <= 0.0031308
    # C_srgb = 1.055 * (C_linear ^ (1/2.4)) - 0.055, if C_linear > 0.0031308
    if is_image(image): image=as_float_image(image,copy=False)
    return np.where(
        image <= 0.0031308,
        image * 12.92,
        1.055 * (image ** (1.0 / 2.4)) - 0.055
    )

def float_color_to_ansi256(*color):
    """
    Convert RGB values (0.0 to 1.0) to the nearest ANSI 256-color code
    Returns an integer

    float_color_to_ansi256(r,g,b) ==== hsv_to_rgb_float_color((r,g,b))

    EXAMPLE:
        >>> for grid_row in np.arange(8):
        ...     for grid_col in np.arange(8):
        ...         brightness = grid_row / 7
        ...         saturation = grid_col / 7
        ...         for color_row in np.arange(8):
        ...             for color_col in np.arange(8):
        ...                 hue = (color_row * 8 + color_col) / 63
        ...                 r, g, b = hsv_to_rgb_float_color(hue, saturation, brightness)
        ...                 ansi_code = float_color_to_ansi256((r, g, b))
        ...                 print(f"\033[48;5;{ansi_code}m  \033[0m", end="")
        ...             print(end="  ")
        ...         print()
        ...     print()

    """
    color = detuple(color)
    color = as_rgb_float_color(color)
    r, g, b = color

    if r == g == b:
        if r < 0.02:
            return 16  # Black
        if r > 0.98:
            return 231  # White
        gray_level = int(r * 24)
        return 232 + gray_level  # Grayscale between black and white
    else:
        # Color cube approximation
        r = int(r * 5)
        g = int(g * 5)
        b = int(b * 5)
        return 16 + 36 * r + 6 * g + b


def get_rgb_byte_color_identity_mapping_image():
    """
    Save this image, and color-grade it. Then the new image can be used as a map!
    Originally made for color grading via superkelight at the Adobe 2023 internship MAGICK project.
    """
    # TODO: When rgb_mapping_cube_to_image is fast, this can be simplified to rgb_mapping_cube_to_image(get_rgb_byte_color_identity_mapping_cube())

    # Creating a meshgrid for x and y coordinates
    x, y = np.meshgrid(np.arange(4096), np.arange(4096))

    # Creating color values based on the equations you provided
    R = x % 256
    G = y % 256
    B = (x // 256) % 16 + 16 * (y // 256)

    # Stacking all three color channels together to create an image tensor
    image = np.stack([R, G, B], axis=-1).astype(np.uint8)
    
    #Output verification
    assert is_rgb_image(image)
    assert is_byte_image(image)
    assert get_image_dimensions(image)==(4096,4096)
    
    return image

def zalgo_text(text:str,amount:int=1):
    """
    EXAMPLE: zalgo_text('Hello World',0) == 'Hello World'
    EXAMPLE: zalgo_text('Hello World',1) == 'H̵ͮe͚͘ĺ̫l̬ͧoͥ̈ W͗͜o̴͇r̖̃l̡͚d̛͓'
    EXAMPLE: zalgo_text('Hello World',2) == 'H̓ͨ̐e͆͟͢l̘͆͝l̦̘ͪo̰ͮ͢ W̴̓ͅǫ̨͛r̞̫͘l̴̼̎d̯͑̕'
    """
    assert isinstance(text,str)
    assert isinstance(amount,int)
    assert amount>=0
    if amount==0:
        return text
    amount+=1
    
    pip_import('zalgo_text','zalgo-text')
    import zalgo_text.zalgo as zalgo
    z=zalgo.zalgo()
    z.maxAccentsPerLetter=amount
    z.numAccentsDown= (1, amount)
    z.numAccentsMiddle= (1, amount-1)
    z.numAccentsUp= (1, amount)
    return z.zalgofy(text)

def big_ascii_text(text:str,*,font='standard'):
    r"""
    Returns big ascii art text!
    EXAMPLE:
     ➤ big_ascii_text('Hello World!')
        ans =  
         _   _        _  _         __        __              _      _  _ 
        | | | |  ___ | || |  ___   \ \      / /  ___   _ __ | |  __| || |
        | |_| | / _ \| || | / _ \   \ \ /\ / /  / _ \ | '__|| | / _` || |
        |  _  ||  __/| || || (_) |   \ V  V /  | (_) || |   | || (_| ||_|
        |_| |_| \___||_||_| \___/     \_/\_/    \___/ |_|   |_| \__,_|(_)
    Some of my favorite fonts:
        varsity
        sub-zero
        stop
        stforek
        starwars
        standard
        speed
        slant
        serifcap
        roman
        puffy
        poison
        nvscript
        fratkur
        doh
        cybermedium
        big
        alpha
        fancy92
        fancy89
        fancy57
        fancy61
    """

    pip_import('art')
    import art
    assert font in art.FONT_NAMES,'Please choose from the following fonts:'+'\n'+repr(art.FONT_NAMES)
    big_text=art.text2art(text,font)
    return big_text

def bytes_to_file(data: bytes, path: str = None):
    """
    Enhanced Documentation:
    
    Saves bytes data to a file at the specified path or a temporary location.
    Automatically creates parent directories if they don't exist. Handles file permission
    issues by creating temporary files when no path is specified.
    
    Parameters:
        data (bytes): Binary data to write to file. Must be bytes type, not str or other types.
        path (str, optional): File path where to save the data. If None, creates a temporary file.
                              Parent directories are created automatically if they don't exist.
    
    Returns:
        str: The actual path where the file was saved (same as input path, or temp path if None given).
    
    Usage Examples:
        >>> # Save bytes to temporary file
        >>> data = b"Hello, binary world!"
        >>> temp_path = bytes_to_file(data)
        >>> print(f"Saved to: {temp_path}")  # /var/folders/.../tmpXXXXXXXX
        
        >>> # Save to specific path
        >>> data = b"\\x89PNG\\r\\n\\x1a\\n..."  # PNG file bytes
        >>> saved_path = bytes_to_file(data, "my_image.png")
        >>> print(f"Image saved to: {saved_path}")  # my_image.png
        
        >>> # Save to nested path (creates directories)
        >>> data = b"Some file content"
        >>> path = bytes_to_file(data, "folder/subfolder/file.dat")
        >>> print(f"File saved to: {path}")  # folder/subfolder/file.dat
        
        >>> # Read back the saved data
        >>> with open(saved_path, 'rb') as f:
        ...     read_back = f.read()
        >>> print(f"Data matches: {data == read_back}")  # True
    
    Related Functions:
        - file_to_bytes(): Read bytes from a file (inverse operation)
        - temporary_file_path(): Generate temporary file paths
        - make_parent_directory(): Create parent directories
        - object_to_file(): Save arbitrary objects to files
        - decode_video_from_bytes(): Create video files from bytes
        
    Notes:
        - Automatically creates parent directories if they don't exist
        - Uses binary write mode ('wb') to preserve exact byte content
        - Safe for any binary data: images, videos, compressed files, etc.
        - Cross-platform path handling
        - Raises AssertionError if data is not bytes type
        
    Tags: files, bytes, binary, io, filesystem, save, write
    """
    assert isinstance(data, bytes), 'Expected bytes, got ' + str(type(data))

    if path is None:
        path = temporary_file_path()

    def helper():
        with open(path, 'wb') as out:
            out.write(data)
        return path

    try:
        return helper()
    except FileNotFoundError:
        #Faster than checking for folder exists every time
        make_parent_directory(path)
        return helper()


_file_to_bytes_cache={}
def file_to_bytes(path: str, use_cache=False):
    """
    Enhanced Documentation:
    
    Reads binary data from a file path or URL, returning raw bytes content.
    Supports both local file paths and remote URLs, with optional caching
    for improved performance on repeated reads.
    
    Args:
        path (str): File path or URL to read from. For URLs, must be valid
                   according to is_valid_url(). For files, path must exist.
        use_cache (bool, optional): If True, caches the result for faster
                                   repeated access to the same path/URL.
                                   Default: False.
    
    Returns:
        bytes: Raw binary content of the file or URL response.
               Empty bytes if file is empty.
    
    Examples:
        >>> # Read local file
        >>> with open('/tmp/test.txt', 'wb') as f:
        ...     f.write(b"Hello, bytes!")
        13
        >>> data = rp.file_to_bytes('/tmp/test.txt')
        >>> print(f"Read {len(data)} bytes")
        Read 13 bytes
        >>> print(data)
        b'Hello, bytes!'
        
        >>> # Read with caching
        >>> data_cached = rp.file_to_bytes('/tmp/test.txt', use_cache=True)
        >>> print(f"Data matches: {data == data_cached}")
        True
        
        >>> # Read from URL (if reachable)
        >>> # url_data = rp.file_to_bytes('https://httpbin.org/bytes/100')
        >>> # print(f"Downloaded {len(url_data)} bytes from URL")
    
    Related Functions:
        - bytes_to_file(): Inverse operation - writes bytes to file
        - curl_bytes(): Used internally for URL downloads  
        - file_to_base64(): Converts result to base64 encoding
        - is_valid_url(): Used to distinguish URLs from file paths
    
    Usage Patterns:
        - Reading binary files (images, videos, executables, etc.)
        - Downloading binary content from URLs
        - Loading data for processing pipelines
        - Creating file content hashes or checksums
    
    Implementation Details:
        - For URLs: Uses curl_bytes() for reliable downloading
        - For files: Uses standard Python file I/O with 'rb' mode
        - Cache stores results by path string (exact match required)
        - No automatic retry logic for failed downloads
    
    Tags: file-io, bytes, binary-data, url-download, caching
    """

    if use_cache and path in _file_to_bytes_cache:
        return _file_to_bytes_cache[path]

    if is_valid_url(path):
        data = curl_bytes(path)

    else:
        with open(path, 'rb') as out:
            data = out.read()

    if use_cache:
        _file_to_bytes_cache[path]=_file_to_bytes_cache

    return data

def file_to_base64(path: str, use_cache=False):
    """
    Reads a file and encodes its contents to base64 string.
    EXAMPLE:
        >>> file_to_base64('/tmp/test.txt')  # Returns base64 of file contents
    """
    return bytes_to_base64(file_to_bytes(path, use_cache))

_file_to_object_cache={}
def file_to_object(path:str, use_cache=False):
    """
    Load any Python object from a binary file using RP's object serialization.
    
    Enhanced Documentation:
    Deserializes Python objects from RP Object (.rpo) files or any binary file
    containing RP-serialized data. Uses the bytes_to_object conversion pipeline
    for robust object reconstruction. Supports optional caching for performance.
    
    Parameters:
    - path (str): File path to object file (.rpo extension recommended)
    - use_cache (bool, optional): Enable caching for faster repeated loads. Default False.
    
    Returns:
    - Any: The deserialized Python object from the file
    
    Usage patterns:
    - Loading RP objects: obj = file_to_object('data.rpo')
    - Cached loading: obj = file_to_object('large_data.rpo', use_cache=True)
    - Used by _omni_load() for .rpo files
    - Alternative to pickle for RP-specific serialization
    
    Related functions:
    - object_to_file(): Save objects to binary files
    - bytes_to_object(): Convert raw bytes to objects
    - file_to_bytes(): Read file as byte data
    - load_pickled_value(): Alternative using Python's pickle
    - _omni_load(): Auto-detects .rpo files and calls this function
    
    File format:
    - .rpo files: RP Object format (recommended)
    - Any binary file with RP-serialized object data
    - Cross-platform compatible within RP ecosystem
    
    Examples:
    >>> import tempfile
    >>> # Create and save test object
    >>> test_obj = {"data": [1, 2, 3], "meta": {"version": 1.0}}
    >>> with tempfile.NamedTemporaryFile(suffix='.rpo', delete=False) as f:
    ...     rpo_path = f.name
    >>> object_to_file(test_obj, rpo_path)
    >>> 
    >>> # Load the object back
    >>> loaded_obj = file_to_object(rpo_path)
    >>> type(loaded_obj)  # <class 'dict'>
    >>> loaded_obj["data"]  # [1, 2, 3]
    >>> loaded_obj["meta"]["version"]  # 1.0
    >>> import os; os.unlink(rpo_path)  # Cleanup
    
    >>> # Works with cached loading
    >>> object_to_file({"cached": True}, rpo_path)
    >>> obj1 = file_to_object(rpo_path, use_cache=True)  # Loads from file
    >>> obj2 = file_to_object(rpo_path, use_cache=True)  # Loads from cache
    >>> obj1 == obj2  # True
    >>> import os; os.unlink(rpo_path)  # Cleanup
    
    Note: RP Object format is different from pickle. Use object_to_file() to
    create compatible files. Cache persists until program exit.
    """
    if use_cache and path in _file_to_object_cache:
        return _file_to_object_cache[path]

    output = bytes_to_object(file_to_bytes(path))
    
    if use_cache:
        _file_to_object_cache[path]=output
        
    return output

def object_to_file(object,path:str):
    """
    Saves an object to a file using RP's object serialization format (.rpo).
    Uses compression and handles any Python object that can be pickled.
    EXAMPLE:
        >>> object_to_file([1, 2, 3], '/tmp/test.rpo')
    """
    return bytes_to_file(object_to_bytes(object),path)

def bytes_to_base64(bytestring: bytes) -> str:
    """
    Encodes bytes to base64 string.
    EXAMPLE:
        >>> bytes_to_base64(b'hello') == 'aGVsbG8='
    """
    import base64
    return base64.b64encode(bytestring).decode('utf-8')

def base64_to_bytes(base64_string: str) -> bytes:
    """
    Decodes base64 string to bytes.
    EXAMPLE:
        >>> base64_to_bytes('aGVsbG8=') == b'hello'
    """
    import base64
    return base64.b64decode(base64_string)
    
def bytes_to_base16(bytestring: bytes) -> str:
    """
    Encodes bytes to base16 (hex) string.
    EXAMPLE:
        >>> bytes_to_base16(b'hello') == '68656c6c6f'
    """
    import binascii
    return binascii.hexlify(bytestring).decode('utf-8')

def base16_to_bytes(base16_string: str) -> bytes:
    """
    Decodes base16 (hex) string to bytes.
    EXAMPLE:
        >>> base16_to_bytes('68656c6c6f') == b'hello'
    """
    import binascii
    return binascii.unhexlify(base16_string)

def func_call_to_shell_command(func, *args, **kwargs):
    """
    In some circumstances (with exotic args or kwargs) this could be better than the fire.Fire module

    EXAMPLE:
        >>> func_call_to_shell_command(print,[1,2,3])
        ans = /opt/homebrew/opt/python@3.10/bin/python3.10 -m rp exec 'rp.r._call_from_base64_string('"'"'gASVPwAAAAAAAAB9lCiMBGZ1bmOUjAhidWlsdGluc5SMBXByaW50lJOUjARhcmdzlF2UKEsBSwJLA2WFlIwGa3dhcmdzlH2UdS4='"'"')'
        >>> !/opt/homebrew/opt/python@3.10/bin/python3.10 -m rp exec 'rp.r._call_from_base64_string('"'"'gASVPwAAAAAAAAB9lCiMBGZ1bmOUjAhidWlsdGluc5SMBXByaW50lJOUjARhcmdzlF2UKEsBSwJLA2WFlIwGa3dhcmdzlH2UdS4='"'"')'
        Transformed command into 'import os;os.system(\'/opt/homebrew/opt/python@3.10/bin/python3.10 -m rp exec \\\'rp.r._call_from_base64_string(\\\'"\\\'"\\\'gASVPwAAAAAAAAB9lCiMBGZ1bmOUjAhidWlsdGluc5SMBXByaW50lJOUjARhcmdzlF2UKEsBSwJLA2WFlIwGa3dhcmdzlH2UdS4=\\\'"\\\'"\\\')\\\'\')'
        [1, 2, 3]
    """
    import shlex
    
    python_path = sys.executable
    bundle = dict(func=func, args=args, kwargs=kwargs)

    base64_string = bytes_to_base64(object_to_bytes(bundle))
    python_command = "rp.r._call_from_base64_string(%s)" % repr(base64_string)
    
    command = shlex.quote(python_path) + " -m rp exec " + shlex.quote(python_command)

    return command

def _call_from_base64_string(base64_string):
    bundle = bytes_to_object(base64_to_bytes(base64_string))

    func = bundle["func"]
    args = bundle["args"]
    kwargs = bundle["kwargs"]

    return func(*args, **kwargs)


def _launch_ranger():
    """
    Ranger is a curses-based file manager with Vim keybindings
    It's really useful for quickly/visually browsing through files and directories!
    Whatsmore, is that we can launch it in this process - which is what we'll do.
    Currently this method is private, as I can't think of a reason to use it outside of pseudo_terminal and I don't want to clutter rp's namespace more
    """

    old_dir=get_current_directory()

    pip_import('ranger')
    import ranger
    old_os_environ_pwd=os.environ.get('PWD')#This is how ranger determines the current directory. We want to make sure it syncs up with RP's PWD, but also want to resore os.environ.get('PWD') afterwards in-case some other function needs it to work the way it originally did
    old_args=sys.argv
    try:
        sys.argv=sys.argv[:1]
        os.environ['PWD']=get_current_directory()
        out=ranger.main()
        import logging
        #Commented this out because this only seems to happen on the robotics lab computers, and not on glass. Until I figure out why, I'll avoid changing things such as the logger
        # logging.disable() #For some reason, ranger will activate the logger and spam text in rp from some other thread. This is bad, so we disable it.
    finally:
        os.environ['PWD']=old_os_environ_pwd
        sys.argv=old_args
        import logging
        logging.disable() #For some reason ranger starts logging and spams rp with tons of irrelevant messages...ignore it.

    if get_current_directory()!=old_dir:
        fansi_print("RNG: CD'd into "+get_current_directory(),'blue','bold')

    return out

def curl(url:str, *, show_progress=False)->str:
    """
    Meant to imitate the 'curl' command in linux
    Sends a get request to the given URL and returns the result string
    """
    return curl_bytes(url, show_progress=show_progress).decode('utf-8')

def curl_bytes(url, *, show_progress=False):
    """
    Fetches a file from a specified URL and returns its bytes

    Parameters:
        url (str): The URL of the file to fetch.
        show_progress (bool): If True, displays download progress bar

    Returns:
        bytes: The bytestring of the file data.

    Example:
        display_image(decode_image_from_bytes(curl_bytes('https://fileinfo.com/img/ss/xl/jpg_44-2.jpg')))
    """
    pip_import('requests')

    assert isinstance(url,str)

    if path_exists(url):
        #It's ok if the url is just a file path lol
        #Makes other code simpler
        return file_to_bytes(url)

    import requests
    from io import BytesIO

    if show_progress:
        response = requests.get(url, stream=True)
        response.raise_for_status()

        content_length = response.headers.get('content-length')
        total_bytes = int(content_length) if content_length else None
        display_progress = _eta_bytes(total_bytes, title="Download", min_interval=1/60, completion_verb="Downloaded")

        from time import time as time_time
        start_time = time_time()
        chunks = []
        bytes_received = 0
        for chunk in response.iter_content(chunk_size=8192):
            chunks.append(chunk)
            bytes_received += len(chunk)
            display_progress(bytes_received)

        # Force final update to show completion
        if total_bytes is not None:
            display_progress(total_bytes)
        else:
            # For unknown size, print completion message
            from datetime import timedelta
            elapsed = time_time() - start_time
            _print_status("Download: Done! Downloaded {0} in {1}\n".format(human_readable_file_size(bytes_received), timedelta(seconds=elapsed)))

        return b''.join(chunks)
    else:
        response = requests.get(url)
        response.raise_for_status()
        return BytesIO(response.content).getvalue()

def ntfy_send(message:str, topic='rp'):
    """ Send a message via ntfy - a free app requiring no API keys. Great for pining about finished jobs etc! Go to ntfy.sh - it's wonderful """
    pip_import('requests')
    import requests
    
    assert connected_to_internet()
    
    url = "https://ntfy.sh/"+topic
    headers = {
        #PRIORITIES:
        #    Num  Name      Description
        #    1    min       Lowest importance (no sound, no vibration)
        #    2    low       Low importance
        #    3    default   Normal importance (default if not set)
        #    4    high      High importance (e.g. rings/vibrates)
        #    5    urgent    Highest importance (persistent alarm, maximum visibility)
        "Priority": "default"
    }
    data = message
    
    response = requests.post(url, headers=headers, data=data)
    
    if not response.status_code==200:
        raise IOError(response.status_code)


def get_computer_name():
    """
    Returns the name of the current computer
    https://stackoverflow.com/questions/799767/getting-name-of-windows-computer-running-python-script
    There are apparently a few ways to do this (according to stackoverflow)
    """
    import socket
    return socket.gethostname()

def cv_image_filter(image,kernel):
    """
    Convolves an image with a custom kernel matrix on a per-channel basis
    
    EXAMPLE:
       img=load_image('https://mcusercontent.com/1f7db88dcefeafdd417098188/images/78188951-5329-4a51-8808-f68231d17609.png')
       kernel=gaussian_kernel(40,40)
       kernel=resize_image(kernel,(80,5))
       kernel/=kernel.sum()
       for theta in range(180):
           display_image(cv_image_filter(img,rotate_image(kernel,theta)))
    
    Please note: I don't know if we have to ensure that the kernel 
    """
    assert is_image(image ),'Input must be an image as defined by rp.is_image'
    assert is_image(kernel),'The kernel must also be an image as defined by rp.is_image'
    image=_prepare_cv_image(image)

    kernel=as_grayscale_image(kernel)
    kernel=as_float_image(kernel)

    if is_binary_image(kernel):
        kernel=as_byte_image(kernel)

    import cv2
    return cv2.filter2D(image,-1,kernel)

def random_rotation_matrix(dim=3):
    """
    Also known as a real orthonormal matrix
    Every vector in the output matrix is orthogonal to every vector but itself
    Every vector in the output matrix has magnitude 1
    Source: https://stackoverflow.com/questions/38426349/how-to-create-random-orthonormal-matrix-in-python-numpy
    """
    random_state = np.random
    H = np.eye(dim)
    D = np.ones((dim,))
    for n in range(1, dim):
        x = random_state.normal(size=(dim-n+1,))
        D[n-1] = np.sign(x[0])
        x[0] -= D[n-1]*np.sqrt((x*x).sum())
        # Householder transformation
        Hx = (np.eye(dim-n+1) - 2.*np.outer(x, x)/(x*x).sum())
        mat = np.eye(dim)
        mat[n-1:, n-1:] = Hx
        H = np.dot(H, mat)
        # Fix the last sign such that the determinant is 1
    D[-1] = (-1)**(1-(dim % 2))*D.prod()
    # Equivalent to np.dot(np.diag(D), H) but faster, apparently
    H = (D*H.T).T
    return H

def wordcloud_image(words,width=512,height=512,colormap='viridis',**kwargs):
    """
    EXAMPLE:
       display_image(wordcloud_image(get_source_code(r)))
    """
    pip_import('wordcloud')
    
    if not isinstance(words,str):
        words='\n'.join(words)
    
    #NOTE: Two different wordcloud items might be 'Action Thriller' vs 'Action Drama', which might have two distinct colors (as opposed to three: 'Action','Thriller','Drama'        
    #If we have collocations=True, we want to randomly shuffle all of the words, otherwise we might get duplicates
    words=line_split(words)
    words=shuffled(words)
    words=line_join(words)
    
    from wordcloud import WordCloud
    #For collocations, see https://stackoverflow.com/questions/43954114/python-wordcloud-repetitve-words
    wordcloud = WordCloud(width=width,height=height,collocations=False,colormap=colormap,**kwargs)
    wordcloud.generate(words)
    return wordcloud.to_array()

def display_pandas_correlation_heatmap(dataframe,*,title=None,show_numbers=False,method='pearson',block=False):
    """
    This function is used for exploratory analysis with pandas dataframes. It lets you see which variables are correlated to which other variables, and by how much.
    The dataframe argument should be a pandas.DataFrame object
    show_numbers will, when True, show the correlation value as a number over each square in the grid. Typically, it only looks good if the squares on the grid are large (otherwise the numbers won't fit). Because of this, I turned it off by default - but if you enable it it can make your plot much more informative!
    EXAMPLE:
       display_pandas_correlation_heatmap(show_numbers=True,dataframe=pip_import('pandas').read_csv('https://raw.githubusercontent.com/mwaskom/seaborn-data/master/titanic.csv'))
    """
    pip_import('seaborn')
    pip_import('matplotlib')
    pip_import('pandas')
    import pandas as pd
    import matplotlib.pyplot as plt
    import seaborn as sns

    assert isinstance(dataframe,pd.DataFrame)

    f, ax = plt.subplots(figsize=(10, 8))
    corr = dataframe.corr(method=method)

    # Generate a custom diverging colormap
    # TODO: Possibly add an argument to change the colormap
    cmap = sns.diverging_palette(230, 20, as_cmap=True)

    sns.heatmap(corr, square=True, ax=ax, annot=show_numbers,cmap=cmap)
    
    if title is not None:
        plt.title(title)

    update_display(block=block)

def view_table(data):
    """
    Launches a program that lets you view tabular data
    Kinda like microsoft excel, but in a terminal
    Can view numpy arrays
    Can view pandas dataframes
    Can view .csv files (given a filepath)
    Can view .csv files (given the contents as a string)
    Can view lists of lists such as view_table([[1,2,3],['a','b','c'],[[1,2,3],{'key':'value'},None]])
    Can view multiline strings that look like tables, such as 'a b c\nd e f\nthings stuff things fourth thing six'
    """


    pip_import('pandas')
    pip_import('tabview')
    import tabview
    import pandas as pd


    temp_file=temporary_file_path('csv')
    try:
        #This works for view_table([[1,2,3],[4,5,6]])
        tabview.view(data) 
    except Exception:
        #ERROR: ValueError: The truth value of an array with more than one element is ambiguous. Use a.any() or a.all()
        if isinstance(data,str):

            if file_exists(data):
                #Perhaps the data is the path to a .csv file...
                tabview.view(data)
                return

            #If data is a string, perhaps it is the contents of some .csv file
            string_to_text_file(temp_file,data)

        else:
            dataframe=pd.DataFrame(data)
            dataframe.to_csv(temp_file,index=False)

        tabview.view(temp_file)
    finally:
        if file_exists(temp_file):
            delete_file(temp_file)

def launch_visidata(target):
    """
    Launches VisiData to view and potentially edit a file or data object. 
    Useful for manually editing DataFrame or numpy arrays, or inspecting them statistically

    Parameters:
        - target (str or data object): The file path or data object (e.g., numpy array, pandas DataFrame) to view.
              Some known supported filetypes: .tsv, .csv, .parquet, .json (and more)
              Some known supported datatypes: np.ndarray, pd.DataFrame, list of lists

    Behavior:
        - If `target` is a string representing a file path and the file exists, VisiData opens the file directly.
        - If `target` is a data object, it is first converted into a pandas DataFrame, then saved to a temporary CSV file which VisiData opens.

    Important:
        - Changes made in VisiData can be saved back to the original DataFrame or file by pressing Ctrl+S before exiting VisiData.
        - The function returns the path to the file if `target` was a file path, or the modified DataFrame if `target` was a data object.

    Returns:
        - str or pandas.DataFrame: Depending on whether the target was a file path or a data object, either the file path or the modified DataFrame is returned.

    EXAMPLE:
        >>> ans = launch_visidata(np.random.randn(16,16))
    """
    
    pip_import("visidata")
    vd = sys.executable + " -m visidata "
    if isinstance(target, str) and file_exists(target):
        os.system(vd + shlex.quote(target))
        return target
    else:
        temp_path = temporary_file_path("csv")
        pip_import("pandas")
        import pandas as pd

        dataframe = pd.DataFrame(target)

        try:
            dataframe.to_csv(temp_path, index=False)
            os.system(vd + shlex.quote(temp_path))
            output = pd.read_csv(temp_path)
        finally:
            if file_exists(temp_path):
                delete_file(temp_path)

        return output

#Pseudo-terminal HISTORY files
_pterm_hist_file=None
def _write_to_pterm_hist(entry):
    assert isinstance(entry,str)
    global _pterm_hist_file
    if _pterm_hist_file is None:
        _pterm_hist_file = open(pterm_history_filename,'a+')
        _pterm_hist_file.write('\n\n')
        _pterm_hist_file.write('\n############################################################')
        _pterm_hist_file.write('\n########### BEGINNING OF PSEUDO TERMINAL SESSION ###########')
        _pterm_hist_file.write('\n###########%s###########'%_format_datetime(get_current_date()).center(86-48))
        _pterm_hist_file.write('\n############################################################')
    _pterm_hist_file.write('\n'+entry)
    
def _prepare_cv_image(image):
    """
    OpenCV is a bit finicky sometimes
    Apart from just as_float_image, there are some other requirements
    """
    assert is_image(image)

    if is_pil_image(image):
        image = as_numpy_image(image)
    elif is_float_image(image):
        #Float16 is no good
        image = image.astype(_float_image_dtype())

    return image
    
def cv_resize_image(image, size, interp="auto", *, alpha_weighted=False, copy=True):
    """
    This function is similar to r.resize_image (which uses scipy), except this uses OpenCV and is much faster
    Unlike r.resize_image, this function will not always return a floating point image. If given a byte image, the result will also be a byte image
    If copy==False, and the size doesn't change the image, just returns the original image as is without copying it

    ARGS:
        image: An image as defined by rp.is_image
        size (scalar or (h,w) tuple):
            - A single number: Will scale the entire image by that size
            - A tuple with integers in it: Will scale the image to those dimensions (height, width)
            - A tuple with None in it: A dimension with None will default to the image's dimension
        interp (str) options:
            'auto'       Chooses between 'area' and 'bilinear' on a per-axis basis
            'nearest'    Nearest neighbor interpolation. Very fast, but also very crude and aliased, much cronchy
            'pyrdown'    Uses cv2.pyrDown and then uses area interp. Results in up to 10x speed boosts over 'area' or 'auto'
            'bilinear'   Uses bilinear interpolation when making an image larger, but doesn't antialias images getting smaller
            'bicubic'    Uses bicubic interpolation when making an image larger, but doesn't antialias images getting smaller
            'area'       Uses mean pooling for shrinking images, but 'nearest' for growing them
        alpha_weighted (bool): Default False. Takes into consideration the alpha channel and weights RGB operations by it
        
    EXAMPLE: alpha_weighted:
        >>> # When downscaling this image, we see crusty edges unless alpha_weighted=True
        ...
        ... text_image = pil_text_to_image(
        ...     "Hello World", size=256, color=(1, 0, 0, 1), background_color=(0, 0, 1, 0)
        ... )
        ...
        ... display_image(
        ...     vertically_concatenated_images(
        ...                                 cv_resize_image(text_image, 0.25                      ) ,
        ...         with_alpha_checkerboard(cv_resize_image(text_image, 0.25, alpha_weighted=True )),
        ...         with_alpha_checkerboard(cv_resize_image(text_image, 0.25, alpha_weighted=False)),
        ...     )
        ... )
    """
    
    pip_import('cv2')
    import cv2  

    if alpha_weighted:
        return _alpha_weighted_rgba_image_func(cv_resize_image, image, size, interp)


    assert is_image(image)
    
    if is_binary_image(image):
        #OpenCV's resize function doesn't support boolean images
        image=as_byte_image(image)
    else:
        image=_prepare_cv_image(image)

    in_height, in_width = get_image_dimensions(image)
    height, width = _size_to_height_width(size, in_height, in_width)

    if width==0 or height==0:
        return _zeros_like(image, shape=(height,width,*image.shape[2:]))

    #Take a shortcut - and if we don't need to copy it, just return the original tensor for speed's sake
    if (height,width) == (in_height, in_width):
        if copy: return image.copy()
        else   : return image

    #For CV2, we can have 'pyrdown' option which makes things much faster
    if interp=='pyrdown':
        #Use gaussian subsampling pyrDown to shrink the image as much as we can
        #This results in massive speed boosts, for large->small images (like 1000x1000 to 10x10) this boost can be up to 10x.
        #Not sure why this is faster though.
        while get_image_height(image) >= 2*height and get_image_width(image) >= 2*width:
            image = cv2.pyrDown(image)
        interp='auto'
    
    #Choose an interpolation method
    interp_methods = {
        "bilinear": cv2.INTER_LINEAR,
        "bicubic": cv2.INTER_CUBIC,
        "nearest": cv2.INTER_NEAREST,
        "area": cv2.INTER_AREA,  #Good for downsampling, but when upsampling acts like nearest neigbors
        "auto": None,  #Automatically chooses between 'area' and 'bilinear' and does that independently for each axis
    }
    assert interp in interp_methods, 'cv_resize_image: Interp must be one of the following: %s'%str(list(interp_methods))
    interp_method=interp_methods[interp]

    if interp == "auto": return _auto_interp_for_resize_image(cv_resize_image, image, (height, width))

    out = cv2.resize(image,(width,height),interpolation=interp_method)
    
    return out

def _size_to_height_width(size, in_height, in_width):
    """
    Common helper among image resizing funcs
    Takes in a size (either a scalar or (height, width) tuple
    Returns a height, width tuple
    """
    if is_number(size):
        assert size>=0, 'Cannot resize an image by a negative factor'
        height=np.ceil(in_height*size)
        width =np.ceil(in_width *size)
    else:
        height,width=size
        height=in_height if height is None else height
        width =in_width  if width  is None else width
    
    height=int(height)
    width =int(width )

    return height, width

def cv_resize_images(
    *images,
    size,
    interp="auto",
    alpha_weighted=False,
    show_progress=False,
    copy=True,
    lazy=False
):
    """Batch resize images using OpenCV. Faster than resize_image for multiple images.
    See cv_resize_image for single images."""
    images=detuple(images)

    as_numpy = is_numpy_array(images)

    images=(cv_resize_image(image, size, interp, copy=copy) for image in images)

    if show_progress:
        images = eta(images, title="rp.cv_resize_images")

    if not lazy: 
        #Process them all
        images=list(images)

        if as_numpy:
            #If the input was a numpy array, convert the output to that too
            if show_progress: print(end="resize_images: Converting to numpy array...")
            images = as_numpy_array(images)
            if show_progress: print(end="done!")

    return images

resize_images = cv_resize_images  # For now, they will be the same thing

def resize_videos(
    *videos,
    size,
    interp="auto",
    alpha_weighted=False,
    show_progress=False,
    lazy=False,
    lazy_frames=False
    ):
    """
    Resize multiple videos to specified size by resizing each frame.

    Wraps resize_images for each video, handles batch processing efficiently.
    Used by resize_videos_to_min_size() and similar functions.

    Parameters:
        *videos: Video arrays to resize
        size (tuple): Target (height, width) 
        interp (str): Interpolation method ('auto', 'bilinear', etc.)
        show_progress (bool): Display progress bar
        lazy (bool): Return generator instead of computing immediately

    Examples:
        >>> import numpy as np
        >>> video1 = np.random.rand(10, 480, 640, 3)  # 10 frames, 480x640
        >>> video2 = np.random.rand(15, 720, 1280, 3)  # 15 frames, 720x1280
        >>> resized = resize_videos(video1, video2, size=(240, 320))
        >>> len(resized)  # Returns list of 2 resized videos
        2

    Tags: video-processing, resize, batch, plural
    """

    videos=detuple(videos)

    as_numpy = is_numpy_array(videos)

    output = (
        resize_images(
            video,
            size=size,
            interp=interp,
            alpha_weighted=alpha_weighted,
            show_progress=False,
            lazy=lazy_frames,
        )
        for video in videos
    )
    
    if show_progress: videos = eta(videos, title='resize_videos')
    if not lazy: 
        output=list(output)
        if as_numpy:
            #If the input was a numpy array, convert the output to that too
            if show_progress: print(end="resize_videos: Converting to numpy array...")
            output = as_numpy_array(output)
            if show_progress: print(end="done!")

    return output

def skia_resize_image(image, size, interp='auto'):
    """
    Resizes a 4-channel (RGBA) uint8 NumPy array using Skia's high-level
    resize method, which supports mipmapping for high-quality downscaling.
    
    WHEN TO USE OVER cv_resize_image:
        I found this is much faster when shrinking large images with the 'auto' interp,
        which handles graceful downsampling - sometimes reaching 10x performance differences.
        However, on medium-sized images, or when not mipmapping, cv_resize_image is faster
        Usually, use cv_resize_image - but if shrinking images quickly try this function
    
    NOTE: You will only ever get a speed-boost over CV2 if the input image is
          already an RGBA byte image!
          Also this speed boost comes from mipmaps, so squishing images -> skinny looks bad...

    Args:
        image: The source NumPy array with shape (H, W, 4) and dtype uint8.
        size: A tuple (height, width) for the target size, or a scalar
              If a scalar is provided, scales image proportionally to that scalar
              If a (height, width) tuple is provided and height is None or width is None, 
                  that height or width is ignored - so you could scale just height or just width
        interp: Interpolation mode. Can be 'nearest', 'linear', 'cubic',
                'area', or 'auto'. 'auto' and 'area' will enable mipmapping
                when shrinking the image for best quality.

    Returns:
        A new, resized NumPy array with shape (height, width, 4) and dtype uint8.
        
    EXAMPLE (Shows speed + Quality):

        Some example results with the webcam mode uncommented:
            CV AUTO    FPS: 388.108
            CV PYRDOWN FPS: 698.399
            SKIA AUTO  FPS: 1595.684

        >>> @globalize_locals
        ... def demo_resize_interp():
        ...     import numpy as np
        ...     import time
        ...     
        ...     FACTOR=10
        ...     RES=128
        ... 
        ...     c = get_checkerboard_image(RES*FACTOR,RES*FACTOR,tile_size=32)
        ...     original_height,original_width=RES,RES
        ...     c=as_byte_image(c)
        ...     c=as_rgba_image(c)
        ...     c=np.ascontiguousarray(c)
        ...     frames = 360  # Total number of frames for a smoother and longer animation
        ...     start_angle = np.pi / 4  # Starting at 45 degrees
        ...     max_multiplier = 3  # Maximum size multiplier
        ...     output=[]
        ...     for _ in range(2):    
        ...         skia_total_time=0
        ...         cv_auto_total_time=0
        ...         cv_pyrdown_total_time=0
        ...         total_frames=0
        ...         for frame in range(frames):
        ... 
        ...             #UNCOMMENT TO USE WEBCAM DEMO
        ...             #c=load_image_from_webcam()
        ...             #c=as_rgba_image(c)
        ...             #c=np.ascontiguousarray(c)
        ...         
        ...             # Calculate the new dimensions using sine and cosine with a 45-degree offset
        ...             # Amplitude is adjusted to vary directly from 1 to max_multiplier times the original size
        ...             new_height = int((np.sin(2 * np.pi * frame / frames + start_angle) + 1) / 2 * (max_multiplier - 1) * original_height + 1)
        ...             new_width = int((np.cos(2 * np.pi * frame / frames + start_angle) + 1) / 2 * (max_multiplier - 1) * original_width + 1)
        ...         
        ...             # Resize and display the image
        ... 
        ... 
        ...             #Best but slowest
        ...             tic()
        ...             co=c
        ...             cv_auto_resized_image = cv_resize_image(co, (new_height, new_width), interp='auto')
        ...             cv_auto_total_time+=toc()
        ... 
        ...             #Middle grounds
        ...             tic()
        ...             co=c
        ...             cv_pyrdown_resized_image = resized_image = cv_resize_image(co, (new_height, new_width), interp='pyrdown')
        ...             cv_pyrdown_total_time+=toc()
        ...             
        ...             #Worst but fastest
        ...             tic()
        ...             skia_auto_resized_image = skia_resize_image(c, (new_height, new_width), interp='auto')
        ...             skia_total_time+=toc()
        ... 
        ...             total_frames+=1
        ... 
        ...             print('-'*10,total_frames,'-'*10)
        ...             if cv_auto_total_time   :print('CV AUTO    FPS: %.03f'%(total_frames/cv_auto_total_time   ))
        ...             if cv_pyrdown_total_time:print('CV PYRDOWN FPS: %.03f'%(total_frames/cv_pyrdown_total_time))
        ...             if skia_total_time      :print('SKIA AUTO  FPS: %.03f'%(total_frames/skia_total_time      ))
        ...             
        ...             resized_images = [
        ...                 cv_auto_resized_image,
        ...                 cv_pyrdown_resized_image,
        ...                 skia_auto_resized_image,
        ...             ]
        ... 
        ...             #resized_image=shift_image_hue(resized_image,len(interp)/6) #Different background colors so we can easily see when interp changes
        ...             color='dark blue'
        ...             resized_images = crop_images(resized_images, height=300, width=300)
        ...             resized_images=[blend_images(color,x) for x in resized_images]
        ...             resized_images=labeled_images(resized_images,['cv_auto_resized_image','cv_pyrdown_resized_image','skia_auto_resized_image'])
        ...             resized_images=horizontally_concatenated_images(resized_images)
        ...             output.append(resized_images)
        ...             display_image(resized_images)
        ...         return output
        ...     
        ... 
        ... demo_video = demo_resize_interp()

    """
    pip_import('skia')
    import skia

    #TODO: MAKE THESE FUNCTIONS EXPOSED TO RP IF WE EVER HAVE REASON TO REUSE THEM

    def _as_skia_image(image):
        pip_import('skia')
        import skia
        
        if isinstance(image,skia.Image):
            return image
        
        #Conform to the correct input types
        image=as_numpy_image(image, copy=False)
        image=as_rgba_image(image,copy=False)
        image=as_byte_image(image,copy=False)
        if not image.flags['C_CONTIGUOUS']:
            image=np.ascontiguousarray(image)
            
        #Output Validation
        assert image.ndim==3
        assert image.shape[2]==4
        assert image.dtype==np.uint8

        return skia.Image.fromarray(image,copy=False)

    def _get_skia_sampling(interp: str, mipmap: bool = False) -> skia.SamplingOptions:
        """
        Determines the best Skia SamplingOptions based on the interpolation string
        and mipmap setting.

        interp: Interpolation mode. Can be 'nearest', 'linear', 'cubic',
                'area', or 'auto'. 'auto' and 'area' will enable mipmapping
                when mipmap is True for best quality.
        mipmap: Whether to enable mipmapping for high-quality downscaling.
        """
        pip_import('skia')
        import skia

        # Defaults
        filter_mode = skia.FilterMode.kLinear
        mipmap_mode = skia.MipmapMode.kNone  # No mipmapping by default

        if interp in ['linear', 'bilinear']:
            filter_mode = skia.FilterMode.kLinear
        elif interp == 'nearest':
            filter_mode = skia.FilterMode.kNearest
        elif interp in ['cubic', 'bicubic']:
            # For high-quality cubic resampling, this is the preferred method.
            # It ignores filter/mipmap modes.
            return skia.SamplingOptions(skia.CubicResampler.Mitchell())
        elif interp == 'area' or interp == 'auto':
            # 'area' and 'auto' are interpreted as "use high quality for downscaling".
            # Mipmapping is the Skia way to achieve this.
            if mipmap:
                mipmap_mode = skia.MipmapMode.kLinear
        else:
            raise ValueError("Unsupported interpolation mode: {0}".format(repr(interp)))

        # Use positional arguments as required by the constructor.
        return skia.SamplingOptions(filter_mode, mipmap_mode)

    #THE MEAT OF THIS FUNCTION
    src_image = _as_skia_image(image)
    src_height, src_width = get_image_dimensions(image)
    dst_height, dst_width = _size_to_height_width(size,src_height,src_width)

    is_downscaling = dst_width < src_width or dst_height < src_height
    sampling_options = _get_skia_sampling(
        interp,
        mipmap=is_downscaling
    )

    resized_image = src_image.resize(
        width=dst_width,
        height=dst_height,
        options=sampling_options
    )
    
    return resized_image.toarray()

    
def torch_resize_image(image, size, interp="auto", *, copy=True):
    """
    The given image should be a CHW torch tensor.

    Valid sizes:
        - A single number: Will scale the entire image by that size
        - A tuple with integers in it: Will scale the image to those dimensions (height, width)
        - A tuple with None in it: A dimension with None will default to the image's dimension

    If copy==False, and the size doesn't change the image, just returns the original image as is without copying it

    EXAMPLE:
        image = load_image('https://avatars.githubusercontent.com/u/17944835?v=4&size=64')
        image=as_torch_image(image)
        display_image(torch_resize_image(image,10,'area'))
    """
    
    assert rp.r.is_torch_image(image)

    pip_import('einops')
    pip_import('torch')

    import torch.nn.functional as F
    from einops import rearrange

    # Choose an interpolation method
    interp_methods = {
        "bilinear",
        "bicubic",
        "area",  # Good for downsampling, but when upsampling acts like nearest neigbors
        "nearest",
        "nearest-exact",  # https://github.com/pytorch/pytorch/pull/64501 - for integer size factors it's the same as 'nearest'
        "auto",  #Automatically chooses between 'area' and 'bilinear' and does that independently for each axis
    }
    assert interp in interp_methods, 'torch_resize_image: Interp must be one of the following: %s'%str(list(interp_methods))

    in_height, in_width = get_image_dimensions(image)
    height, width = _size_to_height_width(size, in_height, in_width)

    #Take a shortcut - and if we don't need to copy it, just return the original tensor for speed's sake
    if (height,width) == (in_height, in_width):
        if copy: return image.clone()
        else   : return image

    if interp=='auto': return _auto_interp_for_resize_image(torch_resize_image, image, (height, width))
    
    out  = rearrange(
        F.interpolate(
            rearrange(image, "c h w -> 1 c h w"),
            size=(height, width),
            mode=interp,
        ),
        "1 c h w -> c h w",
    )

    assert out.shape==(image.shape[0], height, width)

    return out

def torch_resize_images(*images, size, interp="auto", copy=True):
    """Batch resize images using PyTorch. GPU-accelerated when available.
    See torch_resize_image for single images."""
    images = detuple(images)
    as_tensor = is_torch_tensor(images)
    resized = [gather_args_call(torch_resize_image, x) for x in images]
    if as_tensor:
        import torch
        resized = torch.stack(resized)
    return resized


def torch_remap_image(
    image,
    x,
    y,
    *,
    relative=False,
    interp="bilinear",
    add_alpha_mask=False
):
    """
    Remap an image tensor using the given x and y coordinate tensors.
    Out-of-bounds regions will be given 0's
    Analagous to rp.cv_remap_image, which is used for images as defined by rp.is_image()
    
    If the image is RGBA, then out-of-bounds regions will have 0-alpha.
    This is like a UV mapping - where x and y's values are mapped to the image.
    If relative=True, it will warp the image - treating x and y like dx and dy.
        Note: Because this is a mapping, the direction of movement will be opposite dx and dy - so you may need to negate them!

    If add_alpha_mask=True, an additional alpha channel full of 1's will be concatenated to the input image tensor.
    This alpha channel will become 0 for out-of-bounds regions after remapping, serving as an indicator for invalid regions.

    Args:
        image (torch.Tensor): Input image tensor of shape [C, H, W], where C is the number of channels (e.g., 3 for RGB, 4 for RGBA),
                              H is the height, and W is the width of the image.
        x (torch.Tensor): X-coordinate tensor of shape [H_out, W_out] specifying the x-coordinates for remapping,
                          where H_out is the output height and W_out is the output width.
        y (torch.Tensor): Y-coordinate tensor of shape [H_out, W_out] specifying the y-coordinates for remapping.
        relative (bool, optional): If True, treat x and y as deltas (dx and dy) and perform relative warping. Default is False.
        interp (str, optional): Interpolation method. Can be one of 'bilinear', 'bicubic', or 'nearest'. Default is 'bilinear'.
        add_alpha_mask (bool, optional): If True, an additional alpha channel full of 1's will be concatenated to the input image tensor. Default is False.

    Returns:
        torch.Tensor: Remapped image tensor.
            - If add_alpha_mask=False, the output shape is [C, H_out, W_out], where:
                - C is the number of channels in the input image.
                - H_out is the output height.
                - W_out is the output width.
            - If add_alpha_mask=True, the output shape is [C+1, H_out, W_out], where:
                - C+1 includes the additional alpha channel.
                - H_out is the output height.
                - W_out is the output width.

    EXAMPLE:
        >>> def calculate_wave_pattern(h, w, frame):
        ...     # Create a grid of coordinates
        ...     y, x = torch.meshgrid(torch.arange(h), torch.arange(w))
        ...     
        ...     # Calculate the distance from the center of the image
        ...     center_x, center_y = w // 2, h // 2
        ...     dist_from_center = torch.sqrt((x - center_x)**2 + (y - center_y)**2)
        ...     
        ...     # Calculate the angle from the center of the image
        ...     angle_from_center = torch.atan2(y - center_y, x - center_x)
        ...     
        ...     # Calculate the wave pattern based on the distance and angle
        ...     wave_freq = 0.05  # Frequency of the waves
        ...     wave_amp = 10.0   # Amplitude of the waves
        ...     wave_offset = frame * 0.05  # Offset for animation
        ...     
        ...     dx = wave_amp * torch.cos(dist_from_center * wave_freq + angle_from_center + wave_offset)
        ...     dy = wave_amp * torch.sin(dist_from_center * wave_freq + angle_from_center + wave_offset)
        ...     
        ...     return dx, dy
        ... 
        >>> def demo_wiggly_dog():
        ...     real_image = as_torch_image(
        ...         rp.cv_resize_image(
        ...             load_image(
        ...                 "https://i.natgeofe.com/n/4f5aaece-3300-41a4-b2a8-ed2708a0a27c/domestic-dog_thumb_square.jpg"
        ...             ),
        ...             (512, 512),
        ...         )
        ...     )
        ...     
        ...     #real_image = torch.rand(real_image.shape)
        ...     
        ...     out_frames = []
        ...     
        ...     for frame in range(200):
        ...         h, w = get_image_dimensions(real_image)
        ...         
        ...         dx, dy = calculate_wave_pattern(h, w, frame)
        ...         
        ...         warped_image = torch_remap_image(real_image, dx, dy, relative=True, interp='bilinear', add_alpha_mask=True)    
        ...         warped_image = as_numpy_image(warped_image)
        ...         warped_image = with_alpha_checkerboard(warped_image)
        ...         out_frames.append(warped_image)
        ...         
        ...     display_video(out_frames)
        ... 
        >>> demo_wiggly_dog()

    Note: For interp='nearest', will x and y be floored or rounded?
        Answer: The values will be rounded! Shifting x relative by -.01 will not make any change!
        EXAMPLE ANALYSIS:
            >>> load_image('https://avatars.githubusercontent.com/u/17944835?v=4&size=64')
            >>> image = as_torch_image(load_image('https://avatars.githubusercontent.com/u/17944835?v=4&size=64'))
            >>> x, y = image[:2] * 0

            >>> #Testing what happens when we shift with nearest interp...it rounds the positions!
            >>> display_image(torch_remap_image(image, x    , y, relative=True                  )) # Does not change the image
            >>> display_image(torch_remap_image(image, x+1  , y, relative=True                  )) # Shifts image to left by one pixel  
            >>> display_image(torch_remap_image(image, x-1  , y, relative=True                  )) # Shifts image to right by one pixel
            >>> display_image(torch_remap_image(image, x    , y, relative=True, interp='nearest')) # Same as with bilinear - does nothing
            >>> display_image(torch_remap_image(image, x+.01, y, relative=True, interp='nearest')) # Does not change anything!
            >>> display_image(torch_remap_image(image, x-.01, y, relative=True, interp='nearest')) # Does not change anything!
            >>> display_image(torch_remap_image(image, x-.49, y, relative=True, interp='nearest')) # Does not change anything!  
            >>> display_image(torch_remap_image(image, x-.5 , y, relative=True, interp='nearest')) # Changes stuff. Some pixels are shifted, some aren't
            >>> display_image(torch_remap_image(image, x-.51, y, relative=True, interp='nearest')) # Just like x-1, shifts image to right by 1 pixel

            >>> #Checking 'nearest' recursive stability - it passes!
            >>> new_image=image
            >>> for _ in range(10):
            ...     display_image(new_image)
            ...     new_image = torch_remap_image(new_image, x, y, relative=True, interp="nearest")
            >>> assert (image==new_image).all()

            >>> #Checking 'bilinear' recursive stability - it numerically  passes, but is not bitwise-perfect
            >>> new_image=image
            >>> for _ in range(10):
            ...     display_image(new_image)
            ...     new_image = torch_remap_image(new_image, x, y, relative=True, interp="bilinear")
            >>> print((image==new_image).all()     ) #Printed: tensor(False)
            >>> print((image-new_image).abs().max()) #Printed: tensor(8.3447e-06)
    """

    assert rp.r.is_torch_image(image), "image must be a torch tensor with shape [C, H, W]"
    assert is_torch_tensor(x) and is_a_matrix(x), "x must be a torch tensor with shape [H_out, W_out]"
    assert is_torch_tensor(y) and is_a_matrix(y), "y must be a torch tensor with shape [H_out, W_out]"
    assert x.shape == y.shape, "x and y must have the same shape, but got x.shape={} and y.shape={}".format(x.shape, y.shape)
    assert image.device==x.device==y.device, "all inputs must be on the same device"

    pip_import('einops')
    pip_import('torch')

    import torch
    import torch.nn.functional as F
    from einops import rearrange

    in_c, in_height, in_width = image.shape
    out_height, out_width = x.shape

    if add_alpha_mask:
        alpha_mask = torch.ones_like(image[:1])
        image = torch.cat([image, alpha_mask], dim=0)

    if relative:
        assert in_height == out_height, "For relative warping, input and output heights must match, but got in_height={} and out_height={}".format(in_height, out_height)
        assert in_width  == out_width , "For relative warping, input and output widths must match, but got in_width={} and out_width={}".format(in_width, out_width)
        x = x + torch.arange(in_width , device=x.device, dtype=x.dtype)
        y = y + torch.arange(in_height, device=y.device, dtype=y.dtype)[:,None]

    x = rearrange(x, "h w -> 1 h w")
    y = rearrange(y, "h w -> 1 h w")

    # Normalize coordinates to [-1, 1] range - which F.grid_sample requires
    x = (x / (in_width - 1)) * 2 - 1
    y = (y / (in_height - 1)) * 2 - 1

    # Stack x and y coordinates
    grid = torch.stack([x, y], dim=-1)
    grid = grid.to(image.dtype)

    # Choose an interpolation method
    interp_methods = {
        "bilinear": "bilinear",
        "bicubic": "bicubic",
        "nearest": "nearest",
    }
    assert interp in interp_methods, 'torch_remap_image: interp must be one of the following: {}'.format(list(interp_methods))
    interp_mode = interp_methods[interp]

    # Remap the image using grid_sample
    out = F.grid_sample(rearrange(image, "c h w -> 1 c h w"), grid, mode=interp_mode, align_corners=True)
    out = rearrange(out, "1 c h w -> c h w")

    # Assert the shape of the output tensor
    expected_c = in_c+1 if add_alpha_mask else in_c
    assert out.shape == (expected_c, out_height, out_width), "Expected output shape: ({}, {}, {}), but got: {}".format(expected_c, out_height, out_width, out.shape)

    return out

def apply_uv_map(image, uv_map, *, uv_form="xy", interp="bilinear", relative=False):
    """
    Applies a UV map to an image to remap it
    Unlike cv_remap_image or torch_remap_image, UV maps are on a scale from 0 to 1 (not absolute pixel values)
    Can handle both images defined by rp.is_image and torch_images
    Defaults to the datatype of image (since this is what we would normally backprop through if it is torch)
    
    EXAMPLE:
        >>> image = load_image(
        ...     "https://www.servicedogtrainingschool.org/storage/app/uploads/public/62f/bad/31c/62fbad31c7a74968893226.png",
        ...     use_cache=True,
        ... )
        ... image = cv_resize_image(image, 0.5)
        ... uv_map = load_image(
        ...     "https://github.com/RyannDaGreat/Images/blob/master/test_images/parker_puzzle_uv_map.png?raw=true",
        ...     use_cache=True,
        ... )
        ...
        ... #Uncomment any combination of these two lines to test them as torch images or a mix
        ... #image =as_torch_image(image)
        ... #uv_map=as_torch_image(as_float_image(uv_map))
        ...
        ... display_image(
        ...     with_alpha_checkerboard(
        ...         with_drop_shadow(
        ...             tiled_images(
        ...                 labeled_images(
        ...                     [
        ...                         image,
        ...                         uv_map,
        ...                         apply_uv_map(image, uv_map),
        ...                     ],
        ...                     ["image", "uv_map", "apply_uv_map(image,uv_map)"],
        ...                     font="G:Quicksand",
        ...                     size=30,
        ...                 ),
        ...                 border_thickness=50,
        ...                 border_color=(1, 1, 1, 0),
        ...                 length=3,
        ...             ),
        ...             x=10,
        ...             y=10,
        ...             blur=100,
        ...         ),
        ...         tile_size=32,
        ...         first_color=1,
        ...         second_color=0.9,
        ...     )
        ... )

    """
    
    #In case we want U to represent first axis (which is y) instead of x
    assert uv_form in ['xy', 'yx'], 'rp.apply_uv_map: uv_form should be either "xy" or "yx", but is '+repr(uv_form)
    xi = uv_form.find('x')
    yi = uv_form.find('y')
    
    if is_image(uv_map):
        assert not is_grayscale_image(image),'A grayscale UV map is pretty dang useless. You sure you didnt make a mistake?'
        assert not is_binary_image(image),'A binary UV map is pretty dang useless. You sure you didnt make a mistake?'
        uv_map=as_float_image(uv_map)
        x=uv_map[:,:,xi]
        y=uv_map[:,:,yi]
    elif is_torch_tensor(uv_map):
        assert uv_map.ndim==3, 'rp.apply_uv_map: uv_map, if torch tensor, should be in CHW form if a torch tensor. Given '+str(uv_map.shape)
        x=uv_map[xi]
        y=uv_map[yi]
    else:
        assert False, 'rp.apply_uv_map: Invalid uv_map of type '+str(type(uv_map))+' ; check that it is an image as defined by rp.is_image or is a torch tensor'

    image_height, image_width = get_image_dimensions(image)
    x=x*image_width
    y=y*image_height

    if is_image(image):
        assert not is_grayscale_image(image),'A grayscale UV map is pretty dang useless. You sure you didnt make a mistake?'
        assert not is_binary_image(image),'A binary UV map is pretty dang useless. You sure you didnt make a mistake?'
        x=as_numpy_array(x)
        y=as_numpy_array(y)
        output = cv_remap_image(image, x, y, interp=interp, relative=relative)
    elif is_torch_tensor(image):
        assert image.ndim==3, 'rp.apply_uv_map: image, if torch tensor, should be in CHW form if a torch tensor. Given '+str(uv_map.shape)
        import torch
        x=torch.tensor(x).to(image.device)
        y=torch.tensor(y).to(image.device)
        output = torch_remap_image(image, x, y, interp=interp, relative=relative)
    else:
        assert False, 'rp.apply_uv_map: Invalid image of type '+str(type(image))+' ; check that it is an image as defined by rp.is_image or is a torch tensor'
        
    return output

def get_identity_uv_map(height=256,width=256,uv_form='xy'):
    """
    Returns an RGB UV-Map image with the form uv_form
    
    EXAMPLE:
        >>> display_image(
        ...     with_alpha_checkerboard(
        ...         with_drop_shadow(
        ...             tiled_images(
        ...                 labeled_images(
        ...                     [get_identity_uv_map(uv_form=f) for f in "xy yx".split()],
        ...                     ["uv_form=xy", "uv_form=yx"],
        ...                     font="G:Quicksand",
        ...                     size=30,
        ...                 ),
        ...                 border_thickness=50,
        ...                 border_color=(1, 1, 1, 0),
        ...             ),
        ...             x=10,
        ...             y=10,
        ...             blur=100,
        ...         ),
        ...         tile_size=32,
        ...         first_color=1,
        ...         second_color=.9,
        ...     )
        ... )
    """

    assert uv_form in ['xy', 'yx'], 'rp.get_identity_uv_map: uv_form should be either "xy" or "yx", but is '+repr(uv_form)
    xi = uv_form.find('x')
    yi = uv_form.find('y')

    output=np.zeros((height,width,3))
    x,y=xy_float_images(height,width)

    output[:,:,xi]=x
    output[:,:,yi]=y

    return output

def validate_tensor_shapes(return_dims=None, *, verbose=False, **kwargs):
    """
    Validates that tensor dimensions match expected shapes and extracts dimension values.
    Reads the tensors from the caller's scope using the variable names found in kwargs.
    
    Args:
        return_dims: String of space-separated dimension names to return (e.g., "H W"). 
                    If provided, only returns the specified dimensions instead of all validated dimensions.
        verbose: Boolean, if True suppresses shape information printing (default: True)
        **kwargs: Either tensor variables with form strings (e.g., image="H W C") 
                 or manual dimension specifications (e.g., C=3)
                 You can also prefix them with 'numpy:' or 'torch:' for type assertions
    
    Returns:
        EasyDict with extracted dimension values (dims.H, dims.W, etc.)
    
    Raises:
        ValueError: With detailed error message when validation fails
    
    WHY USE THIS:
    - Catch shape mismatches early with clear error messages
    - Extract dimension values like H, W, C into easy-to-use variables
    - Ensure multiple tensors share compatible dimensions
    - Avoid hard-to-debug shape errors deep in your model
    
    EXAMPLES:

        0. Original use-case (more typical):
            >>> #First, validate the relationships
            ... rp.validate_shapes(
            ...     video ='torch: PT PH PW RGB',
            ...     mask  ='torch: PT PH PW',
            ...     tracks='numpy: PT N XY',
            ...     track_colors='N LC',
            ...     dotted_latent='LT LC LH LW',
            ...     verbose='bold white random green',
            ...     XY = 2, #Keeping XY to show that X and Y are the ordering
            ...     XY = 3,
            ... )
            ... #Then, assign the vars as so
            ... PT, PH, PW = mask.shape
            ... PT, N, XY = tracks.shape
            ... LT, LC, LH, LW = dotted_latent.shape
            ... 
            ... #PRINTS THE FOLLOWING:
            ... #
            ... # Shape Validation:
            ... #     Expected Forms:
            ... #       • dotted_latent: LT LC LH LW
            ... #       • mask: PT PH PW
            ... #       • track_colors: N LC
            ... #       • tracks: PT N XY
            ... #       • video: PT PH PW RGB
            ... #       Where:
            ... #         XY=2
            ... #         RGB=3
            ... #     
            ... #     Actual Shapes:
            ... #       • dotted_latent: 13,16,60,90
            ... #       • mask: 49,480,720
            ... #       • track_colors: 200,16
            ... #       • tracks: 49,200,2
            ... #       • video: 49,480,720,3

        0. Original use-case (fancier):
            >>> H, W, C = rp.validate_shapes(
            ...     #Instead of rp.destructure, we can also do this to be more pythonic
            ...     'H W C',
            ...     
            ...     #The tensors we want to check
            ...     video ='PT PH PW 3',
            ...     mask  ='PT PH PW',
            ...     tracks='PT N XY',
            ...
            ...     **rp.gather_vars('PT PH PW PT N XY'), #Manually specify existing dims
            ...
            ...     verbose='bold yellow', #Format the printout with fansi
            ... )

        1. Basic usage - extract dimensions:
            >>> img = torch.zeros(224, 224, 3)
            >>> dims = validate_tensor_shapes(img="H W C")
            >>> dims.H  # 224
            >>> dims.W  # 224
            >>> dims.C  # 3
        
        2. Check that multiple tensors share dimensions:
            >>> img = torch.zeros(224, 224, 3)
            >>> mask = torch.zeros(224, 224, 1) 
            >>> dims = validate_tensor_shapes(img="H W C", mask="H W 1")
            # This passes because H and W are the same in both
            
            # This would FAIL because the heights don't match:
            >>> img = torch.zeros(224, 224, 3)
            >>> mask = torch.zeros(256, 224, 1)
            >>> validate_tensor_shapes(img="H W C", mask="H W 1")
            ValueError: [detailed error showing H=224 vs H=256]
        
        3. Enforce specific dimension values:
            >>> img = torch.zeros(224, 224, 3)
            >>> validate_tensor_shapes(img="H W 3")  # Passes, requires channel dim to be exactly 3
            >>> img = torch.zeros(224, 224, 4)
            >>> validate_tensor_shapes(img="H W 3")  # FAILS with clear error message
        
        4. Set dimension values manually:
            >>> img = torch.zeros(224, 224, 3)
            >>> dims = validate_tensor_shapes(img="H W C", C=5)  # Fail! C=3 in img but we set C=5
        
        5. Works with any object that has a shape attribute (torch, numpy, etc.):
            >>> numpy_array = np.zeros((224, 224, 3))
            >>> dims = validate_tensor_shapes(numpy_array="H W C")
            
        6. Validate tensor types with numpy: and torch: prefixes:
            >>> numpy_array = np.zeros((224, 224, 3))
            ... torch_tensor = torch.zeros(32, 10)
            ... # This passes - tensors match their expected types
            ... validate_tensor_shapes(
            ...     torch_tensor="torch: B D",     # Expects a torch tensor
            ...     numpy_array="numpy: H W C"     # Expects a numpy array
            ... )
            ... 
            ... # This would FAIL - wrong tensor types
            ... validate_tensor_shapes(
            ...     torch_tensor="numpy: B D",     # torch_tensor is not a numpy array
            ...     numpy_array="torch: H W C"     # numpy_array is not a torch tensor
            ... )
            ValueError: [detailed error showing type mismatches]

    Please see rp.r._test_validate_tensor_shapes() for more cases!

    """
    def format_shape(shape):
        return ','.join(map(str, shape))
    
    # Get the caller's scope to find tensor variables
    caller_scope = _get_visible_scope(1)
    
    # Separate dimension constraints from tensor form strings
    dim_constraints = {}  # {name: int, ...}
    tensor_forms = {}     # {name: form_str, ...}
    name_shape_forms = [] # [(name, shape, form), ... ]
    
    # Store actual shapes for reporting
    actual_shapes = {}
    
    # Collect all errors
    tensor_type_errors = []
    missing_shape_errors = []
    dim_count_errors = []
    literal_mismatch_errors = []
    inconsistencies = {}

    # Process kwargs
    for key, value in kwargs.items():
        if not isinstance(value, str):
            # This is a dimension constraint
            dim_constraints[key] = value
            continue
            
        value = value.strip()
        original_value = value
        type_check = None
        
        # Handle type prefixes
        if value.startswith('torch:'):
            type_check = 'torch'
            value = value[6:].strip()
        elif value.startswith('numpy:'):
            type_check = 'numpy'
            value = value[6:].strip()
            
        # Save the cleaned form string
        tensor_forms[key] = original_value
        
        # Find the tensor in the caller's scope
        if key in caller_scope:
            tensor = caller_scope[key]
            
            # Apply type checks if specified
            if type_check == 'torch' and not is_torch_tensor(tensor):
                tensor_type_errors.append('{} is {} but should be a torch tensor'.format(key, type(tensor)))
            elif type_check == 'numpy' and not is_numpy_array(tensor):
                tensor_type_errors.append('{} is {} but should be a numpy array'.format(key, type(tensor)))
            
            # Get the shape
            if hasattr(tensor, 'shape'):
                shape = tensor.shape
                name_shape_forms.append([key, shape, value])
                # Store the actual shape for reporting
                actual_shapes[key] = shape
            else:
                missing_shape_errors.append("{} has no shape (type '{}')".format(key, type(tensor).__name__))
        else:
            missing_shape_errors.append("{} not found".format(key))
    
    # Important - create a copy of constraints to use for final dimensions
    dims = dim_constraints.copy()
    dim_sources = {}  # Track which tensors defined each dimension
    
    # Track which dimensions were explicitly provided via kwargs
    kwarg_dims = set(dim_constraints.keys())
    
    for tensor_name, shape_tuple, form_str in name_shape_forms:

        shape_string = format_shape(shape_tuple)
        
        # Use the cleaned form string (without type prefixes) for validation
        form_parts = form_str.strip().split()
        
        if len(shape_tuple) != len(form_parts):
            dim_count_errors.append(
                "{} has shape {} with {} dims but form {} needs {}".format(
                    tensor_name, shape_string, len(shape_tuple), form_str, len(form_parts))
            )
            continue  # Skip further processing of this shape
        
        for i, (dim_value, dim_name) in enumerate(zip(shape_tuple, form_parts)):
            # Handle integer literals in form string
            if dim_name.isdigit():
                expected = int(dim_name)
                if dim_value != expected:
                    literal_mismatch_errors.append(
                        "{} has shape {} but needs form {}: expected {} at dim {} but got {}".format(
                            tensor_name, shape_string, form_str, expected, i, dim_value)
                    )
                continue
                
            # Handle named dimensions
            if dim_name in dims and dim_name not in kwarg_dims:
                # Only check consistency for dimensions NOT provided as kwargs
                current = dims[dim_name]
                if current != dim_value:
                    # Record this inconsistency
                    if dim_name not in inconsistencies:
                        inconsistencies[dim_name] = {}
                    
                    # Record this tensor's value
                    inconsistencies[dim_name][tensor_name] = dim_value
                    
                    # If first inconsistency, record previous sources too
                    if len(inconsistencies[dim_name]) == 1:
                        for src, val in dim_sources[dim_name].items():
                            inconsistencies[dim_name][src] = val
            elif dim_name not in kwarg_dims:
                # Only set dimensions from tensors if not already set via kwargs
                dims[dim_name] = dim_value
            else:
                # Check if the tensor's value conflicts with the kwarg value
                kwarg_value = dims[dim_name]
                #TODO: If a tensor has self-inconsistencies only one is reported, such as form H H H vs shape (1,2,3)
                if kwarg_value != dim_value:
                    if dim_name not in inconsistencies:
                        inconsistencies[dim_name] = {}
                    
                    # Record both values
                    inconsistencies[dim_name]['kwargs'] = kwarg_value
                    inconsistencies[dim_name][tensor_name] = dim_value
            
            # Track which tensor provided which dimension
            if dim_name not in dim_sources:
                dim_sources[dim_name] = {}
            dim_sources[dim_name][tensor_name] = dim_value
    
    # Compile all errors into a single message if any were found
    all_errors = []
    
    # Always show expected forms and actual shapes
    all_errors.append("Shape Validation Error!")

    all_errors.append("Expected Forms:")
    for tensor_name, form_str in sorted(tensor_forms.items()):
        all_errors.append("  • {}: {}".format(tensor_name, form_str))
    
    # Show kwarg specifications if any
    if dim_constraints:
        all_errors.append("  Where:")
        for dim, value in sorted(dim_constraints.items()):
            all_errors.append("    {}={}".format(dim, value))
    
    # Show actual shapes
    all_errors.append("\nActual Shapes:")
    for tensor_name, shape in sorted(actual_shapes.items()):
        shape_string = format_shape(shape)
        all_errors.append("  • {}: {}".format(tensor_name, shape_string))
    
    has_errors = bool(missing_shape_errors or dim_count_errors or literal_mismatch_errors or inconsistencies or tensor_type_errors)
    
    if missing_shape_errors:
        all_errors.append("\nMissing or invalid tensors:")
        for error in missing_shape_errors:
            all_errors.append("  • {}".format(error))

    if tensor_type_errors:
        all_errors.append("\nTensors are of wrong types:")
        for error in tensor_type_errors:
            all_errors.append("  • {}".format(error))
    
    if dim_count_errors:
        all_errors.append("\nDimension count mismatches:")
        for error in dim_count_errors:
            all_errors.append("  • {}".format(error))
    
    if literal_mismatch_errors:
        all_errors.append("\nLiteral value mismatches:")
        for error in literal_mismatch_errors:
            all_errors.append("  • {}".format(error))
    
    if inconsistencies:
        all_errors.append("\nInconsistent dimensions:")
        for dim_name, sources in inconsistencies.items():
            all_errors.append("  • '{}' has values:".format(dim_name))
            for source, value in sources.items():
                if source == 'kwargs':
                    all_errors.append("    - {} (specified as kwarg)".format(value))
                else:
                    all_errors.append("    - {} from {}".format(value, source))
    
    error_message = "\n".join(all_errors)
    
    # If there are validation errors, raise the error
    if has_errors:
        raise ValueError(error_message)
    else:
        # Print the shapes info without raising an error if not silent=False
        if verbose:
            report_string = (
                "Shape Validation:\n"
                +indentify(
                    line_join(error_message.splitlines()[1:]),
                    '    ',
                )
            )
            if isinstance(verbose, str):
                #Allow stylization!
                report_string = fansi(report_string, verbose)
            print(report_string)

    dims = as_easydict(dims)
    
    if return_dims:
        assert isinstance(return_dims, str), type(return_dims)
        return_dims = return_dims.split()
        dims = gather(dims, return_dims)

    return dims


def _test_validate_tensor_shapes():
    import torch

    def run_test(name, func, should_raise=False):
        """Run a test function and report results."""
        print("\n{}\nTEST: {}\n{}".format('='*80, name, '-'*80))
        try:
            result = func()
            if should_raise:
                print("❌ FAILED: Expected error but got result {}".format(result))
            else:
                print("✅ PASSED: {}".format(result))
        except Exception as e:
            if should_raise:
                print("✅ EXPECTED ERROR: {}".format(type(e).__name__))
                print(str(e))
            else:
                print("❌ UNEXPECTED ERROR: {}".format(type(e).__name__))
                print(str(e))

    # ======== SUCCESSFUL CASES ========
    
    # Test Case 1: Single tensor with simple form
    def test_single_tensor():
        img = torch.zeros((224, 224, 3))
        dims = validate_tensor_shapes(img="H W C")
        return "dims.H={}, dims.W={}, dims.C={}".format(dims.H, dims.W, dims.C)
    run_test("Single tensor", test_single_tensor)
    
    # Test Case 2: Multiple tensors with consistent dimensions
    def test_multiple_tensors_consistent():
        img = torch.zeros((224, 224, 3))
        mask = torch.zeros((224, 224, 1))
        dims = validate_tensor_shapes(img="H W C", mask="H W 1")
        return "dims.H={}, dims.W={}, dims.C={}".format(dims.H, dims.W, dims.C)
    run_test("Multiple tensors (consistent)", test_multiple_tensors_consistent)
    
    # Test Case 3: Mixed tensor types (numpy and torch)
    def test_mixed_tensor_types():
        img = torch.zeros((224, 224, 3))
        mask = np.zeros((224, 224, 1))
        dims = validate_tensor_shapes(img="H W C", mask="H W 1")
        return "dims.H={}, dims.W={}, dims.C={}".format(dims.H, dims.W, dims.C)
    run_test("Mixed tensor types", test_mixed_tensor_types)
    
    # Test Case 4: With manual dimension specification
    def test_manual_dimension():
        img = torch.zeros((224, 224, 3))
        dims = validate_tensor_shapes(img="H W C", C=5)
        return "Manual dimension specification: dims.C={} (not 3 from img)".format(dims.C)
    run_test("Manual dimension specification", test_manual_dimension, should_raise=True)
    
    # ======== ERROR CASES ========
    
    # Test Case 5: Non-existent tensor
    def test_missing_tensor():
        img = torch.zeros((224, 224, 3))
        return validate_tensor_shapes(img="H W C", not_existing="H W C")
    run_test("Missing tensor", test_missing_tensor, should_raise=True)
    
    # Test Case 6: Variable without shape attribute
    def test_not_a_tensor():
        img = torch.zeros((224, 224, 3))
        not_a_tensor = "just a string"
        return validate_tensor_shapes(img="H W C", not_a_tensor="H W C")
    run_test("Not a tensor", test_not_a_tensor, should_raise=True)
    
    # Test Case 7: Dimension count mismatch
    def test_dim_count_mismatch():
        img = torch.zeros((224, 224, 3))
        wrong_form = torch.zeros((224, 224))
        return validate_tensor_shapes(img="H W C", wrong_form="H W C")
    run_test("Dimension count mismatch", test_dim_count_mismatch, should_raise=True)
    
    # Test Case 8: Literal mismatch
    def test_literal_mismatch():
        img = torch.zeros((224, 224, 3))
        mask = torch.zeros((224, 224, 4))
        return validate_tensor_shapes(img="H W 3", mask="H W 3")
    run_test("Literal value mismatch", test_literal_mismatch, should_raise=True)
    
    # Test Case 9: Dimension inconsistency between tensors
    def test_inconsistent_dims():
        img = torch.zeros((224, 224, 3))
        larger_img = torch.zeros((256, 256, 3))
        return validate_tensor_shapes(img="H W C", larger_img="H W C")
    run_test("Inconsistent dimensions", test_inconsistent_dims, should_raise=True)



#Math functions that work across libraries
def _ceil(x):
    """ Works across libraries - such as numpy, torch, pure python """
    if is_numpy_array (x):return np.ceil(x).astype(int)
    if is_torch_tensor(x):return x.ceil().long()
    return math.ceil(x)

def _floor(x):
    """ works across libraries - such as numpy, torch, pure python """
    if is_numpy_array (x):return np.floor(x).astype(int)
    if is_torch_tensor(x):return x.floor().long()
    return math.floor(x)

def _round(x):
    """ works across libraries - such as numpy, torch, pure python """
    if is_numpy_array (x):return np.round(x).astype(int)
    if is_torch_tensor(x):return x.round().long()
    return round(x)

def _sin(x):
    """ works across libraries - such as numpy, torch, pure python """
    if is_numpy_array (x):return np.sin(x)
    if is_torch_tensor(x):return __import__('torch').sin(x)
    return math.sin(x)

def _cos(x):
    """ works across libraries - such as numpy, torch, pure python """
    if is_numpy_array (x):return np.cos(x)
    if is_torch_tensor(x):return __import__('torch').cos(x)
    return math.cos(x)

def _tan(x):
    """ works across libraries - such as numpy, torch, pure python """
    if is_numpy_array (x):return np.tan(x)
    if is_torch_tensor(x):return __import__('torch').tan(x)
    return math.tan(x)

def _exp(x):
    """ works across libraries - such as numpy, torch, pure python """
    if is_numpy_array (x):return np.exp(x)
    if is_torch_tensor(x):return __import__('torch').exp(x)
    return math.exp(x)

def _log(x):
    """ works across libraries - such as numpy, torch, pure python """
    if is_numpy_array (x):return np.log(x)
    if is_torch_tensor(x):return __import__('torch').log(x)
    return math.log(x)

def _log10(x):
    """ works across libraries - such as numpy, torch, pure python """
    if is_numpy_array (x):return np.log10(x)
    if is_torch_tensor(x):return __import__('torch').log10(x)
    return math.log10(x)

def _sqrt(x):
    """ works across libraries - such as numpy, torch, pure python """
    if is_numpy_array (x):return np.sqrt(x)
    if is_torch_tensor(x):return __import__('torch').sqrt(x)
    return math.sqrt(x)

def _cbrt(x):
    """Sign-preserving cube root for scalar / numpy / torch."""
    if is_numpy_array (x): return np.cbrt(x)  # handles negatives
    if is_torch_tensor(x): return th.sign(x) * th.abs(x).pow(1/3)
    return math.copysign(abs(x)**(1/3), x)

def _abs(x):
    """ works across libraries - such as numpy, torch, pure python """
    if is_numpy_array (x):return np.abs(x)
    if is_torch_tensor(x):return __import__('torch').abs(x)
    return abs(x)

def _pow(x, y):
    """ works across libraries - such as numpy, torch, pure python """
    if is_numpy_array (x):return np.power(x, y)
    if is_torch_tensor(x):return __import__('torch').pow(x, y)
    return math.pow(x, y)

def _fft(x):
    """ works across libraries - such as numpy, torch """
    if is_numpy_array (x):return np.fft.fft(x)
    if is_torch_tensor(x):return __import__('torch').fft.fft(x)
    raise ValueError("FFT not available for Python scalars")

def _ifft(x):
    """ works across libraries - such as numpy, torch """
    if is_numpy_array (x):return np.fft.ifft(x)
    if is_torch_tensor(x):return __import__('torch').fft.ifft(x)
    raise ValueError("IFFT not available for Python scalars")

def _tanh(x):
    """ works across libraries - such as numpy, torch, pure python """
    if is_numpy_array (x):return np.tanh(x)
    if is_torch_tensor(x):return __import__('torch').tanh(x)
    return math.tanh(x)

def _sigmoid(x):
    """ works across libraries - such as numpy, torch, pure python """
    if is_numpy_array (x):return 1 / (1 + np.exp(-x))
    if is_torch_tensor(x):return __import__('torch').sigmoid(x)
    return 1 / (1 + math.exp(-x))

def _relu(x):
    """ works across libraries - such as numpy, torch, pure python """
    if is_numpy_array (x):return np.maximum(0, x)
    if is_torch_tensor(x):return __import__('torch').relu(x)
    return max(0, x)

def _softmax(x, dim=-1):
    """ works across libraries - such as numpy, torch """
    if is_numpy_array (x):
        e_x = np.exp(x - np.max(x, axis=dim, keepdims=True))
        return e_x / e_x.sum(axis=dim, keepdims=True)
    if is_torch_tensor(x):return __import__('torch').softmax(x, dim=dim)
    raise ValueError("Softmax not applicable for Python scalars")

def _log2(x):
    """ works across libraries - such as numpy, torch, pure python """
    if is_numpy_array (x):return np.log2(x)
    if is_torch_tensor(x):return __import__('torch').log2(x)
    return math.log2(x)

def _asin(x):
    """ works across libraries - such as numpy, torch, pure python """
    if is_numpy_array (x):return np.arcsin(x)
    if is_torch_tensor(x):return __import__('torch').asin(x)
    return math.asin(x)

def _acos(x):
    """ works across libraries - such as numpy, torch, pure python """
    if is_numpy_array (x):return np.arccos(x)
    if is_torch_tensor(x):return __import__('torch').acos(x)
    return math.acos(x)

def _atan(x):
    """ works across libraries - such as numpy, torch, pure python """
    if is_numpy_array (x):return np.arctan(x)
    if is_torch_tensor(x):return __import__('torch').atan(x)
    return math.atan(x)

def _clip(x, min_val, max_val):
    """ works across libraries - such as numpy, torch, pure python """
    if is_numpy_array (x):return np.clip(x, min_val, max_val)
    if is_torch_tensor(x):return __import__('torch').clamp(x, min_val, max_val)
    return max(min_val, min(max_val, x))

def _nan_to_num(x):
    """ works across libraries - such as numpy, torch, pure python """
    if is_numpy_array (x):return np.nan_to_num(x)
    if is_torch_tensor(x):return __import__('torch').nan_to_num(x)
    return math.atan(x)

# Alias for _clip
def _clamp(x, min_val, max_val):
    return _clip(x, min_val, max_val)

def _atan2(y, x):
    """ works across libraries - such as numpy, torch, pure python """
    if is_numpy_array (y):return np.arctan2(y, x)
    if is_torch_tensor(y):return __import__('torch').atan2(y, x)
    return math.atan2(y, x)

def _sinh(x):
    """ works across libraries - such as numpy, torch, pure python """
    if is_numpy_array (x):return np.sinh(x)
    if is_torch_tensor(x):return __import__('torch').sinh(x)
    return math.sinh(x)

def _cosh(x):
    """ works across libraries - such as numpy, torch, pure python """
    if is_numpy_array (x):return np.cosh(x)
    if is_torch_tensor(x):return __import__('torch').cosh(x)
    return math.cosh(x)

def _sign(x):
    """ works across libraries - such as numpy, torch, pure python """
    if is_numpy_array (x):return np.sign(x)
    if is_torch_tensor(x):return __import__('torch').sign(x)
    return -1 if x < 0 else (1 if x > 0 else 0)

#Nah don't need this, just multiply by 360/rp.tau - more explicit that way
# def _degrees(x):
#     """ works across libraries - such as numpy, torch, pure python """
#     if is_numpy_array (x):return np.degrees(x)
#     if is_torch_tensor(x):return __import__('torch').rad2deg(x)
#     return math.degrees(x)

#Nah don't need this, just multiply by rp.tau/360 - more explicit that way
# def _radians(x):
#     """ works across libraries - such as numpy, torch, pure python """
#     if is_numpy_array (x):return np.radians(x)
#     if is_torch_tensor(x):return __import__('torch').deg2rad(x)
#     return math.radians(x)

def _create_array_like(x, *, func_name, shape=None, dtype=None):
    """Helper function for creating arrays/tensors across libraries"""
    target_shape = shape if shape is not None else x.shape
    target_dtype = dtype if dtype is not None else x.dtype
    
    if is_torch_tensor(x):  # PyTorch tensor
        import torch as th
        device = x.device
        if func_name == 'zeros':
            return th.zeros(target_shape, dtype=target_dtype, device=device)
        elif func_name == 'ones':
            return th.ones(target_shape, dtype=target_dtype, device=device)
        elif func_name == 'randn':
            return th.randn(target_shape, dtype=target_dtype, device=device)
        elif func_name == 'rand':
            return th.rand(target_shape, dtype=target_dtype, device=device)
    elif is_numpy_array(x):  # NumPy array
        import numpy as np
        if func_name == 'zeros':
            return np.zeros(target_shape, dtype=target_dtype)
        elif func_name == 'ones':
            return np.ones(target_shape, dtype=target_dtype)
        elif func_name == 'randn':
            return np.random.randn(*target_shape).astype(target_dtype)
        elif func_name == 'rand':
            return np.random.rand(*target_shape).astype(target_dtype)
    else:
        assert False, type(x)
            
    raise ValueError("Unsupported type "+str(type(x))+" or function "+func_name)

def _zeros_like(x, *, shape=None, dtype=None):
    """Works across libraries - such as numpy, torch"""
    return _create_array_like(x, func_name='zeros', shape=shape, dtype=dtype)

def _ones_like(x, *, shape=None, dtype=None):
    """Works across libraries - such as numpy, torch"""
    return _create_array_like(x, func_name='ones', shape=shape, dtype=dtype)

def _randn_like(x, *, shape=None, dtype=None):
    """Works across libraries - such as numpy, torch"""
    return _create_array_like(x, func_name='randn', shape=shape, dtype=dtype)

def _rand_like(x, *, shape=None, dtype=None):
    """Works across libraries - such as numpy, torch"""
    return _create_array_like(x, func_name='rand', shape=shape, dtype=dtype)

def _maximum(x, y):
    """ works across libraries - such as numpy, torch """
    if is_numpy_array (x):return np.maximum(x, y)
    if is_torch_tensor(x):return __import__('torch').maximum(x, y)
    return max(x,y)

def _minimum(x, y):
    """ works across libraries - such as numpy, torch """
    if is_numpy_array (x):return np.minimum(x, y)
    if is_torch_tensor(x):return __import__('torch').minimum(x, y)
    return min(x,y)

def _max(x, dim=None, keepdim=False):
    """ works across libraries - such as numpy, torch """
    if is_numpy_array (x):return np.max(x, axis=dim, keepdims=keepdim)
    if is_torch_tensor(x):
        if dim is None:return __import__('torch').max(x)
        result = __import__('torch').max(x, dim=dim, keepdim=keepdim)
        return result.values if hasattr(result, 'values') else result
    if dim is None:return max(x) if hasattr(x, '__iter__') else x
    raise ValueError("type not supported: "+str(type(x)))

def _min(x, dim=None, keepdim=False):
    """ works across libraries - such as numpy, torch """
    if is_numpy_array (x):return np.min(x, axis=dim, keepdims=keepdim)
    if is_torch_tensor(x):
        if dim is None:return __import__('torch').min(x)
        result = __import__('torch').min(x, dim=dim, keepdim=keepdim)
        return result.values if hasattr(result, 'values') else result
    if dim is None:return min(x) if hasattr(x, '__iter__') else x
    raise ValueError("type not supported: "+str(type(x)))

def _sum(x, dim=None, keepdim=False):
    """ works across libraries - such as numpy, torch """
    if is_numpy_array (x):return np.sum(x, axis=dim, keepdims=keepdim)
    if is_torch_tensor(x):return __import__('torch').sum(x, dim=dim, keepdim=keepdim)
    if dim is None:return sum(x) if hasattr(x, '__iter__') else x
    raise ValueError("type not supported: "+str(type(x)))

def _mean(x, dim=None, keepdim=False):
    """ works across libraries - such as numpy, torch """
    if is_numpy_array (x):return np.mean(x, axis=dim, keepdims=keepdim)
    if is_torch_tensor(x):return __import__('torch').mean(x, dim=dim, keepdim=keepdim)
    raise ValueError("type not supported: "+str(type(x)))

def _copy_tensor(x):
    """ works across libraries - such as numpy, torch """
    if is_numpy_array (x): return copy(x) #After benchmarking, I found this is faster than x.copy() or x+0
    if is_torch_tensor(x): return x.clone() #You cannot use copy(x) or else it doesn't actually copy the data
    return copy(x)


#NOT Doing std, np and torch behave differently (one has N-1 correction [torch], the other doesn'ti [numpy])
# def _std(x, dim=None, keepdim=False):
#     """ works across libraries - such as numpy, torch, pure python """
#     if is_numpy_array (x):return np.std(x, axis=dim, keepdims=keepdim)
#     if is_torch_tensor(x):return __import__('torch').std(x, dim=dim, keepdim=keepdim)
#     if dim is None:
#         import statistics
#         return statistics.stdev(x) if hasattr(x, '__iter__') and len(x) > 1 else 0
#     raise ValueError("dim parameter not supported for Python scalars")




def get_bilinear_weights(x, y):
    """
    Calculate bilinear interpolation weights for a set of (x, y) coordinates.

    This function takes a set of (x, y) coordinates and returns the corresponding
    bilinear interpolation weights and the integer coordinates of the 4 neighboring
    pixels for each input coordinate.

    The math behind this function is explained here:
        https://www.desmos.com/calculator/esool5qrrd

    Args:
        x (torch.Tensor or numpy.ndarray): The x-coordinates of the input points.
        y (torch.Tensor or numpy.ndarray): The y-coordinates of the input points.

    Returns:
        tuple: A tuple containing three elements:
            - X (torch.Tensor or numpy.ndarray): The integer x-coordinates of the 4 neighboring pixels for each input point. Shape: (4, *x.shape)
            - Y (torch.Tensor or numpy.ndarray): The integer y-coordinates of the 4 neighboring pixels for each input point. Shape: (4, *y.shape)
            - W (torch.Tensor or numpy.ndarray): The bilinear interpolation weights for each of the 4 neighboring pixels. Shape: (4, *x.shape)

    Note:
        - x and y should have the same shape.
        - This function works with both PyTorch tensors and NumPy arrays.
        - Was originally called calculate_subpixel_weights, featured in TRITON's source/unprojector.py 
            (see https://github.com/TritonPaper/TRITON/blob/master/source/unprojector.py)
    """
    assert x.shape == y.shape, "x and y must have the same shape"
    shape = x.shape

    Rx = x % 1
    Ry = y % 1
    Qx = 1 - Rx
    Qy = 1 - Ry

    A=Rx*Ry #Weight for  ceil(x), ceil(y)
    B=Rx*Qy #Weight for  ceil(x),floor(y)
    C=Qx*Qy #Weight for floor(x),floor(x)
    D=Qx*Ry #Weight for floor(x), ceil(y)

    Cx = _ceil(x)
    Cy = _ceil(y)
    Fx = _floor(x)
    Fy = _floor(y)

    stack = pip_import('torch').stack if is_torch_tensor(x) else np.stack
    
    X = stack((Cx, Cx, Fx, Fx))  # All X values
    Y = stack((Cy, Fy, Fy, Cy))  # All Y values
    W = stack((A, B, C, D))      # Weights

    assert X.shape == (4, *shape), "Expected X.shape == (4, *x.shape), but got {}".format(X.shape)
    assert Y.shape == (4, *shape), "Expected Y.shape == (4, *y.shape), but got {}".format(Y.shape)
    assert W.shape == (4, *shape), "Expected W.shape == (4, *x.shape), but got {}".format(W.shape)

    return X, Y, W

def torch_scatter_add_image(image, x, y, *, relative=False, interp='floor', height=None, width=None, prepend_ones=False):
    """
    Scatter add an image tensor using the given x and y coordinate tensors.
    Pixels warped out-of-bounds will be skipped.
    This is similar to torch_remap_image, but uses scatter_add instead of remapping.
    
    If relative=True, it will treat x and y as deltas (dx and dy) and perform relative scatter adding.
        Note: Because this is a scatter operation, the direction of movement will be the same as dx and dy.

    Args:
        image (torch.Tensor): Input image tensor of shape [C, H, W], where C is the number of channels (e.g., 3 for RGB, 4 for RGBA),
                              H is the height, and W is the width of the image.
        x (torch.Tensor): X-coordinate tensor of shape [H_in, W_in] specifying the x-coordinates for scatter adding,
                          where H_in is the input height and W_in is the input width.
        y (torch.Tensor): Y-coordinate tensor of shape [H_in, W_in] specifying the y-coordinates for scatter adding.
        relative (bool, optional): If True, treat x and y as deltas (dx and dy) and perform relative scatter adding. Default is False.
        interp (str, optional): Specifies how to handle fractional coordinates. Can be one of 'bilinear' (the slowest one), 'floor' (the fastest one), 'ceil', or 'round'. Default is 'floor'.
        height (int, optional): The output height. If not specified, it is inferred from the input image height.
        width (int, optional): The output width. If not specified, it is inferred from the input image width.
        prepend_ones (bool, optional): If True, prepends a channel of ones to the input tensor before calculation. Useful for getting the sum easily by accessing output[0]. This option is simply for convenience. Default is False.

    Returns:
        torch.Tensor: Scatter added image tensor of shape [C, H_out, W_out] or [C+1, H_out, W_out] if prepend_ones is True, where:
            - C is the number of channels in the input image.
            - H_out is the output height.
            - W_out is the output width.

    EXAMPLE (image warping):

        >>> def demo_torch_scatter_add_image(interp,normalize=False,):
        ...     import torch
        ... 
        ...     url = "https://upload.wikimedia.org/wikipedia/en/7/7d/Lenna_%28test_image%29.png"
        ...     image=as_torch_image(load_image(url))
        ...     
        ...     # Define the animation parameters
        ...     num_frames = 100
        ...     wave_speed = 0.1
        ...     wave_freq = 0.025
        ...     wave_amp = 20
        ... 
        ...     def get_frames():
        ...         for frame in range(num_frames):
        ...             y, x = torch.meshgrid(torch.arange(image.shape[1]), torch.arange(image.shape[2]))
        ...             x = x.float()
        ...             y = y.float()
        ... 
        ...             # Apply sine and cosine waves to create caustics effect
        ...             dx = wave_amp * torch.cos(wave_freq * (x + y) + frame * wave_speed)
        ...             dy = wave_amp * torch.sin(wave_freq * (x - y) + frame * wave_speed)
        ... 
        ...             caustics_image = torch_scatter_add_image(
        ...                 image,
        ...                 dx,
        ...                 dy,
        ...                 relative=True,
        ...                 interp=interp,
        ...                 prepend_ones=True
        ...             )
        ...             total, caustics_image = caustics_image[0],caustics_image[1:]
        ...             if normalize:
        ...                 caustics_image/=total
        ...
        ...             # Append the current frame to the list of frames
        ...             yield caustics_image
        ...
        ...     display_video(get_frames(),framerate=60)
        ...
        ... demo_torch_scatter_add_image('floor')
        ... demo_torch_scatter_add_image('floor',normalize=True)
        ... demo_torch_scatter_add_image('bilinear',normalize=True)

    EXAMPLE (image resizing):

        >>> def demo_torch_scatter_add_image_resizing(interp,normalize=False,):
        ...     import torch
        ... 
        ...     url = "https://content.whrb.org/files/3916/1997/2511/Lindsey_Stirling_2021_by_F._Scott_Schafer.png"
        ...     image=load_image(url)
        ...     image=resize_image_to_fit(image,height=256,width=256)
        ...     image=as_torch_image(image)
        ...     num_frames=300
        ...     
        ...     old_height, old_width = get_image_dimensions(image)
        ...     
        ...     def get_frames():
        ...         for frame in range(num_frames):
        ...             new_width =3*frame+10
        ...             new_height=3*frame+10
        ...         
        ...             x, y = xy_torch_matrices(
        ...                 old_height,
        ...                 old_width,
        ...                 max_x=new_width,
        ...                 max_y=new_height,
        ...             )
        ...             
        ...             resized_image = torch_scatter_add_image(
        ...                 image,
        ...                 x,
        ...                 y,
        ...                 height=new_height,
        ...                 width=new_width,
        ...                 interp=interp,
        ...                 prepend_ones=True,
        ...             )
        ...             
        ...             total, resized_image = resized_image[0],resized_image[1:]
        ...             
        ...             if normalize:
        ...                 resized_image/=total
        ... 
        ...             #Create the preview image
        ...             resized_image=as_numpy_image(resized_image)
        ...             resized_image=bordered_image_solid_color(resized_image,color='red')
        ...             resized_image=crop_image(resized_image,height=500,width=500)
        ...             resized_image = labeled_image(
        ...                 resized_image,
        ...                 f"{interp} normalize={normalize} {(new_width,new_height)}",
        ...                 font="G:Quicksand",
        ...             )
        ... 
        ...             # Append the current frame to the list of frames
        ...             yield resized_image
        ... 
        ...     display_video(get_frames(),framerate=60)
        ... 
        ... demo_torch_scatter_add_image_resizing('bilinear',normalize=True)
        ... demo_torch_scatter_add_image_resizing('floor',normalize=False)
        ... demo_torch_scatter_add_image_resizing('floor',normalize=True)
        ... demo_torch_scatter_add_image_resizing('bilinear',normalize=False)

    EXAMPLE (noise warping):

        >>> def resize_noise(noise, new_height, new_width):
        ...     #I'm putting this in rp.git_import('CommonSource').noise_warp
        ...     #Can resize gaussian noise, adjusting for variance and preventing cross-correlation
        ...     
        ...     C, old_height, old_width = noise.shape
        ... 
        ...     x, y = xy_torch_matrices(
        ...         old_height,
        ...         old_width,
        ...         max_x=new_width,
        ...         max_y=new_height,
        ...     )
        ... 
        ...     resized = torch_scatter_add_image(
        ...         noise,
        ...         x,
        ...         y,
        ...         height=new_height,
        ...         width=new_width,
        ...         interp='round',
        ...         #interp='bilinear', #This introduces cross correlation! Can't use this for noise warping.
        ...         prepend_ones=True
        ...     )
        ... 
        ...     total, resized = resized[:1], resized[1:]
        ... 
        ...     adjusted = resized / total**.5
        ... 
        ...     return adjusted
        ... 
        ... import torch
        ... frames=[]
        ... base_noise=torch.randn(3,512,512)
        ... for size in resize_list(range(10,1024),512):
        ...     new_noise=resize_noise(base_noise,size,size)
        ...     
        ...     frame=as_numpy_image(new_noise)/5+.5
        ...     frame=bordered_image_solid_color(frame,'red')
        ...     frame=crop_image(frame,height=1024,width=1024)
        ...     frame=labeled_image(frame,f"size={size}, mean={float(new_noise.mean()):.2} std={float(new_noise.std()):.2}")
        ...     frames.append(frame)
        ...     display_image(frame)
        ...     
        ... ans=printed(save_video_mp4(frames,video_bitrate='max'))
        ... display_video(frames,loop=True)


    """

    assert rp.r.is_torch_image(image), "image must be a torch tensor with shape [C, H, W], but got image with type {}".format(type(image))
    assert is_torch_tensor(x) and is_a_matrix(x), "x must be a torch matrix, but got x with type {} and shape {}".format(type(x), x.shape)
    assert is_torch_tensor(y) and is_a_matrix(y), "y must be a torch matrix, but got y with type {} and shape {}".format(type(y), y.shape)
    assert x.shape == y.shape, "x and y must have the same shape, but got x.shape={} and y.shape={}".format(x.shape, y.shape)
    assert image.device==x.device==y.device, "all inputs must be on the same device, but got image.device={}, x.device={}, and y.device={}".format(image.device, x.device, y.device)
    assert interp in ['floor', 'ceil', 'round', 'bilinear'], "interp must be one of 'floor', 'ceil', 'round', or 'bilinear', but got {}".format(interp)
    assert height is None or (isinstance(height, int) and height > 0), "height must be a positive integer or None, but got {}".format(height)
    assert width is None or (isinstance(width, int) and width > 0), "width must be a positive integer or None, but got {}".format(width)

    pip_import('einops')
    pip_import('torch')

    import torch
    from einops import rearrange

    if prepend_ones:
        #We might prepend a channel of ones to keep track of how many were added so we can normalize later
        #For example, to get the mean we would divide output[1:]/output[0]
        ones = torch.ones_like(image[:1])
        image = torch.cat([ones, image], dim=0)

    in_c, in_height, in_width = image.shape
    out_height, out_width = x.shape

    assert y.shape == x.shape == (in_height, in_width), "x and y should have the same height and width as the input image, aka {} but x.shape=={} and y.shape=={}".format((in_height, in_width),x.shape,y.shape)
    
    # If we don't specify the output width and height in args, copy the height and width of the input image
    out_width = width if width is not None else in_width
    out_height = height if height is not None else in_height
    
    x = x.to(image.device)
    y = y.to(image.device)

    # Apply the specified rounding interp to the coordinates
    if interp == 'bilinear':
        #A form of antialiasing
        components = []
        for X, Y, W in zip(*get_bilinear_weights(x, y)):
            components.append(
                torch_scatter_add_image(
                    image * W[None],
                    X,
                    Y,
                    relative=relative,
                    interp="floor",
                    height=height,
                    width=width,
                    prepend_ones=False,
                )
            )
        return sum(components)
    if interp == 'round':
        x = x.round()
        y = y.round()
    elif interp == 'ceil':
        x = x.ceil()
        y = y.ceil()
    else:
        assert interp == 'floor'
        # Will be implicitly floored during conversion

    # Make sure x and y are int64 values, for indexing in torch_scatter
    x = x.long()
    y = y.long()

    if relative:
        assert in_height == out_height, "For relative scatter adding, input and output heights must match, but got in_height={} and out_height={}".format(in_height, out_height)
        assert in_width == out_width, "For relative scatter adding, input and output widths must match, but got in_width={} and out_width={}".format(in_width, out_width)
        x = x + torch.arange(in_width , device=x.device, dtype=x.dtype)
        y = y + torch.arange(in_height, device=y.device, dtype=y.dtype)[:,None]

    # Initialize the output tensor with zeros
    out = torch.zeros((out_height * out_width, in_c), dtype=image.dtype, device=image.device)

    # Compute the flattened indices for scatter_add
    # And Filter out out-of-bounds indices based on the specified output height and width
    indices = y * out_width + x
    valid_indices = (y >= 0) & (y < out_height) & (x >= 0) & (x < out_width)
    valid_mask = rearrange(valid_indices, "h w -> (h w)")
    indices    = rearrange(indices, "h w -> (h w)")
    valid_indices = indices[valid_mask]

    # Flatten the image tensor
    flat_image = rearrange(image, "c h w -> (h w) c")
    valid_flat_image = flat_image[valid_mask]

    # Perform scatter_add operation using torch.index_add
    out.index_add_(0, valid_indices, valid_flat_image)

    # Reshape the output tensor back to the original shape
    out = rearrange(out, "(h w) c -> c h w", h=out_height, w=out_width)

    return out

def accumulate_flows(*flows,reduce=True,reverse=False):
    """
    Accumulates a sequence of flows into a single flow or a sequence of accumulated flows.

    A flow is a 2HW aka [XY]HW tensor, either torch or numpy
    And therefore, flows is its plural: T2HW aka T[XY]HW

    Parameters
    ----------
    flows : numpy.ndarray or torch.Tensor or list of numpy.ndarray or list of torch.Tensor
        A 4D tensor in T2HW aka T[XY]HW form, representing a sequence of flows.
        Each flow is a 2D tensor of shape (2, H, W), where the first dimension
        represents the x and y components of the flow vectors.

        The function supports the following input formats:
        - numpy.ndarray or torch.Tensor: shape (T, 2, H, W).
        - list of numpy.ndarray or torch.Tensor: A list, each of shape (2, H, W).

    reduce : bool, optional
        If True (default), returns a single accumulated flow of shape (2, H, W).
        If False, returns a sequence of accumulated flows of shape (T, 2, H, W).

    reverse : bool, optional
        False by default
        If True, calculates the reverse cumulative flow. Useful for remap operations. See cv_optical_flow's docstring for a realtime example of this.
        Definition:
            accumulate_flows(flows, reverse=True) ==== -accumulate_flows(-flows[::-1])

    Returns
    -------
    numpy.ndarray or torch.Tensor
        If `reduce` is True, returns a single accumulated flow in 2HW aka [XY]HW form,
        If `reduce` is False, returns a sequence of accumulated flows in T2HW aka T[XY]HW form,
        The output type/dtype/device format matches the input flows with the same height/width as the inputs

    NOTES:
        The math behind this can be tricky if accumulating for a remap operation! Please see the real-life example in this docstring.
        There's really only one correct way to do it, and you have to understand it.

    EXAMPLE:
        (See rp.cv_optical_flow's docstring's example for a realtime webcam demo!)

    EXAMPLE (shapes):
        >>> flows = np.random.rand(5, 2, 100, 100)  # 5 flows of shape (2, H, W)
        >>> accumulated_flow = accumulate_flows(flows)
        >>> accumulated_flow.shape
        (2, 100, 100)

        >>> flows = [torch.rand(2, 100, 100) for _ in range(5)]  # 5 flows of shape (2, H, W)
        >>> accumulated_flows = accumulate_flows(flows, reduce=False)
        >>> accumulated_flows.shape
        torch.Size([5, 2, 100, 100])
        
    EXAMPLE (example with random warpage):
        >>> original_image = load_image(
        ...     "https://upload.wikimedia.org/wikipedia/en/7/7d/Lenna_%28test_image%29.png",
        ...     use_cache=True,
        ... )
        ... original_image = cv_resize_image(original_image, 2)
        ... original_image = as_rgba_image(original_image)
        ... 
        ... seed = 10
        ... random.seed(seed)
        ... np.random.seed(seed)
        ... flow_field = np.random.randn(*original_image.shape)
        ... flow_field = cv_gauss_blur(flow_field, 400)
        ... flow_field *= 400
        ... display_image(full_range(flow_field))
        ... 
        ... flow_field = flow_field[:, :, :2].transpose(2, 0, 1)
        ... 
        ... cumulative_flow = flow_field
        ... 
        ... nearest_iteratively_warped_image = original_image
        ... bilinear_iteratively_warped_image = original_image
        ... single_warp_image = original_image
        ... output_frames = []
        ... for _ in eta(range(300)):
        ...     bilinear_iteratively_warped_image = cv_remap_image(bilinear_iteratively_warped_image, *flow_field, relative=True, interp="bilinear")
        ...     nearest_iteratively_warped_image = cv_remap_image(nearest_iteratively_warped_image, *flow_field, relative=True, interp="nearest")
        ...     single_warp_image = cv_remap_image(original_image, *cumulative_flow, relative=True)
        ...     cumulative_flow = accumulate_flows(cumulative_flow, flow_field)
        ...     frame = horizontally_concatenated_images(
        ...         labeled_images(
        ...             bordered_images_solid_color(
        ...                 with_alpha_checkerboards(
        ...                     cv_resize_images(
        ...                         [bilinear_iteratively_warped_image, nearest_iteratively_warped_image, single_warp_image],
        ...                         size=0.5,
        ...                     ),
        ...                     tile_size=32,
        ...                     first_color=0.1,
        ...                     second_color=0.2,
        ...                 ),
        ...                 color=(0, 0, 0, 1),
        ...                 thickness=8,
        ...             ),
        ...             ["Bilinear Interp\nIteratively Warped Image", "Nearest-Neighbor\nIteratively Warped Image", "Accumulated Flow\nSingle Warp"],
        ...             font="G:Zilla Slab",
        ...             size=30,
        ...             size_by_lines=True,
        ...         )
        ...     )
        ...     output_frames.append(frame)
        ...     display_image(frame)
        ... output_video = video_with_progress_bar(output_frames)
        ... ans = printed(save_video_mp4(output_video, "accumulate_flow_demo.mp4"))

    EXAMPLE (particle flow noise warping - can run on a laptop)

        >>> from rp import *
        ... import torch
        ... git_import('CommonSource')
        ... import rp.git.CommonSource.noise_warp as nw
        ... 
        ... #UNCOMMENT IF YOU WANT TO USE WEBCAM
        ... get_frame = lambda: as_rgba_image(as_rgba_image(cv_resize_image(load_image_from_webcam(), 1 / 8)))
        ... 
        ... #UNCOMMENT IF YOU WANT TO USE A VIDEO INPUT
        ... #video_path='/Users/ryan/Desktop/Screenshots/cat_on_grass.mov'
        ... #video_path='/Users/ryan/Downloads/kevin_spin_square.mp4'
        ... #video_path='/Users/ryan/Downloads/bear_video.mp4'
        ... video_path='https://warpyournoise.github.io/docs/assets/videos/DeepFloyd/carturn_st_oilpaint_input.mp4'
        ... ################
        ... video=load_video(video_path,use_cache=False)
        ... video=remove_duplicate_frames(video,lazy=False)
        ... video=resize_list_to_fit(video,max_length=100)
        ... video=iter(video)
        ... get_frame=lambda:as_rgba_image(resize_image_to_fit(next(video),height=256))
        ... 
        ... 
        ... frame_a = get_frame()
        ... 
        ... frames = []
        ... flows = []
        ... prev_frame = frame_a
        ... 
        ... vis_frames=[]
        ... 
        ... i=0
        ... 
        ... orig_noise = torch.(as_torch_image(frame_a))
        ... 
        ... try:
        ...     while True:
        ...         
        ...         #DECIDE WHICH FLOW TO USE HERE
        ...         REVERSE_FLOW=True  #Calculates flow from b to a, better for remap
        ...         #REVERSE_FLOW=False #Calculates flow from a to b, better for scatter add
        ... 
        ...         frame_b = get_frame()
        ... 
        ...         #UNCOMMENT ONE OF THESE ALGORITHMS
        ...         if REVERSE_FLOW:frame_a, frame_b = frame_b, frame_a
        ...         flow = cv_optical_flow(frame_b, frame_a, algorithm="DenseRLOF") #Best, Slowest
        ...         #flow = cv_optical_flow(frame_a, frame_b, algorithm="DeepFlow")
        ...         #flow = cv_optical_flow(frame_a, frame_b,algorithm='Farneback') #Fastest, Worst
        ...         #flow = cv_optical_flow(frame_a, frame_b, algorithm="SparseToDense")
        ...         #flow = cv_optical_flow(frame_a, frame_b, algorithm="PCAFlow")
        ...         #flow = cv_optical_flow(frame_a, frame_b, algorithm="DualTVL1")
        ...         if REVERSE_FLOW:frame_a, frame_b = frame_b, frame_a
        ... 
        ...         N = 100
        ...         
        ...         if not i%N:
        ...             #REset iter frame
        ...             prev_frame=frame_a
        ...             flows = []
        ...             frames = []
        ... 
        ...         frames.append(frame_a)
        ...         flows.append(flow)
        ... 
        ...         i+=1
        ...         i%=N
        ... 
        ...         flow_vis = optical_flow_to_image(*flow,sensitivity=.3,mode='brightness')
        ... 
        ...         frames = frames[-N:]
        ...         flows = flows[-N:]
        ...         cum_flow = accumulate_flows(
        ...             (1 if REVERSE_FLOW else -1) * as_numpy_array(flows),
        ...             reverse=True,
        ...         )
        ... 
        ...         def cv_remap_image(image,x,y,*args,**kwargs):
        ...             return as_numpy_image(torch_remap_image(as_torch_image(image),torch.tensor(x),torch.tensor(y),*args,**kwargs))
        ... 
        ...         warped_frame = cv_remap_image(frames[0], *-cum_flow, relative=True)
        ...         
        ...         iter_warped_frame = cv_remap_image(prev_frame, *(-1 if REVERSE_FLOW else 1) * flows[-1], relative=True)
        ...         
        ...         prev_frame = iter_warped_frame
        ... 
        ...         scatter_flow = torch.tensor(
        ...             accumulate_flows(
        ...                 (1 if REVERSE_FLOW else -1) * as_numpy_array(flows),
        ...                 reverse=False,
        ...             )
        ...         )
        ...         scattered = torch_scatter_add_image(
        ...             as_torch_image(frames[0]),
        ...             *scatter_flow,
        ...             relative=True,
        ...             prepend_ones=True,
        ...             interp="bilinear",
        ...         )
        ...         scatter_sum = scattered[0]
        ...         scattered = scattered[1:] / (scatter_sum+.001)
        ...         scattered = as_numpy_image(scattered)
        ... 
        ...         NOISE_SCALE=4
        ...         scattered_noise = torch_scatter_add_image(
        ...             orig_noise[:3],
        ...             *scatter_flow,
        ...             relative=True,
        ...             interp="round",
        ...             prepend_ones=True,
        ...         )
        ...         scatter_noise_sum = scattered_noise[0]
        ...         scattered_noise= scattered_noise[1:]
        ...         
        ...         scattered_noise_before_downscaling = scattered_noise
        ...         scattered_noise_before_downscaling = scattered_noise_before_downscaling / (scatter_noise_sum**.5+.001)
        ...         scattered_noise_before_downscaling = as_numpy_image(scattered_noise_before_downscaling) / 5 + .5
        ...         scattered_noise_before_downscaling = with_alpha_channel(
        ...             scattered_noise_before_downscaling, as_numpy_array(scatter_noise_sum > 0)
        ...         )
        ...         
        ...         scattered_noise = torch_resize_image(
        ...             nw.resize_noise(
        ...                 scattered_noise,
        ...                 size=1/NOISE_SCALE,
        ...                 alpha = scatter_noise_sum,
        ...             ),
        ...             size=get_image_dimensions(frame_a),
        ...             interp="nearest",
        ...         )
        ...         scattered_noise = as_numpy_image(scattered_noise) / 5 + .5
        ...         scatter_noise_sum=as_numpy_array(scatter_noise_sum)
        ...         scatter_noise_sum=cv_resize_image(scatter_noise_sum,1/NOISE_SCALE)
        ...         scatter_noise_sum=cv_resize_image(scatter_noise_sum,get_image_dimensions(frame_a),interp='nearest')
        ...         scattered_noise=with_alpha_channel(scattered_noise,as_numpy_array(scatter_noise_sum>0))
        ...         
        ...         vis_frame = with_alpha_checkerboard(
        ...             image_with_progress_bar(
        ...                 labeled_image(
        ...                     tiled_images(
        ...                         labeled_images(
        ...                             [
        ...                                 frames[0],
        ...                                 frame_a,
        ...                                 warped_frame,
        ...                                 iter_warped_frame,
        ...                                 flow_vis,
        ...                                 scattered,
        ...                                 scattered_noise_before_downscaling,
        ...                                 scattered_noise,
        ...                             ],
        ...                             [
        ...                                 "Original Frame",
        ...                                 "Current Frame",
        ...                                 "Accumulated Flow Warp",
        ...                                 "Iterative Warp",
        ...                                 "Flow Visualization",
        ...                                 "Accumulated Scatter Add",
        ...                                 "Accumulated Scatter Noise",
        ...                                 "Accumulated Scatter Noise (Downscaled)",
        ...                             ],
        ...                             font="Futura",
        ...                         ),
        ...                         border_thickness=0,
        ...                         length=4,
        ...                     ),
        ...                     f"Frame {i}",
        ...                     font="Futura",
        ...                 ),
        ...                 progress=i / (N - 1),
        ...                 size=5,
        ...             ),
        ...             first_color=0.1,
        ...             second_color=0.2,
        ...         )
        ... 
        ...         vis_frames.append(vis_frame)    
        ...         display_image(vis_frame)
        ... 
        ...         frame_a = frame_b
        ... 
        ... except StopIteration:
        ...     pass
        ... 
        ... 
        ... final_video = video_with_progress_bar(
        ...     vis_frames,
        ...     bar_color="cyan",
        ...     size=5,
        ... )
        ... 
        ... display_video(
        ...     final_video,
        ...     loop=True,
        ... )
        ... 
        ... ans = save_video_mp4(final_video, video_bitrate='max')

    """

    flows=detuple(flows)

    assert len(flows)>=1, 'rp.accumulate_flows: should have at least 1 flow so we can infer the shape and type, but len(flows)=='+str(len(flows))

    if reverse:
        #Use reverse flow
        return -gather_args_call(accumulate_flows, [-f for f in flows][::-1], reverse=False)

    if is_numpy_array(flows):
        assert flows.ndim==4, 'rp.accumulate_flows: flows should be in T2HW form, but its shape is '+str(flows.shape)
        out_flows = []
        ox, oy = flows[0]
        for fx, fy in flows[1:]:
            _x = cv_remap_image(fx, ox, oy, relative=True, interp="bilinear")
            _y = cv_remap_image(fy, ox, oy, relative=True, interp="bilinear")
            ox = ox + _x
            oy = oy + _y
            if not reduce: out_flows.append(np.stack((ox, oy)))
        if reduce: return np.stack((ox, oy))  #In  [XY]HW form
        else:      return np.stack(out_flows) #In T[XY]HW form
        
    elif is_torch_tensor(flows):
        assert flows.ndim==4, 'rp.accumulate_flows: flows should be in T2HW form, but its shape is '+str(flows.shape)
        import torch
        out_flows = []
        o = flows[0]
        for f in flows[1:]:
            o = o + torch_remap_image(f, *o, relative=True, interp='bilinear')
            if not reduce: out_flows.append(o.clone())
        if reduce: return o                      #In  [XY]HW form
        else:      return torch.stack(out_flows) #In T[XY]HW form
        
    elif all(map(is_numpy_array,flows)):
        flows=as_numpy_array(flows)
        return gather_args_call(accumulate_flows, flows)
        
    elif all(map(is_torch_tensor,flows)):
        import torch
        return gather_args_call(accumulate_flowsr, torch.stack(flows))
    
    else:
        assert False, 'All flows should be either torch tensors or all numpy arrays, not a mix of both.'


def resize_image_to_hold(image, height: int = None, width: int = None, interp='auto', *, allow_shrink=True, alpha_weighted=False):
    """
    Resizes an image so that the specified bounding box can fit entirely inside the image, while maintaining the
    aspect ratio of the input image.
    If not allow_shrink, it will not resize the image if it can already hold the given bounds

    Also see this function's sister: rp.resize_image_to_fit

    :param image: The input image to be resized.
    :param height: The height of the bounding box (default is None).
    :param width: The width of the bounding box (default is None).
    :param interp: Interpolation method to be used while resizing (default is 'auto').
    :param allow_shrink: A boolean flag that determines if the image can be shrunk (default is True).
    :return: The resized image with the specified dimensions fitting inside.
    
    EXAMPLES:
        im=uniform_float_color_image(height=200,width=300,color=(0,0,0))
        assert resize_image_to_hold(im, height=100 ,width=None,allow_shrink=True ).shape == ( 100,  150, 3) 
        assert resize_image_to_hold(im, height=100 ,width=100 ,allow_shrink=True ).shape == ( 100,  150, 3)
        assert resize_image_to_hold(im, height=None,width=100 ,allow_shrink=True ).shape == (  67,  100, 3)
        assert resize_image_to_hold(im, height=None,width=100 ,allow_shrink=False).shape == ( 200,  300, 3)
        assert resize_image_to_hold(im, height=None,width=1000,allow_shrink=False).shape == ( 667, 1000, 3)
        assert resize_image_to_hold(im, height=None,width=1000,allow_shrink=True ).shape == ( 667, 1000, 3)
        assert resize_image_to_hold(im, height=1000,width=1000,allow_shrink=True ).shape == (1000, 1500, 3)
        assert resize_image_to_hold(im, height=1000,width=None,allow_shrink=True ).shape == (1000, 1500, 3)
        assert resize_image_to_hold(im, height=1000,width=None,allow_shrink=False).shape == (1000, 1500, 3)
    """
    assert rp.is_image(image)
    assert height is not None or width is not None
    image = as_numpy_image(image,copy=False)

    if allow_shrink:
        assert height or width, ('If allow_shrink is True, at least one dimension must be nonzero. '
                                 'Scaling an image to a factor of 0 is not allowed.')

    image_height, image_width = rp.get_image_dimensions(image)

    if height is None and width is None:
        return image + 0  # Return an unmodified copy

    scale = 0
    if height is not None and height > image_height * scale:
        scale = height / image_height

    if width is not None and width > image_width * scale:
        scale = width / image_width

    if not allow_shrink and scale < 1:
        scale = 1

    assert scale > 0, 'Invalid arguments resulted in a scale of 0.'

    return rp.cv_resize_image(image, scale, interp=interp, alpha_weighted=alpha_weighted)

def resize_image_to_fit(
    image,
    height: int = None,
    width: int = None,
    interp="auto",
    *,
    allow_growth=True,
    alpha_weighted=False
):
    """
    Scale image on both axes evenly to fit in this bounding box
    If not allow_growth, it won't modify the image if height and width are larger than the input image
    """
    #TODO: Make height or width optional (done), make cv_resize_image interchangeable with resize_image (todo)
    
    assert rp.is_image(image)
    image_height, image_width = rp.get_image_dimensions(image)
    scale = 1

    image = as_numpy_image(image,copy=False)

    # height and width are optional
    if height is None and width is None:
        return image+0 #Return an unmodified copy
    assert height is not None or width is not None
    if height is None:
        height = 999999
    if width is None:
        width = 999999
    
    if allow_growth:
        scale = 999999 #It will be decreased...
    
    if height < image_height * scale:
        scale = height / image_height
        
    if width < image_width * scale:
        scale = width / image_width
        
    return rp.cv_resize_image(image, scale, interp=interp, alpha_weighted=alpha_weighted)

def resize_images_to_hold(
    *images,
    height: int = None,
    width: int = None,
    interp="auto",
    allow_shrink=True,
    alpha_weighted=False,
    show_progress=False,
    lazy=False
):
    """ Plural of resize_image_to_hold """
    images = detuple(images)

    output = (
        resize_image_to_hold(
            x,
            height,
            width,
            interp=interp,
            allow_shrink=allow_shrink,
            alpha_weighted=alpha_weighted,
        )
        for x in images
    )

    if show_progress:
        output = eta(output, title=get_current_function_name())

    if not lazy:
        output = list(output)

    return output

def resize_images_to_fit(
    *images,
    height: int = None,
    width: int = None,
    interp="auto",
    allow_growth=True,
    alpha_weighted=False,
    show_progress=False,
    lazy=False
):
    """ Plural of resize_image_to_fit """
    images = detuple(images)

    output = (
        resize_image_to_fit(
            x,
            height,
            width,
            interp=interp,
            allow_growth=allow_growth,
            alpha_weighted=alpha_weighted,
        )
        for x in images
    )

    if show_progress:
        output = eta(output, title=get_current_function_name())

    if not lazy:
        output = list(output)

    return output


def resize_video_to_hold(
    video,
    height: int = None,
    width: int = None,
    interp="auto",
    *,
    allow_shrink=True,
    alpha_weighted=False,
    show_progress=False,
    lazy=False
):
    """ Almost the same as resize_images_to_hold - but height and width and interp can be args and returns numpy arrays if input video is a numpy array too """
    output = gather_args_call(resize_images_to_hold, video)

    if is_numpy_array(video): output = as_numpy_array(output)

    return output



def resize_video_to_fit(
    video,
    height: int = None,
    width: int = None,
    interp="auto",
    *,
    allow_growth=True,
    alpha_weighted=False,
    show_progress=False,
    lazy=False
):
    """ Almost the same as resize_images_to_fit - but height and width and interp can be args and returns numpy arrays if input video is a numpy array too """
    output = gather_args_call(resize_images_to_fit, video)

    if is_numpy_array(video): output = as_numpy_array(output)

    return output


def resize_videos_to_fit(
    *videos,
    height: int = None,
    width: int = None,
    interp="auto",
    allow_growth=True,
    alpha_weighted=False,
    show_progress=False,
    lazy=False,
    lazy_frames=False
):
    """ Plural of resize_image_to_fit """
    #CODE IS NEAR DUPLICATE OF RESIZE_IMAGES TO FIT!!!
    videos = detuple(videos)

    output = (
        resize_video_to_fit(
            x,
            height,
            width,
            interp=interp,
            allow_growth=allow_growth,
            alpha_weighted=alpha_weighted,
            lazy=lazy_frames,
        )
        for x in videos
    )

    if show_progress:
        output = eta(output, title=get_current_function_name(), length=len(videos))

    if not lazy:
        output = list(output)

    return output

def resize_videos_to_hold(
    *videos,
    height: int = None,
    width: int = None,
    interp="auto",
    allow_shrink=True,
    alpha_weighted=False,
    show_progress=False,
    lazy=False,
    lazy_frames=False
):
    """ Plural of resize_image_to_hold """
    #CODE IS NEAR DUPLICATE OF RESIZE_IMAGES TO FIT!!!
    videos = detuple(videos)

    output = (
        resize_video_to_hold(
            x,
            height,
            width,
            interp=interp,
            allow_shrink=allow_shrink,
            alpha_weighted=alpha_weighted,
            lazy=lazy_frames,
        )
        for x in videos
    )

    if show_progress:
        output = eta(output, title=get_current_function_name(), length=len(videos))

    if not lazy:
        output = list(output)

    return output

def resize_images_to_max_size(*images, interp="bilinear", alpha_weighted=False):
    """
    Makes sure all images have the same height and width
    Does this by stretching all images to the max size found
    EXAMPLE:
        ans='https://i.ytimg.com/vi/MPV2METPeJU/maxresdefault.jpg https://i.insider.com/5484d9d1eab8ea3017b17e29?width=600&format=jpeg&auto=webp https://s3.amazonaws.com/cdn-origin-etr.akc.org/wp-content/uploads/2017/11/13002248/GettyImages-187066830.jpg https://hips.hearstapps.com/hmg-prod.s3.amazonaws.com/images/best-small-dog-breeds-cavalier-king-charles-spaniel-1598992577.jpg?crop=0.468xw:1.00xh;0.259xw,0&resize=480:*'.split()
        ans=load_images(ans)
        display_image_slideshow(ans)
        display_image_slideshow(resize_images_to_max_size(ans))
    """
    return resize_images(
        *images,
        size=get_max_image_dimensions(*images),
        interp=interp,
        alpha_weighted=alpha_weighted
    )

def resize_images_to_min_size(*images, interp="bilinear", alpha_weighted=False):
    """
    Makes sure all images have the same height and width
    Does this by stretching all images to the min size found
    EXAMPLE:
        ans='https://i.ytimg.com/vi/MPV2METPeJU/maxresdefault.jpg https://i.insider.com/5484d9d1eab8ea3017b17e29?width=600&format=jpeg&auto=webp https://s3.amazonaws.com/cdn-origin-etr.akc.org/wp-content/uploads/2017/11/13002248/GettyImages-187066830.jpg https://hips.hearstapps.com/hmg-prod.s3.amazonaws.com/images/best-small-dog-breeds-cavalier-king-charles-spaniel-1598992577.jpg?crop=0.468xw:1.00xh;0.259xw,0&resize=480:*'.split()
        ans=load_images(ans)
        display_image_slideshow(ans)
        display_image_slideshow(resize_images_to_min(ans))
    """
    return resize_images(*images, size=get_min_image_dimensions(*images), interp=interp, alpha_weighted=alpha_weighted)

def resize_videos_to_min_size(*videos,interp='auto'):
    videos = detuple(videos)
    min_height = min(get_video_heights(videos))
    min_width  = min(get_video_widths (videos))
    return resize_videos(videos, size=(min_height, min_width),interp=interp)

def resize_videos_to_max_size(*videos,interp='auto'):
    videos = detuple(videos)
    max_height = max(get_video_heights(videos))
    max_width  = max(get_video_widths (videos))
    return resize_videos(videos, size=(max_height, max_width),interp=interp)

def _iterfzf_raw(
    # CHECK: When the signature changes, __init__.pyi file should also change.
    iterable,
    # Search mode:
    extended=True, exact=False, case_sensitive=None,
    # Interface:
    multi=False, mouse=True, print_query=False,
    # Layout:
    prompt='> ', preview=None,
    # Misc:
    query='', encoding=None
):
    '''
    A modification of the original iterfzf.iterfzf function
    This one has --ansi and can potentially add more args
    '''
    pip_import('iterfzf')

    import errno, subprocess, sys, iterfzf
    executable=iterfzf.BUNDLED_EXECUTABLE or iterfzf.EXECUTABLE_NAME

    cmd = [
        executable,
        '--ansi', #<--- MY ONLY ACTUAL CHANGE
        "--no-sort",
        "--prompt=" + prompt,
    ]
    if not extended:               cmd.append('--no-extended')
    if case_sensitive is not None: cmd.append('+i' if case_sensitive else '-i')
    if exact:                      cmd.append('--exact')
    if multi:                      cmd.append('--multi')
    if not mouse:                  cmd.append('--no-mouse')
    if print_query:                cmd.append('--print-query')
    if query:                      cmd.append('--query=' + query)
    if preview:                    cmd.append('--preview=' + preview)
    encoding = encoding or sys.getdefaultencoding()
    proc = stdin = byte =None
    lf, cr = u'\n\r'
    for line in iterable:
        if byte is None:
            byte = isinstance(line, bytes)
            if byte: lf,cr = b'\n\r'
        elif isinstance(line, bytes) is not byte: raise ValueError( 'element values must be all byte strings or all ' 'unicode strings, not mixed of them: ' + repr(line))
        if lf in line or cr in line: raise ValueError(r"element values must not contain CR({1!r})/" r"LF({2!r}): {0!r}".format(line, cr, lf))
        if proc is None:
            proc = subprocess.Popen(
                cmd,
                stdin=subprocess.PIPE,
                stdout=subprocess.PIPE,
                stderr=None
            )
            stdin = proc.stdin
        if not byte: line = line.encode(encoding)
        try: stdin.write(line + b'\n'); stdin.flush()
        except IOError as e:
            if e.errno != errno.EPIPE and errno.EPIPE != 32: raise
            break
    if proc is None or proc.wait() not in [0, 1]:
        if print_query: return None, None
        else: return None
    try: stdin.close()
    except IOError as e:
        if e.errno != errno.EPIPE and errno.EPIPE != 32:
            raise
    stdout = proc.stdout
    decode = (lambda b: b) if byte else (lambda t: t.decode(encoding))
    output = [decode(l.strip(b'\r\n\0')) for l in iter(stdout.readline, b'')]
    if print_query:
        try:
            if multi: return output[0], output[1:]
            else: return output[0], output[1]
        except IndexError: return output[0], None
    else:
        if multi: return output
        else:
            try: return output[0]
            except IndexError: return None

def _iterfzf(iterable, *args,**kwargs):
    # pip_import('iterfzf')
    # from iterfzf import iterfzf

    def sanitize_string(string):
        #TODO: This might modify the outputs they expect...add some converter here to make sure it outputs the string they think it will
        return string.replace('\r','\\r').replace('\n','\\n') #Don't glitch out iterfzf I think it has a bug this can fuck up the terminal and forc e you to reset it

    iterable=map(sanitize_string,iterable)

    return _iterfzf_raw(iterable, *args,**kwargs)

def _iterfzf_with_ls_preview(iterable, exact=False, **kwargs):
    """
    Wrapper for _iterfzf that adds a colored ls preview for directory listings.
    Similar to other rp command helpers like _cpah.

    Shows a colored ls output in the preview pane when selecting directories.
    Format: [size] [date] filename with each column in different colors.
    Works with CDH, CDQ, CDHF, and DQ commands.
    """
    import sys
    import shlex

    # Use custom formatter that shows size, date, and filename in colored columns
    # No permission/owner noise - just the useful info
    formatter_path = get_module_path("rp.libs.ls_colored_columns")
    preview_cmd = '{0} {1} {}'.format(shlex.quote(sys.executable), shlex.quote(formatter_path))

    # Only set preview if not already specified
    if 'preview' not in kwargs:
        kwargs['preview'] = preview_cmd

    # iterable = map(fansi_highlight_path, iterable)

    output = _iterfzf(iterable, exact=exact, **kwargs)
    # output = strip_ansi_escapes(output)
    return output

def _recursively_iter_subdirs(root_path='.'):
    """
    Fast recursive directory iterator using scandir - optimal for Unix filesystems.

    Uses os.scandir() which leverages the d_type field from directory entries.
    The getdents() syscall returns entry type for free, so entry.is_dir() requires
    no additional syscall (vs. os.path.isdir which calls stat() per entry).

    This is the fastest possible way to recursively find directories on Unix.
    Benchmark shows ~5.5x speedup vs listdir()+isdir() approach.

    Yields directories in breadth-first order, similar to breadth_first_path_iterator
    but filtered to directories only.

    >>> # Usage in CDQ/CDZ commands
    >>> list(_recursively_iter_subdirs('.'))[:3]  # doctest: +SKIP
    ['./subdir1', './subdir2', './dir/nested']
    """
    import os
    from collections import deque

    queue = deque([root_path])

    while queue:
        current_dir = queue.popleft()

        try:
            with os.scandir(current_dir) as entries:
                for entry in entries:
                    # entry.is_dir() reads d_type field from directory entry
                    # Already in memory from getdents() - no stat() syscall needed
                    if entry.is_dir(follow_symlinks=False):
                        # Yield the path
                        yield entry.path
                        # Add to queue for recursive traversal
                        queue.append(entry.path)
        except (PermissionError, OSError):
            # Skip directories we can't read
            pass

def cv_inpaint_image(image, mask=None, radius=3, *, algorithm: str = "TELEA", invert_mask=False):
    """
    Inpaint an image using OpenCV's inpainting methods.
    The inpainting will be super smooth, and does not use any machine learning

    Parameters:
        - image: The input image to be inpainted, expected to be in RGB format with byte values.
        - mask: A binary mask indicating the regions to inpaint. Must be the same size as `image`.
                If left as None, the inverted alpha channel of image will be used (rounded to a binary mask)
                Like OpenCV, where the mask is True, it will be inpainted. Where it is False, the original image is returned.
        - radius: The radius of a circular neighborhood of each point to inpaint. Defines how far the inpainting method 
          can reach for information while inpainting. Larger values consider more of the surrounding pixels. Default is 3.
        - algorithm: The inpainting method to use. Can be "TELEA" or "NS". Default is "TELEA".
          "TELEA" uses the method by Alexandru Telea [An Image Inpainting Technique Based on the Fast Marching Method].
          "NS" uses the method by Bertalmio, Marcelo, Andrea L. Bertozzi, and Guillermo Sapiro [Navier-Stokes, Fluid Dynamics, and Image and Video Inpainting].

    Returns:
        - inpainted_image: The inpainted image, in the same format and size as the input image.

    Example:
        image=load_image('https://upload.wikimedia.org/wikipedia/en/7/7d/Lenna_%28test_image%29.png')
        mask=np.zeros_like(image).astype(bool)
        mask[128:-128,128:-128]=True #Will inpaint where mask==True
        display_image(cv_inpaint_image(image,mask))
    """
    
    #Imports and setup
    pip_import("cv2")
    import cv2
    algorithms = dict(TELEA=cv2.INPAINT_TELEA, NS=cv2.INPAINT_NS)

    if mask is None:
        assert is_rgba_image(image), 'cv_inpaint_image: Warning: no mask was given, and the input image doesnt have transparency - which means nothing will be inpainted. If making this an error is an issue this might be downgraded to a warning or removed, but right now I assume youve likely made a mistake. Are you sure you didnt mean to pass in an RGBA image or give a mask?'
        mask=image
        mask=get_image_alpha(mask)
        mask=as_binary_image(mask)
        mask=inverted_image(mask)

    #Input assertions
    assert is_image(image) and is_image(mask)
    assert get_image_dimensions(mask) == get_image_dimensions(image), 'mask and image must have same height and width'
    assert isinstance(radius,int) and radius>=1
    assert algorithm in algorithms, repr(algorithm) + ' is not in '+repr(list(algorithms))

        
    #Input conversions
    image = as_byte_image(image)
    image = as_rgb_image(image)
    mask = as_byte_image(as_binary_image(mask))
    mask = as_grayscale_image(mask)
    algorithm = algorithms[algorithm]

    #The actual inpainting
    inpainted_image = cv2.inpaint(image, mask, radius, flags=algorithm)

    #Output assertions
    assert is_rgb_image(inpainted_image)
    assert is_byte_image(inpainted_image)
    assert get_image_dimensions(inpainted_image) == get_image_dimensions(image)

    return inpainted_image

def cv_floodfill_mask(image, position: tuple, tolerance: int = 32, *, bridge_diagonals=False):
    """
    A wrapper for cv2.floodfill
    Takes in an image, and returns a binary mask of same dimensions
    It acts like the paint bucket tool applied to the given position,
    and fills all pixels adjacently that are within the given tolerance.
    The connectivity parameter can either be 4 or 8, and if it

    EXAMPLE:
        image = load_image("https://wallpapers.com/images/hd/girl-aesthetic-looking-up-the-sky-l7l5dq0nsmnq8j70.jpg")
        mask = cv2_floodfill_mask(image, position=(0, 0), tolerance=10)
        display_alpha_image(with_alpha_channel(image,~mask))
    """
    pip_import("cv2")
    import cv2

    assert is_image(image)
    image = as_rgb_image(image)
    image = as_byte_image(image)
    height, width = get_image_dimensions(image)

    # position is an (x,y) tuple for pixel position
    x, y = position
    x = int(x)
    y = int(y)

    # tolerance has to be an RGB color
    tolerance = (tolerance, tolerance, tolerance)

    # When filling, should we bridge diagonal gaps? That's called "8-way connectivity"
    # To get that, set connectivity to 8. Otherwise, if we only want to traverse
    # up down left and right, set connectivity to 4-way via connectivity==4
    connectivity = 8 if bridge_diagonals else 4
    assert connectivity in (4, 8)

    # cv2.floodfill() mutates image! However, its ok - it's only a copy.
    mask = np.zeros([x + 2 for x in get_image_dimensions(image)], dtype=np.uint8)
    z, u, mask, y = cv2.floodFill(
        image,  # image
        mask,  # mask
        (x, y),  # seedpoint
        (255, 255, 255),  # newval
        tolerance,  # lodiff
        tolerance,  # updiff
        connectivity,  # flags
    )

    assert is_grayscale_image(mask)
    mask = as_binary_image(mask * 255)
    assert mask.shape == (height + 2, width + 2)

    mask = mask[1:-1, 1:-1]
    assert get_image_dimensions(mask) == get_image_dimensions(image)

    return mask


def get_path_inode(path: str) -> int:
    """
    Returns the inode number of the file or directory at the given path.

    Parameters:
    path (str): The filesystem path of the file or directory.

    Returns:
    int: The inode number of the file or directory.

    Raises:
    FileNotFoundError: If the path does not exist.
    PermissionError: If there is a permission error accessing the file.

    Example:
    >>> get_path_inode('/path/to/file')
    1234567
    """
    # Use os.stat() to get file/directory statistics
    try:
        stats = os.stat(path)
        return stats.st_ino
    except FileNotFoundError:
        raise FileNotFoundError("The path specified does not exist.")
    except PermissionError:
        raise PermissionError("Permission denied accessing the file.")

def _is_dir_entry(x):
    #On python3.5, os.DirEntry doesn't exist, and even though scandir() returns posix.DirEntry instances, not hasattr(__import__('posix'), "DirEntry")
    return type(x).__name__=='DirEntry'

class _PathInfo:
    """
    This class is made to be used by functions in rp for efficeintly processing files
    without calling os.stat too many times.

    NOTE: Its currently private, because the functionality and signatures of this are
    subject to change - only rp functions should use this right now.

    WARNINGS:
        If it is instantiated with a relative-path string, and the current directory changes,
        and then something like is_symlink is accessed - right now it will return the wrong answer
        because its a relative path and the absolute path isnt recorded during __init__!

        For reasons like this, it isn't safe to expose _PathInfo beyond rp yet - only functions
        in rp that make use of it carefully should use it at the load_image_from_screenshot
        
    Questions:
        Should we expose self.entry?
        Should we call it 'entry'? Is that obvious enough?
    Note: The current fields now use @property instead of memoized_property; I found it made it a tiny bit slower to use memoized_property for these fields        
        
    """
    def __init__(self,entry):
        self.entry=entry
        if _is_dir_entry(self.entry):
            self.inode=entry.inode()
            self.path=entry.path
            self.is_symlink=entry.is_symlink()
            self.name=entry.name

            try:
                self.is_folder=entry.is_dir(follow_symlinks=True) #This can, stupidly, recurse infinitely...
            except OSError:
                # ERROR: OSError: [Errno 40] Too many levels of symbolic links: './root'
                self.is_folder=is_a_folder(self.path) #Slower

        elif isinstance(entry,str):
            self.inode=get_path_inode(entry)
            self.path=entry
            self.is_symlink=is_symlink(entry)
            self.is_folder=is_a_folder(entry)
            self.name=get_file_name(entry)
        self.is_file=not self.is_folder
        self.is_hidden=self.name.startswith('.')
            
        
    # @property
    # def path(self):
    #     if isinstance  (self.entry,str): return self.entry
    #     if is_dir_entry(self.entry    ): return self.entry.path
    
    # @property
    # def inode(self):    
    #     if isinstance  (self.entry,str): return get_path_inode(self.path)
    #     if is_dir_entry(self.entry    ): return self.entry.inode()
    
    # @property
    # def is_symlink(self):    
    #     if isinstance  (self.entry,str): return is_symlink(self.path)
    #     if is_dir_entry(self.entry    ): return self.entry.is_symlink()
    
    # @property
    # def is_folder(self):    
    #     if isinstance  (self.entry,str): return is_a_folder(self.path)
    #     if is_dir_entry(self.entry    ): return self.entry.is_dir()

    # @property
    # def is_file(self):    
    #     if isinstance  (self.entry,str): return is_a_file(self.path)
    #     if is_dir_entry(self.entry    ): return not self.is_folder
    
    # @property
    # def name(self):
    #     if isinstance  (self.entry,str): return get_path_name(self.path)
    #     if is_dir_entry(self.entry    ): return self.entry.name

    # @property
    # def is_hidden(self):
    #     return self.name.startswith('.')

    # @property
    # def file_extension(self):
    #     return get_file_extension(self.path)

    def __repr__(self):
        return self.path

    def __hash__(self):
        return hash(self.inode)
    
    def __eq__(self,x):
        if not hasattr(x,'inode'):return False
        return self.inode == x.inode
    
def _get_all_paths_fast(
    root_dir=".",
    *,
    lazy=False,
    recursive=False,
    include_files=True,
    include_folders=True,
    include_symlink_files=None,
    include_symlink_folders=None,
    explore_symlinks=True,
    include_hidden=True,
    ignore_permission_errors=False,
    traversal = "breadth_first"
):
    """
    TODO: Make this multi-threaded!!!!
    Retrieves all paths in the given directory according to the specified parameters.
    This function is designed to be faster than get_all_paths by reducing feature complexity.
    TODO: Add PathInfo class - that can be initialized from dir_entry or string path, and exposes human-readable attributes that can be cached.
    This func can then return them with an arg like return_path_info or something
    TODO: Implement sorting function sort_paths() that 
    TODO: Default args should never call .stat() on any files as that can cause major slowdowns - try to just use what os.scandir() does and don't query any of these things unless we have to
        * To do this we need the custom caching class - right now we have variables like is_hidden etc that are calculated eagerly
        * ***args always default to what is fastest if if they make a difference in speed***

    Args:
        root_dir (str): The root directory to start traversal from.
        lazy (bool): If True, returns a generator; if False, returns a list.
        recursive (bool): If True, traverses directories recursively.
        include_folders (bool): If True, includes folders in the result.
        include_files (bool): If True, includes files in the result.
        include_hidden (bool): If True, includes hidden files and folders.
        include_symlink_files (bool or None): If True, includes file symbolic links. If None, follows the value of include_files.
        include_symlink_folders (bool or None): If True, includes folder symbolic links. If None, follows the value of include_folders.
        explore_symlinks (bool): If True and recursive is True, explores directories pointed to by symlinks.
        ignore_permission_errors (bool): If True, will silently skip over directories that give PermissionError's
        traversal (str): Determines the traversal method; 'depth_first' or 'breadth_first'.

    Returns:
        A generator or list of paths depending on the lazy argument.

    TODO Args:
    """

    from collections import deque

    # When I come up with a more elegant way to specify this than a string with an underscore, it will become an argument
    # traversal = "depth_first"
    # traversal = "breadth_first"
    assert traversal in ["depth_first", "breadth_first"]

    include_symlink_files   = include_files   if include_symlink_files   is None else include_symlink_files   
    include_symlink_folders = include_folders if include_symlink_folders is None else include_symlink_folders 

    def should_include(x:_PathInfo):
        is_hidden = x.name.startswith('.')
        return (include_hidden or not is_hidden) and (
               include_folders         and x.is_folder and not x.is_symlink
            or include_files           and x.is_file   and not x.is_symlink
            or include_symlink_folders and x.is_folder and     x.is_symlink
            or include_symlink_files   and x.is_file   and     x.is_symlink
        )

    def should_explore(x:_PathInfo):
        return (
            recursive
            and x.is_folder
            and (explore_symlinks or not x.is_symlink)
            and x not in explored_paths
        )

    def postprocess_path(x:_PathInfo):
        x=x.path
        if x.startswith('./'):
            x=x[2:]
        return x

    def explore(x:_PathInfo):
        explored_paths.add(x) #Avoid symlink loops

        try: dir_entries=os.scandir(x.path)
        except PermissionError:
            if not ignore_permission_errors: raise
            return

        for x in map(_PathInfo, dir_entries):
            if should_explore(x): to_explore.append(x)
            if should_include(x): yield postprocess_path(x)
            
    root_dir = _PathInfo(root_dir)
    to_explore = deque([root_dir])
    pop_dir = getattr(to_explore, "pop" if traversal == "depth_first" else "popleft")
    explored_paths = set()
    
    #Sequential Version
    def generator():
        while to_explore:
            yield from explore(pop_dir())

    output=generator()
    if not lazy:
        output=list(output)
    return output

    ##Parallelizable Version (not significantly faster from my tests so far even on nfs?)
    #exploring=set()
    #def dir_iterator():
    #    while to_explore or exploring:
    #        while not to_explore and exploring:
    #            #This will only be reached during multi-threading
    #            #The yield here is better than pass, as it keeps
    #            #a single thread from hanging up the GIL
    #            yield None
    #        try:
    #            dir = pop_dir()
    #        except IndexError:
    #            #In multi-threading, two threads might have tried
    #            #popping at the same time - and one did it after
    #            #the list was already empty, throwing an error. 
    #            #Back to the queue it goes!
    #            continue
    #        exploring.add(dir)
    #        yield dir
    #def par_explore(dir):
    #    #print(len(exploring),len(to_explore),dir)
    #    if dir is None:
    #        return
    #    yield from explore(dir)
    #    exploring.remove(dir)
    #import itertools
    #output = lazy_par_map(par_explore,dir_iterator(),num_threads=10) #Like the map function but is multithreaded. This already exist.
    ##output = map(par_explore,dir_iterator())
    #output = itertools.chain.from_iterable(output)
    #return output

def breadth_first_path_iterator(root='.'):
    """
    As opposed to a depth-first path iterator, this goes through every file and directory from the root in a breadth-first manner
    It returns a generator instead of a list, which makes computations more efficient (especially for FZF)
    EXAMPLE:
        for path in breadth_first_path_iterator():
            print(path)
    """

    #Original code: https://code.activestate.com/recipes/511456-breadth-first-file-iterator/
    #The below function is over 10x as fast as the old implementation
    yield from _get_all_paths_fast(root_dir=root,recursive=True,traversal='breadth_first',lazy=True,ignore_permission_errors=True)

    #OLD IMPLEMENTATION WORKED FINE, BUT WAS OVER 10X SLOWER THAN THE CURRENT VERSION
    #import os
    #dirs = [root]
    #seen = set()
    ## while we has dirs to scan
    #while len(dirs) :
    #    nextDirs = []
    #    for parent in dirs :
    #        # scan each dir
    #        try:
    #            for f in os.listdir( parent ) :
    #                # if there is a dir, then save for next ittr
    #                # if it  is a file then yield it (we'll return later)
    #                ff = os.path.join( parent, f )
    #                af = get_absolute_path(ff)
    #                if af in seen:
    #                    #Don't let symlink loops make this function go on infinitely
    #                    continue
    #                seen.add(af)
    #                if os.path.isdir( ff ) :
    #                    yield ff
    #                    nextDirs.append( ff )
    #                else :
    #                    yield ff
    #        except PermissionError:
    #            #If we encounter an error such as a PermissionError, skip the directory
    #            pass
    #    # once we've done all the current dirs then
    #    # we set up the next itter as the child dirs 
    #    # from the current itter.
    #    dirs = nextDirs

def gpt3(text:str):
    """
    Use GPT3 to write some text
    https://deepai.org/machine-learning-model/text-generator
    """
    assert False, 'rp.gpt3: This GPT3 API is dead. Dont use this function.'
    # pip_import('requests')
    # import requests
    # assert isinstance(text,str),'Text must be a string'
    # assert len(text)>0,'Text cannot be empty'
    # response = requests.post(
    #     #If in the future this API key no longer works, you can sign up for one -- its free. Or, if this site is broken, please replace this function with a working API.
    #     "https://api.deepai.org/api/text-generator",
    #     data={
    #         'text': text,
    #     },
    #     headers={'api-key': '68da3879-3ec4-4f51-905d-dd46a1a88405'}
    # )
    # data=response.json()
    # return data['output']

def deepgenx(code:str)->str:
    assert False, 'rp.deepgenx: Deepgenx is dead. Dont use this function.'
    # #Similar to GitHub copilot, except it uses GPT-J and is free
    # #See https://deepgenx.com/
    # #Given some python code, it will continue it and return the original code + the continued code
    # pip_import('requests')
    # import requests
    # import json
    # ans = code
    # ans = requests.post(
    #     "https://api.deepgenx.com:5700/generate",
    #     json=dict(
    #         input=code,
    #         token_max_length=1024,
    #         temperature=1,
    #         top_p=.7,
    #         # Get your token here: https://deepgenx.com/signin
    #         token="8168b4cc9bbc41f0b3b8a403a9ad6b7cf195f01ded98706282980ceff021041e.3a3eed41e43b3a212f8898ba26c6e341a639c2e86bb3110e35f64d312b982086",
    #     ),
    # )
    # ans = ans.text
    # ans = json.loads(ans)
    # assert ans['success'], 'deepgenx request failed: '+repr(ans)
    # ans = ans["message"]
    # ans = line_join(ans)
    # return code+ans

def _get_openai_api_key(key=None):
    if key is not None:
        return key
    elif 'OPENAI_API_KEY' in os.environ:
        return os.environ['OPENAI_API_KEY']
    else:
        #Right now this library is very niche and not well known. This has $100 of credit. When it runs out, it runs out lol.
        #Please be frugal. Don't be an ass.
        return base64_to_bytes('c2stcHJvai1YUC1RcE9ZSHdqN2E0WkZIajNtdF81bUxFSkV4Y1V5RUl6QmJqeWJpenhrZ01Uajg1cnB2NDgxSnQ0dGIwcUlZRS1fTVFMVnFRbVQzQmxia0ZKQTZWajZSM1B0Wl9kbHBWR2JuOUQtbUtEWlJfQ2dhZ0xZNWU3bXZMTTlpXzVwSDNsR2ZYRGZqelU4YXFYS1VxVmVIM2lfRXdZQUE=').decode()
    

def _run_openai_llm(message,model,api_key=None):
    pip_import('openai')
    
    import os
    from openai import OpenAI

    api_key=_get_openai_api_key(api_key)
    client = OpenAI(
        api_key=api_key
    )

    chat_completion = client.chat.completions.create(
        messages=[
            {
                "role": "user",
                "content": message,
            }
        ],
        model="gpt-4o",
    )
    
    return chat_completion.choices[0].message.content

def run_llm_api(message,model='gpt-4o-mini',api_key=None):
    """ Allows you to query different LLM api's """
    assert isinstance(message,str),type(message)
    assert isinstance(model,str),type(model)
    
    if model in ['gpt-4o','gpt-4','gpt-4o-mini','gpt-4-turbo']:
        return _run_openai_llm(message,model,api_key)
    else:
        raise ValueError('Model not supported: '+str(model))


def minify_python_code(code:str):
    """ Just as people minify javascript code, this minifies python code. str->str function. """
    pip_import('python_minifier','python-minifier')
    import python_minifier
    return python_minifier.minify(code)

def image_to_text(image)->str:
    """
    Takes an image, finds text on it, and returns the text as a string
    (Optical character recognition)
    It's kind of mind-blowing how this can be done in just 6 lines of code...
    EXAMPLE:
        print(image_to_text(load_image('http)://www.morefamousquotes.com/images/topics/20170915/quotes-about-hitchhikers-guide-to-the-galaxy.jpg'))
    """
    image=as_rgb_image(image)
    image=as_byte_image(image)
    pip_import('pytesseract')
    from pytesseract import image_to_string
    text=image_to_string(image)
    return text

def cv_equalize_histogram(image,by_value=True):
    """
    Equalizes the histogram of a given image
    If by_balue is True, and image is RGB, equalize it's value in HSV space instead of applying equalization per RGB channel
    EXAMPLE:
        ans=load_image('https://www.cdc.gov/healthypets/images/pets/cute-dog-headshot.jpg')
        ans=as_grayscale_image(ans)
    
        display_image(ans)
        bar_graph(np.cumsum(np.histogram(ans.flatten(),256)[0]))
        input('Before');
    
        ans=cv_equalize_histogram(ans)
        display_image(ans)
        bar_graph(np.cumsum(np.histogram(ans.flatten(),256)[0]))
        print('After')
    """

    pip_import('cv2')
    import cv2
    
    assert is_image(image)
    image=as_byte_image(image)

    if is_grayscale_image(image):
        return cv2.equalizeHist(image)
    elif is_rgb_image(image):
        if by_value:
            h=get_image_hue(image)
            s=get_image_saturation(image)
            v=get_image_value(image)
            
            v=cv_equalize_histogram(v)
            v=as_float_image(v)
            
            hsv = np.stack((h,s,v),axis=2)
            rgb = hsv_to_rgb(hsv)
            
            return rgb
        else:
            r=image[:,:,0]
            g=image[:,:,1]
            b=image[:,:,2]
            r=cv_equalize_histogram(r)
            g=cv_equalize_histogram(g)
            b=cv_equalize_histogram(b)
            return np.concatenate((r,g,b),axis=2)
    else:
        #To the same thing we would for RGB, but don't change the alpha channel
        assert is_rgba_image(image)
        alpha=image[:,:,3]
        output=cv_equalize_histogram(as_rgb_image(image))
        output=np.concatenate((output,alpha),axis=2)
        return output



def compose_rgb_image(r,g,b):
    """ Create an RGB image from three separate channels
    
    Enhanced Documentation:
    =====================
    Combines separate red, green, and blue channel images into a single RGB image.
    This is useful for reconstructing images from separated channels or creating
    composite images from individual color components.
    
    Args:
        r (numpy.ndarray): Red channel (grayscale image)
        g (numpy.ndarray): Green channel (grayscale image)
        b (numpy.ndarray): Blue channel (grayscale image)
        
    Returns:
        numpy.ndarray: RGB image in HW3 format (height, width, 3 channels)
                      As float image with values in [0,1] range
        
    Usage Patterns:
        - Reconstructing images from separated color channels
        - Creating synthetic images from procedural channels
        - Advanced color manipulation and processing
        - Converting from other color spaces
        
    Examples:
        >>> # Create RGB from separate channels
        >>> red_channel = np.ones((100, 100)) * 0.8    # Bright red
        >>> green_channel = np.ones((100, 100)) * 0.4  # Medium green
        >>> blue_channel = np.zeros((100, 100))        # No blue
        >>> 
        >>> rgb_image = compose_rgb_image(red_channel, green_channel, blue_channel)
        >>> print(rgb_image.shape)  # (100, 100, 3)
        >>> print(rgb_image.dtype)  # float64
        
        >>> # Extract, process, and recombine channels
        >>> original = load_image("color_photo.jpg")
        >>> r, g, b = [original[:,:,i] for i in range(3)]
        >>> # Enhance specific channels
        >>> r_boosted = np.clip(r * 1.3, 0, 1)  # Boost red
        >>> g_reduced = g * 0.8                 # Reduce green
        >>> enhanced = compose_rgb_image(r_boosted, g_reduced, b)
        
        >>> # Create gradient patterns
        >>> height, width = 150, 200
        >>> r_gradient = np.linspace(0, 1, width).reshape(1, -1).repeat(height, axis=0)
        >>> g_gradient = np.linspace(0, 1, height).reshape(-1, 1).repeat(width, axis=1)
        >>> b_checkerboard = ((np.arange(height)[:,None] + np.arange(width)) % 2).astype(float)
        >>> pattern = compose_rgb_image(r_gradient, g_gradient, b_checkerboard)
        
        >>> # Color space conversion
        >>> # Convert HSV to RGB (after processing individual H,S,V channels)
        >>> h_channel = create_hue_wheel(size=100)
        >>> s_channel = np.ones((100, 100))
        >>> v_channel = np.ones((100, 100)) * 0.8
        >>> # ... process HSV channels, then convert to RGB ...
        >>> rgb_result = compose_rgb_image(r_from_hsv, g_from_hsv, b_from_hsv)
        
    Related Functions:
        - compose_rgba_image(): Create RGBA image from 4 channels
        - compose_image_from_channels(): Generic composer for RGB/RGBA
        - split_image_channels(): Split image into separate channels
        - as_rgb_image(): Convert existing image to RGB format
        
    Implementation Details:
        - Converts all inputs to grayscale format first
        - Validates that all channels have identical shapes
        - Converts channels to float format for consistency
        - Uses np.stack() to combine channels along axis 2
        - Output is always float type regardless of input types
        
    Error Handling:
        - AssertionError if channels are not grayscale matrices
        - AssertionError if channel shapes don't match
        - Input validation ensures all channels are proper images
        
    Performance Notes:
        - Memory efficient stacking operation
        - Automatic type conversion may create copies
        - Consider pre-converting channels to float for better performance
        
    Tags: image, rgb, channels, composition, color, processing
    """
    r=as_grayscale_image(r, copy=False)
    g=as_grayscale_image(g, copy=False)
    b=as_grayscale_image(b, copy=False)
    assert is_grayscale_image(r),'Each channel must be a matrix, not a tensor'
    assert is_grayscale_image(g),'Each channel must be a matrix, not a tensor'
    assert is_grayscale_image(b),'Each channel must be a matrix, not a tensor'
    assert r.shape==g.shape==b.shape,'All channels must have the same shape'
    r=as_float_image(r, copy=False)
    g=as_float_image(g, copy=False)
    b=as_float_image(b, copy=False)
    return np.stack((r,g,b),axis=2)

def compose_rgba_image(r,g,b,a):
    """ Create an RGBA image from four separate channels
    
    Enhanced Documentation:
    =====================
    Combines separate red, green, blue, and alpha channel images into a single 
    RGBA image. This is useful for reconstructing images from separated channels
    or creating composite images with transparency.
    
    Args:
        r (numpy.ndarray): Red channel (grayscale image)
        g (numpy.ndarray): Green channel (grayscale image) 
        b (numpy.ndarray): Blue channel (grayscale image)
        a (numpy.ndarray): Alpha channel (grayscale image)
        
    Returns:
        numpy.ndarray: RGBA image in HW4 format (height, width, 4 channels)
                      As float image with values in [0,1] range
        
    Usage Patterns:
        - Reconstructing images from separated color channels
        - Creating composite images with custom transparency
        - Advanced image processing requiring channel manipulation
        - Converting from other color spaces or formats
        
    Examples:
        >>> # Create RGBA from separate channels
        >>> red_channel = np.ones((100, 100)) * 0.8    # Red component
        >>> green_channel = np.ones((100, 100)) * 0.4  # Green component  
        >>> blue_channel = np.zeros((100, 100))        # Blue component
        >>> alpha_channel = np.ones((100, 100)) * 0.7  # 70% opacity
        >>> 
        >>> rgba_image = compose_rgba_image(red_channel, green_channel, blue_channel, alpha_channel)
        >>> print(rgba_image.shape)  # (100, 100, 4)
        >>> print(rgba_image.dtype)  # float64
        
        >>> # Extract and recombine channels
        >>> original = load_image("photo_with_alpha.png")
        >>> r, g, b, a = [original[:,:,i] for i in range(4)]
        >>> # Process individual channels
        >>> r_enhanced = r * 1.2  # Enhance red
        >>> reconstructed = compose_rgba_image(r_enhanced, g, b, a)
        
        >>> # Create gradient with transparency
        >>> height, width = 200, 300
        >>> r_grad = np.linspace(0, 1, width).reshape(1, -1).repeat(height, axis=0)
        >>> g_grad = np.linspace(0, 1, height).reshape(-1, 1).repeat(width, axis=1)  
        >>> b_solid = np.full((height, width), 0.5)
        >>> a_fade = np.linspace(1, 0, width).reshape(1, -1).repeat(height, axis=0)
        >>> gradient = compose_rgba_image(r_grad, g_grad, b_solid, a_fade)
        
    Related Functions:
        - compose_rgb_image(): Create RGB image from 3 channels
        - compose_image_from_channels(): Generic composer for RGB/RGBA
        - extract_alpha_channel(): Extract alpha channel from RGBA
        - split_image_channels(): Split image into separate channels
        
    Implementation Details:
        - Converts all inputs to grayscale format first
        - Validates that all channels have identical shapes
        - Converts channels to float format for consistency
        - Uses np.stack() to combine channels along axis 2
        - Output is always float type regardless of input types
        
    Error Handling:
        - AssertionError if channels are not grayscale matrices
        - AssertionError if channel shapes don't match
        - Input validation ensures all channels are proper images
        
    Performance Notes:
        - Memory efficient stacking operation
        - Automatic type conversion may create copies
        - Consider pre-converting channels to float for better performance
        
    Tags: image, rgba, channels, composition, transparency, alpha
    """
    r=as_grayscale_image(r, copy=False)
    g=as_grayscale_image(g, copy=False)
    b=as_grayscale_image(b, copy=False)
    a=as_grayscale_image(a, copy=False)
    assert is_grayscale_image(r),'Each channel must be a matrix, not a tensor'
    assert is_grayscale_image(g),'Each channel must be a matrix, not a tensor'
    assert is_grayscale_image(b),'Each channel must be a matrix, not a tensor'
    assert is_grayscale_image(a),'Each channel must be a matrix, not a tensor'
    assert r.shape==g.shape==b.shape==a.shape,'All channels must have the same shape'
    r=as_float_image(r, copy=False)
    g=as_float_image(g, copy=False)
    b=as_float_image(b, copy=False)
    a=as_float_image(a, copy=False)
    return np.stack((r,g,b,a),axis=2)

def compose_image_from_channels(*channels):
    """ Create an RGB or RGBA image from three or four separate channels """
    assert len(channels) in (3,4),'Cannot create an RGB or RGBA image from %i channels. We need 3 or 4 channels.'%len(channels)
    if len(channels)==3:
        return compose_rgb_image(*channels)
    else:
        return compose_rgba_image(*channels)

def extract_image_channels(image):
    """
    Given an RGB image of shape (height,width,3) return a tensor of (3,height,width)
    This function is the inverse of compose_image_from_channels
    Meant to be used like:
    EXAMPLE:
       r,g,b  =extract_image_channels(image)
       r,g,b,a=extract_image_channels(image)
    """
    assert is_rgb_image(image) or is_rgba_image(image)
    return np.transpose(image,(2,0,1))
extract_rgb_channels=extract_image_channels
extract_rgba_channels=extract_image_channels

def extract_alpha_channel(image):
    """ Gets teh alpha channel of a given image, returned as grayscale image (i.e. numpy matrix) """
    image=as_rgba_image(image)
    return image[:,:,3]

get_alpha_channel=get_image_alpha=extract_alpha_channel #Uncomment this if you think it would make the code nicer!

def apply_image_function_per_channel(function,image,*args,**kwargs):
    """
    Apply a grayscale function on every image channel individually
    Calls the function with any additional args and kwargs you give it, too 
    EXAMPLE: Low Rank Webcam Demo: https://gist.github.com/SqrtRyan/c33e4e40ccf74714a20c229a13c717fe
    """
    assert callable(function),type(function)
    assert is_image(image)
    if is_grayscale_image(image):
        return function(image, *args, **kwargs)
    channels=extract_image_channels(image)
    return compose_image_from_channels(*(function(channel,*args,**kwargs) for channel in channels))

def with_alpha_channel(image, alpha, copy=True):
    """
    Assigns an alpha channel to an image
    The alpha can either be given as a number between 0 and 1,
    or a grayscale image whose brigtness will be used as alpha

    If alpha is given as an array, you can specify image by color
    
    Will output an RGBA float image

    TODO: Mutate image if copy=False
    """

    if not is_image(image):
        # If alpha is given as an array, you can specify image by color
        assert is_image(alpha),'Either alpha or image must be an image...'
        h, w = get_image_dimensions(alpha)
        image = uniform_float_color_image(h, w, color=image)

    if is_number(alpha):
        # Assume alpha is a float between 0 and 1
        alpha = uniform_float_color_image(*get_image_dimensions(image), alpha)

    assert is_image(image)
    assert is_image(alpha)
    assert get_image_dimensions(image) == get_image_dimensions(alpha)

    alpha = as_grayscale_image(alpha,copy=False)
    alpha = as_float_image(alpha,copy=False)

    image = as_rgba_image(image,copy=False)
    image = as_float_image(image,copy=False)
    if copy: image = image.copy()
    image[:, :, 3] = alpha

    return image

with_image_alpha=with_alpha_channel #You can uncomment this if you ever think it will enhance readability along with the functions with_image_green and with_image_hue etc

def with_image_rgb(image, rgb, copy=True):
    """ Counterpart to with_alpha_channel - sets the RGB channels of a potentially RGBA image and returns the modified image """
    return with_image_alpha(rgb, get_image_alpha(image), copy=copy)

pterm=pseudo_terminal#Just a shortcut. Not to be used in code; just Colab etc where I don't want to type pseudo_terminal. What?? Don't look at me like that - I'm lazy lol

#def rich_print(*args,**kwargs):
#    #This function exists because I want to be able to print fancy thing in google colab
#    #This function is 99% unnessecary lol it saves me basically one line of code...
#    return pip_import('rich').print(*args,**kwargs)

def play_the_matrix_animation():
    """
    Plays a super cool animation in your terminal that makes it look like you're hacking the matrix
    (From the movie)
    This code is from: https://github.com/gineer01/matrix-rain
    !/usr/bin/env python3
    """
    
    import random
    import curses
    import time
    
    # Sleep between frame after refresh so that user can see the frame. Value 0.01 or lower results in flickering because
    # the animation is too fast.
    SLEEP_BETWEEN_FRAME = 1/25#.04  # about 25 frames/s is good enough
    
    # How fast the rain should fall. In config, we change it according to screen.
    FALLING_SPEED = 2#2
    
    # The max number of falling rains. In config, we change it according to screen.
    MAX_RAIN_COUNT = 10
    
    # Color gradient for rain
    COLOR_STEP = 20
    NUMBER_OF_COLOR = 45  # The darkest color is 1000 - COLOR_STEP * NUMBER_OF_COLOR. This should be >= 0
    USE_GRADIENT = True
    START_COLOR_NUM = 128  # The starting number for color in gradient to avoid changing the first 16 basic colors
    
    # Different styles for rain head
    HEAD_STANDOUT = curses.COLOR_WHITE | curses.A_STANDOUT  # look better for small font
    HEAD_BOLD = curses.COLOR_WHITE | curses.A_BOLD  # look better for larger font

    
    # TODO This can be a namedtuple
    options = {
        'head': HEAD_BOLD,
        'speed': FALLING_SPEED,
        'count': MAX_RAIN_COUNT,
        'opening_title': " ".join("The Matrix".upper()),
        'end_title': " ".join("The Matrix. Goodbye!".upper()),
    }
    
    MAX_COLS=None
    
    # Reset the options value according to screen size
    def config(stdscr):
        curses.curs_set(0)
        stdscr.nodelay(True)
    
        init_colors()
    
        options['count'] = MAX_COLS // 2
        options['speed'] = 1 + curses.LINES // 25
    
    
    def init_colors():
        curses.start_color()
        nonlocal USE_GRADIENT
        USE_GRADIENT = curses.can_change_color()  # use xterm-256 if this is false
    
        if USE_GRADIENT:
            curses.init_color(curses.COLOR_WHITE, 1000, 1000, 1000)
            curses.init_color(curses.COLOR_BLACK, 0, 0, 0)  # make sure background is black
            for i in range(NUMBER_OF_COLOR + 1):
                green_value = (1000 - COLOR_STEP * NUMBER_OF_COLOR) + COLOR_STEP * i
                curses.init_color(START_COLOR_NUM + i, 0, green_value, 0)
                curses.init_pair(START_COLOR_NUM + i, START_COLOR_NUM + i, curses.COLOR_BLACK)
        else:
            curses.init_pair(1, curses.COLOR_GREEN, curses.COLOR_BLACK)
    
    
    def get_matrix_code_chars():
        l = [chr(i) for i in range(0x21, 0x7E)]
        # half-width katakana. See https://en.wikipedia.org/wiki/Halfwidth_and_fullwidth_forms
        l.extend([chr(i) for i in range(0xFF66, 0xFF9D)])
        return l
    
    
    MATRIX_CODE_CHARS = get_matrix_code_chars()
    
    
    def random_char():
        return random.choice(MATRIX_CODE_CHARS)
    
    
    def random_rain_length():
        return random.randint(curses.LINES // 2, curses.LINES)
    
    
    def rain_forever(stdscr, pool):
        """
        Make rain forever by choosing a random column from pool and make rain at that column and repeat
        :param stdscr: curses's screen object
        :param pool: a list of int: a list of available columns to choose randomly from
        :return: None
        """
        while True:
            if pool:
                x = random.choice(pool)
                pool.remove(x)
            else:
                break
    
            # We want most of the rain start from 0, but some starts randomly
            begin = random.randint(-curses.LINES // 2, curses.LINES // 3)
            if begin < 0:
                begin = 0
    
            # We want most of the rain end at the bottom but some randomly end before reaching the bottom
            end = random.randint(curses.LINES // 2, 2 * curses.LINES)
            if end > curses.LINES:
                end = curses.LINES
    
            should_stop = yield from rain_once(stdscr, x, begin, end)
    
            if should_stop:
                break
            else:
                pool.append(x)
    
    
    def rain_once(stdscr, x, begin, end, last_char=None):
        """
        Make rain once at column x from line begin to line end
        :param stdscr: curses's screen object
        :param x: the column of this rain on the screen
        :param begin: the line to begin
        :param end: the line to end
        :param last_char: the last character to show
        :return: the value received from yield
        """
        max_length = random_rain_length()
        speed = random.randint(1, options['speed'])
        r = yield from animate_rain(stdscr, x, begin, end, max_length, speed, last_char)
        return r
    
    
    def animate_rain(stdscr, x, begin, end, max_length, speed=FALLING_SPEED, last_char=None):
        """
        A rain consists of 3 parts: head, body, and tail
        Head: the white leading rain drop
        Body: the fading trail
        Tail: empty space behind the rain trail
        :param stdscr: curses's screen object
        :param x: the column of this rain on the screen
        :param begin: the line to begin
        :param end: the line to end
        :param max_length: the length of this rain
        :param speed: how fast a rain should fall (the number of lines it jumps each animation frame)
        :param last_char: the last character to show
        :return: the value received from yield
        """
        head, tail = begin, begin
    
        head_style = options['head']
    
        def show_head():
            if head < end:
                stdscr.addstr(head, x, random_char(), head_style)
    
        def get_color(i):
            color_num = NUMBER_OF_COLOR - (head - i) + 1
            if color_num < 0:
                color_num = 0
            return curses.color_pair(START_COLOR_NUM + color_num)
    
        def show_body():
            if USE_GRADIENT:
                for i in range(tail, min(head, end)):
                    stdscr.addstr(i, x, random_char(), get_color(i))
            else:
                middle = head - max_length // 2
                if (middle < begin):
                    middle = begin
                for i in range(tail, min(middle, end)):
                    stdscr.addstr(i, x, random_char(), curses.color_pair(1))
                for i in range(middle, min(head, end)):
                    stdscr.addstr(i, x, random_char(), curses.color_pair(1) | curses.A_BOLD)
    
        def show_tail():
            for i in range(max(begin, tail - speed), min(tail, end)):
                stdscr.addstr(i, x, ' ', curses.color_pair(0))
    
        while tail < end:
            tail = head - max_length
            if tail < begin:
                tail = begin
            else:
                show_tail()
    
            show_body()
    
            show_head()
    
            head = head + speed
            r = yield
    
        if last_char:
            stdscr.addstr(end - 1, x, last_char, curses.color_pair(0))
    
        return r
    
    
    def update_style():
        """
        Cycle thru different styles
        :return: None
        """
        count = 0
    
        for i, s in enumerate(title):  # for each visible letter, add a rain drop
            col = x + i
            if col >= MAX_COLS:
                break
            pool.remove(col)
            if s != ' ':  # space is not visible
                rains.append(rain_once(stdscr, col, 0, y, s))
                count = count + 1
    
        for i in range(len(pool) // 3):
            rains.append(rain_forever(stdscr, pool))
    
        stdscr.clear()
        should_stop = None
        while True:
            for r in rains:
                try:
                    r.send(should_stop)
                except StopIteration:
                    rains.remove(r)
                    count = count - 1
    
            if count == 0:  # finish the title, wait for others to finish then exit
                should_stop = True
    
            ch = stdscr.getch()
            if ch != curses.ERR and ch != ord(' '):  # Use space to proceed animation if nodelay is False
                break  # exit
    
            if not rains:  # all the rains have stopped
                break
    
            time.sleep(SLEEP_BETWEEN_FRAME)
    
    
    def main(stdscr):
        # Do not use the last column due to curses limit
        # See https://docs.python.org/3/library/curses.html#curses.window.addstr
        #   Attempting to write to the lower right corner will cause an exception to be raised
        nonlocal MAX_COLS
        MAX_COLS = curses.COLS - 1
    
        stdscr.addstr(0, 0, "Press any key to start. Press any key (except SPACE) to stop.")
        stdscr.addstr(1, 0, "Press key 'h' to try a different style.")
        stdscr.addstr(curses.LINES // 3, MAX_COLS // 4, options["opening_title"])
        ch = stdscr.getch()  # Wait for user to press something before starting
        config(stdscr)
    
        rains = []
        pool = list(range(MAX_COLS))
    
        while True:
            add_rain(rains, stdscr, pool)
    
            for r in rains:
                next(r)
    
            ch = stdscr.getch()
            if ch != curses.ERR and ch != ord(' '):  # Use space to proceed animation if nodelay is False
                if ch == ord('h'):
                    update_style()
                else:
                    show_title(stdscr, curses.LINES // 2, MAX_COLS // 3, options["end_title"])
                    break  # exit
    
            time.sleep(SLEEP_BETWEEN_FRAME)
    
    
    def add_rain(rains, stdscr, pool):
        if (len(rains) < options['count']) and (len(pool) > 0):
            rains.append(rain_forever(stdscr, pool))
    
    
    curses.wrapper(main)
        
def _string_diff_helper(before:str, after:str, command:str):
    """
    Helper function for string diff operations
    Sets up git repo and executes the provided diff command
    """

    if is_utf8_file(before) and is_utf8_file(after):
        before=load_text_file(before)
        after=load_text_file(after)

    import os, subprocess
    
    original_dir=get_current_directory()
    temp_dir=temporary_file_path()
    make_directory(temp_dir)
    
    file_name='temp.py'
    commit_message='changed '+file_name
    
    try:
        set_current_directory(temp_dir)
        os.system('git init  > /dev/null 2>&1')
        string_to_text_file(file_name,before)
        os.system('git add '+shlex.quote(file_name)+'  > /dev/null 2>&1')
        os.system('git commit -am '+shlex.quote(commit_message)+' > /dev/null 2>&1')
        string_to_text_file(file_name,after)
        os.system(command)
    finally:
        set_current_directory(original_dir)
        delete_directory(temp_dir)

def dunk_string_diff(before:str,after:str):
    """
    this function asssumes you have git installed
    lets you view the diff between two strings interactively
    uses dunk: https://github.com/darrenburns/dunk
    """
    pip_import('dunk')
    _string_diff_helper(before, after, 'git diff | dunk')

def view_string_diff(before:str,after:str):
    """
    this function asssumes you have git installed
    lets you view the diff between two strings interactively
    todo: let you accept/reject changes between the diffs and return the result as a string
    """
    pip_import('ydiff')
    _string_diff_helper(before, after, 'ydiff -s temp.py')

def vim_string_diff(before:str,after:str):
    """
    Requires the program 'vimdiff'
    Returns the modified 'before' as a string
    
    How to use vimdiff:
        https://stackoverflow.com/questions/27832630/merge-changes-using-vimdiff
        Summary:
            do    and    dp   are diff other and diff put respectively
            ]c    and    [c   are goto next diff and prev diff respectively
            ^w^w              switches windows
            :wqa              save when you're done

    Enhanced Documentation:
    
    Interactive string diff editor using Vim's vimdiff. Allows manual merging
    of changes between two string versions with Vim's powerful diff interface.
    
    Parameters:
        before (str): Original string content
        after (str): Modified string content to compare against
    
    Returns:
        str: The manually edited result after vimdiff session
    
    Usage Examples:
        >>> # Edit differences between code versions
        >>> old_code = "def hello():\\n    print('hi')"
        >>> new_code = "def hello():\\n    print('hello world')"
        >>> result = vim_string_diff(old_code, new_code)
        >>> # Opens vimdiff for manual editing
        
        >>> # Merge conflicted file contents
        >>> original = load_text_file("original.txt")
        >>> modified = load_text_file("modified.txt")  
        >>> merged = vim_string_diff(original, modified)
        >>> save_text_file("merged.txt", merged)
    
    Vimdiff Commands:
        - ]c / [c: Jump to next/previous difference
        - do: Diff obtain (pull change from other window)
        - dp: Diff put (push change to other window)  
        - Ctrl+w Ctrl+w: Switch between windows
        - :wqa: Save and quit all windows
    
    Related Functions:
        - view_string_diff(): Non-interactive diff viewing
        - dunk_string_diff(): Colored diff display
        - _string_diff_helper(): Internal diff setup utility
    
    Notes:
        - Requires 'vimdiff' command to be available in PATH
        - Creates temporary git repository for diff context
        - Preserves manual edits made in vimdiff session
        - Returns to original directory after completion
    
    Tags: diff, vim, vimdiff, interactive, merge, strings, editing
    
    Interactively diffs between two strings
    Returns the result of the 'before' string after changes have been made
        (In other words, it returns the file you see on the left of the split in the vimdiff)
    https://vi.stackexchange.com/questions/625/how-do-i-use-vim-as-a-diff-tool
    """
    
    import os
    original_dir=get_current_directory()
    temp_dir=temporary_file_path()
    make_directory(temp_dir)
    
    try:
        set_current_directory(temp_dir)
        string_to_text_file('before.py',before)
        string_to_text_file('after.py' ,after )
        
        vim_extras=[
            'colorscheme slate',   #https://stackoverflow.com/questions/2019281/load-different-colorscheme-when-using-vimdiff
            'nnoremap q :wqa<cr>', #Let us save and exit just by pressing q
            #":echo \\\"Press 'q' to exit when done. Use 'do' and 'dp' to get and put hunks.\\\"", #Print instructions
        ]
        
        #https://stackoverflow.com/questions/12834370/how-to-run-a-vim-command-from-the-shell-command-line
        vim_extras=['+"'+x+'"' for x in vim_extras]
        vim_extras=' '.join(vim_extras)

        os.system('vimdiff %s before.py after.py'%vim_extras)
        #We use the slate colorscheme because it looks less ugly than the default theme in vimdiff...though not that much better
        return text_file_to_string('before.py')
    finally:
        set_current_directory(original_dir)
        delete_directory(temp_dir)

#This comment used to be in vim_string_diff, idk why...
#CONTROLS:
#    def vim_string_diff(before:str,after:str):
#    pip_import('ydiff')
#    import os,ydiff
#    
#    original_dir=get_current_directory()
#    temp_dir=temporary_file_path()
#    make_directory(temp_dir)
#    
#    try:
#        set_current_directory(temp_dir)
#        string_to_text_file('before.py',before)
#        string_to_text_file('after.py' ,after )
#        os.system('vimdiff before.py after.py')
#    finally:
#        set_current_directory(original_dir)
#        delete_directory(temp_dir)

def vim_paste():
    """
    Gets the string in the 0th register of vim and returns it
    Looking for a line like
        |3,1,0,1,7,0,1613593399,"Line1","Line2","Line3","Line4","Line5","Line6","Line7"
    TODO: Make this code cleaner
    """
    def is_valid_int(string):
        try:
            int(string)
            return True
        except Exception:
            return False
    def is_valid_line(line):
        original_line=line
        line=line.strip()
        if not line.startswith('|'):return False
        line=line[len('|'):]
        line=line.split(',')
        if not all(map(is_valid_int,line[:7])):return False
        #try:
            #get_lines(original_line) #This function includes some useful assertions
        #except AssertionError as e:
             #print_stack_trace(e)
             #print(e)
            #fansi_print('OIAJSOIDJOISDJ','red')
            #return False
        return True
    def get_lines(line):
        line=line.strip()
        line=line.split(',')
        line=line[7:]
        #stated_number_of_lines=int(line[4])
        #lines=[line[1:-1] for line in line] #"aosijd" --> aosijd
        line=','.join(line)+','
        import ast
        line=ast.literal_eval(line)
        lines=line
        #print(stated_number_of_lines)
        #assert len(lines)==stated_number_of_lines
        return lines
    def get_timestamp(line):
        #When using vim's put, it grabs the line in viminfo that has the latest timestamp
        try:
            line=line.split(',')
            timestamp=line[6]
            timestamp=int(timestamp)
            return timestamp
        except Exception:
            return 0 #By default, we'll return the worst timestamp in-case a line was formatted improperly
    viminfo=text_file_to_string('~/.viminfo')
    lines=viminfo.splitlines()
    prefix='|3,1,0,1'
    lines=[line for line in lines if line.startswith(prefix)]
    # print(lines)

    lines=[line for line in lines if is_valid_line(line)    ]
    # print(lines)
    lines=sorted(lines,key=get_timestamp,reverse=True)
    if not len(lines):
        return '' #If there is no current register 0 in ~/.viminfo, just return an empty string
    first_index=viminfo.splitlines().index(lines[0])
    vimlines=viminfo.splitlines()
    recorded_lines=[vimlines[first_index]]
    index=first_index+1
    while vimlines[index].startswith('|<'):
        recorded_lines.append(vimlines[index])
        index+=1
    def strip_braces(line):
        if line.startswith('|<'):
            line=line[2:]
        line=line[::-1]
        if line[0] in '01234567890':
            line=line[line.find('>')+1:]
        line=line[::-1]
        return line

    recorded_lines[:]=[strip_braces(line) for line in recorded_lines[:]]
    #global ans
    #ans=recorded_lines
    line=''.join(recorded_lines)
    # fansi_print(line,'green')

    return line_join(get_lines(line))


def vim_copy(string:str):
    """
    Gets the string in the 0th register of vim and returns it
    Writing a line like
        |3,1,0,1,7,0,1613593399,"Line1","Line2","Line3","Line4","Line5","Line6","Line7"
    """


    viminfo=text_file_to_string('~/.viminfo') if file_exists('~/.viminfo') else ''

    def get_timestamp(line):
        #When using vim's put, it grabs the line in viminfo that has the latest timestamp
        try:
            line=line.split(',')
            timestamp=line[6]
            timestamp=int(timestamp)
            return timestamp
        except Exception:
            return 0 #By default, we'll return the worst timestamp in-case a line was formatted improperly

    max_timestamp=max(get_timestamp(line) for line in viminfo.splitlines()) if viminfo.splitlines().__len__() else 0

    lines=string.splitlines()
    original_lines=string.splitlines()
    from json import dumps # Will be used instead of repr, because repr might use single quotes (we need double quotes). We use it to escape characters such as \ which might be in string
    lines=list(map(dumps,map(str,lines)))

    #new_line=','.join(['|3,1,0,1,%i,0,%i'%(len(lines),max_timestamp+1)]+lines)
    new_line='|3,1,0,1,%i,0,%i'%(len(lines),max_timestamp+1)
    if len(lines):
        #combined_lines=','.join(lines)
        combined_lines_undended='\n|<'.join([line for line in lines])
        combined_lines='\n|<'.join([(line)+(',>'+str(len(lines[i+1]))if (i<len(lines)-1) else'') for i,line in enumerate(lines)])
        #combined_lines='\n|<'.join([dumps(line)+',>'+str(len(line)+2) for index,line in enumerate(original_lines)])
        combined_lines=line_split(combined_lines)
        combined_lines_undended=line_split(combined_lines_undended)
        combined_lines[-1]=combined_lines_undended[-1]
        combined_lines=line_join(combined_lines)
        new_line+=','+combined_lines


    string_to_text_file('~/.viminfo',new_line+'\n'+viminfo)

def zip_folder_to_bytes(folder_path:str, *, show_progress=False):
    """
    Similar to file_to_bytes
    Takes a folder_path, zips it into a .zip file, then returns the bytes of that zip file
    """
    assert path_exists(folder_path),'zip_folder_to_bytes error: Path does not exist: '+str(folder_path)
    assert is_a_folder(folder_path),'zip_folder_to_bytes error: Path exists but is not a folder: '+str(folder_path)
    temp_zip=temporary_file_path('.zip')
    try:
        make_zip_file_from_folder(folder_path,temp_zip,show_progress=show_progress)
        data=file_to_bytes(temp_zip)
    finally:
        if file_exists(temp_zip):
            delete_file(temp_zip)
    return data
    
class _BundledPath:
    """ A class used internally by rp for web_copy_path and web_paste_path """
    def __init__(self,is_file,data,path):
        assert isinstance(data,bytes)
        self.is_file=is_file
        self.is_folder=not is_file
        self.data=data
        self.path=path
    def __repr__(self):
        return '<rp.r._BundledPath: is_file='+str(self.is_file)+', data='+human_readable_file_size(len(self.data))+', path='+repr(self.path)+'>'

class _BundledPaths:
    """ Multiple paths bundled together """
    def __init__(self, paths_data):
        # paths_data is a list of tuples: [(is_file, data, path), ...]
        self.paths_data = paths_data
        self.is_multiple = True

    def __repr__(self):
        total_size = sum(len(data) for _, data, _ in self.paths_data)
        return '<_BundledPaths: {0} items, {1}>'.format(len(self.paths_data), human_readable_file_size(total_size))

def web_paste_path(path=None,*,ask_to_replace=True, show_progress=False):
    """ FP (file paste) """
    data = web_paste(show_progress=show_progress)
    return gather_args_call(_paste_path_from_bundle, data,path=path)

def _paste_path_from_bundle(data,path=None, *,ask_to_replace=True):
    # Handle multiple paths
    if isinstance(data, _BundledPaths):
        return [_paste_path_from_bundle(_BundledPath(is_file, file_data, file_path), path, ask_to_replace=ask_to_replace)
                for is_file, file_data, file_path in data.paths_data]

    # Original single path handling
    try:
        assert isinstance(data,_BundledPath)
    except Exception:
        raise Exception('web_paste_path error: web_paste data was not created via web_copy_path')

    def request_replace(path)->bool:
        return input_yes_no(fansi("Replace "+get_file_name(path)+"?",'yellow'))
    if path is None and data.path=='.' and data.is_folder:
        temp_zip=temporary_file_path('.zip')
        temp_dir=temporary_file_path()
        try:
            bytes_to_file(data.data,temp_zip)
            unzip_to_folder(temp_zip,temp_dir)
            all_paths_here=get_all_paths(include_files=True,include_folders=True,relative=True)
            moved_paths = []
            for new_path in get_all_paths(temp_dir,include_files=True,include_folders=True):
                if get_file_name(new_path) in all_paths_here and ask_to_replace:
                    if not request_replace(new_path):
                        continue
                destination = move_path(new_path,'.')
                moved_paths.append(destination)
            return delist(moved_paths) if moved_paths else None
        finally:
            if file_exists(temp_zip):
                delete_file(temp_zip)
            if folder_exists(temp_dir):
                delete_folder(temp_dir)
    else:
        if path is None:
            path=get_file_name(data.path)
        assert path is not None
        if data.is_file:
            if ask_to_replace and file_exists(path) and not request_replace(path): return
            bytes_to_file(data.data,path)
        elif data.is_folder:
            temp_zip=temporary_file_path('.zip')
            try:
                bytes_to_file(data.data,temp_zip)
                assert not is_a_file(path),'Path already exists as a file; cannot extract a zip file into it: '+path
                unzip_to_folder(temp_zip,path)
            finally:
                if file_exists(temp_zip):
                    delete_file(temp_zip)
    return path

def web_copy_path(path=None, *, show_progress=False):
    """ FC (file copy) """
    web_copy(_copy_paths_to_bundle(path, show_progress=show_progress), show_progress=show_progress)
    return path

def _copy_paths_to_bundle(paths=None, *, show_progress=False):
    """
    Handle copying single path or multiple paths to a bundle.

    Args:
        paths: Can be a single path (str) or multiple paths (list/tuple).
               If None, prompts for path selection.
        show_progress: If True, shows progress when zipping folders.

    Returns:
        _BundledPath for single path, _BundledPaths for multiple paths.

    Note: Despite the plural name, this function handles both single
          and multiple paths - single paths are automatically wrapped
          in a list for processing.
    """
    if paths is None:
        paths = input_select_path(message='Select a file or folder for web_copy_path:')
        paths = get_relative_path(paths)

    if not isinstance(paths, (list, tuple)):
        paths = [paths]

    paths_data = []
    for path in paths:
        if is_a_module(path):
            path = get_module_path(path)
        assert path_exists(path), 'Path does not exist: {0}'.format(path)
        data = file_to_bytes(path) if is_a_file(path) else zip_folder_to_bytes(path, show_progress=show_progress)
        paths_data.append((is_a_file(path), data, path))

    if len(paths_data) == 1:
        is_file, data, path = get_only(paths_data)
        return _BundledPath(is_file, data, path)
    else:
        return _BundledPaths(paths_data)


def get_all_local_ip_addresses():
    """
    Returns a list of all local ip addresses currently in use on your local network
    Code from: https://stackoverflow.com/questions/207234/list-of-ip-addresses-hostnames-from-local-network-in-python
    Can take up to 20 seconds to complete
    EXAMPLE:
         >>> get_all_local_ip_addresses()
         ans = ['192.168.1.1', '192.168.1.21', '192.168.1.33', '192.168.1.32', '192.168.1.53', '192.168.1.105', '192.168.1.122', '192.168.1.136', '192.168.1.171', '192.168.1.190', '192.168.1.205', '192.168.1.235', '192.168.1.237', '192.168.1.175', '192.168.1.228', '192.168.1.249']
    TODO: Condense this function into less lines. It's pretty big...
    """
     
    import socket,multiprocessing,subprocess,os
    
    def pinger(job_q, results_q):
        """
        Do Ping
        :param job_q:
        :param results_q:
        :return:
        """
        DEVNULL = open(os.devnull, 'w')
        while True:
    
            ip = job_q.get()
    
            if ip is None:
                break
    
            try:
                subprocess.check_call(['ping', '-c1', ip],
                                      stdout=DEVNULL)
                results_q.put(ip)
            except Exception:
                pass
    
    
    def get_my_ip():
        """
        Find my IP address
        :return:
        """
        s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
        s.connect(("8.8.8.8", 80))
        ip = s.getsockname()[0]
        s.close()
        return ip
    
    
    def map_network(pool_size=255):
        """
        Maps the network
        :param pool_size: amount of parallel ping processes
        :return: list of valid ip addresses
        """
    
        ip_list = list()
    
        # get my IP and compose a base like 192.168.1.xxx
        ip_parts = get_my_ip().split('.')
        base_ip = ip_parts[0] + '.' + ip_parts[1] + '.' + ip_parts[2] + '.'
    
        # prepare the jobs queue
        jobs = multiprocessing.Queue()
        results = multiprocessing.Queue()
    
        pool = [multiprocessing.Process(target=pinger, args=(jobs, results)) for i in range(pool_size)]
    
        for p in pool:
            p.start()
    
        # cue hte ping processes
        for i in range(1, 255):
            jobs.put(base_ip + '{0}'.format(i))
    
        for p in pool:
            jobs.put(None)
    
        for p in pool:
            p.join()
    
        # collect he results
        while not results.empty():
            ip = results.get()
            ip_list.append(ip)
    
        return ip_list
    
    return map_network()

def ip_to_mac_address(address):
    """
    EXAMPLE:
         >>> [ip_to_mac_address(x) for x in get_all_local_ip_addresses()]
        ans = ['70:4d:7b:e4:c7:b8', '00:11:32:0e:90:6b', '30:5a:3a:7a:e4:a8', 'b0:83:fe:4c:d2:f8', '68:b5:99:a9:c8:a7', '8c:ae:4c:ee:66:28', '00:03:ea:0d:2c:fd', '10:ce:a9:1e:9b:c3', 'b0:a7:37:e6:af:c0']
    """
    if address==get_my_local_ip_address():
        return get_my_mac_address()
    pip_import('getmac')
    import getmac
    return getmac.get_mac_address(ip=address)

def ip_to_host_name(address:str)->str:
    """
    Will attempt to get the name of the host computer with the given IP address
    If no name is returned, this function returns None
    EXAMPLE:
        >>> get_my_local_ip_address()
       ans = 192.168.1.33
        >>> ip_to_host_name(ans)
       ans = glass
    """
    import socket
    try:
        out=socket.gethostbyaddr(address)
        return out[0]
    except socket.herror:
        return None

def get_mac_address_vendor(address:str)->str:
    """
    EXAMPLE:
        >>> get_my_mac_address()
       ans = 30:5a:3a:7a:e4:a8
        >>> get_mac_address_vendor(ans)
       ans = ASUSTek COMPUTER INC.
    """
    pip_import('mac_vendor_lookup')
    from mac_vendor_lookup import MacLookup
    return MacLookup().lookup(address)

# try:
#     # import numpy as np
#     def autoimportable_module(module_name):
#         class LazyloadedModule(type(rp)):
#             def __getattribute__(self,key):
#                 return getattr(pip_import(module_name),key)
#         return LazyloadedModule(module_name)
#     # np=autoimportable_module('numpy')
#     # icecream=autoimportable_module('icecream')
# except:
#     print("Warning: Cannot import numpy. Please excuse any 'np is None' errors, or try rp.pip_install('numpy')")

def PynputCasette(actions=None):
    """
    Records mouse and keyboard actions, and allows you to play them back. 

    Please see a youtube tutorial I made for this class: 
        https://www.youtube.com/watch?v=9x6uwhg2dpc
    """
    pip_import("pynput")
    from rp.libs.pynput_recorder import PynputCasette

    if actions:
        return PynputCasette(actions)
    else:
        return PynputCasette().record()

def find_and_replace_text_files(query, replacement, paths=".", interactive=False):
    """

    Search and Replace text in files
    This function searches for all text files within the given paths and replaces all instances of the query string with the replacement string.
    It has two modes of operation: non-interactive and interactive. In non-interactive mode, the function will automatically replace all instances of the query string with the replacement string in all text files within the given paths. In interactive mode, the function will prompt the user before making changes to each file, allowing them to preview the changes, skip the file, or make changes to all remaining files.

    Parameters:
    query (str): The string to search for
    replacement (str): The string to replace the query with
    paths (str or list of str): The file or folder path(s) to search for text files. If a folder is provided, all text files within that folder and its subfolders will be processed recursively.
    interactive (bool): Whether to prompt the user before making changes

    Returns:
    receipts (dict): A dictionary containing the original text of all the files that were modified"""

    # Input assertions
    if not isinstance(query, str):
        raise TypeError("The 'query' parameter must be of type str, but got type {}".format(type(query)))
    if not isinstance(replacement, str):
        raise TypeError("The 'replacement' parameter must be of type str, but got type {}".format(type(replacement)))
    if not (isinstance(paths, str) or (is_iterable(paths) and all(isinstance(x, str) for x in paths))):
        raise TypeError("The 'paths' parameter must be of type str or a list of str, but got type {}".format(type(paths)))

    #Prepare an iterator for the files we'll take a look at
    if isinstance(paths, str):
        paths=[paths]
    files=[]
    for path in unique(paths):
        if path in files:
            return
        if is_a_folder(path):
            files += get_all_files(path, recursive=True, relative=False)
    text_files = list(unique(x for x in files if is_utf8_file(x)))
    
    def old_and_new_text(file):
        old_text = text_file_to_string(file)
        new_text = old_text.replace(query, replacement)
        return old_text, new_text
    
    def file_will_change(file):
        old_text, new_text = old_and_new_text(file)
        return old_text != new_text

    #Keep track of what we changed in case the user wants to undo them later
    #These are returned at the end of the function
    #It maps file paths to the old text (aka before this function changed them)
    receipts = {} 
    
    try:
        #Run this function silently
        if not interactive:
            for file in files:
                try:
                    old_text, new_text = old_and_new_text(file)
                    if old_text != new_text:
                        #We change it without notifying the user, because this is not interactive
                        string_to_text_file(file, new_text)
                        receipts[file] = old_text
                except Exception:
                    raise
            
        #Do it interactively    
        else:
            assert interactive

            files = [file for file in files if file_will_change(file)]
            yes_to_all = False
            index = 0
            options = ["y", "n", "p", "a", "c"]
            instructions = "Instructions: Type y (yes), n (no), p (preview), a (yes to all), or c (cancel)"
            indent='  '

            def get_progress_text():
                return "%i/%i" % (index + 1, len(files))

            def input_option():
                try:
                    return input_conditional(
                        question=get_progress_text() + " Modify %s?" % repr(files[index]),
                        condition=lambda x: x in options,
                        on_fail=lambda x: print(instructions),
                    )
                except (KeyboardInterrupt,EOFError):
                    # Pressing ctrl+c or ctrl+d cancels this function
                    print('Received KeyboardInterrupt or EOFError - assuming this means you want to cancel')
                    return 'c'
            
            print("Running rp.find_and_replace_text_files interactively with")
            print(indent + "query = %s" % repr(query))
            print(indent + "replacement = %s" % repr(replacement))
            print(instructions)
            print("Files to go through:")
            if files:
                print(
                    line_join(
                        indent + "%i) %s" % (i + 1, repr(file)) for i, file in enumerate(files)
                    )
                )
            else:
                print(indent + "(couldn't find the query in any of the searched files)")
                return receipts

            while True:
                
                if index >= len(files):
                    break
                
                file = files[index]
                old_text, new_text = old_and_new_text(file)
                
                if not yes_to_all:
                    option = input_option()
                    
                    if option == "a" and input_yes_no("Are you sure you want to modify this and the rest of the files automatically?"):
                            yes_to_all=True
                        
                if yes_to_all or option == "y":
                    string_to_text_file(file, new_text)
                    print(get_progress_text() + " Modified %s" % repr(file))
                    receipts[file] = old_text
                    index += 1
                    
                elif option == "p":
                    view_string_diff(old_text, new_text)
                    
                elif option == "n":
                    print(get_progress_text() + " Skipping %s" % repr(file))
                    index += 1
                    
                elif option == "c":
                    if input_yes_no(" Cancelling...should we revert the %s files we changed?" % progress_text):
                        for file, text in receipts.items():
                            string_to_text_file(file, text)
                            print("Reverted " + repr(file))
                    return
                
    finally:
        return receipts

def _fart(files='.'):
    #For pterm
    #fart is stanky
    fansi_print('FART: Find and Replace Text','blue','bold')
    query      =input(fansi('Before Text: ','blue','bold'))
    replacement=input(fansi('After  Text: ','blue','bold'))
    return find_and_replace_text_files(query,replacement,files,interactive=True)


def import_all_submodules(module,*,recursive=True,strict=False,verbose=False):
    """
    Useful when you're searching for some keyword in a library, but not every submodule has been imported
    Background: Modules sometimes don't import everything all at once. When you import PIL, for example, PIL.Image doesn't exist until you use 'import PIL.image'
       This makes it impossible to search for a function, such as 'imsave' using autocompletion or rp's rinsp_search (aka the ?. operator).
    If recursive is True, it will import all of the submodule's submodules etc
    If strict is True, it will throw an error if any of the modules fail to import properly
    If verbose is True, it will print out each module as it's imported (or failed to import)
    The 'module' parameter can either be a string, or a python module
    
    Enhanced Documentation:
    
    Parameters:
        module: Module object or string name of module to import submodules from
        recursive (bool, default=True): If True, recursively imports submodules of submodules
        strict (bool, default=False): If True, raises error if any submodule fails to import
        verbose (bool, default=False): If True, prints each module as it's imported
    
    Returns:
        None (imports modules into global namespace)
    
    Raises:
        AssertionError: If module parameter is not a string or module object
        ModuleNotFoundError: If strict=True and any submodule fails to import
    
    Usage Patterns:
        - Enable full library search: import_all_submodules('sklearn')
        - Debug import process: import_all_submodules('package', verbose=True)
        - Shallow import only: import_all_submodules('package', recursive=False)
        - Fail on import errors: import_all_submodules('package', strict=True)
    
    Related Functions:
        - rinsp(): Search function that benefits from this module loading
        - module_exists(): Check if module exists before importing
        - get_all_submodule_names(): Get list of submodule names
        - try_import(): Safely import a module
    
    EXAMPLE: import_all_submodules('sklearn',verbose=True)
    EXAMPLE: import_all_submodules(os, recursive=False)
    """
        
    assert is_a_module(module) or isinstance(module,str),'import_all_submodules: the "module" parameter should be either a string or a module, but got type '+repr(type(module))
    if isinstance(module,str):
        assert module_exists(module),'Module doesn\'t exist: '+repr(module)
        
    seen_modules=set()
    
    @memoized
    def try_import(module_name):
        try:
            from importlib import import_module
            output = import_module(module_name)
            seen_modules.add(output)
            if verbose:
                module_info=module_name.ljust(20)+'\t    '+fansi(get_module_path(output),'green')
                fansi_print('Imported: '+module_info,'green','bold')
            return output
        except ModuleNotFoundError:
            #If get_all_submodule_names returned a module that can't be imported, just ignore it
            pass
        except KeyboardInterrupt:
            #Let us cancel this via a keyboard interrupt, but don't let mischevious modules that call sys.exit() hinder this function
            raise
        except BaseException:
            if strict:
                #When strict is True, we want to make sure that none of the modules throw exceptions upon importing them
                raise
        if verbose:
            fansi_print('Not imported: '+module_name,'red','bold')
        
    if isinstance(module,str):
        module=__import__(module)

    def helper(module):
            
        if module in seen_modules or module is None:
            return
        
        for submodule_name in get_all_submodule_names(module):
            submodule=try_import(submodule_name)
            if recursive:
                helper(submodule)
    
    helper(module)
    return seen_modules-{None}
        
def dns_lookup(url:str)->str:
    """
    Takes a url, and returns a string with the ip that's found
    EXAMPLE:
         >> dns_lookup('google.com')
        ans = 172.217.3.110
    """
    assert connected_to_internet(),'Cannot use dns_lookup because we are not connected to the internet'
    import socket
    return socket.gethostbyname(url)

class _MinFileSizeHeap:
    """
    Push file paths to this
    When you pop from it, the smallest files get popped first
    TODO: Use threading and integrate this with _fzf_multi_grep so that it doesn't get hung on large files as often
    This class has been tested and it does work
    """
    def __init__(self):
        self.heap=[]
    def push(self,file):
        import heapq
        assert path_exists(file)
        size=get_file_size(file,human_readable=False)
        heapq.heappush(self.heap,(size,file))
    def pop(self):
        import heapq
        return heapq.heappop(self.heap)[1]

def _fdt_for_command_line():
    try:
        output=_fzf_multi_grep(print_instructions=False)
        if output is None:
            output=[]
        if isinstance(output,str):
            output=[output]
        print(line_join(output))
    except:
        pass


def _fzf_multi_grep(extensions='',print_instructions=True,text_files=None,single_line=False):
    pip_import('iterfzf')

    heap=_MinFileSizeHeap()

    zero_width_space='\u200b' #Prevents file names from being included in the search

    extensions = extensions.split()

    if fansi_is_enabled():
        #This is an arbitrary choice...but sometimes its nice to be able to search file names too.
        #Even can search line numbers...roughly...
        #If this is annoying, you can always use FANSI OFF...
        #...there might be a better way to do this nicely but eh its nice to have more options
        zero_width_space=''

    import iterfzf
    
    def files_walk(root='.'):
        yield from breadth_first_path_iterator(root)
        return

        import os
        for folder,subfolders,files in os.walk(root):
            files=[path_join(folder,file) for file in files]
            yield from files
            
    def load_text(file):
        try:
            return text_file_to_string(file,use_cache=False)
        except Exception:
            return ''
        
    def load_annotated_lines(file):
        text=load_text(file)
        lines=line_split(text)
        num_digits=len(str(len(lines)))
        title=lambda line_number:zero_width_space.join(file+':%s: '%(str(line_number).zfill(num_digits)))
        lines=[title(line_number+1)+line for line_number,line in enumerate(lines)]
        lines=lines[::-1] #For FZF, which pputs the first things on the bottom
        return lines

    def should_read(path):
        if extensions and not ends_with_any(path, extensions):
            return False
        if not is_utf8_file(path):
            return False
        return True
        
    if text_files is None:
        def text_files_walk():
            root='.'
            return map(
                get_relative_path,
                filter(
                    should_read,
                    files_walk(root),
                ),
            )
    else:
        text_files_walk = lambda: text_files
        
    def text_lines_walk():
        import multiprocessing.pool,itertools

        def mute():
            # https://stackoverflow.com/questions/30829924/suppress-output-in-multiprocessing-process
            sys.stderr = open(os.devnull, 'w')

        pool=multiprocessing.pool.ThreadPool(initializer=mute)
        out=pool.imap_unordered(load_annotated_lines,text_files_walk())
        out=itertools.chain.from_iterable(out)
        return out
    
    if print_instructions:
        fansi_print('Press tab or shift tab to select deselect multiple lines','blue')
    
    import json
    if fansi_is_enabled():
        #CODE DUPLICATED FROM QPHP
        preview_width=get_terminal_width()//2-2-2

        output=_iterfzf(text_lines_walk(),multi=True,exact=True,preview='echo {} | %s %s %i FDT '%(
            json.dumps(sys.executable),
            json.dumps(get_module_path("rp.libs.stdin_python_highlighter")),
            preview_width),
        ) #
    else:
        output=_iterfzf(text_lines_walk(),case_sensitive=False,exact=True,multi=True)


    if output is not None:
        output=sorted({line[:line.find(':')].replace(zero_width_space,'') for line in output})
        if len(output)==1:
            return output[0]
    
    return output
    



def unwarped_perspective_image(image, from_points, to_points=None, height:int=None, width:int=None):
    """
    Takes an image, and two corresponding lists of four points, and returns an unwarped image
    If you don't specify the to_points, it will simply unwarp the source quadrangle to the resolution of the input image
    If you specify to_points, it might be useful in-case you want to adjust the transform etc
    Height and width can be manually specified as well, in case you want to capture parts of the perspectie transform that might have been cropped out
    When to_points is not specified, we assume that the from_points start from the top left of the desired area, and progress clockwise

    Works on byte images and floating point images of float64 or float32
    Works on RGB, RGBA and grayscale images natively

    The sister function is rp.unwarped_perspective_contour

    If to_points is not specified, from_points should start from the top left and go clockwise to the other 3 points

    from_points and/or to_points are specified as either:
        - a points array: i.e a list or numpy array in the form [[x0,y0],[x1,y1],[x2,y2],[x3,y3]]
        - a complex vector, i.e like: [0+0j, 1+0j, 1+1j, 0+1j]
        - a cv_countour as defined by rp.as_cv_contour

    
    EXAMPLE:
        while True:
            #Place the apriltags clockwise from 0 at the the topleft on your target area then run this program and look at them through your webcam
            image=load_image_from_webcam()
            tags={}
            for tag in detect_apriltags(image):
                tags[tag.id_number]=tag
                
            print("Detected apriltags:",sorted(tags))
            
            corners=[]
            for id_number in [0,1,2,3]:
                if id_number in tags:
                    corners.append(tags[id_number].center)
            print(corners)
            
            if len(corners)>=4:
                display_clear()
                display_path(corners)
                image=unwarped_perspective_image(image,corners)
            
            display_image(image)    
    """

    pip_import('cv2')
    import cv2
    
    #Not necessary - it can work on flaying point images, float32 and float64 - on both grayscale matrices, RGB and RGBA images
    # image=as_rgb_image (image)
    # image=as_byte_image(image)
    if is_binary_image(image):
        image = as_byte_image(image)
    
    if width  is None:width =get_image_width (image)
    if height is None:height=get_image_height(image)
    
    if to_points is None:to_points=[[0,0],[width,0],[width,height],[0,height]]
    from_points=as_points_array(from_points).astype(np.float32)
    to_points  =as_points_array(to_points  ).astype(np.float32)
    assert len(from_points)==4,'unwarped_perspective_image needs four from_points, but got '+str(len(from_points))
    assert len(to_points  )==4,'unwarped_perspective_image needs four to_points, but got '  +str(len(to_points  ))

    #SOURCE: https://stackoverflow.com/questions/22656698/perspective-correction-in-opencv-using-python
    # use cv2.getPerspectiveTransform() to get M, the transform matrix, and Minv, the inverse
    M = cv2.getPerspectiveTransform(from_points, to_points)
    # use cv2.warpPerspective() to warp your image to a top-down view
    warped = cv2.warpPerspective(image, M, (width, height), flags=cv2.INTER_LINEAR)
    return warped

def unwarped_perspective_contour(contour, from_points, to_points=None, *, height:int=None, width:int=None):
    """
    Transform contour points using perspective transformation.
    The sister function is rp.unwarped_perspective_image
    
    Args:
        contour: Points to transform (points array, complex vector, or cv_contour).
        from_points: Source quadrangle points (clockwise from top-left).
        to_points: Target quadrangle points. If None, uses default rectangle.
        height: Output height for default to_points calculation.
        width: Output width for default to_points calculation.
    
    Notes:
        - Points can be specified as arrays [[x0,y0],[x1,y1],...], 
          complex vectors [0+0j, 1+0j,...], or cv_contours.
        - Either to_points XOR both height/width must be provided, but not both!
    
    Returns:
        numpy.ndarray: Transformed contour as points array [[x0,y0],[x1,y1],...].
        
    EXAMPLE:
        
        >>> image = rp.load_image(
        ...     "https://github.com/RyannDaGreat/Diffusion-Illusions/blob/gh-pages/images/emma.png?raw=true",
        ...     use_cache=True,
        ... )
        ... height, width = get_image_dimensions(image)
        ... 
        ... dot_points = [[50, 50], [240, 200], [100, 200], [100, 233]]
        ... warp_quad = [[10, 10], [300, 0], [350, 400], [0, 250]]
        ... 
        ... dots_x, dots_y = list(zip(*dot_points))
        ... rp.seed_all(123)
        ... colors = rp.as_rgba_float_colors("randomhue" for _ in dot_points)
        ... 
        ... dotted_image = rp.cv_draw_circles(image, dots_x, dots_y, color=colors, rim=3, rim_color='white')
        ... 
        ... unwarped_image = rp.unwarped_perspective_image(image, warp_quad)
        ... unwarped_dotted_image = rp.unwarped_perspective_image(dotted_image, warp_quad)
        ... quad_image = rp.cv_draw_contour(dotted_image, warp_quad)
        ... 
        ... warped_points = unwarped_perspective_contour(dot_points, warp_quad, height=height, width=width)
        ... warped_x, warped_y = list(zip(*warped_points))
        ... warped_dots_image = rp.cv_draw_circles(unwarped_image, warped_x, warped_y, color=colors, rim=3, rim_color='white')
        ... 
        ... blended_image = blend_images(warped_dots_image, unwarped_dotted_image, .5)
        ... 
        ... rp.display_image(
        ...     rp.horizontally_concatenated_images(
        ...         quad_image,
        ...         unwarped_dotted_image,
        ...         warped_dots_image,
        ...         blended_image,
        ...     )
        ... )
        
    """
    pip_import('cv2')
    import cv2
    
    if to_points is None:
        assert height is not None and width is not None, 'unwarped_perspective_contour: You must specify either to_points or both width and height, but all three were None'
        to_points=[[0,0],[width,0],[width,height],[0,height]]
    else:
        assert height is None and width is None, 'unwarped_perspective_contour: Both to_points AND height/width were specified - please only give to_points or height/width'
    
    from_points=as_points_array(from_points).astype(np.float32)
    to_points  =as_points_array(to_points  ).astype(np.float32)
    contour_points = as_points_array(contour).astype(np.float32)
    
    assert len(from_points)==4,'unwarped_perspective_contour needs four from_points, but got '+str(len(from_points))
    assert len(to_points  )==4,'unwarped_perspective_contour needs four to_points, but got '  +str(len(to_points  ))
    
    # Get the perspective transformation matrix
    M = cv2.getPerspectiveTransform(from_points, to_points)
    
    # Transform the contour points
    # Reshape points for cv2.perspectiveTransform (needs shape [N, 1, 2])
    contour_reshaped = contour_points.reshape(-1, 1, 2)
    transformed_points = cv2.perspectiveTransform(contour_reshaped, M)
    
    # Reshape back to original format [N, 2]
    transformed_points = transformed_points.reshape(-1, 2)
    
    return transformed_points

def _pip_import_depth_pro(autoyes=False):

    # Needed the first time it installs
    ml_depth_pro_repo_path = path_join(rp.r._rp_downloads_folder, "ml-depth-pro", "src")
    if ml_depth_pro_repo_path not in sys.path:
        sys.path.append(ml_depth_pro_repo_path)

    try:
        import depth_pro

        return depth_pro
    except ImportError:
        if (
            autoyes
            or rp.r._pip_import_autoyes
            or input_yes_no("Would you like to install ml_depth_pro?")
        ):
            with SetCurrentDirectoryTemporarily(make_directory(rp.r._rp_downloads_folder)):
                if folder_exists('ml-depth-pro'):
                    fansi_print("Failed to import depth_pro: You might have to delete the repo at "+ml_depth_pro_repo_path+" and try this function again if its corrupted.",'red','bold')

                cloned_ml_depth_pro_repo_path = git_clone(
                    "https://github.com/apple/ml-depth-pro"
                )

                assert ml_depth_pro_repo_path == cloned_ml_depth_pro_repo_path

                with SetCurrentDirectoryTemporarily(ml_depth_pro_repo_path):
                    # Download models and install package
                    command = (
                        "bash get_pretrained_models.sh && "
                        + shlex.quote(sys.executable)
                        + " -m pip install -e ."
                    )
                    _run_sys_command(command)

                    fansi_print("RUNNING COMMAND: " + command, "cyan", "bold")

            import depth_pro

            return depth_pro
        else:
            raise


@memoized
def _get_depth_pro_model(device=None):
    #TODO: Make this work even if depth_pro is already installed elsewhere! Like with Wendy!
    _pip_import_depth_pro()
    pip_import("PIL")

    from PIL import Image
    import depth_pro

    depth_pro_repo_path = path_join(rp.r._rp_downloads_folder, "ml-depth-pro") #IF THIS DOESNT EXIST: TODO, DOWNLAOD MODELS HERE IT EVEN IF WE DONT PIP INSTALL 
    with SetCurrentDirectoryTemporarily(depth_pro_repo_path):
        # This has to be done in the install directory...
        rp.fansi_print(
            "rp: Loading ml_depth_pro model to device=%s..." % repr(device),
            "green",
            "bold",
            new_line=False,
        )
        model, transform = depth_pro.create_model_and_transforms()

    # Load model and preprocessing transform
    model.eval()

    if device is not None:
        model = model.to(device)

    rp.fansi_print("done!", "green", "bold", new_line=True)

    return model, transform


def run_depth_pro(image, *, focal_length=None, device=None):
    """
    Estimate depth from a single RGB image using the DepthPro model.

    DepthPro is Apple's monocular depth estimation model released in late 2024.
    It can run on macOS and Linux. Windows compatibility is untested.

    Args:
        image (str, PIL.Image, torch.Tensor): Input RGB image. Can be a file path, URL, HWC numpy array,
            PIL Image, or CHW PyTorch tensor. If a PyTorch tensor is provided, the `device` 
            argument will default to the tensor's device if not explicitly specified.
        focal_length (float, optional): Focal length of the camera in pixels. If not 
            provided, the model will estimate the focal length.
        device (str, optional): PyTorch device to run the model on. Defaults to None,
            which will use the CPU. If `image` is a PyTorch tensor and `device` is 
            not specified, the tensor's device will be used.

    Returns:
        easydict.EasyDict: A dictionary containing:
            - depth (numpy.ndarray): Estimated depth map in pixels.
            - focal_length (float): Estimated or provided focal length in pixels.

    """

    pip_import('pillow_heif')
    pip_import('timm')


    model, transform = _get_depth_pro_model(device)

    import depth_pro

    if is_torch_image(image) and device is None:
        device = image.device
        image = as_numpy_image(image)

    if is_valid_url(image):
        image = load_image(image)

    if isinstance(image, str):
        image, _, f_px = depth_pro.load_rgb(image)
    else:
        assert is_image(image)
        # Load and preprocess an image.
        image = as_rgb_image(image)
        image = as_byte_image(image)
        f_px = None

    image = transform(image).to(device)
    prediction = model.infer(image, f_px=f_px)

    depth = prediction["depth"]  # Depth in [m].
    depth = as_numpy_array(depth)

    focal_length = prediction["focallength_px"]  # Focal length in pixels.
    focal_length = float(focal_length)

    return gather_vars("depth focal_length")


@memoized
def _get_cotracker_model(device=None, dtype=None):
    """
    Loads and caches the CoTracker model.
    
    Args:
        device: The torch device to load the model onto. If None, uses current device.
    
    Returns:
        The loaded CoTracker model

    Downloads are cached here: ~/.cache/torch/hub/facebookresearch_co-tracker_main
    """
    import torch

    model_name = 'cotracker3_offline'

    try:
        #Don't use internet if we don't have to! Try to use the torch hub cache so I can transfer it onto offline computers like XCloud
        cache_path = '~/.cache/torch/hub/facebookresearch_co-tracker_main'
        cache_path = rp.get_absolute_path(cache_path)
        cotracker = torch.hub.load(cache_path, model_name, source='local')
    except FileNotFoundError:
        #Download from the internet...
        cotracker = torch.hub.load("facebookresearch/co-tracker", model_name)

    if dtype is not None:
        cotracker = cotracker.to(dtype)
    if device is not None:
        cotracker = cotracker.to(device)

    return cotracker

def run_cotracker(
    video,
    *,
    device=None,
    queries=None,
    segm_mask=None,
    grid_size=0,
    grid_query_frame=0,
    backward_tracking=False
):
    """
    Runs the CoTracker model on a video for object tracking.
    CoTracker3 is a transformer-based model that can track any point in a video,
    with functionality similar to optical flow but for longer sequences.
    
    Args:
        video: Input video as either a file path, numpy array, or torch tensor.
              Should be in format T×H×W×C for numpy array.
        device: Torch device to run inference on, defaulting to video.device else rp.select_torch_device()
        queries: Query points of shape (N, 3) in format (t, x, y) for frame index
                and pixel coordinates. Used for tracking specific points.
        segm_mask: Segmentation mask of shape (H, W). Can be used with grid_size
                  to compute tracks only within the mask.
        grid_size: Size M for an N=M×M grid of tracking points on a frame. Set >0 to use
                  grid-based tracking.
        grid_query_frame: Frame index to start tracking from (default: 0)
        backward_tracking: Reverses the video and tracks points in reverse
    
    Returns:
        tuple: (pred_tracks, pred_visibility) where:
            - pred_tracks has shape (T, N, 2) containing x,y coordinates
            - pred_visibility has shape (T, N) indicating point visibility
    
    Track Modes:
        1. Grid tracking: Set grid_size > 0 to track NxN points from grid_query_frame
        2. Query tracking: Provide queries tensor to track specific points
        3. Dense tracking: Leave queries=None to compute dense tracks from grid_query_frame
    
    EXAMPLE:
        >>> video = load_video(
        ...     "https://github.com/facebookresearch/co-tracker/raw/refs/heads/main/assets/apple.mp4",
        ...     use_cache=True,
        ... )
        ...
        ... #Cotracker
        ... tracks, visibility = run_cotracker(
        ...     video, 
        ...     device="cuda", 
        ...     grid_size=10, # Track 10x10 grid of points
        ... )
        ... 
        ... #Visualization
        ... colors = [random_rgb_float_color() for _ in tracks[0]]
        ... new_video = []
        ... for frame_number in range(len(video)):
        ...     frame = video[frame_number]
        ...     track = tracks[frame_number]
        ...     for color, (x, y) in zip(colors, track):
        ...         frame = cv_draw_circle(
        ...             frame,
        ...             x,
        ...             y,
        ...             radius=5,
        ...             color=color,
        ...             antialias=True,
        ...             copy=False,
        ...         )
        ...     new_video.append(frame)
        ... fansi_print("SAVED " + save_video_mp4(new_video), "blue cyan", "bold")

    """
    import torch

    pip_import('einops')
    from einops import rearrange
    
    if device is None:
        if is_torch_tensor(video):
            device = video.device
        else:
            device = rp.select_torch_device(device, prefer_used=True)

    dtype = torch.float32

    cotracker = _get_cotracker_model(device, dtype)

    if isinstance(video, str):
        video = load_video(video)
        video = as_rgb_images(video)
    
    if not isinstance(video, torch.Tensor):
        video = torch.tensor(video)
        assert video.ndim == 4, video.ndim              #    T, H, W, C
        video = rearrange(video, 'T H W C -> 1 T C H W')# B, T, C, H, W
    elif video.ndim==4:
        video = rearrange(video, 'T C H W -> 1 T C H W')# B, T, C, H, W

    video = video.to(dtype=dtype, device=device)

    if queries is not None:
        if not is_torch_tensor(queries):
            queries = as_numpy_array(queries)
            queries = torch.tensor(queries)

        queries = rearrange(queries, "N TXY -> 1 N TXY") # B N 3

        if segm_mask is not None:
            queries = rearrange(queries, "H W -> 1 1 H W") # B 1 H W
            segm_mask = segm_mask.to(device)

        queries = queries.to(dtype=dtype, device=device)
    
    pred_tracks, pred_visibility = cotracker(
        video,
        queries=queries,
        segm_mask=segm_mask,
        grid_size=grid_size,
        grid_query_frame=grid_query_frame,
        backward_tracking=backward_tracking
    )

    #Batch size is 1
    pred_tracks     = rearrange(pred_tracks    , '1 T N XY -> T N XY')
    pred_visibility = rearrange(pred_visibility, '1 T N    -> T N   ')
    
    return pred_tracks, pred_visibility

def run_tapnet(
    video,
    *,
    device=None,
    queries=None,
    grid_size=None,
    grid_query_frame=0,
    model="tapnext",  # "tapir", "bootstapir", or "tapnext"
    model_dir=None
):
    """
    Runs the TAPNext/TAPIR model on a video for point tracking.
    TAPNext is a transformer-based model that can track any point in a video,
    with state-of-the-art accuracy and speed (as of Jun 2025).

    See https://github.com/google-deepmind/tapnet
    
    Args:
        video: Input video as either a file path string like './video.mp4', glob of image files like '/path/to/*frames.png', 
               list of PIL images, numpy array (T×H×W×3) with either dtype np.uint8 or floating point values between 0 and 1,
               or as a torch tensor (T×3×H×W) with values between 0 and 1.
        device: Torch device to run inference on, defaults to cuda if available
        queries: Query points of shape (N, 3) in format (t, y, x) for frame index
                 and pixel coordinates. Can be numpy array or torch tensor. Used for tracking specific points.
        grid_size: Size M for an N=M×M grid of tracking points on a frame. Must be provided
                   if queries is None, not used if queries are provided.
        grid_query_frame: Frame index(es) to start tracking from. Can be int or iterable 
                          of ints for multi-frame initialization. Only used when grid_size is not None (default: 0)
                          Not used if queries is not None.
        model: Which model to use: "tapir", "bootstapir", or "tapnext" (default: "tapnext")
        model_dir: Directory to cache downloaded models (default: "~/.cache/tapnet" from rp.git.tapnet.DEFAULT_MODEL_DIR)
        show_progress: If True, shows a progress bar during calculation.
    
    Returns:
        tuple: (pred_tracks, pred_visibility) where:
            - pred_tracks: numpy array of shape (T, N, 2) containing x,y coordinates
            - pred_visibility: numpy array of shape (T, N) indicating point visibility
    
    Track Modes:
        1. Grid tracking: Set grid_size > 0 to track M×M points from grid_query_frame
        2. Query tracking: Provide queries tensor to track specific points
        3. Dense tracking: Default with grid_size=20 if no queries provided

    Note: As of Jun 18 2025, using the MPS device on Mac yields incorrect results. If on Mac, use CPU.
    
    EXAMPLE:

        >>> video = rp.load_video(
        ...     "https://github.com/facebookresearch/co-tracker/raw/refs/heads/main/assets/apple.mp4",
        ...     use_cache=True,
        ... )
        ...
        ... # TAPNext
        ... tracks, visibility = run_tapnet(
        ...     video,
        ...     device="cuda",
        ...     grid_size=10,  # Track 10x10 grid of points
        ...     model='tapnext',
        ... )
        ...
        ... # Visualization
        ... colors = [rp.random_rgb_float_color() for _ in tracks[0]]
        ... new_video = []
        ... for frame_number in range(len(video)):
        ...     frame = video[frame_number]
        ...     track = tracks[frame_number]
        ...     visible = visibility[frame_number]
        ...     for idx, (color, (x, y)) in enumerate(zip(colors, track)):
        ...         if visible[idx]:
        ...             frame = rp.cv_draw_circle(
        ...                 frame,
        ...                 x,
        ...                 y,
        ...                 radius=5,
        ...                 color=color,
        ...                 antialias=True,
        ...                 copy=False,
        ...             )
        ...     new_video.append(frame)
        ... rp.fansi_print("SAVED " + rp.save_video_mp4(new_video), "blue cyan", "bold")

    """
    #THE ABOVE DOCSTRING SHOULD BE MIRRORED VERBATIM FROM rp.git.tapnet.run_tapnet.run_tapnet.__doc__
    from rp.git.tapnet.run_tapnet import run_tapnet as func

    model_dir or _ensure_tapnet_installed(model, model_dir=model_dir)

    return func(
        video,
        device           = device,
        queries          = queries,
        grid_size        = grid_size,
        grid_query_frame = grid_query_frame,
        model            = model,  # "tapir", "bootstapir", or "tapnext"
        model_dir        = model_dir,
    )

def _ensure_tapnet_installed(*models, model_dir=None):
    """
    Gets the code and checkpoints for tapnet
    models is varargs like "tapir", "bootstapir", or "tapnext"
    If not specified, downloads all checkpoints (default behaviour)

    To simply preemptively download checkpoints, just run this func with no args

    Returns the directory where we download the models
    """
    #First you'll need the tapnet repo
    rp.git_import('tapnet') #https://github.com/RyannDaGreat/tapnet

    #Try to pip-install everything
    auto_yes=True
    pip_import("chex"             , auto_yes=auto_yes)
    pip_import("jax"              , auto_yes=auto_yes)
    pip_import("jaxline"          , auto_yes=auto_yes)
    pip_import("optax"            , auto_yes=auto_yes)
    pip_import("haiku","dm-haiku" , auto_yes=auto_yes)
    pip_import("tree","dm-tree"   , auto_yes=auto_yes)
    pip_import("typing_extensions", auto_yes=auto_yes)
    pip_import("matplotlib"       , auto_yes=auto_yes)
    pip_import("mediapy"          , auto_yes=auto_yes)
    pip_import("cv2"              , auto_yes=auto_yes)
    pip_import("einshape"         , auto_yes=auto_yes)
    pip_import("ipympl"           , auto_yes=auto_yes)
    pip_import("tqdm"             , auto_yes=auto_yes)

    try:
        sys.path.append(rp.get_module_path_from_name('rp.git.tapnet'))
        from rp.git.tapnet.run_tapnet import get_tapnet_model, checkpoint_urls
    except (ImportError,ModuleNotFoundError) as e:
        requirements_path = rp.path_join(
            rp.get_module_path_from_name("rp.git.tapnet"),
            "requirements_inference.txt",
        )
        requirements_check_str = strip_ansi_escapes(check_pip_requirements(requirements_path, silent=True))
        raise type(e)(
            str(e)
            + "\nYou might want to run "
            + shlex.quote(sys.executable)
            + " -m pip install -r "
            + shlex.quote(requirements_path)
            + "\n"
            + requirements_check_str
        ) from e

    #Download any models if specified, else download all of them
    models = models or list(checkpoint_urls)
    for model_name in models:
        get_tapnet_model(model_name, model_dir, download_only=True)

    return model_dir

def _pip_import_pyflow():
    """
    This function attempts to import the 'pyflow' module. If the import fails, 
    it will clone the 'pyflow' repository from GitHub, build the module, and then import it.

    Returns:
        module: The 'pyflow' module if successfully imported.

    WARNING: This didn't work on my M1 Mac!
    """
    try:
        import pyflow
        return pyflow
    except ImportError:

        import platform
        assert not currently_running_mac() and platform.processor()=='arm', 'pyflow doesnt compile on M1 Macs - I tried...'

        rp.make_directory(_rp_downloads_folder)
        rp.pip_import("Cython")
        with rp.SetCurrentDirectoryTemporarily(_rp_downloads_folder):
            pyflow_path = 'pyflow.git'
            
            if not rp.folder_exists(pyflow_path):
                pyflow_path = rp.git_clone("https://github.com/pathak22/pyflow.git")
                
            with rp.SetCurrentDirectoryTemporarily(pyflow_path):
                sys.path.append(rp.get_absolute_path('.'))
                
                try:
                    import pyflow
                    return pyflow
                except ImportError:
                    _run_sys_command(sys.executable + " setup.py build_ext -i")

        import pyflow
        return pyflow


def get_optical_flow_via_pyflow(image_from, image_to):
    """
    Returns a [2, H, W] numpy array for dx and dy respectively, measured in pixels

    Uses pyflow: https://github.com/pathak22/pyflow
    Automatically installs it if it's not available
    
    Enhanced Documentation:
    Computes optical flow between two images using the pyflow library.
    Optical flow represents the apparent motion of pixels between frames,
    useful for motion analysis, video stabilization, and frame interpolation.
    
    Note: pyflow doesn't compile on M1 Macs due to architecture incompatibility.
    
    Args:
        image_from: Source image (numpy array) - RGB/RGBA format
        image_to: Target image (numpy array) - RGB/RGBA format, same size as source
        
    Returns:
        tuple: (dx, dy) arrays each with shape [H, W]
               - dx: horizontal displacement in pixels (positive = rightward)
               - dy: vertical displacement in pixels (positive = downward)
               
    Backend Implementation:
        This is a _via_ variant function implementing the pyflow backend.
        Part of RP's multiplexing pattern for optical flow computation.
        
    Limitations:
        - Does not work on Apple M1/M2 Macs (compilation issues)
        - Requires pyflow installation (auto-installed on first use)
        - Images must be same dimensions

        
    Examples:
        >>> from rp.r import get_optical_flow_via_pyflow, load_images
        >>> # Note: This will fail on M1/M2 Macs
        >>> try:
        ...     image_0 = 'https://github.com/pathak22/pyflow/blob/master/examples/car2.jpg?raw=true'
        ...     image_1 = 'https://github.com/pathak22/pyflow/blob/master/examples/car1.jpg?raw=true'
        ...     image_0, image_1 = load_images([image_0, image_1], use_cache=True)
        ...     dx, dy = get_optical_flow_via_pyflow(image_0, image_1)
        ...     print(f"Flow shape: dx={dx.shape}, dy={dy.shape}")
        ... except Exception as e:
        ...     print(f"Failed (expected on M1 Macs): {e}")
        
        # Visualize optical flow
        >>> flow_image = optical_flow_to_image(dx, dy)
        >>> display_image(flow_image)
        
        # Apply flow for frame prediction
        >>> predicted = cv_remap_image(image_0, -dx, -dy, relative=True)
        >>> display_image_slideshow([image_0, predicted, image_1])
        
    Related Functions:
        - cv_optical_flow(): OpenCV-based optical flow (more compatible)
        - optical_flow_to_image(): Visualize flow as color image
        - cv_remap_image(): Apply flow displacement to images
        
    Tags: optical-flow, motion-analysis, computer-vision, pyflow, backend-variant
    """
    _pip_import_pyflow()
    import pyflow

    assert rp.is_image(image_from)
    assert rp.is_image(image_to  )
    assert rp.get_image_dimensions(image_from) == rp.get_image_dimensions(image_to) 

    #Can handle float and byte RGBA RGB and grayscale, but not binary images
    if is_binary_image(image_from): image_from = as_rgb_image(image_from)
    if is_binary_image(image_to  ): image_from = as_rgb_image(image_to  )

    # 0 or default:RGB, 1:GRAY (but pass gray image with shape (h,w,1))
    colType = 1 if rp.is_grayscale_image(image_from) and rp.is_grayscale_image(image_to) else 0
    image_from = rp.as_rgb_image(rp.as_float_image(image_from))
    image_to   = rp.as_rgb_image(rp.as_float_image(image_to  ))
    if colType == 1:
        image_from = image_from[:,:,:1]
        image_to   = image_to  [:,:,:1]

    # pyflow can complain about non-contiguous arrays
    image_from = np.ascontiguousarray(image_from)
    image_to   = np.ascontiguousarray(image_to  )
        
    # Flow Options: From their demo.py
    # I don't know what these do yet, so I'll leave them as default
    alpha = 0.012
    ratio = 0.75
    minWidth = 20
    nOuterFPIterations = 7
    nInnerFPIterations = 1
    nSORIterations = 30

    #This spams the console. Not sure how to best prevent that. There's no argument for silence.
    #'warped' is just image_before warped into image_after using the flow
    dx, dy, warped = pyflow.coarse2fine_flow(
        image_from,
        image_to,
        alpha,
        ratio,
        minWidth,
        nOuterFPIterations,
        nInnerFPIterations,
        nSORIterations,
        colType,
    )

    flow = np.stack([dx, dy], axis=0)

    return flow

def cv_optical_flow(frame_a, frame_b, algorithm="DenseRLOF"):
    """
    Calculate the optical flow between two frames using the specified algorithm.

    Args:
        frame_a (np.ndarray, PIL.Image): The first frame.
        frame_b (np.ndarray, PIL.Image): The second frame.
        algorithm (str): The optical flow algorithm to use.
            Supported algorithms: "DenseRLOF", "Farneback", "DualTVL1", "PCAFlow", "DeepFlow", "SparseToDense".
            Default is "DenseRLOF".

    Returns:
        numpy.ndarray: The computed optical flow with shape (2, height, width).

    Raises:
        ValueError: If an unsupported optical flow algorithm is specified.
        
    EXAMPLE (can run on macbook):

        >>> import torch
        ... from rp import *
        ... 
        ... #UNCOMMENT IF YOU WANT TO USE WEBCAM
        ... get_frame = lambda: as_rgba_image(as_rgba_image(cv_resize_image(load_image_from_webcam(), 1 / 8)))
        ... 
        ... #UNCOMMENT IF YOU WANT TO USE A VIDEO INPUT
        ... #video=load_video('/Users/ryan/Desktop/Screenshots/cat_on_grass.mov',use_cache=True)
        ... video=load_video('https://warpyournoise.github.io/docs/assets/videos/DeepFloyd/carturn_st_oilpaint_input.mp4',use_cache=True)
        ... video=remove_duplicate_frames(video,lazy=True)
        ... video=iter(video)
        ... get_frame=lambda:as_rgba_image(resize_image_to_fit(next(video),height=256))
        ... 
        ... 
        ... frame_a = get_frame()
        ... 
        ... frames = []
        ... flows = []
        ... prev_frame = frame_a
        ... 
        ... vis_frames=[]
        ... 
        ... i=0
        ... 
        ... orig_noise = torch.randn_like(as_torch_image(frame_a))
        ... 
        ... try:
        ...     while True:
        ...         
        ...         #DECIDE WHICH FLOW TO USE HERE
        ...         REVERSE_FLOW=True  #Calculates flow from b to a, better for remap
        ...         REVERSE_FLOW=False #Calculates flow from a to b, better for scatter add
        ...         
        ... 
        ...         frame_b = get_frame()
        ... 
        ...         #UNCOMMENT ONE OF THESE ALGORITHMS
        ...         if REVERSE_FLOW:frame_a, frame_b = frame_b, frame_a
        ...         flow = cv_optical_flow(frame_b, frame_a, algorithm="DenseRLOF") #Best, Slowest
        ...         #flow = cv_optical_flow(frame_a, frame_b, algorithm="DeepFlow")
        ...         #flow = cv_optical_flow(frame_a, frame_b,algorithm='Farneback') #Fastest, Worst
        ...         #flow = cv_optical_flow(frame_a, frame_b, algorithm="SparseToDense")
        ...         #flow = cv_optical_flow(frame_a, frame_b, algorithm="PCAFlow")
        ...         #flow = cv_optical_flow(frame_a, frame_b, algorithm="DualTVL1")
        ...         if REVERSE_FLOW:frame_a, frame_b = frame_b, frame_a
        ... 
        ...         N = 30
        ...         
        ...         if not i%N:
        ...             #REset iter frame
        ...             prev_frame=frame_a
        ...             flows = []
        ...             frames = []
        ... 
        ...         frames.append(frame_a)
        ...         flows.append(flow)
        ... 
        ...         i+=1
        ...         i%=N
        ... 
        ...         flow_vis = optical_flow_to_image(*flow,sensitivity=None)
        ... 
        ...         frames = frames[-N:]
        ...         flows = flows[-N:]
        ...         cum_flow = accumulate_flows(
        ...             (1 if REVERSE_FLOW else -1) * as_numpy_array(flows),
        ...             reverse=True,
        ...         )
        ...         warped_frame = cv_remap_image(frames[0], *-cum_flow, relative=True)
        ...         
        ...         iter_warped_frame = cv_remap_image(prev_frame, *(-1 if REVERSE_FLOW else 1) * flows[-1], relative=True)
        ...         
        ...         prev_frame = iter_warped_frame
        ... 
        ...         scatter_flow = torch.tensor(
        ...             accumulate_flows(
        ...                 (1 if REVERSE_FLOW else -1) * as_numpy_array(flows),
        ...                 reverse=False,
        ...             )
        ...         )
        ...         scattered = torch_scatter_add_image(
        ...             as_torch_image(frames[0]),
        ...             *scatter_flow,
        ...             relative=True,
        ...             prepend_ones=True,
        ...             interp="bilinear",
        ...         )
        ...         scatter_sum = scattered[0]
        ...         scattered = scattered[1:] / (scatter_sum+.001)
        ...         scattered = as_numpy_image(scattered)
        ... 
        ...         scattered_noise = torch_scatter_add_image(
        ...             orig_noise[:3],
        ...             *scatter_flow,
        ...             relative=True,
        ...             interp="round",
        ...             prepend_ones=True,
        ...         )
        ...         scatter_noise_sum = scattered_noise[0]
        ...         scattered_noise= scattered_noise[1:] / (scatter_noise_sum**.5+.001)
        ...         scattered_noise = as_numpy_image(scattered_noise) / 5 + .5
        ...         scattered_noise=with_alpha_channel(scattered_noise,as_numpy_array(scatter_noise_sum>0))
        ...         
        ...         vis_frame = with_alpha_checkerboard(
        ...             image_with_progress_bar(
        ...                 labeled_image(
        ...                     tiled_images(
        ...                         labeled_images(
        ...                             [
        ...                                 warped_frame,
        ...                                 frame_a,
        ...                                 iter_warped_frame,
        ...                                 flow_vis,
        ...                                 scattered,
        ...                                 scattered_noise,
        ...                             ],
        ...                             [
        ...                                 "Accumulated Flow",
        ...                                 "Current Frame",
        ...                                 "Iterative Warp",
        ...                                 "Flow Visualization",
        ...                                 "Accumulated Scatter Add",
        ...                                 "Accumulated Scatter Noise",
        ...                             ],
        ...                             font="Futura",
        ...                         ),
        ...                         border_thickness=0,
        ...                     ),
        ...                     f"Frame {i}",
        ...                     font="Futura",
        ...                 ),
        ...                 progress=i / (N - 1),
        ...                 size=5,
        ...             ),
        ...             first_color=0.1,
        ...             second_color=0.2,
        ...         )
        ... 
        ...         vis_frames.append(vis_frame)    
        ...         display_image(vis_frame)
        ... 
        ...         frame_a = frame_b
        ... 
        ... except StopIteration:
        ...     pass
        ... 
        ... 
        ... display_video(
        ...     video_with_progress_bar(
        ...         vis_frames,
        ...         bar_color="cyan",
        ...         size=5,
        ...     ),
        ...     loop=True,
        ... )

    """
    pip_import("cv2")
    pip_import("numpy")

    import cv2
    import numpy as np

    frame_a = as_rgb_image(as_byte_image(frame_a))
    frame_b = as_rgb_image(as_byte_image(frame_b))
    assert frame_a.shape == frame_b.shape

    # Convert PIL images to OpenCV format
    cv_frame_a = cv2.cvtColor(np.array(frame_a), cv2.COLOR_RGB2BGR)
    cv_frame_b = cv2.cvtColor(np.array(frame_b), cv2.COLOR_RGB2BGR)

    algorithms = {
        "DenseRLOF": lambda: cv2.optflow.DenseRLOFOpticalFlow_create(),
        "Farneback": lambda: cv2.calcOpticalFlowFarneback,
        "DualTVL1": lambda: cv2.optflow.DualTVL1OpticalFlow_create(),
        "PCAFlow": lambda: cv2.optflow.createOptFlow_PCAFlow(),
        "DeepFlow": lambda: cv2.optflow.createOptFlow_DeepFlow(),
        "SparseToDense": lambda: cv2.optflow.createOptFlow_SparseToDense()
    }

    if algorithm not in algorithms:
        raise ValueError("Unsupported optical flow algorithm: {}. "
                         "Supported algorithms are: {}."
                         .format(algorithm, ", ".join(algorithms.keys())))

    if algorithm in ["Farneback", "DualTVL1", "DeepFlow", "SparseToDense"]:
        #These flow algorithms operate in grayscale only
        cv_frame_a = cv2.cvtColor(cv_frame_a, cv2.COLOR_BGR2GRAY)
        cv_frame_b = cv2.cvtColor(cv_frame_b, cv2.COLOR_BGR2GRAY)

    if algorithm == "Farneback":
        #For some reason Farneback needs more parameters
        params = dict(
            pyr_scale=0.5,
            levels=3,
            winsize=15,
            iterations=3,
            poly_n=5,
            poly_sigma=1.2,
            flags=0
        )
        flow = algorithms[algorithm]()(cv_frame_a, cv_frame_b, None, **params)
    else:
        flow = algorithms[algorithm]().calc(cv_frame_a, cv_frame_b, None)

    flow = np.transpose(flow, (2, 0, 1))
    return flow

def calculate_flows(video, flow_func=cv_optical_flow, *, show_progress=False, lazy=False):
    """
    Calculates optical flow for a whole video, given a video and a function that computes flow(prev_frame, next_frame)
    Returns an iterable of optical flows, and if not lazy it has length len(video)-1
    
    Enhanced Documentation:
    
    Parameters:
        video: Iterable of video frames (images)
        flow_func (callable, default=cv_optical_flow): Function that takes (prev_frame, next_frame) 
                                                       and returns optical flow field
        show_progress (bool, default=False): If True, shows progress bar (requires video to have length)
        lazy (bool, default=False): If True, returns generator; if False, returns list
    
    Returns:
        List or generator of optical flows. Length is len(video)-1 (one flow per frame transition).
        Flow format depends on the flow_func used.
    
    Flow Functions:
        - cv_optical_flow(): OpenCV dense optical flow (returns flow field arrays)
        - Custom functions: Can return any flow representation (vectors, scalars, etc.)
    
    Usage Patterns:
        - Basic flow calculation: calculate_flows(video)
        - Custom flow algorithm: calculate_flows(video, flow_func=my_flow_function)
        - Memory efficient: calculate_flows(video, lazy=True)
        - Progress tracking: calculate_flows(video, show_progress=True)
    
    Related Functions:
        - cv_optical_flow(): Default dense optical flow computation
        - accumulate_flows(): Combine flows over time
        - cv_remap_image(): Apply optical flow to warp images
        - video_with_progress_bar(): Add progress bars to videos
    
    Examples:
        >>> flows = calculate_flows(video_frames)  # List of flow fields
        >>> lazy_flows = calculate_flows(video_frames, lazy=True)  # Generator
        >>> custom_flows = calculate_flows(video_frames, flow_func=lambda a,b: np.mean(np.abs(a-b)))
    """
    if show_progress:
        assert has_len(video), 'Cannot show progress because video doesnt have a length, type(video)='+str(type(video))
        length=len(video)
        
    def helper():
        video_iter=iter(video)
        image=next(video_iter)
        for new_image in video_iter:
            flow=flow_func(image,new_image)
            image=new_image
            yield flow

    output=helper()
    
    if show_progress:
        output=IteratorWithLen(output,length)
        output=eta(output,title='rp.calculate_flows')
            
    if not lazy:
        output=list(output)
    
    return output



def optical_flow_to_image(dx, dy, *, mode='saturation', sensitivity=None):
    """
    Visualize optical flow as an RGB image - and return the image.
    
    The hue represents the angle of the flow, while magnitude is represented by either brightness or saturation.
    It has the same general idea as torchvision.utils.flow_to_image - when mode = 'saturation'
    
    Args:
       dx (numpy.ndarray matrix): The x-component of the optical flow.
       dy (numpy.ndarray matrix): The y-component of the optical flow.
       mode (str, optional): The visualization mode. Can be:
           - 'saturation': The saturation represents the magnitude. Default.
           - 'brightness': The brightness represents the magnitude.
       sensitivity (float, optional): If not specified, flow magnitudes are normalized at every frame
           Otherwise, the flow magnitudes are multiplied by this amount before visualizing
           (If you expect a max magnitude of 5 for example, you should set sensitivity=1/5)

       TODO: Use floating-point HSV precision, and a custom mag factor
       mag_factor (float, optional): the magnitude will be scaled by this number if specified,
                                     otherwise the magnitude will be scaled with full_range
    
    Returns:
       numpy.ndarray: The RGB image visualizing the optical flow.
    
    Raises:
       AssertionError: If dx and dy are not float matrices with the same shape, or if the mode is invalid.

    EXAMPLE:
        (see get_optical_flow_via_pyflow's docstring for an example)
    """
    assert rp.is_a_matrix(dx), "dx must be a matrix"
    assert rp.is_a_matrix(dy), "dy must be a matrix"
    if is_torch_tensor(dx):dx=as_numpy_array(dx)
    if is_torch_tensor(dy):dy=as_numpy_array(dy)
    assert rp.is_float_image(dx), "dx must be a float image"
    assert rp.is_float_image(dy), "dy must be a float image"
    assert dx.shape == dy.shape, "dx and dy must have the same shape"
    assert mode in ['saturation', 'brightness'], "mode must be either 'saturation' or 'brightness'"
    dx=dx.astype(float) # np.float16 doesnt work
    dy=dy.astype(float) # np.float16 doesnt work
    
    rp.pip_import('cv2')
    import cv2
    
    hsv = np.zeros((*dx.shape, 3), dtype=np.uint8)
    hsv[:] = 255
    mag, ang = cv2.cartToPolar(dx, dy)

    if sensitivity is None:
        norm_mag = cv2.normalize(mag, None, 0, 255, cv2.NORM_MINMAX)
    elif is_number(sensitivity):
        norm_mag = mag
        norm_mag = sensitivity * mag
        norm_mag = np.tanh(norm_mag) #Soft clip it between 0 and 1
        norm_mag = np.clip(norm_mag * 255, 0, 255)
        norm_mag = norm_mag.astype(np.uint8)
    else:
        assert False, sensitivity
        


    hsv[..., 0] = ang * 180 / np.pi / 2
    hsv[..., {'brightness': 2, 'saturation': 1}[mode]] = norm_mag       
    rgb = cv2.cvtColor(hsv, cv2.COLOR_HSV2BGR)
    
    return rgb

def optical_flow_to_arrow_grid(dx, dy, *, step=16, color=(0,1,0), background=None):
    """
    Visualize optical flow as a grid of arrows on an optional background image.

    Args:
        dx (numpy.ndarray): The x-component of the optical flow.
        dy (numpy.ndarray): The y-component of the optical flow.
        step (int, optional): Spacing between arrows in the grid. Default is 16 pixels.
        color (tuple, optional): Color of the arrows as (B, G, R). Default is green.
        background (numpy.ndarray, optional): Background image (height x width x channels) 
            on which the flow will be drawn. If not provided, a black background is used.

    Returns:
        numpy.ndarray: An image with arrows drawn to represent the optical flow.
    
    Raises:
        AssertionError: If dx and dy are not numpy arrays or if they do not have the same shape.
        
    EXAMPLE:

        >>> def next_image():
        ...     while true:
        ...         out=load_image_from_webcam()
        ...         out=cv_resize_image(out,.25)
        ...         yield out
        ...
        ... images=next_image()
        ... image=next(images)
        ...
        ... while true:
        ...     new_image=next(images)
        ...     flow=cv_optical_flow(image,new_image)
        ...     arrows_image = draw_flow_grid(*flow,background=image)
        ...     display_image(arrows_image)
        ...     image=new_image

        >>> def stream():
        ...     while True:
        ...         image=load_image_from_webcam()
        ...         image=resize_image_to_fit(image,256,256)
        ...         yield image
        ... for x in calculate_flows(
        ...     stream(),
        ...     lazy=True,
        ... ):
        ...     display_image(
        ...         optical_flow_to_arrow_grid(
        ...             *x * 3, step=32, color="black", background=optical_flow_to_image(*x,sensitivity=.1)
        ...         )
        ...     )

    """
    pip_import('numpy')
    pip_import('cv2')

    import numpy as np
    import cv2

    color = as_rgb_float_color(color)
    color = float_color_to_byte_color(color)

    assert isinstance(dx, np.ndarray) and isinstance(dy, np.ndarray), "dx and dy must be numpy arrays"
    assert dx.shape == dy.shape, "dx and dy must have the same shape"

    if background is not None:
        assert isinstance(background, np.ndarray), "Background must be a numpy array"
        assert background.ndim == 3 and background.shape[2] == 3, "Background must be a color image (HWC format)"
        img = background.copy()
    else:
        height, width = dx.shape
        img = np.zeros((height, width, 3), dtype=np.uint8)

    for y in range(0, img.shape[0], step):
        for x in range(0, img.shape[1], step):
            endx, endy = int(x + dx[y, x]), int(y + dy[y, x])
            cv2.arrowedLine(img, (x, y), (endx, endy), color, thickness=1, line_type=cv2.LINE_AA, tipLength=0.3)

    return img

def cv_remap_image(image, x, y, *, relative=False, interp = 'bilinear'):
    """
    If image is RGBA, then out-of-bounds regions will have 0-alpha
    This is like a UV mapping - where x and y's values are mapped to image
    If relative=True, it will warp image - treating x and y like dx and dy
        Note: Because this is a mapping, the direction of movement will be opposite dx and dy - so you may need to negate them!

    EXAMPLE:
        (see get_optical_flow_via_pyflow's docstring for an example)
    
    """
    assert rp.is_a_matrix(x), "x must be a matrix"
    assert rp.is_a_matrix(y), "y must be a matrix"
    assert rp.get_image_dimensions(x) == rp.get_image_dimensions(y) 

    #Can handle float and byte RGBA RGB and grayscale, but not binary images
    if is_binary_image(image): image = as_byte_image(image)
    
    out_height, out_width = rp.get_image_dimensions(x    )
    in_height , in_width  = rp.get_image_dimensions(image)

    x = x.astype(np.float32)
    y = y.astype(np.float32)

    if relative:
        #Treat x and y as deltas - like with optical flow
        assert in_height == out_height, 'rp.cv_remap_image: If using relative=True, the UV map must be the same shape as the input image'
        assert in_width  == out_width , 'rp.cv_remap_image: If using relative=True, the UV map must be the same shape as the input image'
        in_x, in_y = np.meshgrid(np.arange(in_width), np.arange(in_height))
        x += in_x
        y += in_y

    rp.pip_import('cv2')
    import cv2

    #Choose an interpolation method
    interp_methods = {
        "bilinear": cv2.INTER_LINEAR,
        "bicubic": cv2.INTER_CUBIC,
        "nearest": cv2.INTER_NEAREST,
        #Can't use "area" interp
    }
    assert interp in interp_methods, 'rp.cv_remap_image: interp=%s is not valid. Please choose from %s' % (interp, list(interp_methods))
    interp_method = interp_methods[interp]
    
    out = cv2.remap(image, x, y, interp_method)

    return out

@memoized
def _get_apriltag_detector(**kwargs):
    assert not currently_running_windows(),'The "apriltag" library doesnt currently work on Windows, sorry :( Try using Unix'
    pip_import('apriltag')
    import apriltag
    options = apriltag.DetectorOptions(**kwargs)
    detector = apriltag.Detector(options)
    return detector
class AprilTag:
    """ Created by the rp.detect_apriltags function """
    def __init__(self,corners,id_number:int,family:str):
        self.corners  =as_points_array(corners  )
        self.id_number=int            (id_number)
        self.family   =str            (family   )
    def __hash__(self):
        return hash(self.id_nubmer,tuple(map(tuple,self.corners)))
    def __eq__(self,x):
        return isinstance(x,AprilTag) and hash(self)==hash(x)
    @property
    def center(self):
        return np.mean(self.corners,axis=0)
    def __repr__(self):
        return 'AprilTag(corners=%s, id_number=%i, family=%s)'%(repr(self.corners.tolist()),self.id_number,self.family)
        
def detect_apriltags(image,family:str='tag36h11'):
    """
    Apriltags are a particular type of AR Marker, which looks like a QR Code
    Apriltags are lower resolution than normal QR codes though
    Each apriltag corresponds to a single number
    Some apriltags to print out: https://www.dotproduct3d.com/uploads/8/5/1/1/85115558/apriltags_0-99.pdf
    To test out a whole bunch at one time, try printing this out: https://dfimg.dfrobot.com/nobody/makelog/4cd2b76a8912dfe060413b7dece0dfdf.png
    The apriltag's corners are specified clockwise from the top left corner of the apriltag
    
    EXAMPLE: (Try waving some of these apriltags around your webcam after printing them out)
        while True:
            image=load_image_from_webcam()
            results=detect_apriltags(image)
            print("[INFO] {} total AprilTags detected".format(len(results)))
            
            for r in results:
                # extract the bounding box (x, y)-coordinates for the AprilTag
                # and convert each of the (x, y)-coordinate pairs to integers
                import cv2
                (ptA, ptB, ptC, ptD) = r.corners
                ptB = (int(ptB[0]), int(ptB[1]))
                ptC = (int(ptC[0]), int(ptC[1]))
                ptD = (int(ptD[0]), int(ptD[1]))
                ptA = (int(ptA[0]), int(ptA[1]))
        
                # draw the bounding box of the AprilTag detection
                cv2.line(image, ptA, ptB, (0, 255, 0), 2)
                cv2.line(image, ptB, ptC, (0, 255, 0), 2)
                cv2.line(image, ptC, ptD, (0, 255, 0), 2)
                cv2.line(image, ptD, ptA, (0, 255, 0), 2)
        
                # draw the center (x, y)-coordinates of the AprilTag
                (cX, cY) = (int(r.center[0]), int(r.center[1]))
                cv2.circle(image, (cX, cY), 5, (0, 0, 255), -1)
        
                # draw the tag family on the image
                tagFamily = r.family
                tagFamily = str(r.id_number)
                cv2.putText(image, tagFamily, (ptA[0], ptA[1] - 15), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 0), 2)
                
                #Unwarp the image using the apriltag:
                #image=unwarped_perspective_image(image,r.corners)
        
            display_image(image)
    """

    family=family.lower()    
    supported_families='tag25h9 tag36h11 tagCircle21h7 tagCircle49h12 tagCustom48h12 tagStandard41h12 tagStandard52h13'.split()
    assert family in supported_families,'detect_apriltags only supports the following apriltag families'+str(supported_families)
    
    image=as_grayscale_image(image)
    image=as_byte_image(image)
    
    results = _get_apriltag_detector(families=family).detect(image)
    results = [AprilTag(result.corners,result.tag_id,result.tag_family.decode("utf-8")) for result in results]
    return results

def get_apriltag_image(value:int, family:str='tag36h11',size=1):
    """
    Returns an image with the apriltag corresponding to the given value
    Please note: the output images are of minimum resolution - they're very small!
                 this is what the size option is for. You can specify a factor or a resolution.
    See https://pypi.org/project/moms-apriltag/
    Example:
        tag = get_apriltag_image(123, scale=64)
        display_image(tag)
    """
    assert isinstance(family, str)

    pip_import("moms_apriltag")
    import moms_apriltag

    if family in moms_apriltag.apriltags_v2:
        generator_class = moms_apriltag.TagGenerator2
    elif family in moms_apriltag.apriltags_v3:
        generator_class = moms_apriltag.TagGenerator3
    else:
        raise ValueError(
            "Invalid apriltag family: "
            + repr(family)
            + "\n"
            + "Please choose from "
            + repr(moms_apriltag.apriltags_v2 + moms_apriltag.apriltags_v3)
        )

    tag_generator = generator_class(family)
    
    tag = tag_generator.generate(value,scale=1)
    assert is_grayscale_image(tag)
    assert is_byte_image(tag)

    #Resize the tag nicely
    tag = cv_resize_image(tag, size=size, interp='nearest')
    
    #This should really be a binary image, conceptually speaking
    tag=as_binary_image(tag)

    return tag

def get_apriltag_images(*values, family:str='tag36h11', size=1):
    """
    Generate multiple AprilTag images. Plural version of get_apriltag_image.
    
    Enhanced Documentation:
    Creates AprilTag marker images for multiple numeric values simultaneously.
    AprilTags are fiducial markers used in computer vision applications.
    Accepts either multiple value arguments or a single list of values.
    
    Usage patterns:
    - Generating marker sets for calibration
    - Creating printed marker sheets
    - Batch marker generation for tracking systems
    
    Parameters:
        *values: Either multiple integer values, or a single iterable of integers
        family (str): AprilTag family ('tag36h11', etc.). Default: 'tag36h11'
        size (int|tuple): Scale factor or target size for output images. Default: 1
        
    Returns:
        list[np.ndarray]: Binary images (0/255 grayscale) of generated tags
        
    Examples:
        >>> # Multiple values as arguments  
        >>> tags = get_apriltag_images(1, 2, 3)
        >>> len(tags)
        3
        >>> # Single list of values
        >>> tags = get_apriltag_images([10, 20, 30])
        >>> len(tags)  
        3
        >>> # Larger size
        >>> big_tags = get_apriltag_images([1, 2], size=64)
        >>> big_tags[0].shape[0] > 10  # Much larger than default
        True
        
    Note: Requires 'moms-apriltag' package (auto-installed via pip_import)
    
    Related functions: get_apriltag_image, detuple
    Tags: apriltag, computer-vision, markers, fiducial, plural, batch
    """
    values=detuple(values)
    return [get_apriltag_image(value=v,family=family,size=size) for v in values]

def _display_filetype_size_histogram(root='.'):
    """
    Enhanced Documentation:
    Displays a histogram showing total disk usage by file type (extension)
    in a directory tree. Used by the RP REPL terminal system to provide
    quick filesystem analysis.
    
    Args:
        root (str): Directory to analyze (default: current directory)
        
    Returns:
        None: Displays results directly to terminal
        
    Examples:
        >>> # Analyze current directory
        >>> _display_filetype_size_histogram()
        # Shows output like:
        # png: 2.3MB (45 files)
        # py: 1.8MB (123 files)  
        # txt: 0.5MB (12 files)
        
    Note:
        This is a private function primarily used by pseudo_terminal()
        for the REPL command system.
        
    Related Functions:
        - get_file_extension(): Get file extensions
        - get_file_size(): Get file sizes
        - pseudo_terminal(): RP's REPL system that uses this
        
    Tags: filesystem, analysis, histogram, size, terminal, private
    """
    assert is_a_folder(root)

    paths = get_all_paths(
        root                            ,
        recursive                = True ,
        ignore_permission_errors = True ,
        include_files            = True ,
        include_folders          = False,
        include_symlinks         = False,
    )

    filetypes=set(get_file_extension(file) for file in paths)

    mem_hist = {
        filetype: sum(
            get_file_size(file, human_readable=False)
            for file in paths
            if get_file_extension(file) == filetype
        )
        for filetype in filetypes
    }

    num_hist = {
        filetype: len([file for file in paths if get_file_extension(file) == filetype])
        for filetype in filetypes
    }
        
    entries=[]
    for filetype in sorted(set(mem_hist),key=mem_hist.get):
        mem_percent=mem_hist[filetype]/sum(mem_hist.values())
        mem_percent*=100
        entries.append(
            (
                filetype,
                "    ",
                human_readable_file_size(mem_hist[filetype]),
                "   %10.5f%%" % mem_percent,
                "    %i" % num_hist[filetype],
            )
        )

    ans=horizontally_concatenated_strings(list(map(line_join,zip(*entries))),rectangularize=True)
        

    printed_lines=[]
    printed_lines.append(ans)
    printed_lines.append('')
    printed_lines.append('Root: '+get_current_directory())
    printed_lines.append('Total Size: '+human_readable_file_size(sum(mem_hist.values())))
    printed_lines=line_join(printed_lines)

    _maybe_display_string_in_pager(printed_lines)
    print(printed_lines)

def _nbca(paths,auto_yes=False,parallel=False):
    if isinstance(paths,str):
        paths=line_split(paths)
    def do_path(x):
        try:
            if file_exists(x) and x.endswith('.ipynb'):
                clear_jupyter_notebook_outputs(x,auto_yes=auto_yes)
            else:
                print(fansi("r.nbca: Skipping; not a ipynb file:",'yellow'),x)
        except Exception as e:
            pass
    if not parallel:
        for x in paths:
            do_path(x)
    else:
        par_map(do_path,paths)

def clear_jupyter_notebook_outputs(path:str=None, auto_yes=False):
    """
    This clears all outputs of a jupyter notebook file
    This is useful when the file gets so large it crashes the web browser (storing too many images in it etc)
    Source: https://stackoverflow.com/questions/28908319/how-to-clear-an-ipython-notebooks-output-in-all-cells-from-the-linux-terminal
    
    Enhanced Documentation:
    
    Parameters:
        path (str, optional): Path to .ipynb file. If None, opens file selector dialog
        auto_yes (bool, default=False): If True, skips confirmation prompt and clears immediately
    
    Returns:
        str: Path of the processed notebook file
    
    Requirements:
        - jupyter: Used for nbconvert to clear outputs 
        - nbstripout: Used to remove additional metadata
        
    Process:
        1. Validates that file is a .ipynb notebook
        2. Shows confirmation dialog (unless auto_yes=True)
        3. Uses jupyter nbconvert with ClearOutputPreprocessor to remove outputs
        4. Uses nbstripout to remove additional metadata
        5. Reports file size reduction
    
    Usage Patterns:
        - Interactive clearing: clear_jupyter_notebook_outputs()
        - Batch clearing: clear_jupyter_notebook_outputs(path, auto_yes=True)
        - Check file size reduction after clearing
    
    Related Functions:
        - input_select_file(): Used when no path provided
        - get_file_extension(): Validates .ipynb extension  
        - get_file_size(): Reports size before/after clearing
        - shell_command(): Executes jupyter and nbstripout commands
        - pip_import(): Auto-installs required dependencies
    
    Examples:
        >>> clear_jupyter_notebook_outputs('notebook.ipynb')  # With confirmation
        >>> clear_jupyter_notebook_outputs('notebook.ipynb', auto_yes=True)  # No confirmation
        >>> clear_jupyter_notebook_outputs()  # Interactive file selection
    """
    # TODO: Look at https://pypi.org/project/nbstripout/ -- this removes metadata, and might allow us to git commit WITHOUT wiping??
    if path is None:
        path=input_select_file(file_extension_filter='ipynb')
    path=get_absolute_path(path)

    assert get_file_extension(path)=='ipynb','clear_jupyter_notebook_outputs: You must select a .ipynb file'

    if auto_yes or input_yes_no('Are you sure you want to clear the outputs of '+path+'?'):
        pip_import('jupyter')
        pip_import('nbstripout')
        escaped_path = '"'+path+'"'
        command=sys.executable+' -m jupyter nbconvert --ClearOutputPreprocessor.enabled=True --clear-output '+escaped_path
        command+='\n'+sys.executable+' -m nbstripout '+escaped_path
        original_file_size=get_file_size(path,human_readable=True)
        shell_command(command)
        new_file_size=get_file_size(path,human_readable=True)
        color='green' if new_file_size!=original_file_size else 'blue'
        fansi_print(path,color,'bold')
        fansi_print('    - Old file size: %s'%original_file_size,color,'bold')
        fansi_print('    - New file size: %s'%get_file_size(path,human_readable=True),color,'bold')

    return path

# @memoized #Only run once
def _initialize_bokeh():
    pip_import('bokeh')
    pip_import('IPython')
    
    from bokeh.io import output_notebook
    from IPython.utils import io
    
    # Suppress output - output_notebook() creates an output cell
    # https://stackoverflow.com/questions/23692950/how-do-you-suppress-output-in-jupyter-running-ipython
    
    # with io.capture_output() as captured: 
    #     output_notebook(hide_banner=True)
    output_notebook(hide_banner=True)
        
def line_graph_via_bokeh(values, *,
                         xlabel:str = None,
                         ylabel:str = None,
                         title :str = None,
                         logx:float = None,
                         logy:float = None,
                         #Bokeh-specific kwargs:
                         height:float = 400, #Height of the display. 375 is the minimum height to see all tools at once with title and ylabel.
                         width :float = None #Width of the display. Set to None for maximal width.
                        ):
    """
    Uses the Bokeh library to display an interactive line graph in an IPython notebook
    Only works in IPython notebooks right now
    
    TODO:
        - Currently only displays one graph at a time. Might add the ability to do multiple graphs in the future.
        - Currently doesn't let you use custom x-coordinates. Might add that functionality in the future.
        - Currently doesn't support legend labels, custom colors, custom line widths, etc. This should be added.
        - Currently only uses a dark theme. Should add custom themes in the future.
        - Allow custom log-axis bases (not just base 10)
    
    NOTES:
        - This function should be as similar to rp.line_graph() as possible, while still getting as much ...
            ... bokeh-specific functionality as possible
    
    EXAMPLES:
        #Run these in an iPython notebook, such as Jupyter Lab or Google Colab       
        line_graph_via_bokeh(random_ints(4),height=400,width=400);
        line_graph_via_bokeh(random_ints(40),logy=True,logx=False);
        line_graph_via_bokeh(random_ints(40),logy=True,logx=True,title='New Jersey',xlabel='Cows',ylabel='Time');
        line_graph_via_bokeh({"Cows":random_ints(40),"Donkeys":random_ints(40)}logy=True,logx=True,title='New Jersey',xlabel='Cows',ylabel='Time');
    """
    
    assert running_in_jupyter_notebook(), 'line_graph_via_bokeh is meant to be used in a notebook (like Colab or Jupyter Lab etc)'

    #Import stuff
    _initialize_bokeh() #Bokeh has to be initialized before use
    import bokeh
    from bokeh.plotting import figure, show
    from bokeh.io       import curdoc, output_notebook
    from bokeh.themes   import built_in_themes


    #Create figure
    fig_kwargs = {}
    if title  is not None: assert isinstance(title ,str) ; fig_kwargs['title'       ] = title
    if xlabel is not None: assert isinstance(xlabel,str) ; fig_kwargs['x_axis_label'] = xlabel
    if ylabel is not None: assert isinstance(ylabel,str) ; fig_kwargs['y_axis_label'] = ylabel
    if logx              :                                 fig_kwargs['x_axis_type' ] = 'log'
    if logy              :                                 fig_kwargs['y_axis_type' ] = 'log'
    if height is not None:                                 fig_kwargs['height'      ] = height  
    if width  is not None:                                 fig_kwargs['width'       ] = width  

    fig_kwargs['tools'] = 'pan,wheel_zoom,box_zoom,reset,hover,crosshair' #http://docs.bokeh.org/en/0.11.1/docs/user_guide/tools.html
    
    fig = figure(**fig_kwargs)

    crosshair = fig.tools[-1]
    crosshair.line_color = '#999999' #This compliments our dark theme. The default crosshair color is black.

    #Some extra tools I don't know the names of for the above string that lists tools...
    fig.tools.append(bokeh.models.ZoomInTool ())
    fig.tools.append(bokeh.models.ZoomOutTool())
    fig.tools.append(bokeh.models.SaveTool   ())

    #Get rid of the bokeh logo on the toolbar; it takes up too much room
    fig.toolbar.logo=None
    
    if title is not None:
        #Put the title on the middle-top, not the top-left
        fig.title.align = 'center'

    if width is None:
        #Scale the figure to the notebook width.
        # fig.sizing_mode = 'scale_width'   #Scale both display width and height by same factor to take maximal web-browser width
        fig.sizing_mode = 'stretch_width' #Scale just display width to take maximal web-browser width


    #Put the 0,1,2,...etc number lines on x=0 and y=0, like desmos
    # fig.yaxis.fixed_location=0
    # fig.xaxis.fixed_location=0

    if not isinstance(values, dict):
        #Create (x,y) points
        y = values
        x = list(range(len(y)))

        #Draw the line graph onto the figure
        fig.line(x, y, line_width=2, color='#007FFF')
    else:
        #Add each line in the dict to the figure
        for legend_label,y in values.items():
            legend_label = str(legend_label)

            x = list(range(len(y)))

            color_min = 127
            color_max = 255
            # color = tuple(random_int(color_min,color_max) for _ in range(3)) #Random color
            color = tuple(random.Random(hash(legend_label)+seed_shift).randint(color_min,color_max) for seed_shift in range(3)) #Pseudo-random color that depends on legend_label so it will do same colors upon running code twice
            color = byte_color_to_hex_color(color)

            fig.line(x, y, line_width=2, color=color, legend_label=legend_label)


    #Display figure with dark theme
    theme = 'dark_minimal' #TODO: Make this an argument
    assert theme in built_in_themes, repr(theme) + ' not in ' + repr(built_in_themes)
    old_theme = curdoc().theme
    try:
        curdoc().theme = theme
        show(fig) #Display the graph
    finally:
        curdoc().theme = old_theme

def histogram_via_bokeh(values, bins:int=50, *,
                        xlabel:str = None,
                        ylabel:str = None,
                        title :str = None,
                        logx:float = None,
                        logy:float = None,
                        height:float = 400,
                        width :float = None):
    """
    Uses the Bokeh library to display an interactive histogram in an IPython notebook
    Only works in IPython notebooks right now
    
    EXAMPLES:
        # Run these in an iPython notebook, such as Jupyter Lab or Google Colab
        import numpy as np
        import random
        histogram_via_bokeh(np.random.randn(1000), bins=50, height=400, width=400)
        histogram_via_bokeh([random.randint(0, 100) for _ in range(1000)], bins=20, xlabel='Value', ylabel='Frequency', title='Random Integers Histogram')
    
    """
    #TODO: Consolidate redundant code between this function and line_graph_via_bokeh, which came first.
    #Written partially with GPT4: https://shareg.pt/SieYJms
    
    
    assert running_in_jupyter_notebook(), 'histogram_via_bokeh is meant to be used in a notebook (like Colab or Jupyter Lab etc)'

    _initialize_bokeh()
    import bokeh
    from bokeh.plotting import figure, show
    from bokeh.io import curdoc, output_notebook
    from bokeh.themes import built_in_themes

    fig_kwargs = {}
    if title  is not None: assert isinstance(title ,str) ; fig_kwargs['title'       ] = title
    if xlabel is not None: assert isinstance(xlabel,str) ; fig_kwargs['x_axis_label'] = xlabel
    if ylabel is not None: assert isinstance(ylabel,str) ; fig_kwargs['y_axis_label'] = ylabel
    if logx              :                                 fig_kwargs['x_axis_type' ] = 'log'
    if logy              :                                 fig_kwargs['y_axis_type' ] = 'log'
    if height is not None:                                 fig_kwargs['height'      ] = height
    if width  is not None:                                 fig_kwargs['width'       ] = width

    fig_kwargs['tools'] = 'pan,wheel_zoom,box_zoom,reset,hover,crosshair'

    fig = figure(**fig_kwargs)

    crosshair = fig.tools[-1]
    crosshair.line_color = '#999999'

    fig.tools.append(bokeh.models.ZoomInTool ())
    fig.tools.append(bokeh.models.ZoomOutTool())
    fig.tools.append(bokeh.models.SaveTool   ())

    fig.toolbar.logo=None

    if title is not None:
        fig.title.align = 'center'

    if width is None:
        fig.sizing_mode = 'stretch_width'

    hist, edges = np.histogram(values, bins=bins)

    if logy:
        hist = np.array(hist, dtype=np.float64)
        hist[hist == 0] = np.nan

    fig.quad(top=hist, bottom=0, left=edges[:-1], right=edges[1:], fill_color='#007FFF', line_color="white", alpha=0.6)

    theme = 'dark_minimal'
    assert theme in built_in_themes, repr(theme) + ' not in ' + repr(built_in_themes)
    old_theme = curdoc().theme
    try:
        curdoc().theme = theme
        show(fig)
    finally:
        curdoc().theme = old_theme

def get_git_branch(path='.') -> str:
    """
    Gets the current git branch name.

    Args:
        path (str): The path to the git repository. Defaults to the current directory.

    Returns:
        str: The name of the current git branch, or None if not in a git repository.
    """
    import subprocess
    path = get_git_repo_root(path)
    try:
        branch = subprocess.check_output(["git", "rev-parse", "--abbrev-ref", "HEAD"], cwd=path).decode('utf-8').strip()
        return branch
    except subprocess.CalledProcessError:
        return None

def get_git_is_dirty(path='.') -> bool:
    """
    Checks if the git repository has uncommitted changes.

    Args:
        path (str): The path to the git repository. Defaults to the current directory.

    Returns:
        bool: True if there are uncommitted changes, False otherwise.
    """
    import subprocess
    path = get_git_repo_root(path)
    try:
        subprocess.check_call(["git", "diff", "--quiet", "HEAD"], cwd=path)
        return False
    except subprocess.CalledProcessError:
        return True

def get_git_remote_url(repo='.'):
    assert folder_exists(repo)
    repo=is_a_git_repo(repo)
    if not repo:
        assert False, 'Is not a git repo: '+str(repo)
    
    pip_import('git')
    import git
    ans=git.Remote.urls
    ans=git.Repo(repo)
    ans=ans.remotes
    ans=ans[0]
    ans=ans.urls
    ans=list(ans)
    ans=ans[0]
    return ans

def get_current_git_hash(folder='.'):
    assert folder_exists(folder)
    assert is_a_git_repo(folder), 'Not in a git repo'
    pip_import('git')
    import git
    with SetCurrentDirectoryTemporarily(folder): 
        repo = git.Repo(search_parent_directories=True)
        sha = repo.head.object.hexsha
        return sha

def get_git_commit_message(folder='.'):
    assert folder_exists(folder)
    assert is_a_git_repo(folder), 'Not in a git repo'
    _ensure_git_installed()
    pip_import('git')
    import git
    with SetCurrentDirectoryTemporarily(folder): 
        #https://stackoverflow.com/questions/6806266/git-python-get-commit-feed-from-a-repository
        repo = git.Repo(search_parent_directories=True)
        master = repo.head.reference
        return master.commit.message

def get_git_commit_date(path: str):
    """
    Returns the datetime of the latest commit in a Git repository.

    Args:
        path: The path to the Git repository.

    Returns:
        A datetime object representing the latest commit date, or None if the
        path is not a valid Git repository or an error occurs.
    """
    pip_import('git')
    from git import Repo

    root = get_git_repo_root(path)
    repo = Repo(root)
    latest_commit = repo.head.commit
    commit_date = latest_commit.committed_datetime
    local_tz = get_current_timezone(form='tzinfo')
    commit_date = commit_date.astimezone(local_tz)
    return commit_date




_is_a_git_repo_cache=dict()
def is_a_git_repo(path='.', use_cache=False):
    """Returns False if it's not in a git repo, returns the root .git folder if it is"""
    pip_import('git')
    import git
    try:
        _ = git.Repo(path,search_parent_directories=True).git_dir
        output= _
    except git.exc.InvalidGitRepositoryError:
        output= False
    _is_a_git_repo_cache[path]=output
    return output

def get_git_repo_root(path='.', use_cache=False):
    "Returns the git root of path (the .git path). If it's not a git repo, it throws an error."
    output = is_a_git_repo(path, use_cache)
    assert output, 'Is not a git repo: '+str(path)
    return output


def _distill_github_url(url):
    """
    Distills a GitHub URL to its base repository URL.

    https://github.com/fperazzi/davis-2017/tree/main --> https://github.com/fperazzi/davis-2017
    https://github.com/AeroScripts/repo_name?tab=readme-ov-file --> https://github.com/AeroScripts/repo_name
    """
    from urllib.parse import urlparse
    parsed = urlparse(url)
    if parsed.netloc == "github.com":
        path_parts = parsed.path.split("/")
        if len(path_parts) >= 3:  # Ensure at least user/repo
            return "https://github.com/" + path_parts[1] + "/" + path_parts[2]
    return url  # Return original URL if not a valid GitHub URL or doesn't match expected format.

def _get_repo_name_from_url(url):
    """
    Url should look like: https://github.com/gabrielloye/RNN-walkthrough/
    https://github.com/gabrielloye/RNN-walkthrough/ --> RNN-walkthrough
    """
    url = _distill_github_url(url)
    assert is_valid_url(url)
    url=url.strip()
    if url.endswith('/'):
        url=url[:-1]
    url=url.split('/')
    url=url[-1]
    return url

#OLD VERSION:
# def git_clone(url,path=None,*,show_progress=False):
#     """ Git clones the url and returns the path to its root """
#
#     url = _distill_github_url(url)
#
#     if path is None:
#         path=_get_repo_name_from_url(url)
#         path=get_absolute_path(path)
#
#     command='git clone '+shlex.quote(url)+' '+shlex.quote(path)
#
#     if show_progress:
#         print(command)
#     else:
#         command+=' > /dev/null'
#
#     os.system(command)  
#     return path
#
#     #OLD VERSION:
#     # pip_import('git')
#     # import git
#     # git.Repo.clone_from(url,path)
#     # return path

def git_clone(url, path=None, *, depth=None, branch=None, single_branch=False, show_progress=False):
    """
    Git clones the repo at the given url to the specified path.
    
    :param url: URL of the Git repository to clone
    :param path: Local path to clone into (defaults to repo name)
    :param depth: Create a shallow clone with history truncated to the specified number of commits
    :param branch: Clone a specific branch instead of the default (usually master/main) 
    :param single_branch: Clone only the specified branch, not all branches
    :param show_progress: Print the git clone command before executing
    :return: Path to the cloned repo
    """
    _ensure_git_installed()

    url = _distill_github_url(url)

    if path is None:
        path = _get_repo_name_from_url(url)
        path = get_absolute_path(path)

    command = 'git clone ' + shlex.quote(url) + ' ' + shlex.quote(path)
    
    if depth  is not None: command += ' --depth ' + str(depth)
    if branch is not None: command += ' --branch ' + shlex.quote(branch)
    if single_branch:      command += ' --single-branch'

    if show_progress: print(command)
    else            : command += ' > /dev/null'

    os.system(command)
    return path

def git_pull(path='.', *, branch=None, show_progress=False):
    """Git pulls the latest changes from the remote repository."""
    _ensure_git_installed()

    assert connected_to_internet()

    path = get_absolute_path(path)
    
    command = 'cd ' + shlex.quote(path) + ' && git pull'
    
    if branch is not None: command += ' --branch ' + shlex.quote(branch)

    if show_progress: rp.fansi_print('rp.git_pull: '+command, 'green bold')
    else            : command += ' > /dev/null'
        
    os.system(command)

def _get_git_info(folder='.'):
    _ensure_git_installed()

    pip_import('git')
    import git
    repo=git.Repo(folder)
    info={}
    info['active_branch'   ] = repo.active_branch
    info['alternates'      ] = repo.alternates
    info['bare'            ] = repo.bare
    info['branches'        ] = repo.branches
    info['common_dir'      ] = repo.common_dir
    info['daemon_export'   ] = repo.daemon_export
    info['description'     ] = repo.description
    info['head'            ] = repo.head
    info['heads'           ] = repo.heads
    info['index'           ] = repo.index
    info['references'      ] = repo.references
    info['refs'            ] = repo.refs
    info['remotes'         ] = repo.remotes
    info['submodules'      ] = repo.submodules
    info['tags'            ] = repo.tags
    info['untracked_files' ] = repo.untracked_files
    info['working_tree_dir'] = repo.working_tree_dir
    return info

def get_git_date_modified(file_path):
    """
    Retrieves the date when a file was last modified according to git, returning a datetime object.
    Raises exceptions with informative messages to help debug potential issues.

    Parameters:
        file_path (str): The path to the file within the git repository.

    Returns:
        datetime.datetime: The datetime object representing the last modification date of the file.

    Raises:
        FileNotFoundError: If the specified file does not exist.
        EnvironmentError: If there are issues related to the Git environment or command execution.
        ValueError: If the file is not tracked by Git or if date parsing fails.

    Example:
        >>> file_path = 'path/to/your/file'
        >>> try:
        >>>     print(get_git_date_modified(file_path))
        >>> except Exception as e:
        >>>     print(e)
    """
    _ensure_git_installed()

    import subprocess
    import shlex
    import os
    from datetime import datetime

    # Check if the file path exists
    if not os.path.exists(file_path):
        raise FileNotFoundError("The specified file '{}' does not exist.".format(file_path))

    # Check if the current directory is a Git repository
    is_git_repo_cmd = subprocess.run("git rev-parse --is-inside-work-tree", shell=True, text=True, capture_output=True)
    if is_git_repo_cmd.returncode != 0:
        raise EnvironmentError("Not a git repository. Git command output: {}".format(is_git_repo_cmd.stderr.strip()))

    # Check if the file is tracked by Git
    is_tracked_cmd = subprocess.run("git ls-files --error-unmatch {}".format(shlex.quote(file_path)), shell=True, text=True, capture_output=True)
    if is_tracked_cmd.returncode != 0:
        raise ValueError("The file '{}' is not tracked by Git. Git command output: {}".format(file_path, is_tracked_cmd.stderr.strip()))

    # Running the git command
    cmd = "git log -1 --format=%cd --date=iso {}".format(shlex.quote(file_path))
    try:
        result = subprocess.run(cmd, shell=True, check=True, text=True, capture_output=True)
        return datetime.fromisoformat(result.stdout.strip())
    except subprocess.CalledProcessError as e:
        raise EnvironmentError("Git command failed: {}".format(e.stderr.strip()))
    except ValueError as e:
        raise ValueError("Date parsing error: {}. Git command output: {}".format(e, result.stdout.strip()))

def select_git_commit():
    """
    Let user select a git commit using input_select

    Returns:
        Selected commit hash
    
    EXAMPLE:
        
        >>>   4: a7b9b7e387 [[Sat Mar 15, 2025 at  5:56:39PM]] EXAMPLES
        ...   3: 04f2e93f7d [[Mon Mar 17, 2025 at  3:55:57PM]] Torchtools
        ...   2: 9b00705d67 [[Mon Mar 17, 2025 at  3:56:04PM]] Mathfuncs
        ...   1: 8160e2c31c [[Mon Mar 17, 2025 at  3:56:45PM]] Math funcs
        ...   0: 03f40d3fc9 [[Mon Mar 17, 2025 at  9:23:54PM]] Align
        ... Please enter an integer between 0 and 156 (inclusive), or '?' for more options.
        ...  >
        
    """
    # Args:
    #     n: Number of commits to show. If None, shows all commits.
    n = None

    import subprocess
    from datetime import datetime

    # Get git log with format: hash, author date, commit message
    cmd = ["git", "log"]
    if n is not None:
        cmd.append("-" + str(n))
    cmd.extend(["--pretty=format:%H|%ad|%s", "--date=raw"])
    result = subprocess.run(cmd, capture_output=True, text=True)

    if result.returncode != 0:
        print("Error getting git log: " + str(result.stderr))
        return None

    # Parse the git log output
    commits = []
    for line in result.stdout.strip().split("\n"):
        if not line:
            continue
        parts = line.split("|", 2)
        if len(parts) != 3:
            continue

        commit_hash, date_raw, message = parts
        # Parse date (timestamp and timezone offset)
        date_parts = date_raw.split()
        if len(date_parts) == 2:
            timestamp = int(date_parts[0])
            date = datetime.fromtimestamp(timestamp)
            formatted_date = "[[" + format_date(date, align=True) + "]]"
            formatted_date = fansi(formatted_date, "light blue white")

            # Create display string: 10 chars of hash + date + message
            commit_string = commit_hash[:10]
            commit_string = fansi(commit_string, "italic yellow light gray", truecolor=True)
            display = commit_string + " " + formatted_date
            display = fansi(display, background_color="on dark dark altbw randomhue", truecolor=True)
            display = rp.r._fansi_fix(display)
            display += " " + message
            commits.append((commit_hash, display))

    # Use input_select to let user choose
    if commits:
        options = [display for _, display in commits]
        selected = input_select("Select a git commit:", options, stringify=str, reverse=True)

        # Find the hash for the selected display
        for commit_hash, display in commits:
            if display == selected:
                return commit_hash

    return None



def git_untracked_files():
    """ Returns a list of untracked file paths """
    import subprocess
    result = subprocess.run(
        ["git", "status", "--porcelain", "--untracked-files"],
        capture_output=True, text=True, check=True
    )

    # Lines that start with "??" are untracked files
    untracked = [
        line[3:] for line in result.stdout.splitlines() if line.startswith("?? ")
    ]
    return untracked


def git_existing_ignored_files():
    """ Returns a list of file paths that exist but are ignored by git (via .gitignore)

    >>> # Example (assuming you have some ignored files):
    >>> ignored = git_existing_ignored_files()
    >>> isinstance(ignored, list)
    True
    """
    import subprocess
    result = subprocess.run(
        ["git", "ls-files", "--others", "--ignored", "--exclude-standard"],
        capture_output=True, text=True, check=True
    )

    # Each line is an ignored file path
    ignored = [line for line in result.stdout.splitlines() if line.strip()]
    return ignored



def _autoformat_python_code_via_black(code:str):
    """Formats Python code using the Black code formatter.
    
    Uses Black's automatic code formatting to apply consistent style according to PEP 8.
    The function is configured with a long line length (1000 chars) to prevent aggressive 
    line wrapping, making it suitable for programmatic code generation and manipulation.
    
    Enhanced Documentation:
    
    This is a _via_ variant function that provides Black-based code formatting. It's 
    particularly useful for:
    - Cleaning up generated Python code
    - Standardizing code format in automated tools
    - Preparing code snippets for display or analysis
    - Ensuring consistent spacing and formatting
    
    The function automatically installs the 'black' dependency if not available using
    RP's pip_import pattern.
    
    Args:
        code (str): Python source code to format. Must be valid, parseable Python code.
    
    Returns:
        str: Formatted Python code with Black's standard formatting applied.
    
    Raises:
        black.parsing.InvalidInput: If the input code cannot be parsed as valid Python.
    
    Examples:
        >>> # Basic function formatting
        >>> code = '''def test( a,b, c):
        ...     return a+b+c'''
        >>> formatted = _autoformat_python_code_via_black(code)
        >>> print(formatted)
        def test(a, b, c):
            return a + b + c
        <BLANKLINE>
        
        >>> # Class and method formatting
        >>> complex_code = '''class TestClass:
        ...     def method(self,x,y=10,*args,**kwargs):
        ...         result=[i for i in range(100) if i%2==0]
        ...         return result'''
        >>> formatted = _autoformat_python_code_via_black(complex_code)
        >>> print(formatted)
        class TestClass:
            def method(self, x, y=10, *args, **kwargs):
                result = [i for i in range(100) if i % 2 == 0]
                return result
        <BLANKLINE>
    
    Related Functions:
        - autoformat_python_via_black: Public alias for this function
        - autoformat_python_via_black_macchiato: Alternative formatter for partial/indented code
        - format_signature: Uses this function internally for formatting function signatures
    
    Tags: formatting, black, code-style, via-variant, pip-import
    """
    pip_import('black')
    import black
    return black.format_str(code,mode=black.Mode(line_length=1000))

# Public alias for _autoformat_python_code_via_black with legacy compatibility
autoformat_python_via_black = _autoformat_python_code_via_black


def autoformat_python_via_black_macchiato(python_code_snippet: str, max_line_length=None) -> str:
    """
    Format a Python code snippet using the black macchiato tool.
    
    Args:
    python_code_snippet (str): A string containing the Python code to format.

    Returns:
    str: The formatted Python code.
    
    EXAMPLES:

        >>> #The regular black formatter can't handle indented or partial code
        >>> autoformat_python_via_black("    def f(a,b,c,):")
        ERROR: black.parsing.InvalidInput: Cannot parse: 2:0: <line number missing in source>

        >>> #...but black macchiato can!
        >>> print(autoformat_python_via_black_macchiato("def f(a,b,c,):"))
        def f(
            a,
            b,
            c,
        ):

        >>> print(autoformat_python_via_black_macchiato("def f(a,b,c):"))
        def f(a, b, c):

        >>> print(autoformat_python_via_black_macchiato("def f(a,b,c):pass"))
        def f(a, b, c):
            pass

        >>> #Note how that indent is propagated throughout the code!
        >>> print(autoformat_python_via_black_macchiato("    def f(a,b,c):pass"))
            def f(a, b, c):
                pass

        >>> print(autoformat_python_via_black_macchiato("[1,2,3]"))
        [1, 2, 3]

        >>> print(autoformat_python_via_black_macchiato("[1,2,3,]"))
        [
            1,
            2,
            3,
        ]

        >>> print(autoformat_python_via_black_macchiato("    [1,2,3,]"))
            [
                1,
                2,
                3,
            ]

    """
    pip_import('macchiato')
    import macchiato
    import io

    black_args = []

    if max_line_length is not None:
        assert isinstance(max_line_length, int)
        black_args += ["--line-length", str(max_line_length)]

    with tempfile.TemporaryDirectory() as temp_dir:
        with SetCurrentDirectoryTemporarily(temp_dir):
            #black-macchiato, internally, creates a temp file in the current directory no matter where you are.
            #This can cause read/write permission errors on a mac when in the / directory
            #Also, network storage can slow it down. So, instead, we go to a temporary directory local to the current machine.
            #This makes it more robust and faster.

            input_file = io.StringIO(python_code_snippet)
            output_file = io.StringIO()
            
            # Use the macchiato function to format the code
            exit_code = macchiato.macchiato(input_file, output_file, args=black_args)
            
            if exit_code != 0:
                raise ValueError("rp.autoformat_python_via_black_macchiato: Formatting failed with exit code:", exit_code)
            
            return output_file.getvalue()

def autoformat_bash_via_beautysh(code):
    """ Uses https://github.com/lovesegfault/beautysh to autoformat bash code """
    pip_import('beautysh')
    import beautysh
    output,_=beautysh.Beautify().beautify_string(code)
    return output

def autoformat_html_via_bs4(code: str) -> str:
    """Given a string of HTML, autoformats it"""
    pip_import('bs4')
    from bs4 import BeautifulSoup
    
    soup = BeautifulSoup(code, 'html.parser')

    formatted_code = soup.prettify()

    return formatted_code

autoformat_html = autoformat_html_via_bs4

def add_trailing_commas(code: str) -> str:
    """
    https://github.com/asottile/add-trailing-comma
    
    """
    pip_import("add_trailing_comma")
    from add_trailing_comma._main import _fix_src

    return _fix_src(code)


def autoformat_json(data, indent=4):
    """
    Formats a JSON string with specified indentation.

    Parameters:
    - data (str, object): The JSON string or json-like object to format.
    - indent (int or str, optional): The number of spaces used for indentation or a specific string.
                                     Default is 4 (which means four spaces).
                                     Can be specified as '\t' for tab or any other string.

    Returns:
    - str: A nicely formatted JSON string.

    Raises:
    - TypeError: If the input is not a string.
    - ValueError: If the input string is not valid JSON and cannot be parsed.
    """
    import json
    
    try:
        if isinstance(data, str):
            data = json.loads(data)
        
        return json.dumps(data, indent=indent)
    except json.JSONDecodeError as e:
        raise ValueError("rp.autoformat_json: Failed to parse JSON.") from e


def as_numpy_images(images,copy=True):
    """ Will convert an array of images to BHWC np.ndarray form if it isn't already - supports BCHW torch tensors, PIL images, list of numpy images, etc """
    if _is_numpy_array(images):
        if copy:
            return images.copy()
        else:
            return images
    elif is_torch_tensor(images):
        import torch
        assert isinstance(images,torch.Tensor)
        assert len(images.shape)==4,'Should be 4d tensor: (batch size, num channels, height, width)'
        images=as_numpy_array(images)
        images=images.transpose(0,2,3,1)
        return images   
    else:
        if not is_iterable(images):
            raise TypeError('Unsupported image datatype: %s ; its not even iterable!'%type(images))
        if all((is_pil_image(x) or is_torch_tensor(x) or _is_numpy_array(x)) for x in images):
            return [as_numpy_image(x,copy=copy) for x in images]
        else:
            raise TypeError('Unsupported image datatype: %s of %s'%(type(images),repr(set(map(type,images)))))

def as_pil_image(image, copy=True):
    """ Will convert an a PIL images if it isn't already - supports BCHW torch tensors, numpy images, etc 

    Enhanced Documentation:
    
    Converts images to PIL (Python Imaging Library) format for compatibility with PIL-based
    operations and libraries. Handles various input formats including numpy arrays and
    torch tensors. Essential for interfacing with PIL ecosystem.
    
    Args:
        image: Input image to convert. Accepts numpy arrays, torch tensors, existing PIL images
        
    Returns:
        PIL.Image: Image in PIL format, ready for PIL operations
        
    Examples:
        >>> import numpy as np
        >>> # Convert numpy array to PIL
        >>> np_img = np.random.randint(0, 255, (100, 100, 3), dtype=np.uint8)
        >>> pil_img = as_pil_image(np_img)
        >>> type(pil_img)
        <class 'PIL.Image.Image'>
        
        >>> # Works with grayscale images
        >>> gray_img = np.random.randint(0, 255, (100, 100), dtype=np.uint8)
        >>> pil_gray = as_pil_image(gray_img)
        >>> pil_gray.mode  # Should be 'L' for grayscale
        'L'
        
        >>> # PIL image passed through unchanged
        >>> result = as_pil_image(pil_img)
        >>> result is pil_img  # Same object returned
        True
        
    Note: Used by as_pil_images, save_image_webp, save_image_avif, save_image_jxl,
    and display functions. Automatically handles format conversion and mode detection.
    
    Related: as_pil_images, as_numpy_image, is_pil_image, save_image_webp
    
    Tags: conversion, pil, pillow, images, format-conversion, compatibility
    """
    assert is_image(image), 'as_pil_image: Input is not an image as defined by rp.is_image'

    if is_pil_image(image):
        if copy:
            image = image.copy()
        return image

    pip_import('PIL')
    from PIL.Image import fromarray

    image = as_numpy_image(image)
    assert is_image(image), image.shape
    
    if not is_grayscale_image(image) and not is_byte_image(image):
        #PIL.Image.fromarray can't handle:
        #    RGBA float images
        #    RGBA binary images
        #    RGB float images
        #    RGB binary images
        #But it can handle:
        #    RGB byte images
        #    RGBA byte images
        #    Grayscale binary images
        #    Grayscale float images
        #    Grayscale byte images
        image = as_byte_image(image)

    return fromarray(image)

def as_pil_images(images, *, copy=True):
    """ Will convert an array of images to PIL images if it isn't already - supports BCHW torch tensors, PIL images, list of numpy images, etc """
    return [as_pil_image(x, copy=copy) for x in images]

def as_numpy_image(image,*,copy=True):
    """ Will convert an image to HWC np.ndarray form if it isn't already - supports CHW torch tensors, PIL images etc 

    Enhanced Documentation:
    - Standardizes different image formats to NumPy HWC arrays for consistent processing
    - Handles: NumPy arrays, PIL images, CHW torch tensors, Taichi Fields → all become HWC NumPy
    - copy=True: Always returns new copy; copy=False: MIGHT not copy but sometimes still might, sometimes might mutate original

    Common usage:
        >>> img = as_numpy_image(pil_image)  # PIL → NumPy
        >>> img = as_numpy_image(torch_tensor)  # CHW torch → HWC NumPy
        >>> img = as_numpy_image(img, copy=False)  # Performance optimization, but original might change

    Tags: conversion, standardization, numpy, images
    """
    if isinstance(image,np.ndarray):
        if copy:
            return image.copy()
        else:
            return image
    elif is_torch_tensor(image):
        if is_a_matrix(image):
            return as_numpy_array(image)
        return as_numpy_images(image[None])[0]
    elif is_pil_image(image) or _is_taichi_field(image):
        return as_numpy_array(image)
    else:
        assert False,'Unsupported image type: '+str(type(image))

def as_numpy_video(video):
    """
    Convert video to NumPy THWC format from various input formats.

    Enhanced Documentation:
    - Handles torch TCHW → numpy THWC conversion using einops
    - Standardizes video format for consistent processing

    Examples:
        >>> # Torch tensor TCHW → NumPy THWC
        >>> import torch
        >>> torch_video = torch.rand(10, 3, 480, 640)  # T=10, C=3, H=480, W=640
        >>> numpy_video = as_numpy_video(torch_video)
        >>> numpy_video.shape  # Should be THWC
        (10, 480, 640, 3)

    Tags: video, conversion, numpy, torch
    """
    if is_numpy_array(video):
        return video
    if is_torch_tensor(video):
        pip_import('einops')
        import einops
        return as_numpy_array(einops.rearrange(video, 'T C H W -> T H W C'))
    return [as_numpy_image(x) for x in video]

def as_numpy_videos(videos):
    """
    Convert batch of videos to NumPy BTHWC format.

    Handles torch BTCHW → numpy BTHWC conversion for batched videos.
    Plural version of as_numpy_video for batch processing.

    Examples:
        >>> import torch
        >>> batch_videos = torch.rand(2, 5, 3, 240, 320)  # 2 videos, 5 frames each, 240x320 RGB
        >>> numpy_batch = as_numpy_videos(batch_videos)  
        >>> numpy_batch.shape  # Should be BTHWC
        (2, 5, 240, 320, 3)

    Tags: video, batch, conversion, numpy, plural
    """
    if is_numpy_array(videos):
        return videos
    if is_torch_tensor(videos):
        pip_import('einops')
        import einops
        return as_numpy_array(einops.rearrange(videos, 'B T C H W -> B T H W C'))
    return [as_numpy_video(x) for x in videos]

def as_torch_videos(videos, *, device=None, dtype=None, copy=False):
    """ Plural of rp.as_torch_video """
    import torch
    videos = [gather_args_call(as_torch_video, video) for video in videos]
    if 1==len(set(x.shape for x in videos))==len(set(x.device for x in videos)):
        videos = torch.stack(videos)
    return videos
    
def as_torch_images(images, *, device=None, dtype=None, copy=False):
    """ Plural of rp.as_torch_image AKA rp.as_torch_video """
    import torch

    if _is_numpy_array(images) or all(is_image(x) for x in images):

        #Convert to floating point, because that will happen anyway...
        if _is_numpy_array(images) and images.dtype==np.uint8:
            images=images/255
        else:
            images=[as_float_image(x) for x in images]

        if len(set(map(get_image_dimensions,images)))==1:
            images=as_numpy_array(images)
        else:
            return [as_torch_image(x) for x in images]

        assert len(images.shape)!=3,'Grayscale images are not yet supported'

        images=images.transpose(0,3,1,2)

        if device=='mps' and dtype in [None, torch.float64]:
            # TypeError: Cannot convert a MPS Tensor to float64 dtype as the MPS framework doesn't support float64. Please use float32 instead.
            dtype=torch.float32 

        images=torch.tensor(images, device=device, dtype=dtype)
        return images

    elif is_torch_tensor(images):
        if copy:
            images = images.clone()

        #Not creating a copy. GPU tensors are expensive.
        return images

    else:
        raise TypeError('Unsupported image datatype: %s'%type(images))

as_torch_video = as_torch_images

def as_torch_image(image, *, device=None, dtype=None, copy=False):
    """ Converts an image to a floating point torch tensor in CHW form """
    if is_torch_tensor(image):
        return image.clone()
    elif is_pil_image(image):
        return gather_args_call(as_numpy_image(image, copy=False))
    elif isinstance(image,np.ndarray):
        return gather_args_call(as_torch_images, image[None])[0]
    else:
        assert False,'Unsupported image type: '+str(type(image))

_load_safetensors_cache={}
def _load_safetensors_metadata(path, device="cpu"):
    """Helper to extract metadata from a safetensors file."""
    pip_import("safetensors")
    from safetensors import safe_open
    with safe_open(path, framework="pt", device=device) as f:
        return as_easydict(f.metadata())


def _load_safetensors(path, device="cpu", *, show_progress=False, verbose=False, include_tensors=True, include_shapes=False, include_dtypes=False):
    """
    Internal helper that always returns dict[key] -> dict mapping.
    Values are empty dicts if nothing requested.
    """
    pip_import("safetensors")
    from safetensors import safe_open

    # Load Safetensors file
    with safe_open(path, framework="pt", device=device) as f:
        def get_tensor(k): return f.get_tensor(k)
        def get_shape(k): return f.get_slice(k).get_shape()
        def get_dtype(k):
            dtype_str = str(f.get_slice(k).get_dtype())
            import torch
            dtype_map = {
                "F32": torch.float32,
                "F16": torch.float16,
                "BF16": torch.bfloat16,
                "F64": torch.float64,
                "I8": torch.int8,
                "I16": torch.int16,
                "I32": torch.int32,
                "I64": torch.int64,
                "U8": torch.uint8,
                "BOOL": torch.bool,
            }
            return dtype_map.get(dtype_str, dtype_str)  # fallback to string if unknown

        keys = f.keys()

        if show_progress:
            keys = rp.eta(keys, title="rp.load_safetensors")

        data = {}
        for k in keys:
            if verbose:
                print("    - " + str(k))

            item = {}
            if include_tensors: item['tensor'] = get_tensor(k)
            if include_shapes: item['shape'] = get_shape(k)
            if include_dtypes: item['dtype'] = get_dtype(k)
            data[k] = item

        return data


def load_safetensors(
    path,
    device="cpu",
    *,
    show_progress=False,
    verbose=False,
    use_cache=False,
    include_tensors=True,
    include_shapes=False,
    include_dtypes=False,
    include_metadata=False
):
    """
    Loads tensors from a .safetensors file with flexible output options.

    Args:
        path (str): Path to .safetensors file, or a glob for safetensor files, or a list of files
        device (str, optional): Device (cpu, cuda, etc.). Defaults to 'cpu'.
        show_progress (bool, optional): Show progress bar. Defaults to False.
        verbose (bool, optional): Print tensor names. Defaults to False.
        include_tensors (bool): Include tensor data. Defaults to True.
        include_shapes (bool): Include tensor shapes as lists. Defaults to False.
        include_dtypes (bool): Include tensor dtypes as torch.dtype objects. Defaults to False.
        include_metadata (bool): If True, returns (data, metadata) tuple. Defaults to False.

    Returns:
        - All False: List of tensor names
        - One True: Dict[name -> value] (tensor/shape/dtype)
        - Multiple True: Dict[name -> EasyDict] with .tensor/.shape/.dtype attributes
        - include_metadata=True: (data, metadata) tuple
        
    Reference: https://huggingface.co/docs/safetensors/en/index

    EXAMPLE:
        >>> # Just get tensor names
        >>> keys = load_safetensors('model.safetensors', include_tensors=False)
        >>> print(keys[:3])
        ['layer1.weight', 'layer1.bias', 'layer2.weight']

        >>> # Get only shapes
        >>> shapes = load_safetensors('model.safetensors', include_tensors=False, include_shapes=True)
        >>> print(shapes['layer1.weight'])
        [512, 256]

        >>> # Get shapes and dtypes together
        >>> info = load_safetensors('model.safetensors', include_tensors=False, include_shapes=True, include_dtypes=True)
        >>> print(info['layer1.weight'])
        {'shape': [512, 256], 'dtype': torch.float32}

        >>> # Get only dtypes
        >>> dtypes = load_safetensors('model.safetensors', include_tensors=False, include_dtypes=True)
        >>> print(dtypes['layer1.weight'])
        torch.float32

        >>> # Default behavior - load tensors
        >>> tensors = load_safetensors('model.safetensors')
        >>> print(tensors['layer1.weight'].shape)
        torch.Size([512, 256])

    """
    # Handle Cache
    cache_key = handy_hash([path, device, include_tensors, include_shapes, include_dtypes, include_metadata])
    cache = _load_safetensors_cache
    if use_cache:
        if cache_key not in cache:
            cache[cache_key] = gather_args_recursive_call(use_cache=False)
        return cache[cache_key]
    elif cache_key in cache:
        del cache[cache_key]

    if not file_exists(path):
        # Handle globs/folders of shards (i.e. multiple files)
        subpaths = rp_glob(path)
        if not subpaths:
            subpaths = rp_glob(get_absolute_path(path))
            if not subpaths:
                raise FileNotFoundError('rp.load_safetensors: No safetensor files at '+str(path))

        # Recursively load all shards and merge
        shard_results = [gather_args_call(_load_safetensors, subpath) for subpath in subpaths]
        raw_data = merged_dicts(shard_results)

        metadata_path = subpaths[0]

    else:
        # Handle single file
        raw_data = gather_args_call(_load_safetensors, path)
        metadata_path = path

    # Post-process based on what was requested
    num_includes = include_tensors + include_shapes + include_dtypes

    if num_includes == 0:
        result = list(raw_data.keys())
    else:
        value_func = get_only_value if num_includes==1 else as_easydict
        result = {k: value_func(v) for k, v in raw_data.items()}
        result = as_easydict(result)

    #Possibly output a tuple with metadata
    if include_metadata:
        metadata_dict = _load_safetensors_metadata(metadata_path, device)
        return result, metadata_dict
    else:
        return result


def save_safetensors(tensors, path, metadata=None, *, verbose=False):
    """
    Saves tensors to a .safetensors file.

    Args:
        tensors (dict or easydict): Dictionary of tensors to save (str -> tensor)
        path (str): Path to save the .safetensors file
        metadata (dict, optional): Metadata to include in the file. Defaults to None. (str -> str)

    Returns:
        str: Path to the saved file
        
    Reference: https://huggingface.co/docs/safetensors/en/index

    EXAMPLE:
        >>> tensors = {"weight": torch.randn(3, 4), "bias": torch.randn(4)}
        >>> save_safetensors(tensors, "model.safetensors")
        'model.safetensors'
        >>> loaded = load_safetensors("model.safetensors")
        >>> loaded.keys()
        ['weight', 'bias']
    """
    import os
    from rp.r import pip_import
    pip_import("safetensors")
    
    from safetensors.torch import save_file
    
    # Convert to regular dict if it's an easydict
    tensors = dict(tensors)
    
    if verbose:
        print("Saving {0} tensors to {1}".format(len(tensors), path))
        for k, v in tensors.items():
            print("    - {0}: {1}".format(k, v.shape if hasattr(v, 'shape') else 'no shape'))
    
    make_parent_directory(path)
    
    # Save the tensors
    save_file(tensors, path, metadata)
    
    return path


class ImageDataset:
    """
    This class is meant to be used with Pytorch dataloaders
    #TODO: Possibly migrate this to a new torch submodule of rp to avoid cluttering this namespace
    """

    def __init__(self,
                 files:str,
                 use_cache:bool=False,
                 transform=None):
                     
        assert directory_exists(files) or isinstance(files,list)
        if isinstance(files,str):
            self.image_paths=get_all_files(files,sort_by='number')
        else:
            self.image_paths=files
        self.transform=transform
        self.use_cache=use_cache
    
    def __len__(self):
        return len(self.image_paths)
    
    def __getitem__(self,i):
        image_path=self.image_paths[i]
        image=load_image(image_path,use_cache=self.use_cache)
        if self.transform:
            image=self.transform(image)
        return image

def _get_select_torch_device_lock_file():
    return path_join(tempfile.gettempdir(), 'select_torch_device_lock.txt')

_select_torch_device_used_gpus = set()        
def select_torch_device(n=0, *, silent=False, prefer_used=False, reserve=False):
    """
    Returns the appropriate PyTorch device based on the available hardware and system configuration.
    
    This function selects the best device for running PyTorch operations based on the user's
    hardware, OS, and settings. It supports GPU devices if available and falls back to CPU if
    needed. The function also supports Macs with MPS (Metal Performance Shaders) and
    allows for GPU cycling to balance workload across multiple GPUs.
    
    The function selects the GPU with the nth highest available VRAM. In the case of ties for VRAM
    availability, GPUs are considered in the order of their IDs, and n determines which of the tied
    GPUs will be selected. If n >= number of available GPUs, the function wraps around using
    modulus, cycling through the available GPUs.

    Args:
        n (int, optional): The index of the GPU to select based on available VRAM (zero-based). Defaults to 0.
        silent (bool, optional): If True, suppresses print statements. Defaults to False.
        prefer_used (bool, optional): If True, selects a GPU already in use by the current
            process if available. Defaults to False.
        reserve (bool, optional): If True, will reserve the GPU for future usage - by allocating a small amount of VRAM immediately
            It requires the filelock library, which will be used to prevent other processes from claiming the same GPU at the same time

    Returns:
        torch.device: The selected PyTorch device ('cpu', 'mps', or 'cuda:<gpu_id>').
        
    Examples:
        - If you have 4 GPUs with free VRAMs [200, 0, 100, 100] and you select n=0, you will get 'cuda:1'.
        - If you select n=1, you will get 'cuda:2', as it's the GPU with the second highest free VRAM.
        - If you select n=2, you will get 'cuda:3', and if you select n=3, you get 'cuda:0' and so on.
        - If you select n=5, you will get 'cuda:1' again, as it cycles through the available GPUs.

    Edge Cases:
        - If running on a Mac without MPS support, the function falls back to CPU.
        - If no GPUs are available, the function selects the CPU.
        - If n >= number of available GPUs, the function cycles through the available GPUs
          and selects one based on the index n % number of GPUs.
        - If prefer_used is set and no GPUs are currently in use by the process, the function
          selects an available GPU based on the index n.
        - If prefer_used is set and there are GPUs currently in use by the process, the function
          will select from those GPUs.

    Example (reserve):
        Put this in torch_device_selector.py:
            from rp import *
            import torch
            sleep(random_float(0))
            device=select_torch_device(reserve=True)
            torch.zeros(1).to(device)
            sleep(10)
            print("SELECTED",device)

        Then run this shell command (adjust the number of lines to your number of GPU's):
            python torch_device_selector_test.py &
            python torch_device_selector_test.py &
            python torch_device_selector_test.py &
            python torch_device_selector_test.py &
            python torch_device_selector_test.py &
            python torch_device_selector_test.py &
            python torch_device_selector_test.py &
            python torch_device_selector_test.py

        Because of reserve's use of the 'filelock' library, they won't clobber each other
        Each GPU will be reserved once, by a different process

    """

    import torch
    global _select_torch_device_used_gpus 

    if currently_running_mac():
        if not hasattr(torch.backends,'mps') or not torch.backends.mps.is_available():
            # Check that MPS is available
            if not silent:
                if not hasattr(torch.backends,'mps') or not torch.backends.mps.is_built():
                    print(
                        end="MPS not available because the current PyTorch install was not "
                        "built with MPS enabled."
                    )
                else:
                    print(
                        end="MPS not available because the current MacOS version is not 12.3+ "
                        "and/or you do not have an MPS-enabled device on this machine."
                    )
                print(" Falling back to CPU.")
            device = torch.device("cpu")
        else:
            if not silent:
                print("Returning MPS because you're on a Mac, and it's available.")
            device = torch.device("mps")
        return device

    if not torch.cuda.is_available():
        fansi_print("Selecting CPU because torch.cuda is not available - either you don't have cuda, or torch wasn't compiled to support it.",style='bold')
        return torch.device('cpu')

    if reserve:
        pip_import('filelock') #https://py-filelock.readthedocs.io/en/latest/
        from filelock import FileLock
        import torch #Assume we have this installed already - pip_import shouldn't handle this

        #Keep the GPU lock file local to the current running machine - don't lock GPU selection over different conda instances or comptuers over NFS
        gpu_lock_file = _get_select_torch_device_lock_file()

        lock = FileLock(gpu_lock_file)
        with lock:
            selected_device = select_torch_device(
                n=n,
                silent=silent,
                prefer_used=prefer_used,
                reserve=False,
            )

            #Reserve it. Even after this function exits, this process will still be listed as using VRAM.
            torch.zeros(1).to(selected_device)

            return selected_device

    all_gpus = get_visible_gpu_ids()
    used_gpus = list(set(get_gpu_ids_used_by_process()) | _select_torch_device_used_gpus)

    if prefer_used and used_gpus and not silent:
        if len(used_gpus)==1:
            print("This computer has %i GPUs, but we will be choosing cuda:%i because we're already using it."              % (len(all_gpus),     used_gpus[0]))
        if len(used_gpus)>1:
            print("This computer has %i GPUs, but we will be choosing from the subset %s because we're already using them." % (len(all_gpus), str(used_gpus)  ))
        available_gpus = used_gpus
    else:
        available_gpus = all_gpus

    gpu_count = len(available_gpus)

    if not silent and n>=gpu_count:
        new_n = n % gpu_count
        print("You selected index n=%i (zero-based). The GPU selection will cycle through the %i available GPUs. We've set n to %i (GPU index after cycling)." % (n, gpu_count, new_n))
        n = new_n

    selected_gpu_id = get_gpu_with_most_free_vram(n, choices=available_gpus)

    #On docker environments, we can't reliably detect which GPU's are used by this process - so just record what this function outputs and assume we're using that GPU.
    _select_torch_device_used_gpus |= {selected_gpu_id}

    if gpu_count == 0:
        if not silent:
            fansi_print("We have no GPUs. Selecting 'cpu'.",style='bold')
        output=torch.device("cpu")
    
    else:
        output=torch.device("cuda:%i"%selected_gpu_id)

    if not silent and gpu_count>0:
        gpu_summary=print_gpu_summary(silent=True)
        lines=gpu_summary.splitlines()
        
        arrow_header='Selecting %s –––> '%output
        empty_header=' '*len(arrow_header)
        arrow_header=fansi(arrow_header,style='bold')
        headers=[empty_header for _ in lines]

        #EXAMPLE OUTPUT WE DONT WANT:
        #                        ┏━━━━━━━━┳━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━┳━━━━━━┳━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
        #                        ┃ GPU ID ┃       Name       ┃      Used      ┃   Free ┃  Total ┃ Temp ┃ Util ┃ Processes                          ┃
        #                        ┡━━━━━━━━╇━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━╇━━━━━━╇━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩
        #                        │   0    │ NVIDIA RTX A5000 │  21.4GB  89.2% │  2.6GB │ 24.0GB │ 84°C │ 100% │ ryan 21.1GB: 503120 20.2GB, 504826 │
        #  Selecting cuda:1 –––> │        │                  │                │        │        │      │      │ 718MB, 741137 238MB                │
        #                        │   1    │ NVIDIA RTX A5000 │ 319.9MB   1.3% │ 23.7GB │ 24.0GB │ 35°C │   0% │                                    │
        #                        │   2    │ NVIDIA RTX A5000 │  18.7GB  77.9% │  5.3GB │ 24.0GB │ 78°C │  74% │ ryan 18.4GB: 504826 18.4GB         │
        #                        │   3    │ NVIDIA RTX A5000 │ 319.9MB   1.3% │ 23.7GB │ 24.0GB │ 36°C │   0% │                                    │
        #                        └────────┴──────────────────┴────────────────┴────────┴────────┴──────┴──────┴────────────────────────────────────┘
        #We must correctly select the line number
        # headers[3+selected_gpu_id]=arrow_header <--- Old version, not good enough: see above example
        possible_lines=[i for i in range(len(lines)) if strip_ansi_escapes(lines[i]).strip().startswith("│   "+str(selected_gpu_id))]
        if possible_lines:
            selected_line_number = min(possible_lines)
            headers[selected_line_number]=arrow_header 
            
            lines=[header+line for header,line in zip(headers,lines)]
            if not silent:
                print(line_join(lines))

    return output


def _torch_device_to_index(device):
    """
    Convert a given device specifier into its corresponding index.
    
    The function handles multiple formats for specifying devices, 
    such as "cuda:3", torch.device(3), or int(3).
    
    Args:
        device (str, int, torch.device): Device specifier.
    
    Returns:
        int: Device index.
    
    Raises:
        TypeError: If the device format is not understood.
        ValueError: If the device format is understood but is not valid (e.g., 'cpu').
    """
    
    # If device is already an integer.
    if isinstance(device, int):
        return device

    # If device is a string in format "cuda:x".
    elif isinstance(device, str) and ":" in device:
        try:
            _, index_str = device.split(":")
            return int(index_str)
        except (ValueError, AttributeError):
            # Failed to split or convert to int, so fall back to the PyTorch logic.
            pass

    # If device has a valid index attribute.
    elif hasattr(device, "index") and device.index is not None:
        return device.index

    # Fall back to torch-specific logic.
    try:
        pip_import('torch')
        import torch
        index = torch.device(device).index
        if index is not None:
            return index
        else:
            # If the result is None, then it's possibly an invalid device format.
            raise ValueError("The device '%s' is not valid. For example, 'cpu' is not a valid GPU device."%device)
    except (ImportError, ValueError):
        raise TypeError(
            "_torch_device_to_index: Can't make sense of the input, which is of type "+str(type(device))
        )

def _waste_gpu(gpu_id):
    import torch

    vram = get_free_vram(gpu_id)  # Measured in bytes
    vram = int(vram * 0.9)  # Proportion of VRAM to waste

    fansi_print("Attempting to waste " + human_readable_file_size(vram) + " on GPU #" + str(gpu_id),'cyan cyan blue','bold')

    matrix_vram = vram
    matrix_vram = matrix_vram // 2  # Make extra room to hold the temp matrix
    matrix_vram //= 4  # Using 32 bit floats
    matrix_size = int(matrix_vram**0.5)
    matrix = torch.ones(
        (matrix_size, matrix_size),
        dtype=torch.float32,
        device="cuda:"+str(gpu_id),
    )

    while True:
        (matrix @ matrix).cpu()

def waste_gpus(*gpu_ids):
    """
    Keeps all GPU's busy on a system, using as much VRAM as possible. Used for stress-testing. Should take minimal CPU.
    Alternatively, if you only want to waste certain GPU's, pass them in as args like waste_gpus(2,3,4) to leave 0 and 1 empty
    """

    if not gpu_ids:
        gpu_ids = get_visible_gpu_ids()
    
    for gpu_id in gpu_ids:
        run_as_new_thread(_waste_gpu, gpu_id)

    while True:
        fansi_print(
            get_current_function_name() + ": " + format_current_date(), "blue cyan"
        )
        print_gpu_summary()
        sleep(1)

def set_cuda_visible_devices(*devices):
    """
    Sets the CUDA_VISIBLE_DEVICES environment variable.
    
    This configuration restricts which GPUs are visible to libraries like PyTorch, TensorFlow, etc.
    The function should be used before any GPU-specific function calls.
    The visible devices will be carried over to child processes - very useful for running other people's
        code if they didn't make a way to select the GPU's it runs on (i.e. if they use "tensor.cuda()" everywhere)
    
    The devices can be specified in various ways:
        - Integer indices (e.g., 0, 1, 2).
        - Strings with the format "cuda:x" (e.g., "cuda:0").
        - torch.device objects.
    
    Args:
        devices: Variable length argument list containing GPU devices to set as visible.
                 Each device can be an integer, a string in the format "cuda:x", or a torch.device object.
                 If no devices are provided, all GPUs will be made visible.
    
    Returns:
        list[int]: Indices of the previously visible GPUs.
        
    Raises:
        TypeError: If a provided device format is not understood.
        ValueError: If a valid device format is provided but does not correspond to a GPU (e.g., 'cpu').
    
    Note:
        Ensure you have the necessary libraries installed if passing objects like torch.device.

    Examples:
        >>> set_cuda_visible_devices(select_torch_device())
        [] #Example output - the default is all are visible
        >>> set_cuda_visible_devices()
        [0, 1, 2]  # example output, the previously visible GPUs
        >>> set_cuda_visible_devices([])
        [0]  # example output
        >>> set_cuda_visible_devices('cuda:0', 3)
        [0, 2]  # example output
        >>> set_cuda_visible_devices(('cuda:0', 3))
        [0, 1]  # example output
    """
    
    devices = detuple(devices)
    if not is_iterable(devices):
        devices = [devices]

    devices = [_torch_device_to_index(device) for device in devices]
    assert all(isinstance(x, int) for x in devices), "All devices should be represented as integers."

    env_key = "CUDA_VISIBLE_DEVICES"
    prev_visible_devices = os.environ.get(env_key, list())

    # Set the CUDA_VISIBLE_DEVICES environment variable.
    if devices:
        devices_str = ",".join(map(str, devices))
        os.environ[env_key] = devices_str
    elif env_key in os.environ:
        del os.environ[env_key]

    # Process previous visible devices for the output.
    if prev_visible_devices:
        prev_visible_devices = list(map(int, prev_visible_devices.split(',')))

    return prev_visible_devices


def get_cuda_visible_devices():
    """ Returns a list of ints """
    import ast
    key = 'CUDA_VISIBLE_DEVICES'
    if key in os.environ:
        out = os.environ[key]
        if out:
            output = ast.literal_eval(out)

            if isinstance(output, int):
                output = [output]

            output = list(output)
            
            return output
    return []

def run_removestar(code:str,max_line_length=100,quiet=False,qualify=False):
    """
    Takes something like:
       from numpy import *
       from rp import *
       asarray([1,2,3])
       display_image(x)
    And turns it into this:
       from numpy import asarray
       from rp import display_image
       asarray([1,2,3])
       display_image(x)
    It removes the stars


    EXAMPLE:
        >>> code='''from rp import *
        ... 
        ... # 2025-03-11 02:24:15.105806
        ... N = 300
        ... image = load_image(
        ...     "https://github.com/RyannDaGreat/Diffusion-Illusions/blob/gh-pages/images/emma.png?raw=true",
        ...     use_cache=True,
        ... )
        ... 
        ... colors = as_rgba_float_colors(
        ...     "random blue" for _ in range(N)
        ... )
        ... rim_colors = as_rgba_float_colors("white randombw dark black randomgray" for _ in range(N))
        ... y = random_ints(N, get_image_height(image) - 1)
        ... x = random_ints(N, get_image_width(image) - 1)
        ... radii = random_floats(N, 1, 30)
        ... rims = random_floats(N, -10, 10)
        ... 
        ... display_image(cv_draw_circles(image, x, y, radii, colors, rims, rim_colors))
        ... '''
        ... display_code_cell(run_removestar(code,qualify=False),title='Without Qualify')
        ... display_code_cell(run_removestar(code,qualify=True),title='With Qualify')
        ... 
        ... 
        ...     ┌─────────────────────────────────────────Without Qualify──────────────────────────────────────────
        ...    1│from rp import (as_rgba_float_colors, cv_draw_circles, display_image, get_image_height,
        ...    2│                get_image_width, load_image, random_floats, random_ints)
        ...    3│
        ...    4│# 2025-03-11 02:24:15.105806
        ...    5│N = 300
        ...    6│image = load_image(
        ...    7│    "https://github.com/RyannDaGreat/Diffusion-Illusions/blob/gh-pages/images/emma.png?raw=true",
        ...    8│    use_cache=True,
        ...    9│)
        ...   10│
        ...   11│colors = as_rgba_float_colors(
        ...   12│    "random blue" for _ in range(N)
        ...   13│)
        ...   14│rim_colors = as_rgba_float_colors("white randombw dark black randomgray" for _ in range(N))
        ...   15│y = random_ints(N, get_image_height(image) - 1)
        ...   16│x = random_ints(N, get_image_width(image) - 1)
        ...   17│radii = random_floats(N, 1, 30)
        ...   18│rims = random_floats(N, -10, 10)
        ...   19│
        ...   20│display_image(cv_draw_circles(image, x, y, radii, colors, rims, rim_colors))
        ... 
        ... 
        ...     ┌───────────────────────────────────────────With Qualify───────────────────────────────────────────
        ...    1│import rp
        ...    2│
        ...    3│# 2025-03-11 02:24:15.105806
        ...    4│N = 300
        ...    5│image = rp.load_image(
        ...    6│    "https://github.com/RyannDaGreat/Diffusion-Illusions/blob/gh-pages/images/emma.png?raw=true",
        ...    7│    use_cache=True,
        ...    8│)
        ...    9│
        ...   10│colors = rp.as_rgba_float_colors(
        ...   11│    "random blue" for _ in range(N)
        ...   12│)
        ...   13│rim_colors = rp.as_rgba_float_colors("white randombw dark black randomgray" for _ in range(N))
        ...   14│y = rp.random_ints(N, rp.get_image_height(image) - 1)
        ...   15│x = rp.random_ints(N, rp.get_image_width(image) - 1)
        ...   16│radii = rp.random_floats(N, 1, 30)
        ...   17│rims = rp.random_floats(N, -10, 10)
        ...   18│
        ...   19│rp.display_image(rp.cv_draw_circles(image, x, y, radii, colors, rims, rim_colors))
    """
    pip_import('removestar')

    if qualify:
        modules = get_star_modules(code)
        new_code = gather_args_call(run_removestar, qualify=False)
        new_code = qualify_imports(new_code, modules)
        return new_code

    from removestar.removestar import fix_code
    return fix_code(code,file='filename is irrelevant',max_line_length=max_line_length,quiet=True)
_removestar = run_removestar

def qualify_imports(code, *module_names):
    """
    EXAMPLE:
        >>> code = '''
        ... from numpy.random import randn
        ... from rp import display_image
        ... 
        ... display_image(randn(512,512,3))
        ... '''
        ... display_code_cell(code)
        ... display_code_cell(qualify_imports(code,'rp'))
        ... display_code_cell(qualify_imports(code,'numpy.random'))
        ... display_code_cell(qualify_imports(code,'rp','numpy.random'))
        ... 
        ... #   ┌───────────Code Cell────────────
        ... #  1│from numpy.random import randn
        ... #  2│from rp import display_image
        ... #  3│
        ... #  4│display_image(randn(512,512,3))
        ...  
        ... #   ┌──────────────Code Cell──────────────
        ... #  1│from numpy.random import randn
        ... #  2│import rp
        ... #  3│rp.display_image(randn(512, 512, 3))
        ...  
        ... #   ┌───────────────────Code Cell───────────────────
        ... #  1│import numpy.random
        ... #  2│from rp import display_image
        ... #  3│display_image(numpy.random.randn(512, 512, 3))
        ...  
        ... #   ┌───────────────────Code Cell───────────────────
        ... #  1│import numpy.random
        ... #  2│import rp
        ... #  3│rp.display_image(numpy.random.randn(512, 512, 3))
    """

    module_names = detuple(module_names)

    if isinstance(module_names, str):
        module_names = [module_names]

    for module_name in module_names:
        code = _qualify_imports(code, module_name)

    return code

def _qualify_imports(code, *module_names):
    pip_import('libcst')

    import libcst as cst
    from libcst import matchers as m

    class QualifyImportsTransformer(cst.CSTTransformer):
        def __init__(self, module_names):
            self.module_names = set(module_names)
            self.replacements = {}

        def get_full_module_name(self, module):
            parts = []
            while isinstance(module, cst.Attribute):
                parts.insert(0, module.attr.value)
                module = module.value
            if isinstance(module, cst.Name):
                parts.insert(0, module.value)
            return '.'.join(parts)

        def leave_ImportFrom(self, original_node, updated_node):
            if original_node.module:
                mod_name = self.get_full_module_name(original_node.module)
                if mod_name in self.module_names:
                    # Handle "from module import *" case
                    if isinstance(original_node.names, cst.ImportStar):
                        # For star imports, we mark this module for aggressive qualification
                        # All bare names that could be from this module will be qualified
                        self.star_imported_modules = getattr(self, 'star_imported_modules', set())
                        self.star_imported_modules.add(mod_name)
                        return cst.Import(names=[cst.ImportAlias(name=original_node.module)])
                    else:
                        # Handle specific imports like "from module import name1, name2"
                        for alias in original_node.names:
                            if isinstance(alias, cst.ImportAlias) and alias.asname is None:
                                name = alias.name.value
                                self.replacements[name] = mod_name
                        return cst.Import(names=[cst.ImportAlias(name=original_node.module)])
            return updated_node

        def leave_Call(self, original_node, updated_node):
            """Handle function calls - this is where we want to qualify star import names."""
            # Only process if the function being called is a bare name (not already qualified)
            if isinstance(updated_node.func, cst.Name):
                func_name = updated_node.func.value
                
                # Check if this should be qualified due to star imports
                star_imported_modules = getattr(self, 'star_imported_modules', set())
                if star_imported_modules and self._is_likely_module_function(func_name):
                    # Qualify the function call
                    mod_name = next(iter(star_imported_modules))
                    attrs = mod_name.split('.') + [func_name]
                    attr_node = cst.Name(attrs[0])
                    for attr in attrs[1:]:
                        attr_node = cst.Attribute(value=attr_node, attr=cst.Name(attr))
                    
                    # Return the call with qualified function name
                    return updated_node.with_changes(func=attr_node)
            
            return updated_node

        def leave_Name(self, original_node, updated_node):
            # Handle explicit replacements (non-star imports)
            if original_node.value in self.replacements:
                mod_name = self.replacements[original_node.value]
                attrs = mod_name.split('.') + [original_node.value]
                attr_node = cst.Name(attrs[0])
                for attr in attrs[1:]:
                    attr_node = cst.Attribute(value=attr_node, attr=cst.Name(attr))
                return attr_node
            
            return updated_node
        
        def _is_likely_module_function(self, name):
            """Determine if a name is likely a function from a module (not a built-in)."""
            # Don't qualify obvious built-ins and common Python names
            builtins_and_common = {
                'print', 'len', 'str', 'int', 'float', 'bool', 'list', 'dict', 'set', 'tuple',
                'range', 'enumerate', 'zip', 'map', 'filter', 'sum', 'min', 'max', 'abs',
                'isinstance', 'hasattr', 'getattr', 'setattr', 'type', 'super', 'next',
                'open', 'round', 'sorted', 'reversed', 'any', 'all', 'iter', 'format',
                'Exception', 'ValueError', 'TypeError', 'KeyError', 'IndexError',
                'True', 'False', 'None', 'self', 'cls'
            }
            
            # Also don't qualify single letters or very short names (likely variables)
            if len(name) <= 2:
                return False
                
            return name not in builtins_and_common

    tree = cst.parse_module(code)
    transformer = QualifyImportsTransformer(module_names)
    modified_tree = tree.visit(transformer)

    return modified_tree.code

def get_star_modules(code):
    '''
    EXAMPLE:
        >>> get_star_modules("""from numpy import randn
        from rp import *
        from pandas import *
        import torch as t
        """)
        ans = ['pandas', 'rp']
    '''
    code=code.splitlines()
    code=[x for x in code if x.startswith('from ') and x.endswith('import *')]
    code=[x[len('from '):-len(' import *')].strip() for x in code]
    return sorted(set(code))

def remove_fstrings(code):
    """
    Removes f-strings, using str.format notation instead. This is good for making code backwards-compatiable with python 3.5
    """
    from rp.libs.refactor.string_format.fstring_converter import convert_string
    return convert_string(code)

# def refactor_pyupgrade(
#     code: str,
#     min_version: tuple[int, ...] = (3,),
#     keep_percent_format: bool = False,
#     keep_mock: bool = False,
#     keep_runtime_typing: bool = False
# ) -> str:
#     """Modernize Python code by upgrading syntax to newer versions.
#
#     Args:
#         code: Python source code to modernize
#         min_version: Target Python version tuple, e.g. (3, 8) for Python 3.8+
#             - (3,): Basic Python 3 upgrades (dict() → {}, .keys() → list())
#             - (3, 6): f-strings, variable annotations
#             - (3, 8): Positional-only params, := operator
#             - (3, 10): Union[X, Y] → X | Y, match statements
#         keep_percent_format: Don't upgrade % formatting → .format()
#             - True: keeps "Hello %s" % name
#             - False: converts to "Hello {}".format(name)
#         keep_mock: Don't upgrade mock imports
#             - True: keeps "from mock import Mock"
#             - False: converts to "from unittest.mock import Mock"
#         keep_runtime_typing: Don't remove typing imports only used in annotations
#             - True: keeps "from typing import List" even if only in type hints
#             - False: removes unused typing imports when min_version supports built-ins
#
#     Returns:
#         Modernized Python source code
#     """
#     import pyupgrade._main
#     from pyupgrade._data import Settings
#
#     settings = Settings(
#         min_version=min_version,
#         keep_percent_format=keep_percent_format,
#         keep_mock=keep_mock,
#         keep_runtime_typing=keep_runtime_typing,
#     )
#
#     # Apply plugin fixes
#     refactored_code = pyupgrade._main._fix_plugins(code, settings)
#
#     # Apply token fixes
#     refactored_code = pyupgrade._main._fix_tokens(refactored_code)
#
#     return refactored_code

def refactor_flynt(
    code: str,
    *,
    aggressive: bool = False,
    multiline: bool = True,
    len_limit: int = None,
    percent: bool = True,
    format: bool = True,
    concat: bool = False,
    join: bool = False
) -> str:
    """
    Refactor Python code using flynt to convert to f-strings.

    Args:
        code: Python source code to refactor
        aggressive: Use aggressive mode for transformations
        multiline: Allow multiline f-strings
        len_limit: Length limit for f-strings (None for no limit)
        percent: Transform %-style formatting to f-strings
        format: Transform .format() calls to f-strings
        concat: Transform string concatenations to f-strings
        join: Transform static string joins to f-strings

    Returns:
        Refactored Python code as string
    """
    from flynt.api import fstringify_code
    from flynt.state import State

    # Create state with provided options
    state = State(
        quiet=True,  # Don't print reports
        dry_run=False,  # Apply changes
        stdout=False,  # Don't print to stdout
        aggressive=aggressive,
        multiline=multiline,
        len_limit=len_limit,
        transform_percent=percent,
        transform_format=format,
        transform_concat=concat,
        transform_join=join,
    )

    # Apply flynt transformations
    result = fstringify_code(code, state)

    if result is None:
        return code  # Return original if transformation failed

    return result.content


def file_line_iterator(file_name, *, with_len=False, reverse=False):
    """
    Opens a file and iterates through its lines.
    
    This function requires a better name. The purpose of this function is to provide
    an iterator that reads lines from a given file one by one.
    
    Args:
        file_name (str): The path to the file to be read.
        with_len (bool, optional): If set to True, precomputes the number 
            of lines in the file using the `number_of_lines_in_file` function. 
            This can be useful for tools like tqdm to show progress. 
            Defaults to False.
        reverse (bool, optional): If set to True, iterates through the file's
            lines in reverse order (from last line to first). Uses memory-mapped
            files for efficiency. Defaults to False.
    
    Returns:
        Iterator: An iterator that yields lines from the given file.

    EXAMPLE:

        >>> file=get_module_path(r)
        ... assert line_join(file_line_iterator(file)) == line_join(list(file_line_iterator(file,reverse=True))[::-1])

    """

    iterator = (_reverse_file_line_gen if reverse else _file_line_gen)(file_name)

    if with_len:
        length = number_of_lines_in_file(file_name)
        iterator = IteratorWithLen(iterator, length)

    return iterator

def _file_line_gen(file_name):
    with open(file_name, 'r') as file:
        while True:
            line = file.readline()
            if not line:
                file.close()
                return
            if line.endswith('\n'):
                yield line[:-1]
            else:
                yield line

def _reverse_file_line_gen(file_name):
    """Generator that yields lines from a file in reverse order using mmap for efficiency."""
    import mmap
    import os
    
    with open(file_name, 'rb') as f:
        # Handle empty file
        if os.fstat(f.fileno()).st_size == 0:
            return
            
        with mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ) as mmapped_file:
            file_size = len(mmapped_file)
            
            # We'll track positions where lines end (including newlines)
            end_pos = file_size
            
            while end_pos > 0:
                # Find the previous newline (start of current line)
                start_pos = mmapped_file.rfind(b'\n', 0, end_pos - 1) + 1
                
                # Extract the line including potential newline
                raw_line = mmapped_file[start_pos:end_pos]
                line = raw_line.decode('utf-8', errors='replace')
                
                # Strip trailing newline if present (matching _file_line_gen behavior)
                if line.endswith('\n'):
                    yield line[:-1]
                else:
                    yield line
                
                # Move to the end of the previous line
                end_pos = start_pos


class IteratorWithLen:
    def __init__(self, iterator, length: int):
        self.iterator = iterator
        self._length = length

    def __iter__(self):
        return self

    def __next__(self):
        return next(self.iterator)

    def __len__(self):
        return self._length

    def __repr__(self):
        return '<IteratorWithLen len='+str(self._length)+'>'
    
@memoized
def get_system_fonts(filetypes="ttf ttc otf"):
    """ Returns a list of paths to all fonts of specified types on this computer.
    
    Enhanced Documentation:
    Cross-platform font discovery function that locates system fonts across macOS, Linux, and Windows.
    Each platform uses its native font location conventions and utilities. Supports filtering by
    font file extensions to find specific font types.
    
    Usage patterns in codebase:
    - Font path resolution for text rendering and typography functions
    - System font enumeration for UI components
    - Font validation and availability checking
    - Used by _get_font_path() and other typography functions
    
    Args:
        filetypes (str, optional): Space-separated string of font file extensions to include.
            Defaults to "ttf ttc otf". Case-insensitive matching.
            Examples: "ttf", "otf ttc", "ttf otf woff"
            Empty string includes all files in font directories.
    
    Returns:
        list: List of absolute paths to font files matching the specified types.
              Paths are strings pointing to actual font files on the system.
              Returns empty list if no fonts found or unsupported platform.
    
    Examples:
        >>> # Get all default font types (TTF, TTC, OTF)
        >>> fonts = get_system_fonts()
        >>> len(fonts)
        660
        >>> fonts[0]
        '/System/Library/Fonts/AppleSDGothicNeo.ttc'
        >>> 
        >>> # Get only TrueType fonts
        >>> ttf_fonts = get_system_fonts("ttf")
        >>> len(ttf_fonts)
        464
        >>> 
        >>> # Get multiple specific types
        >>> custom_fonts = get_system_fonts("otf woff")
        
    Platform Implementation:
        - macOS: Searches /System/Library/Fonts, /Library/Fonts, ~/Library/Fonts
        - Linux: Uses fc-list command to enumerate system fonts
        - Windows: Lists fonts in C:\\Windows\\fonts directory
        
    Related Functions:
        - _get_font_path(): Resolves specific font names to paths
        - Uses internally: _get_all_paths_fast(), shell_command(), file_exists()
        
    Tags: fonts, typography, cross-platform, system-resources
    """
    import os
    if currently_running_mac():
        out=[]
        extensions_list = filetypes.lower().split() # Convert to lowercase for case-insensitive matching and split into list

        locations = [
            '/System/Library/Fonts',
            '/Library/Fonts',
            '~/Library/Fonts'
        ]

        for location in locations:
            full_location = os.path.expanduser(location) # Expand ~ to user home
            all_paths = _get_all_paths_fast(full_location, recursive=True) # Get all paths recursively

            def filter_by_extension(path):
                if not extensions_list: # If no extensions specified, include all files (or filter based on some default font-like extensions if desired)
                    return True # Keep all if no extension filter

                return any(path.lower().endswith("." + ext) for ext in extensions_list) # Check if path ends with any of the specified extensions

            filtered_paths = filter(filter_by_extension, all_paths) # Apply the filter
            out.extend(filtered_paths) # Add filtered paths to the output list


        # Potentially add Network fonts, but less common for standard fonts, and could be slow in some network environments.
        # out+=['/Network/Library/Fonts/'+x for x in shell_command('ls -R /Network/Library/Fonts | grep -i ttf').splitlines()]
        return out
    elif currently_running_linux():
        #https://askubuntu.com/questions/552979/how-can-i-determine-which-fonts-are-installed-from-the-command-line-and-what-is
        ans=shell_command('fc-list')
        ans=line_split(ans)
        ans=[x[:x.find(':')] for x in ans]
        ans=[x for x in ans if file_exists(x)]
        return list(ans)
    elif currently_running_windows():
        #https://stackoverflow.com/questions/64070050/how-to-get-a-list-of-installed-windows-fonts-using-python
        import os
        return list(os.listdir(r'C:\Windows\fonts'))
    else:
        assert False,'Unsupported operating system: not mac, windows or linux'


class BoolSet(MutableSet):
    """
    A hybrid between EasyDict and the set class that lets you check for items via attributes
    It behaves exactly like a set, except with a simple addition:
    You can check and set existence via indexing and attributes, similar to EasyDict
    
    EXAMPLE:
        >>> ans = BoolSet(['A','B','C'])
        >>> ans |= {'D'}   #Adds D to the set
        >>> ans.E=True     #Adds E to the set
        >>> ans['Z']=True  #Adds Z to the set
        >>> del ans.A      #Removes A from the set
        >>> ans.B=False    #Removes B from the set
        >>> ans['Y']=False #Does nothing cause Y is not in the set
        >>> print(ans)
        BoolSet({'D', 'E', 'C'})
        >>> print(ans.A)
        False
        >>> print(ans.C)
        True
        
    """
    def __init__(self, iterable=None):
        object.__setattr__(self, '_data', set(iterable) if iterable is not None else set())

    # --- Abstract methods required by MutableSet ---
    def __contains__(self, item):
        return item in self._data

    def __iter__(self):
        return iter(self._data)

    def __len__(self):
        return len(self._data)

    def add(self, item):
        self._data.add(item)

    def discard(self, item):
        self._data.discard(item)

    # --- Combined boolean-style interface ---
    def __getitem__(self, item):
        return item in self

    def __setitem__(self, item, value):
        if not isinstance(value, (bool, int)):
            raise TypeError("Value must be a boolean (True to add, False to remove)")
        
        if value:
            self.add(item)
        else:
            self.discard(item)
            
    # --- Easydict-like attribute access ---
    def __getattr__(self, name):
        return self[name]

    def __delattr__(self, name):
        self[name]=False

    def __setattr__(self, name, value):
        """
        Allows `my_set.item = True` assignments for new items, but raises
        an error if the attribute or method already exists.
        """
        # First, check if an attribute with this name already exists on the
        # object, its class, or its parent classes. hasattr() is perfect for this.
        try:
            object.__getattribute__(self, name)
            error = True
        except AttributeError:
            error = False
        
        if error:
            raise AttributeError(
                "Cannot overwrite existing attribute or method "+repr(name)+". Try using index assignment instead."
            )

        # If the attribute does not exist, treat it as a set item operation.
        self[name] = value

    def __repr__(self):
        return self.__class__.__name__+'('+repr(self._data)+')'


def repeat_last(it):
    """
    Yield items from iterable and then repeat its last item forever.

    EXAMPLE:
        >>> list(zip([1,2,3], repeat_last(['a','b'])))
        [(1, 'a'), (2, 'b'), (3, 'b')]

    EXAMPLE:
        >>> for x in repeat_last([1, 2, 3, 4, 5]):
        >>>     print(end=str(x))
        1234555555555555555555555555555555555555555555555...

    """
    from itertools import repeat
    
    for x in it:
        yield x

    if 'x' in locals():
        yield from repeat(x)

def zip_repeat_last(*iters):
    """
    Zip iterables, repeating each last item until all are exhausted.

    parameters:
        iters: non-empty iterables.

    returns:
        finite generator of tuples.

    EXAMPLE:
        >>> list(zip_repeat_last([1, 2, 3, 4], ['a', 'b']))
        [(1, 'a'), (2, 'b'), (3, 'b'), (4, 'b')]
    """
    iters = [iter(it) for it in iters]

    lasts = []
    for it in iters:
        try:
            lasts.append(next(it))
        except StopIteration:
            raise ValueError("empty iterable passed to zip_repeat_last")

    while True:
        yield tuple(lasts)
        advanced = False
        for i, it in enumerate(iters):
            try:
                lasts[i] = next(it)
                advanced = True
            except StopIteration:
                pass
        if not advanced:
            break

def resize_list(array:list, length: int):
    """
    This function stretches or compresses a list to a given length using nearest-neighbor interpolation. 
    The last element of the input list is always included in the output list, regardless of the target length.
    
    If the given array is a torch tensor or numpy array, the output will be the same type

    Parameters:
        array (list): The input list to resize.
        length (int): The target length of the list.

    Assumptions:
        The function assumes that the target length is a non-negative integer.

    Returns:
        list: The resized list to the target length.

    Examples:
        >>> resize_list([0,1,2,3,4], 5)
        [0, 1, 2, 3, 4]
        # The target length is the length of the input array. No change.

        >>> resize_list([0,1,2,3,4,5,6,7,8,9], 5)
        [0, 2, 4, 6, 9]
        # Elements are skipped when resizing to a shorter length, but the last element is always included.

        >>> resize_list([1,2,3,4,5,6,7,8,9], 3)
        [1, 5, 9]

        >>> resize_list([1,2,3,4,5,6,7,8,9], 5)
        [1, 3, 5, 7, 9]

        >>> resize_list([0,1,2,3,4], 10)
        [0, 0, 1, 1, 2, 2, 3, 3, 4, 4]
        # Elements are duplicated during resizing to a longer length.

        >>> resize_list([0,1,2,3,4], 1)
        [4]
        # Even when resizing to length 1, the last element is included.

        >>> resize_list(range(3000000000000000),4)
        [0, 1000000000000000, 1999999999999999, 2999999999999999]
        # Pro-tip: this ran in .00001 seconds! You can use it to get indices by passing in a range object

    """

    assert isinstance(length, int), "Length must be an integer, but got %s instead"%repr(type(length))
    assert length >= 0, "Length must be a non-negative integer, but got %i instead"%length

    if len(array) > 1 and length > 1:
        step = (len(array) - 1) / (length - 1)
    else:
        step = 0  # default step size to 0 if array has only 1 element or target length is 1
        
    indices = [round(i * step) for i in range(length)]
    
    if is_numpy_array(array) or is_torch_tensor(array):
        return array[indices]
    else:
        return [array[i] for i in indices]

def resize_lists(*arrays:list, length:int):
    """ Plural of rp.resize_list """
    arrays = detuple(arrays)

    output = [resize_list(array, length) for array in arrays]

    if   is_numpy_array (arrays): output = as_numpy_array(output)
    elif is_torch_tensor(arrays): output = __import__('torch').tensor(output)

    return output

def resize_vector(x, length: int, *, interp: str = "linear", loop: bool = False):
    """
    Resize a 1D sequence to `length` using 'nearest' or 'linear'.

    Returns:
      - np.ndarray for list/ndarray inputs
      - torch.Tensor for torch inputs
    Always 1-D (asserted). No reshaping internally.

    Rules:
      - 'nearest' → endpoint grid, first always included, loop ignored
      - 'linear'  → np.interp / F.interpolate(align_corners=True)
      - loop=True → only for 'linear', appends first sample once
      - length==0 → empty, length==1 → first element

    EXAMPLES:

        >>> resize_vector([0,1,2,3,4], 3, "nearest")        # → [0,2,4]
        >>> resize_vector([0,1,2,3,4], 1, "nearest")        # → [0]
        >>> resize_vector([0,1,2,3,4], 0, "nearest")        # → []
        >>> resize_vector(np.array([0.,10.]), 5, "linear")  # → [0.,2.5,5.,7.5,10.]
        >>> resize_vector([7.5], 4, "linear")               # → [7.5,7.5,7.5,7.5]
        >>> resize_vector([0,100,200], 7, "linear", loop=1) # → [0.,50.,100.,150.,200.,100.,0.]
        >>> resize_vector(torch.tensor([0.,10.,20.,30.]),3,"nearest") # → tensor([0.,10.,30.])
        >>> resize_vector(torch.tensor([0.,10.,20.,30.]),7,"linear")  # → tensor([0.,5.,10.,15.,20.,25.,30.])
        >>> resize_vector(torch.tensor([1.],dtype=torch.float16),4,"linear").dtype # → torch.float32
        >>> resize_vector(torch.tensor([0.,1.]).cuda(),4,"linear").device          # → cuda:0

    """
    assert isinstance(length, int) and length >= 0
    mode = interp.lower()
    assert mode in ("nearest", "linear")

    if is_torch_tensor(x):
        th = __import__("torch")
        import torch.nn.functional as F
        assert x.dim() == 1, "resize_vector: torch input must be 1D, got dim="+str(x.dim())
        xt = x

        if length == 0:
            return xt[:0]

        if mode == "nearest":
            return resize_list(xt, length)  # nearest BEFORE looping

        if loop and xt.numel() > 1:
            xt = th.cat([xt, xt[:1]])

        t = xt.to(dtype=th.float32).view(1, 1, -1)
        out = F.interpolate(t, size=length, mode="linear", align_corners=True)
        return out.view(-1)

    else:
        arr = as_numpy_array(x)
        assert arr.ndim == 1, "resize_vector: NumPy input must be 1D, got ndim="+str(arr.ndim)

        if length == 0:
            return arr[:0]

        if mode == "nearest":
            return as_numpy_array(resize_list(arr, length))  # nearest BEFORE looping

        if loop and arr.size > 1:
            arr = np.concatenate([arr, arr[:1]])

        xp = np.linspace(0.0, 1.0, num=arr.size, endpoint=True)
        xi = np.linspace(0.0, 1.0, num=length, endpoint=True)
        return np.interp(xi, xp, arr)

def resize_vectors(*vectors, length:int, interp="linear", loop:bool = False):
    """ Plural of resize_vector """
    vectors = detuple(vectors)
    
    output = [resize_vector(vector, length=length, interp=interp, loop=loop) for vector in vectors]

    if   is_numpy_array (vectors): output = as_numpy_array(output)
    elif is_torch_tensor(vectors): output = __import__('torch').tensor(output)

    return output


def resize_lists_to_max_len(*lists):
    """
    Resize multiple lists to match the longest one's length.

    Uses resize_list() with nearest-neighbor interpolation to stretch/compress each list 
    to the maximum length found. Elements are duplicated when expanding, sampled when shrinking.

    Examples:
        >>> resize_lists_to_max_len([1, 2], [3, 4, 5, 6], [7])
        [[1, 1, 2, 2], [3, 4, 5, 6], [7, 7, 7, 7]]

    Tags: lists, resize, batch, plural, interpolation
    """
    lists=detuple(lists)
    if is_numpy_array(lists): return lists.copy() #Shortcut!
    length=max(map(len,lists))
    return [resize_list(l,length) for l in lists]

def resize_lists_to_min_len(*lists):
    """
    Resize multiple lists to match the shortest one's length.

    Uses resize_list() with nearest-neighbor interpolation to stretch/compress each list 
    to the minimum length found. Always preserves the last element when shrinking.

    Examples:
        >>> resize_lists_to_min_len([1, 2, 3, 4], [5, 6], [7, 8, 9])
        [[1, 4], [5, 6], [7, 9]]

    Tags: lists, resize, batch, plural, interpolation
    """
    lists=detuple(lists)
    if is_numpy_array(lists): return lists.copy() #Shortcut!
    length=min(map(len,lists))
    return [resize_list(l,length) for l in lists]


def resize_list_to_fit(array:list, max_length:int):
    """
    Will squeeze the input array to fit in the given max_length if it has to.
    If the array is already smaller than the max_length, it won't be changed.

    Examples:
        >>> resize_list_to_fit([0,1,2,3,4,5,6,7,8,9], 5)
        [0, 2, 4, 6, 8]
        # It was resized to size 5, because len(array)==10 which is larger than max_length==5

        >>> resize_list_to_fit([0,1,2,3,4], 10)
        [0, 1, 2, 3, 4]
        # It was unchanged because len(array)==5 which is fits in max_length==10
    """

    if len(array)<max_length:
        max_length = len(array)

    return resize_list(array,max_length)

def resize_lists_to_fit(arrays:list, max_length:int):
    return [resize_list_to_fit(x, max_length) for x in arrays]

def list_transpose(list_of_lists:list):
    """
    EXAMPLE:
     >>> list_transpose([[1,2,3],[4,5,6]])
     ans = [[1, 4], [2, 5], [3, 6]]
    
    TODO: Fix this behaviour (extend it to list-of-lists with variable lengths)
     ans = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
     >>> split_into_sublists(ans,3)
     ans = [[1, 2, 3], [4, 5, 6], [7, 8, 9], [10]]
     >>> list_transpose(ans)
     ans = [[1, 4, 7, 10]] #What I want: [[1,4,7,10],[2,5,8],[3,6,9]]
    """
    if not len(list_of_lists):
        #Handle the null case
        return list_of_lists

    #Optimizations for tensors - effectively the same but potentially much faster
    if is_numpy_array (list_of_lists): return list_of_lists.transpose(0,1)
    if is_torch_tensor(list_of_lists): return list_of_lists.permute  (0,1)

    assert len(set(map(len,list_of_lists)))==1, 'Right now list_transpose only handles rectangular list_of_lists. This functionality may be added in the future.'
    return list(map(list,zip(*list_of_lists)))

def dict_zip(*dicts):
    """
    Given multiple dicts, return a list of tuples:
    [
        (key1, (dict1[key1], dict2[key1], ...)),
        (key2, (dict1[key2], dict2[key2], ...)),
        ...
    ]
    Only keys present in all dicts are included.

    EXAMPLE USAGE:
        
        >>> @memoized
        ... def load_model(model_path):
        ...     cached_model_path = get_cache_file_path(model_path)
        ...     copy_file(model_path, cached_model_path, show_progress=True, resume=True)
        ...     return load_safetensors(cached_model_path)
        ... 
        ... 
        ... model_a_path = "/root/CleanCode/Github/DiffSynth-Studio/models/train/Wan2.2-I2V-A14B_high_noise_lora_GWTF_Dev_Deepspeed_<T=81>/step-250.safetensors"
        ... model_b_path = "/root/CleanCode/Github/DiffSynth-Studio/models/train/Wan2.2-I2V-A14B_high_noise_lora_GWTF_Dev_Deepspeed_<T=81>/step-40000.safetensors"
        ... model_a = load_model(model_a_path)
        ... model_b = load_model(model_b_path)
        ... keys = assert_equality(set(model_a), set(model_b))
        ... alpha = 0.5
        ... model_z = {
        ...     key: blend(val_a, val_b, alpha)
        ...     for key, (val_a, val_b) in eta(dict_zip(model_a, model_b), "Blending LoRAs")
        ... }
        ... 
        ... save_safetensors(model_z,f'merge_models_test/alpha={alpha}.safetensors')
        
    """
    dicts = detuple(dicts)
    keys = set.intersection(*map(set, dicts))
    output = ([key,[d[key] for d in dicts]] for key in keys)
    output = IteratorWithLen(output, len(keys))
    return output

def dict_transpose(dic):
    """
    Transposes a nested dictionary, reversing the roles of keys and sub-keys.
    Skips keys that do not exist in all sub-dictionaries.

    Example 1:
        >>> d = {
        ...     'a': {'q': 1, 'w': 2},
        ...     'b': {'q': 3, 'e': 4},
        ...     'c': {'r': 5, 't': 6}
        ... }
        >>> dict_transpose(d)
        {
            'q': {'a': 1, 'b': 3},
            'w': {'a': 2},
            'e': {'b': 4},
            'r': {'c': 5},
            't': {'c': 6}
        }

    Example 2:
        >>> d = {
        ...     'x': {'y': 10, 'z': 20},
        ...     'v': {'y': 30, 'w': 40}
        ... }
        >>> dict_transpose(d)
        {
            'y': {'x': 10, 'v': 30},
            'z': {'x': 20},
            'w': {'v': 40}
        }

    Example 3:
        >>> #This function is its own inverse
        >>> ans
        ans = {'w': {'a': 2}, 'q': {'b': 3, 'a': 1}, 't': {'c': 6}, 'r': {'c': 5}, 'e': {'b': 4}}
        >>> dict_transpose(ans)
        ans = {'b': {'q': 3, 'e': 4}, 'c': {'t': 6, 'r': 5}, 'a': {'w': 2, 'q': 1}}
        >>> dict_transpose(ans)
        ans = {'w': {'a': 2}, 'q': {'b': 3, 'a': 1}, 't': {'c': 6}, 'r': {'c': 5}, 'e': {'b': 4}}

    Parameters:
        dic (dict): The dictionary to transpose

    Returns:
        dict: The transposed dictionary
    """

    # keys = set().union(*dic.values())  # get all unique keys from sub-dictionaries
    keys = unique(list_flatten(dic.values()))  # preserve ordering
    
    out = {}
    for key in keys:
        temp = {}
        for main_key, sub_dic in dic.items():
            if key in sub_dic:  # only add key if it exists
                temp[main_key] = sub_dic[key]
        if temp:  # only add to output if there's at least one key
            out[key] = temp
    return out

def list_dict_transpose(data):
    """
    Transpose a list of dictionaries or a dictionary of lists.

    If the input is a dictionary of lists, the function will return a list of dictionaries,
    where each dictionary represents a transposed row of the original data. The keys of the
    dictionaries will be the same as the keys in the original dictionary.

    If the input is a list of dictionaries, the function will return a dictionary of lists,
    where each list represents a transposed column of the original data. The keys of the
    dictionary will be the same as the keys in the original dictionaries.
    
    It will try to preserve the types: 
        If EasyDict is used anywhere, it will be used again in the output

    The function assumes that all inner lists or dictionaries have the same length or keys.

    Args:
        data (dict or list): The input data to be transposed.

    Returns:
        dict or list: The transposed data.

    Examples:
        >>> data1 = {'a': [1, 2], 'b': [3, 4]}
        >>> list_dict_transpose(data1)
        [{'a': 1, 'b': 3}, {'a': 2, 'b': 4}]

        >>> data2 = [{'a': 1, 'b': 3}, {'a': 2, 'b': 4}]
        >>> list_dict_transpose(data2)
        {'a': [1, 2], 'b': [3, 4]}

        >>> data3 = {'a': [1, 2], 'b': [3, 4], 'c': [5, 6]}
        >>> list_dict_transpose(data3)
        [{'a': 1, 'b': 3, 'c': 5}, {'a': 2, 'b': 4, 'c': 6}]

        >>> data4 = [{'a': 1, 'b': 3, 'c': 5}, {'a': 2, 'b': 4, 'c': 6}]
        >>> list_dict_transpose(data4)
        {'a': [1, 2], 'b': [3, 4], 'c': [5, 6]}

        >>> data5 = {}
        >>> list_dict_transpose(data5)
        {}

        >>> data6 = []
        >>> list_dict_transpose(data6)
        []
    """

    from copy import copy
    from collections.abc import Mapping, Set, Iterable

    if not len(data):
        # If data is empty, just return an empty output
        return copy(data)

    inner_sample = random_element(data)
    inner_type = type(inner_sample)
    outer_type = type(data)

    #Future: If needed, we can make these arguments
    #The output list and dict types: can be customized
    #Try to automatically infer the best type
    list_type=None
    dict_type=None
    if list_type is None: list_type = list
    if dict_type is None:
        pip_import('easydict')
        from easydict import EasyDict as dict_type

        # #I used to switch between dict and EasyDict...but I think I just like EasyDict
        # if rp.r._is_easydict(inner_sample) or rp.r._is_easydict(data):
        #     pip_import('easydict')
        #     from easydict import EasyDict as dict_type
        # else:
        #     dict_type = dict

    if issubclass(outer_type, Mapping):
        # If data is dict-like {'a':, 'b':[3,4]} turn it to [{'a':1,'b':3},{'a':2,'b':4}]

        keys = set(data)
        length = len(inner_sample)

        # Input assertions
        assert issubclass(inner_type, Iterable)
        assert all(len(data[key]) == length for key in keys), "Right now all contained lists must be same length"

        #                 ┌                                                                        ┐
        #                 │┌                                                                      ┐│
        #                 ││         ┌                                    ┐                       ││
        #                 ││         │┌                                  ┐│                       ││
        #                 ││         ││          ┌   ┐┌ ┐                ││               ┌      ┐││
        output = list_type([dict_type({key : data[key][i] for key in keys}) for i in range(length)])
        #                 ││         ││          └   ┘└ ┘                ││               └      ┘││
        #                 ││         │└                                  ┘│                       ││
        #                 ││         └                                    ┘                       ││
        #                 │└                                                                      ┘│
        #                 └                                                                        ┘

    elif issubclass(outer_type, Iterable):
        # If data is list-like [{'a':1,'b':3},{'a':2,'b':4}] turn it to {'a':[1,2], 'b':[3,4]}

        keys = set(inner_sample)
        length = len(data)

        # Input assertions
        assert issubclass(inner_type, Mapping)
        assert all(set(data[i]) == keys for i in range(length)), "Right now all contained lists must be same length"

        #                 ┌                                                                        ┐
        #                 │┌                                                                      ┐│
        #                 ││               ┌                                     ┐                ││
        #                 ││               │┌                                   ┐│                ││
        #                 ││               ││    ┌ ┐┌   ┐               ┌      ┐││                ││
        output = dict_type({key : list_type([data[i][key] for i in range(length)]) for key in keys})
        #                 ││               ││    └ ┘└   ┘               └      ┘││                ││
        #                 ││               │└                                   ┘│                ││
        #                 ││               └                                     ┘                ││
        #                 │└                                                                      ┘│
        #                 └                                                                        ┘

    else:
        assert False, "r.list_dict_transpose: Unsupported outer_type: " + str(outer_type)
        
    return output
        
dict_list_transpose = list_dict_transpose #Same thing

def broadcast_lists(*lists, strict=True):
    """
    Broadcasts multiple lists to the same length, and turns non-lists into lists

    Args:
        *lists: The sequence of values to broadcast. Each argument can be a list or a non-list value.
        strict (bool): If True, enforce that all non-scalar lists must have the same length or throw an error.

    Some exceptions to the rules:
        - range(...) objects are treated as lists!

    Returns:
        list: A list of lists, where each list has been broadcasted to the same length.

    Raises:
        ValueError: If strict is True and non-scalar lists have different lengths.
    
    EXAMPLE:
        >>> broadcast_lists(5,6)                 --> [[5], [6]]
        >>> broadcast_lists([1],5)               --> [[1], [5]]
        >>> broadcast_lists([1,2,3],[5])         --> [[1, 2, 3], [5, 5, 5]]
        >>> broadcast_lists([1,2,3], 5 )         --> [[1, 2, 3], [5, 5, 5]]
        >>> broadcast_lists([1,2,3],[4,5,6],'X') --> [[1, 2, 3], [4, 5, 6], ['X', 'X', 'X']]
        >>> broadcast_lists([1,2,3],[])
        ERROR: ValueError: Error: All lists must have the same length or be scalars. Lengths provided: [3, 0]
        >>> broadcast_lists([1,2,3],[1,2])
        ERROR: ValueError: Error: All lists must have the same length or be scalars. Lengths provided: [3, 2]

    """
    lists=detuple(lists)    

    if not is_iterable(lists):
        #If you try calling broadcast_lists(123)
        raise ValueError("r.broadcast_lists: Right now if you only provide one argument, it must be iterable. It was not. It was type "+str(type(lists)))

    processed_lists = []
    for i, e in enumerate(lists):

        #Types that get treated as lists!
        if isinstance(e, range):
            e = list(e)

        if not isinstance(e, list):
            processed_lists.append([e])
        else:
            processed_lists.append(e)

    lengths = [len(l) for l in processed_lists]
    broadcast_length = max(lengths)

    if strict:
        unique_lengths = set(lengths)
        # Allow lengths of one (scalar values) or one common length
        valid_lengths = {1, broadcast_length} if broadcast_length != 1 else {1}
        if not unique_lengths.issubset(valid_lengths):
            error_msg = "Error: All lists must have the same length or be scalars. Lengths provided: " + str(lengths)
            raise ValueError(error_msg)

    output = []
    for l in processed_lists:
        if len(l) == 1:
            broadcasted_list = l * broadcast_length
        else:
            broadcasted_list = l
        output.append(broadcasted_list)

    return output

def broadcast_kwargs(kwargs):
    """
    Broadcasts a dict of lists to the same length, and turns non-lists into lists.
    Closely related to rp.broadcast_lists

    Make it possible for the broadcasted args to be lazy too! We're going to have to give a way to broadcast non-lists...


    Args:
        kwargs: The keyword arguments to broadcast. Each value can be a list or a non-list value.

    Returns:
        dict: A list of EasyDict each with the broadcasted kwargs

    EXAMPLE:
        >>> broadcast_kwargs(dict(a=5    ,b=6        )) --> [{'b':  6 , 'a': 5}]
        >>> broadcast_kwargs(dict(a= 1   ,b=['x','y'])) --> [{'b': 'x', 'a': 1}, {'b': 'y', 'a': 1}]
        >>> broadcast_kwargs(dict(a=[1,2],b=['x','y'])) --> [{'b': 'x', 'a': 1}, {'b': 'y', 'a': 2}]
        >>> broadcast_kwargs(dict(a=5    ,b=[1,2,3]  )) --> [{'b':  1 , 'a': 5}, {'b':  2 , 'a': 5}, {'b': 3, 'a': 5}]
    
    EXAMPLE:
        >>> def print_things(text, periods):
        ...     for kwargs in broadcast_kwargs(gather_vars("text periods")):
        ...         print(kwargs.text, "." * kwargs.periods)
        >>> print_things('Hello',3)
        Hello ...
        >>> print_things('Hello',[1,2,3])
        Hello .
        Hello ..
        Hello ...
        >>> print_things(['Hello','World'],[1,2])
        Hello .
        World ..
        >>> print_things(['Hello','World'],3)
        Hello ...
        World ...
        >>> print_things(list('Hello'),6)
        H ......
        e ......
        l ......
        l ......
        o ......
        >>> print_things('World',list(range(5)))
        World
        World .
        World ..
        World ...
        World ....
        >>> print_things(list('World'),list(range(5)))
        W
        o .
        r ..
        l ...
        d ....

    """
    keys = list(kwargs.keys())
    values = list(kwargs.values())
    
    broadcasted_lists = broadcast_lists(*values)
    
    output = {key: value for key, value in zip(keys, broadcasted_lists)}
    output = as_easydict(output)
    
    # >>> list_dict_transpose({'a': [1, 2, 3], 'b': [4, 5, 6], 'c': ['X', 'X', 'X']}) -->
    # ...     [
    # ...         {"c": "X", "b": 4, "a": 1},
    # ...         {"c": "X", "b": 5, "a": 2},
    # ...         {"c": "X", "b": 6, "a": 3},
    # ...     ]
    output = list_dict_transpose(output)
        
    return output

def dict_walk(d, ):
    """
    Recursively yield paths and values from a dictionary, including paths to empty dictionaries.

    This function iteratively explores a potentially nested dictionary. It yields a tuple for each 
    terminal value or empty dictionary. The first element of the tuple is the path (a tuple of keys 
    leading to the item), and the second element is the item itself (value or empty dictionary).

    Args:
        d (dict): The dictionary to traverse.
        types (list of types): The types of objects we should traverse

    Yields:
        tuple: A tuple (path, item), where 'path' is a tuple of keys and 'item' is the value or 
        an empty dictionary at that path.

    Example:
        >>> data = {
        ...     "a": 1,
        ...     "b": {
        ...         "c": 2,
        ...         "d": {
        ...             "e": 3,
        ...             "f": {}
        ...         }
        ...     }
        ... }
        ... 
        ... for path, value in dict_walk(data):
        ...     print(f"Path: {path}, Value: {value}")
        ... 
        ... # Output:
        ... # Path: ('a',), Value: 1
        ... # Path: ('b', 'c'), Value: 2
        ... # Path: ('b', 'd', 'e'), Value: 3
        ... # Path: ('b', 'd', 'f'), Value: {}

    Example (lists are treated as dicts with int keys):
        >>> data = {
        ...     "a": [
        ...         {"b": 1},
        ...         {"c": 2},
        ...     ],
        ... }
        ... 
        ... for path, value in dict_walk(data):
        ...     print(f"Path: {path}, Value: {value}")
        ... 
        ... # Output:
        ... # Path: ('a', 0, 'b'), Value: 1
        ... # Path: ('a', 1, 'c'), Value: 2

    """

    types=[dict,list]
    def should_traverse(value):
        return any(issubclass(type(value), x) for x in types)

    def walk(current_dict, current_path):

        #Make sure its traversed correctly
        assert should_traverse(current_dict), 'rp.dict_walk: Input type %s is not one of the travesable types %s'%(type(d), types)
        if not issubclass(type(current_dict), dict):
            #Traverse it like a dict
            current_dict = list_to_index_dict(current_dict)
        current_dict = dict(current_dict)

        if not current_dict:  # Check if the dictionary is empty
            yield (current_path, current_dict)

        for key, value in current_dict.items():
            new_path = current_path + (key,)
            if should_traverse(value):
                yield from walk(value, new_path)
            else:
                yield (new_path, value)

    yield from walk(d, ())

def monkey_patch(target, name=None):
    """
    A decorator used to add a method to an object.

    :param target: The object to which the method should be added.
    :param name: (optional) The name of the method. If not provided, the name of the function being decorated will be used.
    :return: The `patcher` function, which takes a single argument `func` (the method to be added to the target object).

    Example:
    --------
    class Thing:
        pass

    @monkey_patch(Thing)
    def __repr__(self):
        return "Hello!"

    print(Thing())  # Output: "Hello!"
    """
    def patcher(func):
        """
        Adds the `func` method to the `target` object with the specified `name`.
        """
        setattr(target, name or func.__name__, func)
    return patcher

def copy_with_attr(obj, **kwargs):
    """
    Return a shallow copy of an object with some attributes overridden.

    Useful for immutable-style programming: instead of mutating in-place,
    you create a modified copy.

    Example:
        >>> class Point:
        ...     def __init__(self, x, y):
        ...         self.x = x
        ...         self.y = y
        ...     def __repr__(self):
        ...         return f"Point(x={self.x}, y={self.y})"
        ...
        >>> p1 = Point(1, 2)
        >>> p2 = copy_with_attr(p1, x=10, y=20)
        >>> p1
        Point(x=1, y=2)
        >>> p2
        Point(x=10, y=20)
        >>> p1 is p2
        False
    """
    out = copy(obj)
    for key, value in kwargs.items():
        setattr(out, key, value)
    return out


#UNCOMMENT IF YOU EVER NEED IT. SO FAR, NEVER USED IT.
# class CopyWithMixin:
#     """
#     Mixin that adds a .copy_with_attr(**kwargs) method to objects.
#
#     Useful for immutable-style programming: instead of mutating in-place,
#     you create a modified copy.
#
#     Example:
#         >>> class Point(CopyWithMixin):
#         ...     def __init__(self, x, y):
#         ...         self.x = x
#         ...         self.y = y
#         ...     def __repr__(self):
#         ...         return f"Point(x={self.x}, y={self.y})"
#         ...
#         >>> p1 = Point(1, 2)
#         >>> p2 = p1.copy_with_attr(x=10)
#         >>> p1
#         Point(x=1, y=2)
#         >>> p2
#         Point(x=10, y=2)
#     """
#     def copy_with_attr(self, **kwargs):
#         return copy_with_attr(self, **kwargs)

def _inline_rp_code(code):
    #/p is a big library, and some people might not like that.
    #This function helps you to inline any functions in your code that might be from rp.
    #That assumes they were imported as "from rp import *"
    #You use this function iteratively; until completion.
    #Note that the output can be quite large as many functions in rp are interconnected
    #This is why it's iterative - at every step remove the things you don't care about so it doesn't baloon too big for your tastes
    #This is done with microcompletion \irp for inline_rp
    def extract_imports(code: str) -> dict:
        #Written by chatgpt: https://shareg.pt/k1YDlMk
        """
        Extracts imports from Python code and returns them as a dictionary.
    
        The dictionary has two top-level keys, 'import' and 'from', corresponding
        to the two ways to import modules in Python. The values for the 'import'
        key are dictionaries where the keys are the module names and the values
        are sets of any aliases that the module was imported as. The values for
        the 'from' key are nested dictionaries where the keys are the module names,
        the values are dictionaries where the keys are the imported things (either
        a single name or the '*' wildcard) and the values are sets of any aliases
        that the thing was imported as.
    
        Example:
        >>> code = '''
        ... from numpy import tri, diff, fabs as float_abs, ones, ones_like, fabs
        ... import numba
        ... import numpy as np
        ... import numpy.random as random, threading as threader
        ... import random as builtin_random
        ... from typing import List
        ... from rp import *
        ... '''
        >>> extract_imports(code)
        {
            'import': {
                'numba': {'numba'},
                'np': {'np'},
                'numpy': {'numpy'},
                'random': {'builtin_random'},
                'threader': {'threading'}
            },
            'from': {
                'numpy': {
                    'tri': {'tri'},
                    'diff': {'diff'},
                    'float_abs': {'float_abs', 'fabs'},
                    'ones': {'ones'},
                    'ones_like': {'ones_like'}
                },
                'typing': {
                    'List': {'List'}
                },
                'rp': {
                    '*' : {'*'}
                }
            }
        }
        """
        import ast
        imports = {
            'import': {},
            'from': {}
        }
    
        tree = ast.parse(code)
    
        for node in tree.body:
            if isinstance(node, ast.Import):
                for alias in node.names:
                    module = alias.name
                    if alias.asname:
                        imports['import'].setdefault(module, set()).add(alias.asname)
                    else:
                        imports['import'].setdefault(module, set()).add(module)
            elif isinstance(node, ast.ImportFrom):
                module = node.module
                for alias in node.names:
                    thing = alias.name
                    if alias.asname:
                        imports['from'].setdefault(module, {}).setdefault(thing, set()).add(alias.asname)
                    else:
                        imports['from'].setdefault(module, {}).setdefault(thing, set()).add(thing)
    
        return imports
    
    def remove_first_import_line(code):
        #Removes the first line of code, assuming it's an import
        #If it's multiple lines long, it means removing the first line will result in invalid syntax'
        #So, we keep removing them until it becomes valid again; ergo we've removed the first import line
        assert is_valid_python_syntax(code)
        lines=code.splitlines()
        lines=lines[1:]
        while not is_valid_python_syntax(line_join(lines)):
            lines=lines[1:]
        return line_join(lines)
            
    def get_code(module,name):
        try:
            title='%s.%s'%(module,name)
            thing=getattr(__import__(module),name)
            if get_source_file(thing)!=get_module_path_from_name(module):
                #return ''
                return '#Foriegn: '+title #Foriegn code not from that module
            if callable(thing):
                try:
                    return get_source_code(getattr(__import__(module),name))
                except Exception:
                    return 'def %s:pass#failed to get source code of %s'%(name,title)
            else:
                #return ''
                return 'Uncallable: '+title
        except Exception:
            #return ''
            return '#Failed: '+title
    
    def unarpy(code):
        module='rp.r'
        code='from %s import *\n'%module+code
        code=_removestar(code)
        imports=extract_imports(code)
        try:
            module_imports = imports['from'][module]
        except KeyError:
            return code
        names=[name for name in module_imports if name in module_imports[name]]
        items=[]
        for name in names:
            item=get_code(module,name)
            if item not in code:
                code=code+'\n'+item
        code=remove_first_import_line(code)
        return code
    
    return unarpy(code)


def get_free_ram() -> int:
    """
    Get the amount of RAM currently free.

    :return: The amount of free RAM in bytes.
    """
    pip_import('psutil')
    import psutil
    return psutil.virtual_memory().available

def get_total_ram() -> int:
    """
    Get the total amount of RAM.

    :return: The total amount of RAM in bytes.
    """
    pip_import('psutil')
    import psutil
    return psutil.virtual_memory().total

def get_used_ram() -> int:
    """
    Get the amount of RAM currently in use.

    :return: The amount of used RAM in bytes.
    """
    pip_import('psutil')
    import psutil
    return get_total_ram() - get_free_ram()


@lru_cache(maxsize=None)
def _init_nvml():
    pip_import("py3nvml")
    from py3nvml.py3nvml import nvmlInit

    nvmlInit()


def _get_gpu_memory_info(gpu_id):
    _init_nvml()
    from py3nvml.py3nvml import nvmlDeviceGetMemoryInfo

    handle = _get_gpu_handle(gpu_id)
    return nvmlDeviceGetMemoryInfo(handle)


def _get_gpu_handle(gpu_id):
    _init_nvml()
    from py3nvml.py3nvml import nvmlDeviceGetHandleByIndex

    return nvmlDeviceGetHandleByIndex(gpu_id)

def get_gpu_uuid(gpu_id=None):
    """
    Returns the UUID of a GPU given its ID.
    If gpu_id is None, returns a list of UUIDs for all GPUs.
    This value is hardware-specific and is unique for every GPU.
    It is persistent upon rebooting as well.

    Args:
        gpu_id (int, optional): The ID of the GPU. Default is None.

    Example:
    >>> get_gpu_uuids(0)
    'GPU-f81d4fae-7dec-11d0-a765-00a0c91e6bf6'
    >>> get_gpu_uuids()
    ['GPU-f81d4fae-7dec-11d0-a765-00a0c91e6bf6', 'GPU-5eabcfeb-672e-4f2c-8f5c-45747a087704']
    """
    if gpu_id is None:
        return [get_gpu_uuid(gpu_id=i) for i in get_all_gpu_ids()]

    _init_nvml()
    from py3nvml.py3nvml import nvmlDeviceGetUUID
    handle = _get_gpu_handle(gpu_id)
    uuid = nvmlDeviceGetUUID(handle)
    return uuid

def get_gpu_count():
    """
    Returns the number of available GPUs.

    Example:
    >>> get_gpu_count()
    4
    """
    _init_nvml()
    from py3nvml.py3nvml import nvmlDeviceGetCount

    return nvmlDeviceGetCount()
get_num_gpus = get_gpu_count


def get_visible_gpu_ids():
    """ 
    Return all GPU's that are intended to be available to this process.
    If cuda_visible_devices is set, returns that. Otherwise, return all hardware GPU ID's. 
    """

    #If the CUDA_VISIBLE_DEVICES environment variable is set, respect it!
    cuda_visible_devices = get_cuda_visible_devices()
    if cuda_visible_devices:
        return cuda_visible_devices

    #Otherwise, return ALL gpu's
    return get_all_gpu_ids()



def get_all_gpu_ids():
    """ If you are on a device with GPU's, returns [0, 1, 2, ... (num gpus - 1) ] """
    return list(range(get_gpu_count()))


def get_gpu_ids_used_by_process(pid=None):
    """Get a list of all GPU's used by a given process. Defaults to the current process."""
    if pid is None:
        pid = get_process_id()
        return get_gpu_ids_used_by_process(pid)

    used_gpu_ids = [gpu_id for gpu_id, pids in enumerate(get_gpu_pids()) if pid in pids]
    return used_gpu_ids


def get_gpu_pids(gpu_id=None, *, existing_only=True):
    """
    Returns a list of process IDs running on the given GPU.

    Args:
        gpu_id (int): The ID of the GPU.
        existing_only (bool): If True, only return processes that exist on the current machine
                              If False, return all processes seen by the GPU driver.
                              Explanation: If you run this command when running_in_docker(), and --pid=host wasnt set,
                              NVIDIA's drivers will return process ID's that aren't in the current PID namespace,
                              and so it will appear to this machine as if they don't exist. (Similarly, running nvidia-smi
                              from within such a docker instance will show no processes, even if vram-hungry processes
                              are already running in said instance). Since GPU's are on a hardware-level, they don't know
                              what PID from within docker is running.

    Example:
    >>> get_gpu_pids(0)
    [12345, 67890]
    >>> get_gpu_pids()
    [[12345, 67890], [], [], [35534]]
    """
    if gpu_id is None:
        out = [get_gpu_pids(gpu_id=i) for i in get_all_gpu_ids()]
    else:
        _init_nvml()
        from py3nvml.py3nvml import nvmlDeviceGetComputeRunningProcesses

        handle = _get_gpu_handle(gpu_id)
        running_processes = nvmlDeviceGetComputeRunningProcesses(handle)
        out = [proc.pid for proc in running_processes]

    # Make sure all the processes exist! Sometimes, without this check it will return processes that aren't real.
    # I have no idea why. This only ever happened at Adobe on Sensei. It might be a bug in the py3nvml library?

    def filter_pids_exist(pids):
        if existing_only:
            return [x for x in pids if process_exists(x)]
        return pids
    
    if gpu_id is None:
        out = [filter_pids_exist(y) for y in out]
    else:
        assert isinstance(gpu_id,int)
        out = filter_pids_exist(out)

    return out


def get_free_vram(gpu_id=None):
    """
    Returns the amount of free VRAM for a GPU given its ID.
    The returned value is in bytes.
    If gpu_id is None, returns a list of free VRAM for all GPUs.

    Args:
        gpu_id (int, optional): The ID of the GPU. Default is None.

    Example:
    >>> get_free_vram(0)
    1140111360
    >>> get_free_vram()
    [1140111360, 1132892160, 1140111360, 1132892160]
    """
    if gpu_id is None:
        return [get_free_vram(gpu_id=i) for i in get_all_gpu_ids()]

    memory_info = _get_gpu_memory_info(gpu_id)
    return memory_info.free


def get_total_vram(gpu_id=None):
    """
    Returns the total amount of VRAM for a GPU given its ID.
    The returned value is in bytes.
    If gpu_id is None, returns a list of total VRAM for all GPUs.

    Args:
        gpu_id (int, optional): The ID of the GPU. Default is None.

    Example:
    >>> get_total_vram(0)
    1179648000
    >>> get_total_vram()
    [1179648000, 1179648000, 1179648000, 1179648000]
    """
    if gpu_id is None:
        return [get_total_vram(gpu_id=i) for i in get_all_gpu_ids()]

    memory_info = _get_gpu_memory_info(gpu_id)
    return memory_info.total

def get_used_vram(gpu_id=None, pid=None):
    """
    Returns the amount of used VRAM for a GPU given its ID or for a specific process ID.
    If a process ID is not specified, it will return the total amount used by that GPU across all processes.
    The returned value is in bytes.
    If gpu_id is None, returns a list of used VRAM for all GPUs.

    Args:
        gpu_id (int, optional): The ID of the GPU. Default is None.
        pid (int, optional): The ID of the process. Default is None.

    Example:
    >>> get_used_vram(0)
    385875968
    >>> get_used_vram()
    [385875968, 458752000, 385875968, 458752000]
    >>> get_used_vram(pid=12345)
    [0, 0, 385875968, 0]
    """

    def none_to_zero(used_vram):
        if used_vram is None and currently_running_windows():
            #I don't know why this happens on Windows - but pynvml returns None when getting the vram of a specific process
            #I'll just return -1 so it doesnt crash downstream functions like the GP command right now
            #But -1 is obviously nonsensical, so it lets us know it was due to an error
            return -1
        assert isinstance(used_vram,int)
        return used_vram

    if gpu_id is None:
        return [get_used_vram(gpu_id=i, pid=pid) for i in get_all_gpu_ids()]

    if pid is None:
        memory_info = _get_gpu_memory_info(gpu_id)
        return none_to_zero(memory_info.used)


    pids = get_gpu_pids(gpu_id)
    if pid not in pids:
        return 0

    from py3nvml.py3nvml import nvmlDeviceGetComputeRunningProcesses
    handle = _get_gpu_handle(gpu_id)
    running_processes = nvmlDeviceGetComputeRunningProcesses(handle)
    process_vram = next((proc.usedGpuMemory for proc in running_processes if proc.pid == pid), 0)
    return none_to_zero(process_vram)



def get_gpu_with_most_free_vram(n=0, choices=None):
    """
    Returns the GPU ID with the nth most free VRAM. If there is a tie, the function will prioritize GPUs with lower IDs.
    
    Args:
    n (int, optional): Indicates which GPU to return based on free VRAM. 0 returns the GPU with the most free VRAM, 1 returns the GPU with the second most free VRAM, and so on. Defaults to 0.
    choices (list, optional): A list of GPU IDs to choose from. If not provided, all available GPUs will be considered.

    Example:
    >>> get_gpu_with_most_free_vram()
    2
    >>> get_gpu_with_most_free_vram(choices=[0, 1, 3])
    1
    """
    if choices is None:
        choices = get_all_gpu_ids()
    return sorted(choices, key=get_free_vram, reverse=True)[n]


def get_gpu_name(gpu_id=None):
    """
    Returns the name of a GPU given its ID.
    The returned value is a string.
    If gpu_id is None, returns a list of names for all GPUs.

    Args:
        gpu_id (int, optional): The ID of the GPU. Default is None.

    Example:
    >>> get_gpu_name(3)
    NVIDIA RTX A5000
    >>> get_gpu_name()
    ['NVIDIA RTX A5000', 'NVIDIA RTX A5000', 'NVIDIA RTX A5000', 'NVIDIA RTX A5000']
    """
    if gpu_id is None:
        return [get_gpu_name(gpu_id=i) for i in get_all_gpu_ids()]

    from py3nvml.py3nvml import nvmlDeviceGetName

    handle = _get_gpu_handle(gpu_id)
    return nvmlDeviceGetName(handle)


def get_vram_used_by_current_process(gpu_id=None):
    """
    Returns the amount of VRAM used by the current process for each GPU or a specific GPU.

    Args:
        gpu_id (int, optional): The ID of the GPU. If None, returns a list of VRAM usage for all GPUs.

    Example:
    >>> get_vram_used_by_current_process()
    [0, 385875968, 0, 0]
    >>> get_vram_used_by_current_process(0)
    0
    >>> get_vram_used_by_current_process(1)
    385875968
    """
    import os

    current_pid = os.getpid()
    return get_used_vram(gpu_id=gpu_id, pid=current_pid)

def get_gpu_temperature(gpu_id=None):
    """
    Returns the temperature of a GPU in celcius given its ID.
    If gpu_id is None, returns a list of temperatures for all GPUs.

    Args:
        gpu_id (int, optional): The ID of the GPU. Default is None.

    Example:
    >>> get_gpu_temperature(0)
    65
    >>> get_gpu_temperature()
    [65, 61, 62, 63]
    """
    if gpu_id is None:
        return [get_gpu_temperature(gpu_id=i) for i in get_all_gpu_ids()]

    _init_nvml()
    from py3nvml.py3nvml import nvmlDeviceGetTemperature, NVML_TEMPERATURE_GPU
    handle = _get_gpu_handle(gpu_id)
    temperature = nvmlDeviceGetTemperature(handle, NVML_TEMPERATURE_GPU)
    return temperature

def get_gpu_utilization(gpu_id=None):
    """
    Returns a % of how busy a GPU is.

    Returns the utilization of a GPU in percentage given its ID.
    If gpu_id is None, returns a list of utilization for all GPUs.

    Gives the percent of time over the past second during which 
    one or more kernels were executing on the GPU.

    Args:
        gpu_id (int, optional): The ID of the GPU. Default is None.

    Example:
    >>> get_gpu_utilization(0)
    30
    >>> get_gpu_utilization()
    [30, 40, 50, 60]
    """
    if gpu_id is None:
        return [get_gpu_utilization(gpu_id=i) for i in get_all_gpu_ids()]

    _init_nvml()
    from py3nvml.py3nvml import nvmlDeviceGetUtilizationRates
    handle = _get_gpu_handle(gpu_id)
    utilization = nvmlDeviceGetUtilizationRates(handle)
    return utilization.gpu

def print_gpu_summary(
    include_processes=True,
    include_temperature=True,
    include_percent_vram=True,
    include_utilization=True,
    silent=False,
):
    """
    Prints a summary of GPU information using the Rich library.

    Args:
        include_processes (bool, optional): If True, includes the processes running on each GPU. Defaults to True.
        include_temperature (bool, optional): If True, includes the temperature of each GPU. Defaults to True.
        include_percent_vram (bool, optional): If True, includes the percentage of VRAM usage for each GPU. Defaults to True.
        include_utilization (bool, optional): If True, includes the GPU utilization. Defaults to True.
        silent (bool, optional): If True, does not print the output to the console but instead returns it as a string. Defaults to False.

    Returns:
        Optional[str]: If silent, returns the GPU summary as a string, including ANSI escape sequences for color. Else, returns None.

    Example:
        >>> print_gpu_summary()  # All GPUs visible, no Visible column shown
        ┏━━━━━━━━┳━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━┳━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
        ┃ GPU ID ┃       Name       ┃      Used      ┃   Free ┃  Total ┃ Temp ┃ Processes                   ┃
        ┡━━━━━━━━╇━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━╇━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩
        │   0    │ NVIDIA RTX A5000 │  13.9GB  57.7% │ 10.1GB │ 24.0GB │ 35°C │ ryan 13.5GB: 1763785 13.5GB │
        │   1    │ NVIDIA RTX A5000 │ 311.6MB   1.3% │ 23.7GB │ 24.0GB │ 43°C │                             │
        │   2    │ NVIDIA RTX A5000 │  12.1GB  50.5% │ 11.9GB │ 24.0GB │ 43°C │ ryan 11.8GB: 1794352 11.8GB │
        │   3    │ NVIDIA RTX A5000 │ 311.6MB   1.3% │ 23.7GB │ 24.0GB │ 39°C │                             │
        └────────┴──────────────────┴────────────────┴────────┴────────┴──────┴─────────────────────────────┘
        
        >>> # With CUDA_VISIBLE_DEVICES=0,2, Visible column appears and rows 1,3 are dimmed
        ┏━━━━━━━━┳━━━━━━━━━┳━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━┳━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
        ┃ GPU ID ┃ Visible ┃       Name       ┃      Used      ┃   Free ┃  Total ┃ Temp ┃ Processes                   ┃
        ┡━━━━━━━━╇━━━━━━━━━╇━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━╇━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩
        │   0    │   Yes   │ NVIDIA RTX A5000 │  13.9GB  57.7% │ 10.1GB │ 24.0GB │ 35°C │ ryan 13.5GB: 1763785 13.5GB │
        │   1    │   No    │ NVIDIA RTX A5000 │ 311.6MB   1.3% │ 23.7GB │ 24.0GB │ 43°C │                             │
        │   2    │   Yes   │ NVIDIA RTX A5000 │  12.1GB  50.5% │ 11.9GB │ 24.0GB │ 43°C │ ryan 11.8GB: 1794352 11.8GB │
        │   3    │   No    │ NVIDIA RTX A5000 │ 311.6MB   1.3% │ 23.7GB │ 24.0GB │ 39°C │                             │
        └────────┴─────────┴──────────────────┴────────────────┴────────┴────────┴──────┴─────────────────────────────┘
    """
    pip_import('rich')
    from rich.console import Console
    from collections import defaultdict
    from rich.table import Table
    import rich

    # Get GPU data - show all hardware GPUs
    gpu_ids = get_all_gpu_ids()
    visible_gpu_ids = get_visible_gpu_ids()
    
    # Determine if we need to show the Visible column
    show_visible_column = len(visible_gpu_ids) < len(gpu_ids)
    
    # Create a table with a pretty border
    table = Table(show_header=True, header_style="bold magenta", title_justify="center")
    table.add_column("GPU ID", style="dim", justify="center")
    if show_visible_column: table.add_column("Visible", justify="center")
    table.add_column("Name", style="dim", justify="center")
    table.add_column("Used", style="red", justify="center")
    table.add_column("Free", style="green", justify="right")
    table.add_column("Total", style="cyan", justify="right")
    if include_temperature:
        table.add_column("Temp", style="blue", justify="center")
    if include_utilization:
        table.add_column("Util", style="yellow", justify="center")
    if include_processes:
        table.add_column("Processes", style="cyan", justify="left")

    # Get GPU data
    gpu_ids = get_all_gpu_ids()
    for gpu_id in gpu_ids:
        name = get_gpu_name(gpu_id)
        used_vram = get_used_vram(gpu_id)
        used_vram_human = human_readable_file_size(used_vram)
        free_vram = human_readable_file_size(get_free_vram(gpu_id))
        total_vram = human_readable_file_size(get_total_vram(gpu_id))
        if include_percent_vram:
            percent_vram = 100 * used_vram / get_total_vram(gpu_id)
            used_vram_text = "{: >7} {: >5.1f}%".format(used_vram_human, percent_vram)
        if include_temperature:
            temperature = get_gpu_temperature(gpu_id)
            temperature_text = "{}°C".format(temperature)
        if include_utilization:
            utilization = "{}%".format(get_gpu_utilization(gpu_id)).rjust(4)

        if include_processes:
            processes = get_gpu_pids(gpu_id, existing_only=False)
            user_process_vram = defaultdict(list)
            for pid in processes:
                pid_vram = get_used_vram(gpu_id, pid=pid)
                if process_exists(pid):
                    username = get_process_username(pid)
                else:
                    #Commonly happens if running_in_docker()
                    username = "???"
                user_process_vram[username].append((pid, pid_vram))

            process_text = []
            for username, pid_vram_list in user_process_vram.items():
                total_user_vram = sum(vram for _, vram in pid_vram_list)
                user_processes = ", ".join(
                    "{} {}".format(pid, human_readable_file_size(vram))
                    for pid, vram in pid_vram_list
                )
                process_text.append(
                    "[bold blue]{}[/] [bold yellow]{}[/]: {}".format(username, human_readable_file_size(total_user_vram), user_processes)
                )
            process_column_text = "; ".join(process_text)
        else:
            process_column_text = ""

        # Determine if GPU is visible based on CUDA_VISIBLE_DEVICES
        is_visible = gpu_id in visible_gpu_ids
        visible_text = "[green]Yes[/]" if is_visible else "[red][dim]No[/][/]"

        table.add_row(
            str(gpu_id),
            *[visible_text] * show_visible_column,
            name,
            used_vram_text,
            free_vram,
            total_vram,
            temperature_text if include_temperature else "",
            utilization if include_utilization else "",
            process_column_text,
        )

    console = Console()
    with console.capture() as capture:
        console.print(table)
    
    output = capture.get()

    if silent:
        return output
    else:
        print(output)

def print_notebook_gpu_summary():
    """
     >>> display_notebook_gpu_summary()
        ┏━━━━━━━━┳━━━━━━━━┳━━━━━━━━┳━━━━━━━━┳━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
        ┃ PID    ┃ GPU0   ┃ GPU1   ┃ GPU2   ┃ GPU3 ┃ Notebook Name             ┃
        ┡━━━━━━━━╇━━━━━━━━╇━━━━━━━━╇━━━━━━━━╇━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━┩
        │ 699139 │ ·      │ ·      │ ·      │ ·    │ Untitled2.ipynb           │
        │ 704428 │ ·      │ ·      │ ·      │ ·    │ hidden_characters_1.ipynb │
        │ 504826 │ 718MB  │ ·      │ 18.4GB │ ·    │ hidden_characters_2.ipynb │
        │ 503594 │ 718MB  │ 20.2GB │ ·      │ ·    │ hidden_characters_3.ipynb │
        │ 503120 │ 20.2GB │ ·      │ ·      │ ·    │ hidden_characters_4.ipynb │
        └────────┴────────┴────────┴────────┴──────┴───────────────────────────┘
    """
    pip_import("rich")

    from rich import print
    from rich.table import Table

    sessions = _get_all_notebook_sessions_via_ipybname()
    num_gpus = len(get_all_gpu_ids())  # Get the number of GPUs available
    rows = []

    for session in sessions:
        try:
            kernel_pid = session.kernel.pid

            def ram_to_string(ram: int):
                if ram > 0:
                    return human_readable_file_size(ram)
                else:
                    return "·"  # If we don't use vram just return a small dot, less distracting

            used_vram = get_used_vram(pid=kernel_pid)  # List of ints
            total_used_vram = sum(used_vram)
            used_vram_str = [ram_to_string(x) for x in used_vram]
            rows.append(
                
                    {
                        "pid": kernel_pid,
                        "used_vram_str": used_vram_str,
                        "total_used_vram": total_used_vram,
                        "total_used_vram_str": ram_to_string(total_used_vram),
                        "notebook_name": session.name,
                        "state": session.kernel.execution_state,
                    }
               
            )
        except Exception:
            pass
            print("Exception occurred while processing session:", session.name)

    # Create a rich table and add columns dynamically based on the number of GPUs
    table = Table(show_header=True, header_style="bold magenta")
    table.add_column("PID", style="dim")
    # table.add_column("State", style="dim")
    # table.add_column("VRAM", style="green")
    for i in range(num_gpus):
        table.add_column("GPU%i"%i)
    table.add_column("Notebook Name")

    # There can be multiple servers, which sometimes results in duplicate kernels being listed here...
    rows = unique(rows)
    rows = sorted(rows, key=lambda row: (row["total_used_vram"], row["notebook_name"]))

    # Add rows to the table
    for row in rows:
        # Fill in missing VRAM values with 'N/A' if fewer GPUs were used than available
        padded_vram = row["used_vram_str"] + ["N/A"] * (
            num_gpus - len(row["used_vram_str"])
        )
        table.add_row(
            str(row["pid"]),
            # row["state"], #Removed because it seemed to give the wrong results, saying things were idle that weren't and vice versa...
            # row["total_used_vram_str"],
            *padded_vram,
            row["notebook_name"],
            style=("white"),
        )
    # return table

    # Display the table
    print(table)



def _get_kernel_to_pid_mapping():
    #Takes about .1 seconds to run because of the get_all_pids_and_their_commands bottleneck
    def get_all_pids_and_their_commands():
        # Can probably optimize by only searching through the server's child processes
        # Right now it's a little slow, can take up to .1 seconds
        pip_import("psutil")
        import psutil

        pid_command_map = {}
        # Directly iterate over all processes and access needed properties without filtering in process_iter
        for process in psutil.process_iter():
            try:
                pid = process.pid
                cmdline = process.cmdline()
                cmdline = " ".join(cmdline) if cmdline else ""
                pid_command_map[pid] = cmdline
            except (psutil.NoSuchProcess, psutil.AccessDenied):
                continue
        return pid_command_map

    # There might be false positives, but shouldn't be any false negatives
    # Get all PID and command mappings
    pid_command_map = get_all_pids_and_their_commands()
    kernel_to_pid_map = {}

    # Pattern to find kernel json files in commands
    for pid, command in pid_command_map.items():
        try:
            if "ipykernel_launcher" in command:
                parts = command.split()
                for i, part in enumerate(parts):
                    if part.endswith(".json") and i > 0 and parts[i - 1] == "-f":
                        # Extract kernel id from the JSON file path
                        kernel_id = (
                            part.split("/")[-1]
                            .replace("kernel-", "")
                            .replace(".json", "")
                        )
                        kernel_to_pid_map[kernel_id] = pid
                        break
        except Exception:
            # Maybe a weird process name gave a false positive
            pass
    return kernel_to_pid_map


def _get_all_notebook_sessions_via_ipybname():
    """
    Gets a ton of information about all running Jupyter notebook instances

    Took about .02 seconds to execute on Rlab3

    Warning: I'm not sure what will happen if the notebooks are password protected. If there's a token enironment variable,
             I think ipynbname should handle it right? I havne't tested this though. Especially if we run it from outside Jupyter...

    Output: List of DictReader objects (so we don't need to install EasyDict too)

    EXAMPLE OUTPUT:
        [
            {
                'server': {
                    'base_url': '/',
                    'hostname': '0.0.0.0',
                    'password': False,
                    'pid': 502157,
                    'port': 15901,
                    'root_dir': '/data/hdd2/ws1nfs/ryan/CleanCode/Projects/Peekaboo/Experiments/Github/Diffusion-Illusions',
                    'secure': False,
                    'sock': '',
                    'token': '',
                    'url': 'http://rlab-AS-5014A-TT-ws3:15901/',
                    'version': '1.13.5'
                },
                'id': 'b6dfe4c1-96e2-40a0-bfca-ebba9bd7b532',
                'path': 'hidden_characters_miku_siggrebut__April16_2024_hiddens_SIGG_Final__SIGG_REVISION_ABLATION_PROTO_3_PRIMES.ipynb',
                'name': 'hidden_characters_miku_siggrebut__April16_2024_hiddens_SIGG_Final__SIGG_REVISION_ABLATION_PROTO_3_PRIMES.ipynb',
                'type': 'notebook',
                'kernel': {'pid': 12345, 'id': 'dc691eda-786e-4609-ac78-8d6f8dbc482b', 'name': 'diffilu', 'last_activity': '2024-04-18T01:14:55.458054Z', 'execution_state': 'idle', 'connections': 0},
                'notebook': {
                    'path': 'hidden_characters_miku_siggrebut__April16_2024_hiddens_SIGG_Final__SIGG_REVISION_ABLATION_PROTO_3_PRIMES.ipynb',
                    'name': 'hidden_characters_miku_siggrebut__April16_2024_hiddens_SIGG_Final__SIGG_REVISION_ABLATION_PROTO_3_PRIMES.ipynb'
                }
            }
            ...
        ]
    """
    pip_import("ipynbname")

    import ipynbname as ip

    kernel_to_pid = _get_kernel_to_pid_mapping()

    all_sessions = []
    for srv in ip._list_maybe_running_servers():
        try:
            for session in ip._get_sessions(srv):
                session["server"] = srv
                kernel_id = session["kernel"]["id"]
                # print(session)

                if not kernel_id in kernel_to_pid:
                    # If we can't get the PID for this, skip it for sanity's sake.
                    # Perhaps in the future it will include it as None instead.
                    # print('NOT FOUND',kernel_id)
                    continue
                kernel_pid = kernel_to_pid[kernel_id]
                session["kernel"]["pid"] = kernel_pid

                session = DictReader(session)
                all_sessions.append(session)
        except Exception as e:
            # print(e)
            pass
    return all_sessions



def print_process_info(pid):
    """
    Prints information about a running process
    EXAMPLE:

    >>> print_process_info(get_process_id())
                                                                  Process Information for PID 4021
    ╭───────────────────┬───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
    │ User              │ ryan                                                                                                                                  │
    │ Command           │ /opt/homebrew/Cellar/python@3.10/3.10.17_1/Frameworks/Python.framework/Versions/3.10/Resources/Python.app/Contents/MacOS/Python -m rp │
    │ Status            │ running                                                                                                                               │
    │ PID               │ 4021                                                                                                                                  │
    │ Working Dir       │ /opt/homebrew/lib/python3.10/site-packages/rp                                                                                         │
    │ Parent Process    │ zsh (PID: 3951, Status: running)                                                                                                      │
    │ Subprocess IDs    │ None                                                                                                                                  │
    │ Start Time        │ Fri Aug 22, 2025 at 2:30:21AM EDT                                                                                                     │
    │ Running Time      │ 0:00:12.143396                                                                                                                        │
    │ Memory Usage      │ 442.6MB                                                                                                                               │
    │ CPU Usage         │ 304.80%                                                                                                                               │
    │ Number of Threads │ 11                                                                                                                                    │
    ╰───────────────────┴───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
    /opt/homebrew/Cellar/python@3.10/3.10.17_1/Frameworks/Python.framework/Versions/3.10/Resources/Python.app/Contents/MacOS/Python -m rp
    """

    pip_import('psutil')
    pip_import('rich')

    import psutil
    from rich.console import Console
    from rich.table import Table
    from rich import box
    from rich.syntax import Syntax
    from datetime import datetime

    try:
        # Get the process by PID
        process = psutil.Process(pid)

        # Gather process information
        user = process.username()
        command = " ".join(process.cmdline())
        start_time = format_date(datetime.fromtimestamp(process.create_time()))
        running_time = datetime.now() - datetime.fromtimestamp(process.create_time())
        memory_usage = human_readable_file_size(process.memory_info().rss)
        cpu_usage = "{:.2f}%".format(process.cpu_percent(interval=1.0))
        num_threads = process.num_threads()
        status = process.status()
        cwd = process.cwd()

        # Get parent process information
        parent_pid = process.ppid()
        try:
            parent_process = psutil.Process(parent_pid)
            parent_info = "{} (PID: {}, Status: {})".format(parent_process.name(), parent_pid, parent_process.status())
        except psutil.NoSuchProcess:
            parent_info = "{} (No longer running)".format(parent_pid)

        # Get subprocess IDs (child processes)
        subprocesses = process.children(recursive=True)
        subprocess_ids = ", ".join(str(child.pid) for child in subprocesses) if subprocesses else "None"

        # Create a Rich table to display the information
        table = Table(title="Process Information for PID {}".format(pid), box=box.ROUNDED, show_header=False)
        table.add_column("Key", style="bold cyan")
        table.add_column("Value", style="magenta")

        # Add rows to the table
        table.add_row("User", user)
        table.add_row("Command", Syntax(command, "bash", theme="monokai"))
        table.add_row("Status", status)
        table.add_row("PID", str(pid))
        table.add_row("Working Dir", cwd)
        table.add_row("Parent Process", parent_info)
        table.add_row("Subprocess IDs", subprocess_ids)
        table.add_row("Start Time", start_time)
        table.add_row("Running Time", str(running_time))
        table.add_row("Memory Usage", memory_usage)
        table.add_row("CPU Usage", cpu_usage)
        table.add_row("Number of Threads", str(num_threads))

        # Print the table
        console = Console()
        console.print(table)
        print(command)

    except psutil.NoSuchProcess:
        console = Console()
        console.print("[bold red]No process found with PID {}[/]".format(pid))
    except Exception as e:
        console = Console()
        console.print("[bold red]An error occurred: {}[/]".format(e))



def type_string_with_keyboard(s, time_per_stroke=1/30):
    """ 
    Takes control of your keyboard and types something out with it.

    Note: On Mac, make sure the terminal app has permissions to do this!

    EXAMPLE:
        >>> type_string_with_keyboard("echo cheese!\n")
    """
    pip_import('pynput')
    from pynput.keyboard import Controller, Key
    import time

    keyboard = Controller()

    for character in s:
        if character == '\n':
            keyboard.press(Key.enter)
            keyboard.release(Key.enter)
        elif character=='\x1b':
            keyboard.press(Key.esc)
            keyboard.release(Key.esc)
        elif character=='\b':
            keyboard.press(Key.backspace)
            keyboard.release(Key.backspace)
        else:
            keyboard.press(character)
            keyboard.release(character)

        time.sleep(time_per_stroke)
            

def delaunay_interpolation_weights(key_points, query_points):
    """
    This function calculates the interpolation weights for each query point based
    on the Delaunay triangulation of the data points.

    Args:
        key_points (np.array): A 2d array of data points for Delaunay triangulation.
        query_points (np.array): A 2d array of query points to be interpolated,
                                 or a single 1d vector query point. Note: if 
                                 query_points.ndim==1, then the outputs of this 
                                 function are no longer 2d, and will be 1d instead.

    Returns:
        tuple: A tuple containing two arrays, one with the indices of the vertices
               for each simplex and another with the corresponding weights for
               interpolation. 
    Note:

        For repeated calls with the same key points, it's more efficient to reuse 
        a Delaunay object than to recreate it each time due to the costly nature 
        of Delaunay triangulation. If that is your use case, modify this function.
        
    Example:
       >>> delaunay_interpolation_weights(np.random.randn(100,2), np.random.rand(3,2))
        ans=(
               [[ 4, 50, 28],
                [70,  2, 20],
                [50, 68, 28]]
              ,
               [[0.248, 0.057, 0.695],
                [0.305, 0.006, 0.689],
                [0.241, 0.143, 0.616]]
            )
            #Note that a simplex has (num dim + 1) points. For example, to interp in 1d space, you need two points to lerp between.

    Original Source: https://stackoverflow.com/questions/30373912/interpolation-with-delaunay-triangulation-n-dim
    GPT4 Cleanup: https://chat.openai.com/share/a365f99a-4350-4743-80b5-6ee49ed3414e
    """
    
    # Import things
    pip_import('scipy')
    import numpy as np
    from scipy.spatial import Delaunay

    key_points=as_numpy_array(key_points)
    query_points=as_numpy_array(query_points)
    
    if query_points.ndim == 1:
        # Special case: we're querying a single point
        query_points = [query_points]
        vertices, weights = delaunay_interpolation_weights(key_points, query_points)
        return vertices[0], weights[0]

    # Assert key and query points are matrices, and have the same vector length
    assert key_points.ndim == 2
    assert query_points.ndim == 2
    assert key_points.shape[1] == query_points.shape[1]

    # Create Delaunay object and find simplices containing each query point
    delaunay = Delaunay(key_points)
    simplices = delaunay.find_simplex(query_points)

    # Get the vertices and transformation matrices for each simplex
    try:
        vertices = delaunay.simplices[simplices]
    except AttributeError:
        #In case we're using an older version
        #DeprecationWarning: Delaunay attribute 'vertices' is deprecated in favour of 'simplices' and will be removed in Scipy 1.11.0.
        vertices = delaunay.vertices[simplices]

    transform = delaunay.transform[simplices]

    # Calculate barycentric coordinates for each query point
    bary_coords = np.einsum('ijk,ik->ij', transform[:,:key_points.shape[1],:key_points.shape[1]],
                            query_points - transform[:,key_points.shape[1],:])

    # Compute weights for each vertex of the simplex
    weights = np.c_[bary_coords, 1 - bary_coords.sum(axis=1)]

    # Output shape assertions
    assert vertices.shape == weights.shape == (len(query_points), key_points.shape[1] + 1)

    return vertices, weights


def get_total_disk_space(path='/'):
    """Returns the total size of your hard drive in bytes"""
    import shutil
    total, used, free = shutil.disk_usage(path)
    return total

def get_used_disk_space(path='/'):
    """Returns the amount of your hard drive that holds data in bytes"""
    import shutil
    total, used, free = shutil.disk_usage(path)
    return used

def get_free_disk_space(path='/'):
    """Returns the amount of your hard drive that doesnt hold data in bytes"""
    import shutil
    total, used, free = shutil.disk_usage(path)
    return free

def _ensure_uv_installed():
    """ Clone of pip that's much faster """
    try:
        import uv
    except ImportError:
        pip_install("uv", backend="pip")

_pip_install_needs_sudo=True
_pip_install_default_backend='pip'
def pip_install(pip_args:str,*,backend=None):
    """ Try to install a python package """

    assert isinstance(pip_args,str),'pip_args must be a string like "numpy --upgrade" or "rp --upgrade --no-cache --user" etc'

    if backend is None:
        backend = _pip_install_default_backend
    assert backend in ['pip', 'uv']

    if backend=='uv':
        _ensure_uv_installed()

    if currently_running_unix() and rp.r._pip_install_needs_sudo:
        #Attempt to get root priveleges. Sometimes we need root priveleges to install stuff. 
        #If we're on unix, attempt to become the root for this process. 
        #There's probably a better way to do this but idk how and don't really care that much even though I probably should...
        os.system('sudo echo')#This should only prompt for a password once...making the next command run in sudo.

    pip_cmd = dict(pip="pip", uv="uv pip")[backend]

    command = shlex.quote(sys.executable) + " -m " + pip_cmd + " install " + pip_args

    _run_sys_command(command)
    _refresh_autocomplete_module_list()

    #DONT DELETE THIS COMMENT BLOCK: An alternative way to install things with pip:
    #    from pip.__main__ import _main as pip
    #    errored=pip(['install', package_name]+['--upgrade']*upgrade)
    #    if errored:
    #        assert False,'pip_install: installation of module '+repr(package_name)+' failed.'

def update_rp():
    """ Update this package """
    if input_yes_no("Are you sure you'd like to try updating rp? (You will need to restart it to see any effects)"):
        # --index-url https://pypi.org/simple is so other internal pypi's that are out of date don't prevent the upgrade (looking at you, netflix! https://pypi.netflix.net/simple is often out of date...)
        pip_install("rp --upgrade --no-cache  --index-url https://pypi.org/simple")

def module_exists(module_name):
    """
    Check if a Python module exists without importing it.

    Args:
        module_name (str): The name of the module to check.

    Returns:
        bool: True if the module exists, False otherwise.
    """
    try:
        # Try to import the imp module
        import imp
        try:
            imp.find_module(module_name)
            return True
        except ImportError:
            return False
    except ImportError:
        # If imp is not available, fall back to importlib
        import importlib.util
        # Reference: https://stackoverflow.com/questions/check-if-python-module-exists-without-importing-it
        return importlib.util.find_spec(module_name) is not None


def pip_install_multiple(packages, shotgun=True, quiet=False):
    """
    Install multiple packages via pip.

    If shotgun is True:
        Try to install each package individually. If a package fails, the function
        continues with the next package.
    
    If shotgun is False:
        Attempt to install all packages. If any package fails, none will be installed.
        This is similar to installing packages in one go using a requirements.txt file.
    
    Args:
        packages (str or list): A list of packages or a string of space-separated package names.
        shotgun (bool): Installation strategy. See description above.
        quiet (bool): If True, suppress pip's output.

    Returns:
        List[str]: A list of successfully installed packages.
    """
    import sys
    import subprocess
    import shlex

    if isinstance(packages, str):
        if file_exists(packages):
            fansi_print("Reading packages from "+repr(packages), 'blue')
            packages=text_file_to_string(packages)
        packages = line_split(packages)

    def fix_package(package):
        if '@' in package and len(package.strip().split())==3:
            #clip @ git+https://github.com/openai/CLIP.git
            #    is installed via
            #pip install git+https://github.com/openai/CLIP.git
            package=package.strip().split()[-1]
        return package

    assert is_iterable(packages)
    assert all(isinstance(x, str) for x in packages)
    packages = list(map(fix_package, packages))

    
    successful_packages = []
    base_command = [sys.executable, '-m', 'pip', 'install']
    
    if quiet:
        base_command.append('-q')

    if not shotgun:
        # Install all packages in one go
        # quoted_packages = [shlex.quote(pkg) for pkg in packages]
        quoted_packages = list_flatten([shlex.split(pkg) for pkg in packages])
        command = base_command + quoted_packages
        result = subprocess.run(command)
        
        if result.returncode == 0:  # if the command was successful
            print(fansi("All packages were successfully installed!", "green"))
            return packages
        else:
            print(fansi("Failed to install one or more packages. None were installed.", "red"))
            return []
    else:
        for package in packages:
            command = base_command + shlex.split(package)
            result = subprocess.run(command)
            
            if result.returncode == 0:  # if the command was successful
                successful_packages.append(package)
                print(fansi("Successfully installed {}.".format(package), "green"))
            else:
                print(fansi("Failed to install {}. Continuing with the next package.".format(package), "red"))

        return successful_packages

    global _need_module_refresh
    _need_module_refresh=True




known_pypi_module_package_names={
    # To update this list, copy-paste the output of rp.pypi_inspection.get_pypi_module_package_names()
    # A list of some non-obvious pypi package names given their module names, used by pip_import
    # Let's make this dict as big as we can! More the merrier...
    'Crypto': 'pycrypto',
    'IPython': 'ipython',
    'OpenGL': 'PyOpenGL',
    'PIL': 'Pillow',
    'PyQt5': 'PyQt5-sip',
    'Xlib': 'python-xlib',
    '_ast27': 'typed-ast',
    '_ast3': 'typed-ast',
    '_bimpy': 'bimpy',
    '_black_version': 'black',
    '_dlib_pybind11': 'dlib',
    '_plotly_future_': 'plotly',
    '_plotly_utils': 'plotly',
    '_sentencepiece': 'sentencepiece',
    '_sounddevice': 'sounddevice',
    'absl': 'absl-py',
    'add_trailing_comma' : 'add-trailing-comma',
    'ahocorasick': 'pyahocorasick',
    'async_timeout': 'async-timeout',
    'atari_py': 'atari-py',
    'babel': 'Babel',
    'backports': 'configparser',
    'black_primer': 'black',
    'blackd': 'black',
    'blib2to3': 'black',
    'bs4': 'beautifulsoup4',
    'cached_property': 'cached-property',
    'caffe2': 'torch',
    'chart_studio': 'chart-studio',
    'clinical_trials': 'clinical-trials',
    'clinical_trials/api': 'clinical-trials',
    'clinical_trials/api/xml2dict': 'clinical-trials',
    'colors': 'ansicolors',
    'compose': 'docker-compose',
    'tree_sitter': 'tree-sitter',
    'tree_sitter_python': 'tree-sitter-python',
    # 'cv2': 'opencv-python',
    # 'cv2': 'opencv-contrib-python', #This one is just like opencv, but better...but does it install as reliably? (Update: so far, so good!)
    'cv2': 'opencv-python opencv-contrib-python', #But that didn't work for get_edge_drawing...https://github.com/Comfy-Org/ComfyUI-Manager/discussions/708
    'AppKit':         'pyobjc-framework-Cocoa',
    'Cocoa':          'pyobjc-framework-Cocoa',
    'CoreFoundation': 'pyobjc-framework-Cocoa',
    'Foundation':     'pyobjc-framework-Cocoa',
    'PyObjCTools': 'pyobjc-core',
    'objc':        'pyobjc-core',
    'Vision':      'pyobjc-framework-Vision',
    'Quartz':      'pyobjc-framework-Quartz',
    'pi_heif':'pi-heif',
    'cython': 'Cython',
    'pdfminer':'pdfminer.six',
    'textual' : 'textual[syntax]',
    'lazy_loader': 'lazy-loader',
    'deprecate': 'pyDeprecate',
    'diff_match_patch': 'diff-match-patch',
    'dns': 'dnspython',
    'docx': 'python-docx',
    'dockerpycreds': 'docker-pycreds',
    'dot_parser': 'pydotz',
    'dotenv': 'python-dotenv',
    'dt_authentication': 'dt-authentication-daffy',
    'dt_data_api': 'dt-data-api-daffy',
    'dt_shell': 'duckietown-shell',
    'duckietown_docker_utils': 'duckietown-docker-utils-daffy',
    'easyprocess': 'EasyProcess',
    'editor': 'python-editor',
    'eglRenderer': 'pybullet',
    'examples': 'test-tube',
    'faiss': 'faiss-gpu',
    'ffmpeg': 'ffmpeg-python',
    'fontTools': 'fonttools',
    'getmac': 'get-mac',
    'git': 'GitPython',
    'github': 'PyGithub',
    'glances': 'Glances',
    'google': 'protobuf',
    'google_auth_oauthlib': 'google-auth-oauthlib',
    'googleapiclient': 'google-api-python-client',
    'google_auth_httplib2': 'google-auth-httplib2',
    'gpt_2_simple': 'gpt-2-simple',
    'greptile': 'Greptile',
    'grpc': 'grpcio',
    'gtts_token': 'gTTS-token',
    'httpcore/_async': 'httpcore',
    'httpcore/_backends': 'httpcore',
    'httpcore/_sync': 'httpcore',
    'httpx/_transports': 'httpx',
    'integrations': 'torchmetrics',
    'ioc': 'python-ioc',
    'is_even_aast': 'iseven-aast',
    'jinja2_time': 'jinja2-time',
    'js2py': 'Js2Py',
    'jupyter_lsp': 'jupyter-lsp',
    'jupyterlab_git': 'jupyterlab-git',
    'jupyterlab_server': 'jupyterlab-server',
    'keras': 'keras-nightly',
    'keras_gym': 'keras-gym',
    'keras_preprocessing': 'Keras-Preprocessing',
    'lazy_object_proxy': 'lazy-object-proxy',
    'lib2to3': '2to3',
    'libarchive': 'libarchive-c',
    'libfuturize': 'future',
    'libpasteurize': 'future',
    'lpips_tf': 'lpips-tf',
    'mac_vendor_lookup': 'mac-vendor-lookup',
    'markdown': 'Markdown',
    'markdown_it': 'markdown-it-py',
    'matplotlib_inline': 'matplotlib-inline',
    'mpl_toolkits': 'matplotlib',
    'more_itertols':'more-itertools',
    'mplcairo': 'git+https://github.com/matplotlib/mplcairo',#Some features only available on master branch: https://stackoverflow.com/questions/26702176/is-it-possible-to-do-additive-blending-with-matplotlib
    'mypy_extensions': 'mypy-extensions',
    'neural_style': 'neural-style',
    'nmap3': 'python3-nmap',
    'notebook_as_pdf': 'notebook-as-pdf',
    'nvidia_smi': 'nvidia-ml-py3',
    'oembed': 'python-oembed',
    'ometa': 'Parsley',
    'opt_einsum': 'opt-einsum',
    'ot': 'POT',
    'paho': 'paho-mqtt',
    'pandocattributes': 'pandoc-attributes',
    'parsley': 'Parsley',
    'past': 'future',
    'pasta': 'google-pasta',
    'piglet': 'piglet-templates',
    'pillow_heif': 'pillow-heif',
    'pl_bolts': 'pytorch-lightning-bolts',
    'pl_examples': 'pytorch-lightning',
    'plotlywidget': 'plotly',
    'prompt_toolkit': 'prompt-toolkit',
    'pyasn1_modules': 'pyasn1-modules',
    'pybullet_data': 'pybullet',
    'pybullet_envs': 'pybullet',
    'pybullet_robots': 'pybullet',
    'pybullet_utils': 'pybullet',
    'pydot': 'pydotz',
    'pyfx' : 'python-fx',
    'pyinstrument_cext': 'pyinstrument-cext',
    'pylab': 'matplotlib',
    'pyls': 'python-language-server',
    'pyls_black': 'pyls-black',
    'pyls_jsonrpc': 'python-jsonrpc-server',
    'pyls_spyder': 'pyls-spyder',
    'pynvml': 'nvidia-ml-py3',
    'pytorch_lightning': 'pytorch-lightning',
    'pyvirtualdisplay': 'PyVirtualDisplay',
    'pywt': 'PyWavelets',
    'pyximport': 'Cython',
    'qdarkstyle': 'QDarkStyle',
    'qtawesome': 'QtAwesome',
    'ranger': 'ranger-fm',
    'requests_oauthlib': 'requests-oauthlib',
    'requests_toolbelt': 'requests-toolbelt',
    'requirements': 'requirements-parser',
    'rich_traceback': 'rich-traceback',
    'rtmidi': 'python-rtmidi',
    'rtree': 'Rtree',
    'sentry_sdk': 'sentry-sdk',
    'serial': 'pyserial',#WARNING: there is a 'pip install serial' which ALSO creates a 'serial' module. This module is the WRONG SERIAL MODULE.
    'skimage': 'scikit-image',
    'sklearn': 'scikit-learn',
    'skvideo': 'sk-video',
    'skvideo.io': 'sk-video',
    'slugify': 'python-slugify',
    'smart_open': 'smart-open',
    'spacy_legacy': 'spacy-legacy',
    'speech_recognition': 'SpeechRecognition', #Needs pyaudio, and needs portaudio. I'm not sure if pyaudio needs portaudio?
    'fontTools':'fonttools',
    'sphinx': 'Sphinx',
    'skia': 'skia-python',
    'sphinx_rtd_theme': 'sphinx-rtd-theme',
    'sphinxcontrib': 'sphinxcontrib-htmlhelp',
    'spyder_kernels': 'spyder-kernels',
    'tensorboard_data_server': 'tensorboard-data-server',
    'tensorboard_plugin_wit': 'tensorboard-plugin-wit',
    'tensorflow': 'tensorflow-gpu',
    'tensorflow_estimator': 'tensorflow-estimator',
    'terml': 'Parsley',
    'tesseract_ocr': 'tesseract-ocr',
    'test': 'pyinstrument',
    'test_tube': 'test-tube',
    'tests': 'torchmetrics',
    'text_unidecode': 'text-unidecode',
    'three_merge': 'three-merge',
    'timg/methods': 'timg',
    'typed_ast': 'typed-ast',
    'typing_extensions': 'typing-extensions',
    'websocket': 'websocket-client',
    'speech_recognition' : 'SpeechRecognition',
    'websockets/extensions': 'websockets',
    'werkzeug': 'Werkzeug',
    'wheel-platform-tag-is-broken-on-empty-wheels-see-issue-141': 'sklearn',
    'whisper': 'openai-whisper',
    'xontrib': 'xonsh',
    'yapftests': 'yapf',
    'yaml':'PyYAML',
    'zalgo_text': 'zalgo-text',
    'hitherdither' : "git+https://www.github.com/hbldh/hitherdither",
    'decord' : "eva-decord" if currently_running_mac() else "decord", #https://www.piwheels.org/project/eva-decord/ - decord doesn't pip install on mac
}


#class _rp_persistent_dict:
#    def __init__(self,name):
#        #TODO: Make this a persistent file that's opened by rp upon booting and saved when things change
#        pass
#    def __getitem__(self,key):
#        return self.data[key]
#    def __setitem__(self,key,value):
#        self.data[key]=value
#    def __delitem__(self,key):
#        self[data]=key

class _rp_persistent_set:
    def __init__(self,name=''):
        #TODO: Make this a persistent file that's opened by rp upon booting and saved when things change
        self.data=set()

    def add(self,key):
        self.data|={key}

    def __contains__(self,key):
        return key in self.data

    def delete(self,key):
        self.data-={key}



_pip_import_blacklist=_rp_persistent_set()
_pip_import_lock = threading.Lock()
_pip_import_autoyes=False #This will always be a private variable, but it might be exposed via a function
def pip_import(module_name,package_name=None,*,auto_yes=False):
    """
    TODO: Make this function only request sudo if we need it. Otherwise it's a nuisance.
    TODO: Add an "always" option to "yes" and "no" for installing modules.

    Attempts to import a module, and if successful returns it.
    If it's unsuccessful, it attempts to find it on pypi, and if
       it can, it asks you if you'd like to install it, and if
       you say 'yes', rp will attempt to install it for you.
    Note: There are some cases where it won't ask you, and instead will just go ahead and install the packages needed
      - If you're in Google Colab, it won't ask before installing packages as needed

    The rp module uses tons and tons of packages from pypi.
    You don't need to install all of them to make rp work,
       because not all functions need all of these packages.
    However, when you DO need a certain package's module,
       and we try importing it, we get an import error.
    Normally, this isn't a problem, because most packages on pypi
       have the same package name as the module name.
    For example: 'pip install rp' allows 'import rp', because the pypi-name
       and the import name are the same thing.
    Some modules break this rule though. For example, opencv:
       opencv is installed with 'pip install opencv-python' and imported like 'cv2=pip_import('cv2')'.
    Obviously, "cv2"!="opencv-python". And because of this, when you get an error "can't cv2=pip_import('cv2')",
       you can't just fix it with 'pip install cv2'. You have to google it. That's annoying.
    THIS FUNCTION addresses that problem. pip

    Enhanced Documentation:
    - Most critical RP function (called 310+ times across the entire codebase)
    - Usage patterns: Lazy dependency loading, auto-installation in Colab notebooks, handling package/module name mismatches
    - Related functions: module_exists(), running_in_google_colab(), connected_to_internet(), pip_install()
    - Performance: ~20x faster than importlib.import_module() for successful imports (uses __import__ internally)
    - Comparison: vs standard import - handles missing packages automatically; vs importlib - much faster for basic cases

    Parameters:
        module_name (str): Python module name to import (e.g., 'cv2', 'torch', 'matplotlib.pyplot')
        package_name (str, optional): PyPI package name if different from module name (e.g., 'opencv-python' for 'cv2')
        auto_yes (bool): Skip user confirmation for installation. Defaults to False. Always True in Colab.

    Returns:
        module: The imported Python module object, ready to use

    Examples:
        >>> cv2 = pip_import('cv2')  # Auto-installs opencv-python if needed
        >>> torch = pip_import('torch')  # Handles PyTorch installation
        >>> plt = pip_import('matplotlib.pyplot', 'matplotlib')  # Custom package name
        >>> PIL = pip_import('PIL', 'Pillow')  # Pillow package provides PIL module
        >>> np = pip_import('numpy')  # Simple case where module == package name

    Tags: imports, dependencies, auto-install, lazy-loading, core-utility, package-management
    """

    assert isinstance(module_name,str),'pip_import: error: module_name must be a string, but got type '+repr(type(module_name))#Probably better done with raise typerror but meh whatever

    try:
        #This is approx 20 times faster than the old code using importlib.import_module(module_name)
        #Apparently thats slow but __import__ is fast
        #Idk why? But this makes it much faster...maybe I can phase out importlib from this function entirely...but thats for another day
        return __import__(module_name)
    except ModuleNotFoundError:
        #If we can't find the module, continue on...
        pass

    if package_name is None:
        package_name=module_name
    if package_name in known_pypi_module_package_names:
        package_name=known_pypi_module_package_names[package_name]

    def offer_to_blacklist():
        if input_yes_no('Would you like to blacklist %s? (In the future, pip_import will no longer try to install it)'%module_name):
            _pip_import_blacklist.add(module_name)
            print('TODO: This message tells you how to remove items from the blacklist')

    import importlib

    try:
        #No need for action
        return importlib.import_module(module_name)

    except ImportError:
        if module_exists(module_name) or module_name in _pip_import_blacklist:
            raise  # We're getting an import error for some reason other than not having installed the module

        if connected_to_internet():
            with _pip_import_lock:

                # Double-check: another thread might have installed it while we waited
                try:
                    # If we started 32 threads and each one had a pip_import, we don't want each thread to try installing 
                    return importlib.import_module(module_name)
                except ImportError:
                    pass  # Still not available, proceed with installation
                
                if auto_yes or rp.r._pip_import_autoyes or running_in_google_colab() or input_yes_no("Failed to import module "+repr(module_name)+'. You might be able to get this module by installing package '+repr(package_name)+' with pip. Would you like to try that?'):
                    print("Attempting to install",package_name,'with pip...')
                    pip_install(package_name)
                    fansi_print("pip_import: successfully installed package "+repr(package_name)+"; attempting to import it...",'green',new_line=False)
                    assert module_exists(module_name),'pip_import: error: Internal assertion failed (rp thought we successfully installed your package, but perhaps it didnt actually work, or maybe this package isn\'t compatiable with this version of python. Right now I dont know how to detect this).'# pip_import needs to be fixed if you see this message.'
                    out=pip_import(module_name,package_name)#This shouldn't recurse more than once
                    fansi_print("success!",'green')
                    return out

                else:
                    print("...very well then. Throwing an import error...")
                    offer_to_blacklist()
                    raise

        else:
            #We would fail to install the package becuase we have no internet
            fansi_print("pip_import: normally we would try to install your package via pip, but you're not connected to the internet. Failed to pip_install("+repr(package_name)+')')
            raise

def _import_module(module_name):
    """ Can import a module name with multiple .'s in it, like rp.git.CommonSource. __import__ can't do that it just returns rp """
    return exeval(line_join("%return module", "import " + module_name + " as module"))

_rp_git_token=None #Set in RPRC
def git_import(repo,token=None,*,pull=False):
    """
    Attempts to import a module from rp.git.some_module_name
    If it doesn't exist, it will try to clone it.
    If only the repo name is given, like "CommonSource", it tries to clone from https://github.com/RyannDaGreat/CommonSource (defaults to RyannDaGreat)
    If pull is True, it will attempt to update the module by running "git pull" in it. Use this if you suspect a module might be out of date.
    If pull is a string, like 'Oct 20 2025', it will try to pull if the git repo is out of date - i.e. if the last commit date is before the given pull date

    EXAMPLE:

        >>> git_import('CommonSource',pull='Tue May 13, 2025 at 12:20:50AM EDT')

    """
    assert isinstance(repo,str)
    assert token is None or isinstance(token,str)
    
    module_name = repo #For now these are the same, might diverge in the future if I want other peoples repos
    module = 'rp.git.'+module_name
    path = path_join(_rp_git_dir, module_name)

    if folder_exists(path):
        if isinstance(pull, str):
            #Pull is some date we need to be after
            pull = get_git_commit_date(path) < string_to_date(pull)

        if pull:
            git_pull(path, show_progress=True)
            fansi_print('git_import('+repr(module_name)+'): Most recent commit date: '+format_date(get_git_commit_date(path)), 'green bold')

    try:
        return _import_module(module)
    except ImportError:
        pass
    
    if token is None:
        token=_rp_git_token
        
    if token is None:
        url = "https://github.com/RyannDaGreat/"+repo
    else:
        url = "https://api:%s@github.com/RyannDaGreat/%s.git"%(token,repo)
        
    fansi_print('rp_git_import: Attempting to git clone new module %s at %s to %s'%(module, url, path), 'cyan')
    git_clone(url,path)
    
    try:
        return _import_module(module)
    except ImportError:
        fansi_print('rp_git_import: Failed to import repo='+repr(repo), 'red', 'bold')
        raise


def git_revert_blank_lines():
    """
    Revert hunks that are entirely blank and have equal adds/removes.
    Claude often randomly changes whitespace in blank lines making git diffs hard to read for no reason
    Only effects unstaged lines
    """
    #Uses --unified=0 to get individual hunks.
    try:
        from unidiff import PatchSet
    except ImportError:
        print("Install unidiff: pip install unidiff")
        return

    # Use --unified=0 to get separate hunks for each change
    diff = shell_command('git diff --unified=0')
    if not diff:
        return

    patches = PatchSet(diff)

    for patch in patches:
        # Load current file
        lines = load_file_lines(patch.path)

        # Process hunks in reverse to maintain line numbers
        for hunk in reversed(list(patch)):
            added = []
            removed = []

            for line in hunk:
                if line.is_added:
                    added.append(line)
                elif line.is_removed:
                    removed.append(line)

            # Check: same number of lines AND all blank
            if len(added) == len(removed) and len(added) > 0:
                if all(l.value.strip() == '' for l in added + removed):
                    # This hunk is all blank with equal lines - revert it
                    # The line number is directly where the change is (no context with unified=0)
                    pos = hunk.target_start - 1

                    # Replace added lines with removed lines
                    for i, rem_line in enumerate(removed):
                        if pos + i < len(lines):
                            lines[pos + i] = rem_line.value.rstrip('\n')

        # Save the file
        save_file_lines(lines, patch.path)


def check_pip_requirements(file='requirements.txt', silent=False):
    """
    Test availability of required packages from given requirements file.
    
    Args:
        file (str, optional): Path to the requirements file. Defaults to 'requirements.txt'.
        silent (bool, optional): If True, does not print the output to the console but instead returns it as a string. Defaults to False.
    
    Returns:
        Optional[str]: If silent, returns the requirements table as a string, including ANSI escape sequences for color. Else, returns None.
                       If you need plain text without ansi_escapes, use rp.strip_ansi_escapes(check_pip_requirements("requirements.txt", silent=True))
    
    EXAMPLE:

         >>> check_pip_requirements()
         ... # PRINTED OUTPUT:
         ... #                     Requirements from requirements.txt
         ... #   ┏━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━┓
         ... #   ┃ Line ┃ Requirement           ┃ Status           ┃ Installed Version ┃
         ... #   ┡━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━┩
         ... #   │ 1    │ accelerate            │ Satisfied        │ 0.29.2            │
         ... #   │ 10   │ pandas                │ Satisfied        │ 2.2.2             │
         ... #   │ 14   │ sentencepiece>=0.2.0  │ Satisfied        │ 0.2.0             │
         ... #   │ 2    │ bitsandbytes          │ Satisfied        │ 0.43.1            │
         ... #   │ 8    │ decord>=0.6.0         │ Satisfied        │ 0.6.0             │
         ... #   │ 9    │ wandb                 │ Satisfied        │ 0.17.1            │
         ... #   │ 11   │ torch>=2.4.0          │ Version Conflict │ 2.0.1             │
         ... #   │ 12   │ torchvision>=0.19.0   │ Version Conflict │ 0.15.2            │
         ... #   │ 15   │ imageio-ffmpeg>=0.5.1 │ Version Conflict │ 0.4.2             │
         ... #   │ 16   │ numpy>=1.26.4         │ Version Conflict │ 1.24.4            │
         ... #   │ 3    │ diffusers>=0.30.3     │ Version Conflict │ 0.30.0.dev0       │
         ... #   │ 4    │ transformers>=4.45.2  │ Version Conflict │ 4.42.4            │
         ... #   │ 7    │ peft>=0.12.0          │ Version Conflict │ 0.11.1            │
         ... #   │ 13   │ torchao>=0.5.0        │ Missing          │                   │
         ... #   │ 5    │ huggingface_hub       │ Missing          │                   │
         ... #   │ 6    │ hf_transfer>=0.1.8    │ Missing          │                   │
         ... #   └──────┴───────────────────────┴──────────────────┴───────────────────┘

    """
    pip_import('rich')

    assert file_exists(file), 'check_pip_requirements: requirements file not found: '+str(file)

    try:
        # importlib.metadata is the modern standard library replacement (Python 3.8+)
        # pkg_resources is deprecated and will be removed in setuptools 81+ (2025-11-30)
        from importlib.metadata import distributions
        # Normalize to match pkg_resources.key: lowercase and replace underscores with hyphens
        installed_packages = {dist.name.lower().replace('_', '-'): dist.version for dist in distributions()}
    except ImportError:
        # Fallback for Python < 3.8
        import pkg_resources
        installed_packages = {pkg.key: pkg.version for pkg in pkg_resources.working_set}

    from pip._internal.req.req_file import parse_requirements
    from pip._internal.req.constructors import install_req_from_parsed_requirement
    from pip._internal.network.session import PipSession
    from rich.console import Console
    from rich.table import Table

    session = PipSession()
    requirements = list(parse_requirements(file, session))

    table = Table(title="Requirements from "+str(file))
    table.add_column("Line", style="cyan")
    table.add_column("Requirement", style="magenta")
    table.add_column("Status", style="bold")
    table.add_column("Installed Version", style="green")

    satisfied = []
    conflicted = []
    missing = []

    for requirement in requirements:
        req_to_install = install_req_from_parsed_requirement(requirement)
        package_name = req_to_install.name
        req = req_to_install.req

        line_number = requirement.comes_from.split("line ")[1].split(")")[0] if requirement.comes_from else ""

        if package_name in installed_packages:
            installed_version = installed_packages[package_name]
            if req_to_install.specifier.contains(installed_version):
                satisfied.append((line_number, str(req), installed_version))
            else:
                conflicted.append((line_number, str(req), installed_version))
        else:
            missing.append((line_number, str(req)))

    for item in sorted(satisfied, key=lambda x: x[0]):
        table.add_row(str(item[0]), item[1], '[green]Satisfied[/green]', item[2])

    for item in sorted(conflicted, key=lambda x: x[0]):
        table.add_row(str(item[0]), item[1], '[yellow]Version Conflict[/yellow]', item[2])

    for item in sorted(missing, key=lambda x: x[0]):
        table.add_row(str(item[0]), item[1], '[red]Missing[/red]', '')

    console = Console()
    with console.capture() as capture:
        console.print(table)
        if not conflicted and not missing:
            console.print("[bold green]All required packages are already installed[/bold green]")
    
    output = capture.get()

    if silent:
        return output
    else:
        print(output)


def get_mask_iou(*masks):
    """Calculates the IOU (intersection over union) of multiple binary masks"""
    masks=detuple(masks)
    assert all(is_image(mask) for mask in masks), 'All masks must be images as defined by rp.is_image'
    assert len(set(get_image_dimensions(mask) for mask in masks))==1, 'All masks must have the same dimensions, but got shapes '+repr(set(get_image_dimensions(mask) for mask in masks))
    masks = as_numpy_array(
        as_binary_images(as_grayscale_images(masks, copy=False,), copy=False,)
    )
    intersection = np.min(masks, axis=0)
    union = np.max(masks, axis=0)

    numerator = np.sum(intersection)
    denominator = np.sum(union)
    
    if denominator==0:
        # No division by 0 errors
        return 0
    else:
        return np.sum(intersection) / np.sum(union)

def fuzzy_match(array, target, equals=lambda x, y: x == y):
    """
    Returns True if each element in array can be found in sequence
    (though not necessarily consecutively) within target. Otherwise, returns False.
    
    This runs in O(n) time and O(1) space, where n is the length of the target. 
    The function is optimized for strings using the fuzzy_string_match method.

    Examples:
    --------
        >>> fuzzy_match([1, 2, 3, 4], [2, 3])
        True
        >>> fuzzy_match([1, 2, 3, 4], [2, 2])
        False
        >>> fuzzy_match([1, 2, 3, 4], [2, 4])
        True
        >>> fuzzy_match([1, 2, 3, 4], [3, 4])
        True
    """
    
    # A potential shortcut
    try:
        if len(target) > len(array):
            # In this case, we can return False right away
            return False
        if len(target)==0:
            # An empty list is contained in everything...
            return True
    except TypeError:
        # If either array or target don't support len()
        # It's ok, we can still treat them as iterators
        pass

    # A potential shortcut for strings
    # MUST CHECK EQUALS IS DEFAULT VALUE
    # TODO
    #if isinstance(array, str):
    #    try:
    #        target = "".join(target)
    #        # fuzzy_string_match is faster than this function because it uses regex
    #        return fuzzy_string_match(array, target)
    #    except Exception:
    #        # If we can't join it to be a string, it contained non-strings
    #        # If it contained non-strings, it can't be found array, which is str
    #        return False

    # The meat of this function - it's quite simple.
    target_iter = iter(target)
    for a in array:
        for t in target_iter:
            if equals(t, a):
                break
        else:
            return False
    return True

    #     def test_fuzzy_match():
    #         assert fuzzy_match([1, 2, 3, 4],[]) == True
    #         assert fuzzy_match([2, 3], [1, 2, 3, 4]) == True
    #         assert fuzzy_match([2, 2], [1, 2, 3, 4]) == False
    #         assert fuzzy_match([2, 4], [1, 2, 3, 4]) == True
    #         assert fuzzy_match([3, 4], [1, 2, 3, 4]) == True
    #         assert fuzzy_match([1,3, 4], [1, 2, 3, 4]) == True
    #         assert fuzzy_match([1,2,3, 4], [1, 2, 3, 4]) == True
    #         assert fuzzy_match([1,3, 4,1], [1, 2, 3, 4]) == False
    #         assert fuzzy_match([2,3, 4], [1, 2, 3, 4]) == True
    #         assert fuzzy_match([2,1,3, 4], [1, 2, 3, 4]) == False
    #         assert fuzzy_match([2,1,3, 4,5], [1, 2, 3, 4]) == False # 
    #
    #         # Testing with generators
    #         generator_1 = (i for i in [2, 3])
    #         generator_2 = (i for i in [1, 2, 3, 4])
    #         assert fuzzy_match(generator_1, generator_2) == False
    #
    #         print("All tests passed!")
    #
    #
    # # Running the test function
    #     test_fuzzy_match()

def get_only(collection):
    """Return the sole item of the collection."""
    if len(collection) != 1: raise ValueError("Expected collection with 1 item but got "+str(len(collection)))
    return next(iter(collection))

def get_only_value(dictionary):
    """Return the sole value from a dictionary."""
    if len(dictionary) != 1: raise ValueError("Expected dictionary with 1 key but got "+str(len(dictionary)))
    return next(iter(dictionary.values()))

def _check_rpy_35_syntax():
    """
    Used to prepare r.py for release
    IN CLAUDECODE:
        Run rp call _check_rpy_35_syntax then fix any syntax errors that make it incompatiable with python 3.5 then run again then repeat until no more errors 
    """
    out = is_valid_python_syntax(get_source_code(r), version="3.5", return_error=True)
    if out:
        print(out)
    else:
        print("No syntax errors! r.py is compatiable with python 3.5")


del re #re is random element


#TODO: Fix this. It can help extract function defs among other useful thins
#    def semantic_lines(python_code:str)->list:
#   #Gets every semantic python line.
#   #Meaning, literals that take up more than one line will be treated as one line (aka multiline strings, multiline defs, multiline lists etc)
#   tokens=split_python_tokens(python_code)#This fraks up because split_python_tokens is broken -- it sees '"hello"' as 3 tokens: ['"','hello','"']
#   output=[]
#   stack =[]
#   current_line=''
#   closes={'}':'{',')':'(',']':'['}
#   opens ={'{','(','['}
#   for token in tokens:
#       current_line+=token
#       if token in opens:
#       stack.append(token)
#       elif token in closes and stack and stack[-1] == closes[token]:
#       stack.pop()
#       elif token=='\n' and not stack:
#       output.append(current_line)
#       current_line=''
#   if current_line:
#       output.append(current_line)
#   return output



# endregion

# TODO: Mini-Terminal, Stereo audio recording/only initialize stream if using audio, Plot over images, error stack-printing extract from pseudo_terminal,
# TODO: See 'pseudolambdaidea' file
# TODO: Git auto-commit: see 'ryan_autogitter.py' file
# TODO: A more detailed pseudo_terminal history
# TODO: Make pseudo_temrinal open source!!!!
# TODO: Make a command for pseudo_terminal to kill the current command's execution. Make it so that we try to run all commands as a thread, but we kill those threads if we type "CANCEL" or "ABORT" or something so we dont need to close pseudo_terminal to cancel the process.
#
#
# class blank:# Just a placeholder for call_non_blank_parameters
#     pass
# def call_non_blank_parameters(f,*args,**kwargs):#will be used to streamline my use of te Đ
#     assert callable(f)
#     default_args=f.
#     args=[args]

# Version Oct24 2021

