# -*- coding: UTF-8 -*-
from __future__ import unicode_literals
#THINGS TO DO BEFORE OFFICIAL RELEASE:
#   Rename "path" functions to "2d-somethings" idk what, but it conflicts with file-paths...
#   Rename "display" functions to "plot" functions. "display" functions should be very simple and library-agnostic, while plot can be matplotlib-based.
#   Remove useless functions, and categorize them. Probably should split into multiple files; but that's kinda messy...
#       These functions don't have to be removed from r.py, they just have to be deleted from rp.py (after using from r import *, use something like 'del useless_function')

#TODO: Turn the comments at the beginning of each function into docstrings so they can be read by the builtin help function
# python /Users/Ryan/PycharmProjects/Py27RyanStandard2.7/Groupie.py ftF11dwbP61OfPf9QsXBfS5usCdQdBkkMieObdvZ -g 'The Think Tank'
# Imports that are necessary for the 'r' module:
# Imports I tend to use a lot and include so they their names can be directly imported from th:
# region Import
# This is useful for running things on the terminal app or in blender
# import r# For rinsp searches for functions in the r module, so I don't need to keep typing 'import r' over and over again
import sys
import threading
from builtins import *#For autocompletion with pseudo_terminal
from time import sleep
sys.path.append(__file__[:-len("r.py")])
import rp
import os
import time
import shlex
import glob,sys
import random
import warnings
import pickle
import tempfile
import contextlib
import math
import random
import re
from itertools import product as cartesian_product, combinations as all_combinations
from functools import lru_cache
from multiprocessing.dummy import Pool as ThreadPool  # ⟵ par_map uses ThreadPool. We import it now so we don't have to later, when we use par_map.
from contextlib import contextmanager
from math import factorial

# Places I want to access no matter where I launch r.py
# sys.path.append('/Users/Ryan/PycharmProjects/RyanBStandards_Python3.5')
# sys.path.append('/Library/Frameworks/Python.framework/Versions/3.5/lib/python3.5/site-packages')
# endregion
# region ［entuple， detuple］


def entuple(x):
    # For pesky petty things.
    if isinstance(x,tuple):
        return x
    return x,
def detuple(x):
    """ 
    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.

    EXAMPLE:

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

    """
    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):
    #itc ==== iterate to convergence, where f(x)==x.
    #In other words, we iterate f on x until we reach a fixed point.
    while True:
        y=f(x)
        if y==x:
            return y
        x=y
# region  ［run‚ fog］
def run_func(f,*g,**kwg):  # Pop () ⟶ )(
    return f(*g,**kwg)
def fog(f,*g,**kwg):  # Encapsulate )( ⟶ ()      'fog' ≣ ƒ ∘ g‚ where g can be any number of parameters.
    return lambda:f(*g,**kwg)
# endregion

# region［scoop］
# scoop could have been implemented with seq. I chose not to.
def scoop(funcⵓscoopˏnew,list_in,init_value=None):
    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 par_map, this def features non-lazy evaluation! (Unlike python's map function, which does not. Proof: map(print,['hello']) does not print anything, but [*map(print,['hello'])] does.)
    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 _legacy_par_map(func,*iterables,number_of_threads=None,chunksize=None):
    #THE OLD IMPLEMENTATION: There's nothing wrong with it, it was just old and messy.
    #REST IN PEACE OLD FRIEND! (Made this early in freshman year like 7 years ago lol - its 2023 now)
    #TODO: Test the new one in python3.5!! Just in case its still useful, for compatiability I leave it in here as _legacy_par_map


    # Multi-threaded map function. When I figure out a way to do parallel computations, this def (conveniently high-level) will be replaced.
    try:
        par_pool=ThreadPool(number_of_threads)
        try:
            out=par_pool.map(lambda args:func(*args),zip(*iterables),chunksize=chunksize)  # ⟵ A more complicated version of out=par_pool.map(func,iterable,chunksize=chunksize). Current version lets func accept multiple arguments.
        except Exception:
            out=par_pool.map(func,iterables,chunksize=chunksize)
        par_pool.terminate()  # ⟵ If we don't have this line here, the number of threads running AKA threading.active_count() will continue to grow even after this def has returned, ∴ eventually causing the RunTime error exception mentioned below.
        return out
    except RuntimeError:  # ⟵ Assuming we got "RuntimeError: can't start new thread", we will calculate it sequentially instead. It will give the same result, but it won't be in parallel.
        return seq_map(func,*iterables)



def par_map(func,*iterables,num_threads=None,buffer_limit=0):
    """
    See lazy_par_map for doc. 
    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.
    """
    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):
    # 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):
    # 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):
    # pam is map spelt backwards. pam maps multiple defs onto a single set of arguments (instead of map, which maps multiple sets of arguments onto one function)
    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:
        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,[])
    """

    from itertools import chain
    return list(chain.from_iterable(list_2d))

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 summation(x,start=None):
    # Useful because this literally uses the '+' operator over and over again instead of necessarily treating the elements as numbers.
    # list_flatten(l)≣summation(l)
    # sum(x,[])≣summation(x)
    # sum(x)≣summation(x)
    return scoop(lambda 𝓍,𝓎:𝓍 + 𝓎,x,start if start is not None else x[0]) if len(x) else start
    # assert is_iterable(x)
    # try:
    #     out=x[0]
    # except Exception:
    #     return 0# x has no indices
    # for y in x[1:]:
    #     out+=y
    # return out

def unique(iterable, key=identity):
    """
    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']
    """
    seen = set()
    for item in iterable:
        tag = handy_hash(key(item))
        if tag not in seen:
            seen.add(tag)
            yield item
    
# endregion
# endregion
# region  Time:［gtoc，tic‚ toc‚ ptoc‚ ptoctic‚ millis，micros，nanos］
_global_tic=time.time()
gtoc=time.time  # global toc
def tic() -> callable:
    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(new_line=True) -> None:
    print(str(toc()) + " seconds",end='\n'if new_line else '')
def ptoctic(label='') -> None:
    print(label,end='')
    ptoc()
    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_current_directory():
    # Get the result of 'cd' in a shell. This is the current folder where save or load things by default.
    # SUMMARY: get_current_directory() ≣ sys.path[0] ﹦ ﹙default folder_path﹚ ﹦ ﹙current directory﹚ ﹦ /Users/Ryan/PycharmProjects/RyanBStandards_Python3.5
    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):
    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()
        
#THIS IS DEPRECATED IN FAVOR OF get_all_paths
# def get_all_file_names(file_name_ending: str = '',file_name_must_contain: str = '',folder_path: str = get_current_directory(),show_debug_narrative: bool = False):
#     # SUMMARY: This method returns a list of all file names files in 'folder_path' that meet the specifications set by 'file_name_ending' and 'file_name_must_contain'
#     # Leave file_name_ending blank to return all file names in the folder.
#     # To find all file names of a specific extension, make file_name_ending ﹦ '.jpg' or 'png' etc.
#     # Note: It does not matter if you have '.png' vs 'png'! It will return a list of all files whose name's ends…
#     #     …with file_name_ending (whether that comes from the file type extension or not). Note that you can use this to search…
#     #     …for specific types of file names that YOU made arbitrarily, like 'Apuppy.png','Bpuppy.png' ⟵ Can both be found with…
#     #     …file_name_ending ﹦ 'puppy.png'
#     # file_name_must_contain ⟶ all names in the output list must contain this character sequence
#     # show_debug_narrative ⟶ controls whether to print out details about what this function is doing that might help to debug something.
#     #     …By default this is disabled to avoid spamming the poor programmer who dares use this function.
#     # ;;::O(if)OOO
#     os.chdir(folder_path)
#     if show_debug_narrative:
#         print(get_all_file_names.__name__ + ": (Debug Narrative) Search Directory ﹦ " + folder_path)
#     output=[]
#     for file_name in glob.glob("*" + file_name_ending):
#         if file_name_must_contain in file_name:
#             output.append(file_name)  # I tried doing it with the '+' operator, but it returned a giant list of individual characters. This way works better.
#             if show_debug_narrative:
#                 print(get_all_file_names.__name__ + ": (Debug Narrative) Found '" + file_name + "'")
#     if show_debug_narrative:
#         print(get_all_file_names.__name__ + ' (Debug Narrative) Output ﹦ ' + str(output))
#     return output


# 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():
    import os
    return os.name=='nt'
def currently_running_posix():
    import os
    return os.name=='posix'
def currently_running_mac():
    import platform
    return platform.system()=='Darwin'
def currently_running_linux():
    import platform
    return platform.system()=='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():
    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():
    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

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

    The 'per_line' option applies fansi to each line separately, which is useful for multi-line strings. It is enabled by default.
    The 'truecolor' option enables 24-bit truecolor support if the terminal supports it. It is disabled by default.

    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   |
        - 'underlined': Underlined 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   |

    COLORS:
        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 example!

    EXAMPLES:

        >>> #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')
        ... )

        >>> #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))
        
    """
    # 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, per_line=False, reset=reset, truecolor=truecolor) 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 = {
        "normal": 0,
        "bold": 1,
        "faded": 2,
        "italic": 3,
        "underlined": 4,
        "blinking": 5,
        "invert": 7,
        "hide": 8,
        "strike": 9,
        "sub": 74,
        "super": 73,
    }
    #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 ANSI formatting
    format_sequence = ';'.join(format_codes)
    output = "\x1b[{}m{}".format(format_sequence, text_string)
    if reset:
        output += '\x1b[0m'
    return output




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,new_line=True,reset=True,truecolor=True) -> object:
    """
    This function prints colored text in a 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: print(fansi('ERROR:','red','bold')+fansi(" ATE TOO MANY APPLES!!!",'blue','underlined','yellow'))
    """
    print(
        fansi(
            text_string,
            text_color=text_color,
            style=style,
            background_color=background_color,
            reset=reset,
            truecolor=truecolor,
        ),
        end="\n" if new_line else "",
        flush=True,
    )
# 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
            from itertools import chain
            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).
    """
    
    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:
        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)
    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
_local_clipboard_string_path=__file__+'.rp_local_clipboard'
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 the clipboard so you can paste it later
    First tries to copy the string to the system clipboard.
    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()
    """
    global _local_clipboard_string
    
    
    _copy_text_over_terminal(string) #Experimental

    _set_local_clipboard_string(string)
    try:
        try:
            from rp.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.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 width(image) -> int:
#     return len(image)
# def height(image) -> int:
#     return len(image[0])

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):
    # 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 """
    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_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(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)*color
        assert is_grayscale_image(output)
        return output
    else:
        output = np.ones((height,width,len(color)),dtype=float)*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 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.
            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"

            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(top) and len(top) in {3,4} or is_number(top) or isinstance(top,str)
    assert is_image(bot) or is_color(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",
    }
    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 (top  ):top  =uniform_float_color_image(height, width, top  )  
    if is_color (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
    
    alpha=alpha[:,:,None]

    if   "normal"   in mode.split(): output = bot * (1 - alpha) + top * 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

    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 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
):
    """
    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)
    shadow_alpha=cv_gauss_blur(shadow_alpha,sigma=blur)
    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):
    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(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):
    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):
    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):
    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,
    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.

    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)

    """
    
    def helper():
        nonlocal size, bar_color, background_color, position, reverse
        length = len(video)
        for index, image in enumerate(video):
            progress = index / (length - 1)
            frame = gather_args_call(image_with_progress_bar)
            yield frame

    output = helper()

    if lazy:
        if hasattr(video, '__len__'):
            length = len(video)
            output = IteratorWithLen(output, length)

    else:
        output = list(output)

    return output

def boomerang_video(video):
    if isinstance(video,str):
        video=load_video(video)
    new_video=list(video)[:-1]+list(video)[::-1][:-1]
    return new_video
    


def _get_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)
    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
    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_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_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')
        os.system(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)
    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)
    assert isinstance(origin,str)
    
    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, copy_attr='copy'):
    """
    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 = getattr(out, copy_attr)()
    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(image,scale,interp='bilinear'):
    """
    resize_image resizes images. Who woulda thunk it? Stretchy-squishy image resizing!
    :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
    Note: Auto here is kinda redundant: scipy or skimage does nice interp on its own
    """
    if interp=='auto': interp='bilinear'
    assert interp in {'nearest','bilinear','bicubic'}
    if scale == 1:
        return image
    try:
        from scipy.misc import imresize
        return imresize(image,float(scale),interp)#We multiply scale by 100 because it's measured in percent
    except Exception:pass
    try:
        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)
        # return resize(image,(height,width))

        # if interp=='auto': return _auto_interp_for_resize_image(resize_image, image, (height, width))

        order={'nearest':0,'bilinear':1,'bicubic':3}[interp]
        return resize(image,(height,width),order=order)
    except Exception:pass
    if is_number(scale):
        #Now we're in kinda bad janky territory...though it will still work...it will be slow because now its runnning in pure python...
        try:
            return cv_apply_affine_to_image(dog,scale_affine_2d(scale),output_resolution=scale)#Doesn't support 'interp'
        except Exception:pass
    return grid2d(int(len(image) * scale),int(len(image[0]) * scale),lambda x,y:image[int(x / scale)][int(y / scale)])#The slowest method of all...doesn't support 'interp'
# 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 image_to_xyrgb_lists(image):
    # expects an array like, for example 'image=[[[1,2,3],[4,5,6]],[[7,8,9],[10,11,12]]]'
    out_x=[]
    out_y=[]
    out_r=[]
    out_g=[]
    out_b=[]
    for x_index,x_val in enumerate(image):
        for y_index,y_val in enumerate(x_val):
            out_x.append(x_index)
            out_y.append(y_index)
            out_r.append(y_val[0])
            out_g.append(y_val[1])
            out_b.append(y_val[2])
    return out_x,out_y,out_r,out_g,out_b
def xyrgb_lists_to_image(*xyrgb_lists_as_tuple):
    xyrgb_lists_as_tuple=detuple(xyrgb_lists_as_tuple)  # So we can either accept 5 arguments or one tuple argument with 5 elements.
    assert len(xyrgb_lists_as_tuple) == 5,"One element:list for each channel: X Y R G B"
    x,y,r,g,b=xyrgb_lists_as_tuple
    assert len(x) == len(y) == len(r) == len(g) == len(b),"An outside-noise assumption. If this assertion fails then there is something wrong with the input parameters --> this def is not to blame."
    xyrgb_length=len(x)  # =len(y)=len(r)=len(g)=len(b) etc. We rename it 'xyrgb_length' to emphasize this symmetry.
    out_image=deepcopy_multiply([[None] * (max(y) + 1)],(max(x) + 1))  # Pre-allocating the pixels. [R,G,B] is inserted into each pixel later.
    for index in range(xyrgb_length):
        out_image[x[index]][y[index]]=[r[index],g[index],b[index]]
    return out_image
def xyrgb_normalize(*xyrgb,rgb_old_max=255,rgb_new_max=1,x_new_max=1,y_new_max=1):
    # Converts the (X and Y values, originally ﹙integers: the pixel X and Y indexes﹚) into float values between 0 and 1
    # Also converts the R,G, and B values from the range ［0‚255］⋂ ℤ into the range ［0‚1］⋂ ℝ
    x,y,r,g,b=detuple(xyrgb)
    x_factor=x_new_max / max(x)
    y_factor=y_new_max / max(y)
    x=list(ⵁ * x_factor for ⵁ in x)
    y=list(ⵁ * y_factor for ⵁ in y)

    rgb_factor=rgb_new_max / rgb_old_max
    r=list(ⵁ * rgb_factor for ⵁ in r)
    g=list(ⵁ * rgb_factor for ⵁ in g)
    b=list(ⵁ * rgb_factor for ⵁ in b)

    return x,y,r,g,b
def image_to_all_normalized_xy_rgb_training_pairs(image):
    x,y,r,g,b=xyrgb_normalize(image_to_xyrgb_lists(image))
    return list(zip(x,y)),list(zip(r,g,b))

    # NOTE: This def exists for efficiency purposes.
    # To create a training batch from the image, the minimal syntax would be:
    #     random_parallel_batch(*image_to_all_normalized_xy_rgb_training_pairs(image),a,b)
    # BUT NOTE: It is very inneficient to recalculate this def over and over again.
    # Store the output of this as a vairable, and use like so:
    # precalculated=image_to_all_normalized_xy_rgb_training_pairs(image)
    # new_batch=random_parallel_batch(*precalculated,a,b)


    # region Explanatory Example:
    # # Goal: create input and output from XY to RGB from image and turn them into a random batch for NN input outputs
    # #from r import *
    # x=['x₁','x₂','x₃']
    # y=['y₁','y₂','y₃']
    # r=['r₁','r₂','r₃']
    # g=['g₁','g₂','g₃']
    # b=['b₁','b₂','b₃']
    #
    # inputs=list(zip(x,y))
    # outputs=list(zip(r,g,b))
    # io_pairs=list(zip(inputs,outputs))
    #
    #  ⁠⁠⁠⁠    ⎧                                    ⎫
    #  ⁠⁠⁠⁠    ⎪    ⎧                              ⎫⎪
    # ⁠⁠⁠⁠     ⎪    ⎪   ⎧                         ⎫⎪⎪
    # print(list(zip(*random_batch(io_pairs,2))))
    #  ⁠⁠⁠⁠    ⎪    ⎪   ⎩                         ⎭⎪⎪
    #  ⁠⁠⁠⁠    ⎪    ⎩                              ⎭⎪
    #  ⁠⁠⁠⁠    ⎩                                    ⎭
    #
    #  ⁠⁠⁠⁠ ⎧                                                                      ⎫
    #  ⁠⁠⁠⁠ ⎪⎧                          ⎫  ⎧                                      ⎫⎪
    # # [(('x₂', 'y₂'), ('x₃', 'y₃')), (('r₂', 'g₂', 'b₂'), ('r₃', 'g₃', 'b₃'))]
    #  ⁠⁠⁠⁠ ⎪⎩                          ⎭  ⎩                                      ⎭⎪
    #  ⁠⁠⁠⁠ ⎩                                                                      ⎭
    # endregion
# endregion


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):
    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):
    return _is_instance_of_module_class(x, 'torch', 'Tensor')

def is_torch_image(image):
    "Returns True if image could be a CHW torch image"
    return is_torch_tensor(image) and image.ndim==3

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_pil_image(image) -> bool:
    return _is_instance_of_module_class(image, 'PIL.Image', '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):
        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):
    """
    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) ⋂ ℤ
    """
    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)

    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)


# 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 """
    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 _erase_terminal_line():
    sys.stdout.write('\033[2K\033[1G')#erase and go to beginning of line https://stackoverflow.com/questions/5290994/remove-and-replace-printed-items

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
                _erase_terminal_line()
                print("%s: Done! Loaded %i files 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］


_load_animated_gif_cache={}
def load_animated_gif(location,*,use_cache=True):
    """
    Location should be a url or a file path pointing to a GIF file
    Loads an array of frames of an RGB animated GIF
    Can load from a file or from a URL
    EXAMPLE:
        while True:
           url = 'https://i.pinimg.com/originals/80/26/71/80267166501067a9da5e6b9412bdd9df.gif'
           for frame in load_animated_gif(url,use_cache=True):
               display_image(frame)
               sleep(1/20)
    """
    location = get_absolute_path(location) #Important for caching
    if use_cache and location in _load_animated_gif_cache:
        return _load_animated_gif_cache[location]

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

    if is_valid_url(location):
        try:
            from urllib.request import urlopen
            gif = Image.open(urlopen(location))
        except Exception as e:
            #Sometimes the above method doesn't work.
            #When it doesn't, often downloading the image and loading it from the hard drive will still work; so we'll try that before giving up.
            temp_file=temporary_file_path()
            try:
                download_url(location,temp_file)
                output=load_animated_gif(temp_file)
            finally:
                delete_file(temp_file)
            return output
    else:
        assert file_exists(location), 'No such file exists: ' + repr(location)
        gif = Image.open(location)

    frames = [as_numpy_array(frame.convert('RGB')) for frame in ImageSequence.Iterator(gif)]
    frames = as_numpy_array(frames)

    _load_animated_gif_cache[location]=frames

    return frames

_load_image_cache={}



def load_image_from_clipboard():
    """ #Grab an image copied from your clipboard """
    pip_import('PIL')
    from PIL import ImageGrab
    assert currently_running_windows() or currently_running_mac(),'load_image_from_clipboard() only works on Mac and Windows right now; sorry. This is because of PIL.'
    ans=ImageGrab.grabclipboard()
    path=temporary_file_path('.png')
    ans.save(path)
    ans=load_image(path)
    delete_file(path)
    return ans

def copy_image_to_clipboard(image):
    """
    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 load_image(location,*,use_cache=False):
    """ Automatically detect if location is a URL or a file path and try to smartly choose the appropriate function to load the 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)
    if path_exists(location):
        location=get_absolute_path(location) #This is important for caching. ./image.jpg might mean different things when we're running in different directories.
    if use_cache and location in _load_image_cache and use_cache:
        return _load_image_cache[location].copy()
    if is_valid_url(location):
        out = load_image_from_url (location)
    else:
        out = load_image_from_file(location)
    if use_cache:
        #Only save to the cache if we're using use_cache, otherwise loading thousands of images with this method might run out of memory
        _load_image_cache[location]=out
    return out

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))

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
    """


#     output=[]
#     for i,location in enumerate(locations):
#         image=load_image(location,use_cache=use_cache)
#         output.append(image)
#         if display_progress:
#             show_time_remaining(i)

#     return [load_image(location,use_cache=use_cache) for location in locations]

#def load_images_in_parallel(*locations,use_cache=False):
#    #This is like load_images, except it runs faster.
#    locations=delist(detuple(locations))
#    output=[]
#    show_time_remaining=eta(len(locations))

def load_image_from_file(file_name):
    """ Can try opencv as a fallback if this ever breaks """
    assert file_exists(file_name),'No such image file exists: '+repr(file_name)

    if get_file_extension(file_name)=='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 - the PIL function can do this
        return _load_image_from_file_via_PIL(file_name)

    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)
    except Exception  :raise
    # assert False,'rp.load_image_from_file: Failed to load image file: '+repr(file_name)

_init_pillow_heif_called=False
def _init_pillow_heif():
    global _init_pillow_heif_called

    if not _init_pillow_heif_called:
        #https://stackoverflow.com/questions/54395735/how-to-work-with-heic-image-file-types-in-python
        pip_import('pillow_heif')
        import pillow_heif
        pillow_heif.register_heif_opener()
        _init_pillow_heif_called = True

def _load_image_from_file_via_PIL(file_name):
    if file_name.upper().endswith('.HEIC'):
        _init_pillow_heif()

    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_url(url: str):
    """
    Url should either be like http://website.com/image.png or like data:image/png;base64,iVBORw0KGgoAAAANSUhEUg...
    Returns a numpy image
    """
    assert url.startswith('data:image') or is_valid_url(url), 'load_image_from_url error: invalid url: ' + repr(url)
    
    pip_import('requests')
    pip_import('PIL')

    import requests
    from PIL import Image
    from io import BytesIO
    
    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)
    
    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

    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)

    import base64
    
    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):
    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):
    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
    """
    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):
    return [encode_image_to_base64(image, filetype, quality) for image in images]


def decode_image_from_bytes(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

def save_image(image,file_name=None,add_png_extension: bool = True):
    """
    Todo: Add support for imageio, which can also write images
    Simply save a numpy image to a file.
    The add_png_extension is annoying legacy stuff...sorry...it would break some of my other scripts to change that right now.
    Provide several fallbacks to saving an image file
    """
    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 not folder_exists(get_parent_folder(file_name)):
        #If the specified path's folders don't exist, make them. Don't whine and throw errors.
        make_directory(get_parent_folder(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:
            #Try to save using this jpg-specific method - it guarentees 100% quality
            return save_image_jpg(image, file_name, quality=100)
        except Exception:
            #Use some other method to save it
            pass
    elif get_file_extension(file_name)=='jxl':
        try:
            return save_image_jxl(image, file_name, quality=100)
        except Exception:
            #Use some other method to save it
            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))))
            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.
        - 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 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(path)
        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):
    """
    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')
    """

    if path is None:
        path=get_unique_copy_path('image.jpg')

    make_directory(get_parent_folder(path)) #Make sure the directory exists
    image=np.asarray(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)
    from PIL import 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
        
    none= Image.fromarray(image).save(path, "JPEG", quality=quality, optimize=False, progressive=True,**extra_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.
    
    EXAMPLE (Comparison video between JPG and JXL quality):
        
        >>> emma='https://github.com/RyannDaGreat/Diffusion-Illusions/blob/gh-pages/images/emma.png?raw=true'
        ... emma=load_image(emma,use_cache=True)
        ... emma=full_range(emma)
        ... display_image(emma)
        ... 
        ... dir=make_directory('jxl_jpg_comparisons')
        ... r._pterm_cd(dir)
        ... 
        ... comparisons=[]
        ... 
        ... for quality in [1,2,5,10,20,30,50,70,80,90,95,97,99,100]:
        ...     print(quality)
        ... 
        ...     jpg=save_image_jpg(emma,path=str(quality),quality=quality)
        ...     jxl=save_image_jxl(emma,path=str(quality),quality=quality)
        ...     
        ...     jpg_size=get_file_size(jpg,human_readable=True)
        ...     jxl_size=get_file_size(jxl,human_readable=True)
        ...     
        ...     jxl=load_image(jxl,use_cache=True)
        ...     jpg=load_image(jpg,use_cache=True)
        ...     
        ...     jpg=labeled_image(jpg,'JPG: '+jpg_size,font='G:Hind')
        ...     jxl=labeled_image(jxl,'JXL: '+jxl_size,font='G:Hind')
        ... 
        ...     comparison=horizontally_concatenated_images(jpg,jxl)
        ...     comparison=labeled_image(comparison,'JPG / JXL: Quality = '+str(quality)+"%",font='G:Hind',size=30)
        ...     comparisons.append(comparison)
        ... 
        ... mp4=save_video_mp4(comparisons,framerate=2,video_bitrate='max')
        ... final_grid=tiled_images(comparisons)
        ... png=save_image(final_grid,'comparison_grid.png')
        ... 
        ... open_file_with_default_application(mp4)
        
    """
    pip_import("pillow_jxl", "pillow-jpegxl-plugin")
    pip_import('PIL')
    from PIL import Image

    if path is None:
        path = get_unique_copy_path('image.jxl')
    
    make_directory(get_parent_folder(path))
    image = np.asarray(image)
    image = as_rgb_image(image)
    image = as_byte_image(image)
    
    assert 0 <= quality <= 100, 'JpgXL quality is measured in percent'
    if is_image(image):
        image = as_rgb_image(image)
    
    if add_extension and not get_file_extension(path).lower() == 'jxl':
        path += '.jxl'

    kwargs = dict(lossless=True) if quality == 100 else dict(quality=quality)
    none = Image.fromarray(image).save(path, "JXL", **kwargs)
    return path

def save_openexr_image(image, file_path):
    """
    Counterpart to load_openexr_image
    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()

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_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 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 output_folder is None or isinstance(output_folder, str), "Output folder must be a string, but got {}.".format(type(output_folder))
    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_image_files)

    cancelled = False
    output_files = []

    def _convert_image(input_file):
        output = None #I'm not sure why but somehow the controlflow can avoid declaring output in some edge case. Instead of debugging it I'll just fix it here.

        nonlocal cancelled
        if cancelled:
            if isinstance(cancelled, Exception):
                raise cancelled
            else:
                return None

        try:
            output = 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)

        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_images_converted
            number_of_images_converted += 1
            show_time_remaining(number_of_images_converted)

        return output

    try:
        if show_progress:
            number_of_images_converted = 0
            show_time_remaining = eta(len(input_files), title='Converting Images')
            start_time = gtoc()

        mapper = functools.partial(par_map, buffer_limit=None) if parallel else seq_map
        output_files = mapper(_convert_image, 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 %i images in %.3f seconds' % (len(output_files), elapsed_time))
            #TODO: Sometimes it reports it converted 0 images when it actually worked just fine - it's not keeping track of how many conversions it made?

    except KeyboardInterrupt:
        cancelled = True
        raise

    return output_files




# 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):
    # 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
    # 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
    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)(fog(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):
    # 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 as e:
                    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=text_to_speech_voices_favorites + shuffled(text_to_speech_voices_all)):
    """
    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.
    """
    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.
    """
    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



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})

_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

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) / old_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

_default_wav_output_path='r.mp3_to_wav_temp.wav'  # Expect this file to be routinely overwritten.
def mp3_to_wav(mp3_file_path: str,wav_output_path: str = _default_wav_output_path,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
    """
    shell_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
# 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):
    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')
    TODO: Implement loop for jupyter
    """

    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)
    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):
            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'
            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_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 display_video_in_notebook(video, filetype='gif', *, framerate=60):
    """
    Display a video or image in a Jupyter notebook.

    Args:
        video: The video object to display.
        filetype (str, optional): The filetype of the video or image. Supported filetypes are 'gif', 'png', 'mp4', and 'avi'. Defaults to 'gif'.
        framerate (int, optional): The framerate of the video. Defaults to 60.

    Raises:
        ValueError: If an unsupported filetype is provided.

    Examples:
        >>> video = create_video(...)
        >>> display_video_in_jupyter_notebook(video, filetype='mp4', embed=True)
    """
    embed=True # embed (bool, optional): Whether to embed the video or image content directly in the notebook. If False, the video or image is displayed using the file path. Defaults to False.

    from IPython.display import Image, display, HTML

    filetype = filetype.strip('.').lower()
    image_filetypes = 'gif png'.split()
    video_filetypes = 'mp4 avi'.split()

    try:
        if isinstance(video, str):
            assert file_exists(video), 'rp.display_video_in_notebook: Video file {0} does not exist'.format(video)
            temp_path = video
        else:
            temp_path = temporary_file_path(filetype)
            save_video(video, temp_path, framerate=framerate)

        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))
            else:
                display_object = Image(filename=temp_path)
        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 = HTML('<video loop autoplay controls><source src="{0}" type="video/{1}"></video>'.format(temp_path, filetype))
        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_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('{image_str}',image_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)
    """

    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
    if 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)
            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):
    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):
    assert is_valid_url(url),'Invalid url: %s'%url
    html=curl(url)
    pip_import('html2text')
    import html2text
    output=html2text.html2text(html)
    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 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('        +: 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
    
    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 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)

        #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 brutish_display_image(image):
    from copy import deepcopy
    global plt
    plt=get_plt()
    image=deepcopy(image)
    for x_index,x in enumerate(image):
        for y_index,y in enumerate(x):
            for channel_index,channel in enumerate(y):
                image[x_index][y_index][channel_index]=max(0,min(1,channel))
    display_image(image)
    plt.show(block=True)
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 display_grayscale_image(matrix,pixel_interpolation_method_name: str = 'bicubic',refresh=True):
    pixel_interpolation_method_name=str(pixel_interpolation_method_name).lower()  # Note that None⟶'none'
    assert pixel_interpolation_method_name in [None,'none','nearest','bilinear','bicubic','spline16','spline36','hanning','hamming','hermite','kaiser','quadric','catrom','gaussian','bessel','mitchell','sinc','lanczos']  # These are the options. See http://stackoverflow.com/questions/14722540/smoothing-between-pixels-of-imagesc-imshow-in-matlab-like-the-matplotlib-imshow/14728122#14728122
    global plt
    plt=get_plt()
    plt.imshow(matrix,cmap=plt.get_cmap('gray'),interpolation=pixel_interpolation_method_name)  # "cmap=plt.get_cmap('gray')" makes it show a black/white image instead of a color map.
    if refresh:
        plt.draw()
        plt.show(block=False)  # You can also use the r.block() method at any time if you want to make the plot usable.
        plt.pause(0.0001)  # This is nessecary, keep it here or it will crash. I don't know WHY its necessary, but empirically speaking it seems to be.

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(l,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():
    pip_import('matplotlib')
    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 numpy as np
    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):
    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):
    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'
    return (1 - α) * 𝓍 + α * 𝓎  # More α --> More 𝓎 ⋀ Less 𝓍
def iblend(z,𝓍,𝓎):  # iblend≣inverse blend. Solves for α， given 𝓏﹦blend(𝓍,𝓎,α)
    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):
    """
    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
    """
    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)
    if not as_dict:
        return [iterable[i] for i in indices]  # ≣list(map(lambda i:iterable[i],indices))
    else:
        return {i:iterable[i] for i in indices}

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):
    """
    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_wrapper(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
    """
    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)

    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 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_wrapper:
        def g(a,b,c,d,e,f,g):
            print(gather_args_wrapper(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_wrapper(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_wrapper
        ... 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
    """
    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_wrapper, 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_wrapper(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_wrapper(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):
    return get_current_function(frames_back+1).__name__

def gather_args_recursive_call(*args, frames_back=1, **kwargs):
    frames_back+=1
    function=get_current_function(frames_back)
    return gather_args_call(function,*args,frames_back=frames_back,**kwargs)

def replace_if_none(value):
    """
    TODO: Make this work with older versions of python, using destructure's strategy

    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!

    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 _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) -> 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.

    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,)}
    """

    if bijection:
        # {0: 'a', 1: 'b', 2: 'c'} ⟶ {'c': 2, 'b': 1, 'a': 0}
        return {v:k for v,k in zip(d.values(),d.keys())}
    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()))

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))

# ――――――――――――――――――――――
# 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.
    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()))))))  # 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
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:
    # also works with tuples etc
    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)
# endregion
# region  'youtube_dl'﹣dependent methods: ［rip_music，rip_info］
# noinspection SpellCheckingInspection

default_rip_music_output_filename="rip_music_temp"
def rip_music(URL: str,output_filename: str = default_rip_music_output_filename,desired_output_extension: str = 'wav',quiet=False):
    """
    Ryan Burgert Jan 15 2017
    Rips a music file off of streaming sites and downloads it to the default directory…
    URL: Can take URL's from youtube, Vimeo, SoundCloud...apparently youtube_dl supports over 400 sites!!
    output_filename: Shouldn't include an extension, though IDK if it would hurt. By default the output file is saved to the default directory.
    desired_output_extension: Could be 'wav', or 'mp3', or 'ogg' etc. You have the freedom to choose the type of file you want to download regardless of the type of the original online file; it will be converted automatically (because youtube is a huge mess of file types)
      NOTE: ‘brew install ffmpeg’ (run command in terminal) is necessary for some desired_output_extension types.
    This method returns the name of the file it created.
    Dependency: youtube_dl  ﹙See: https://rg3.github.io/youtube-dl/﹚
    Quiet: If this is true, then nothing will display on the console as this method downloads and converts the file.
    NOTE: youtube_dl has MANY more cool capabilities such as extracting the title/author/cover picture of the songs…
      …as well as breing able to download entire play-lists at once! youtube_dl can also rip videos; which could be very useful in another context!
    EXAMPLE: play_sound_file_via_afplay(rip_music('https://www.youtube.com/watch?v=HcgEHrwdSO4'))
    """
    pip_import('youtube_dl')
    import youtube_dl
    ydl_opts= \
        {
            'format':'bestaudio/best',  # Basically, grab the highest quality that we can get.
            'outtmpl':output_filename + ".%(ext)s",  # https://github.com/rg3/youtube-dl/issues/7870  ⟵ Had to visit this because it kept corrupting the audio files: Now I know why! Don't change this line.
            'postprocessors':
                [{
                    'key':'FFmpegExtractAudio',
                    'preferredcodec':desired_output_extension,
                    # 'preferredquality': '192',
                }],
            'quiet':quiet,  # If this is not enough, you can add a new parameter, 'verbose', to make it jabber even more. You can find these parameters in the documentation of the module that contains the 'YoutubeDL' method (used in a line below this one)
            'noplaylist':True,  # only download single song, not playlist
        }
    with youtube_dl.YoutubeDL(ydl_opts) as ydl:
        ydl.download([URL])
    return output_filename + "." + desired_output_extension

def rip_info(URL: str):
    """
    A companion method for rip_music, this will give you all the meta-data of each youtube video or vimeo or soundcloud etc.
    It will give you this information in the form of a dictionary.
    Known keys:
    ［abr，acodec，age_limit，alt_title，annotations，automatic_captions，average_rating，…
    … categories，creator，description，dislike_count，display_id，duration，end_time，ext，…
    … extractor，extractor_key，format，format_id，formats，fps，height，id，is_live，license，…
    … like_count，playlist，playlist_index，requested_formats，requested_subtitles，resolution，…
    … start_time，stretched_ratio，subtitles，tags，thumbnail，thumbnails，title，upload_date，…
    … uploader，uploader_id，uploader_url，vbr，vcodec，view_count，webpage_url，webpage_url_basename，width］
    """
    pip_import('youtube_dl')
    from youtube_dl import YoutubeDL
    return YoutubeDL().extract_info(URL,download=False)
# endregion
# region  Sending and receiving emails: ［send_gmail_email，gmail_inbox_summary，continuously_scan_gmail_inbox］

    #This region is commented out because it's broken
            ## from rp.r_credentials import default_gmail_address   # ⟵ The email address we will send emails from and whose inbox we will check in the methods below.
            ## from rp.r_credentials import default_gmail_password  # ⟵ Please don't be an asshole: Don't steal this account! This is meant for free use!
            # default_gmail_address=''
            # default_gmail_password=''
            # default_max_ↈ_emails=100  # ≣ _default_max_number_of_emails to go through in the gmail_inbox_summary method.
            # def send_gmail_email(recipientⳆrecipients,subject: str = "",body: str = "",gmail_address: str = default_gmail_address,password: str = default_gmail_password,attachmentⳆattachments=None,shutup=False):
            #     # For attachmentⳆattachments, include either a single string or iterable of strings containing file paths that you'd like to upload and send.
            #     # param recipientⳆrecipients: Can be either a string or a list of strings: all the emails we will be sending this message to.
            #     # Heavily modified but originally from https://www.linkedin.com/pulse/python-script-send-email-attachment-using-your-gmail-account-singh
            #     from email.mime.text import MIMEText
            #     from email.mime.application import MIMEApplication
            #     from email.mime.multipart import MIMEMultipart
            #     import smtplib
            #     emaillist=[x.strip().split(',') for x in enlist(recipientⳆrecipients)]
            #     msg=MIMEMultipart()
            #     msg['Subject']=subject
            #     # msg['From']='presidentstanely@gmail.com'# ⟵       I couldn't find any visible effect from keeping this active, so I decided to remove it.
            #     # msg['Reply-to']='ryancentralorg@gmail.com' # ⟵    I couldn't find any visible effect from keeping this active, so I decided to remove it.
            #     # msg.preamble='Multipart massage mushrooms.\n' # ⟵ I couldn't find any visible effect from keeping this active, so I decided to remove it.
            #     msg.attach(MIMEText(body))
            #     if attachmentⳆattachments:
            #         for filename in enlist(attachmentⳆattachments):
            #             assert isinstance(filename,str)  # These should be file paths.
            #             part=MIMEApplication(open(filename,"rb").read())
            #             part.add_header('Content-Disposition','attachment',filename=filename)  # ⟵ I tested getting rid of this line. If you get rid of the line, it simply lists the attachment as a file on the bottom of the email, …
            #             # … and wouldn't show (for example) an image. With it, though, the image is displayed. Also, for files it really can't display (like .py files), it will simply act as if this line weren't here and won't cause any sort of error.
            #             msg.attach(part)
            #     try:
            #         with smtplib.SMTP("smtp.gmail.com:587") as server:
            #             server.ehlo()
            #             server.starttls()
            #             server.login(gmail_address,password)
            #             server.sendmail(gmail_address,emaillist,msg.as_string())
            #             server.close()
            #         if not shutup:
            #             print('r.send_gmail_email: successfully sent your email to ' + str(recipientⳆrecipients))
            #     except Exception as E:
            #         if not shutup:
            #             print('r.send_gmail_email: failed to send your email to ' + str(recipientⳆrecipients) + ". Error message: " + str(E))
# # region Old version of send_gmail_email (doesn't support attachments):
            # """def send_gmail_email(recipientⳆrecipients, subject:str="", body:str="",gmail_address:str=default_gmail_address,password:str=default_gmail_password,shutup=False):
            #     # param recipientⳆrecipients: Can be either a string or a list of strings: all the emails we will be sending this message to.
            #     import smtplib
            #     FROM = gmail_address
            #     TO = enlist(recipientⳆrecipients)# Original code: recipient if type(recipient) is list else [recipient]
            #     SUBJECT = subject
            #     TEXT = body

            #     # Prepare actual message
            #     message = "From: %s\nTo: %s\nSubject: %s\n\n%s\n" % (FROM, ", ".join(TO), SUBJECT, TEXT)
            #     try:
            #         server = smtplib.SMTP("smtp.gmail.com", 587)
            #         server.ehlo()
            #         server.starttls()
            #         server.login(gmail_address, password)
            #         server.sendmail(FROM, TO, message)
            #         server.close()
            #         if not shutup:
            #             print('r: send_gmail_email: successfully sent the mail')
            #     except:
            #         if not shutup:
            #             print( "r: send_gmail_email: failed to send mail")"""
# # endregion
            # def gmail_inbox_summary(gmail_address: str = default_gmail_address,password: str = default_gmail_password,max_ↈ_emails: int = default_max_ↈ_emails,just_unread_emails: bool = True):
            #     # Parameters captured in this summary include the fields (for the dicts in the output list) of
            #     # TODO［millis，sender，receiver，subject，sender_email，sender_name］  (Just using a TODO so that it's a different color in the code so it stands out more)  (all accessed as strings, of course)
            #     # returns a list of dictionaries. The length of this list ﹦ the number of emails in the inbox (both read and unread).
            #     # max_ↈ_emails ≣ max_number_of_emails --> caps the number of emails in the summary, starting with the most recent ones.
            #     '''Example output:
            #     [{'sender_email': 'notification+kjdmmk_1v73_@facebookmail.com', 'sender': '"Richard McKenna" <notification+kjdmmk_1v73_@facebookmail.com>', 'millis': 1484416777000, 'sender_name': '"Richard McKenna"', 'subject': '[Stony Brook Computing Society] 10 games in 10 days. Today\'s game is "Purple...', 'receiver': 'Stony Brook Computing Society <sb.computing@groups.facebook.com>'},
            #     {'sender_email': 'notification+kjdmmk_1v73_@facebookmail.com', 'sender': '"Richard McKenna" <notification+kjdmmk_1v73_@facebookmail.com>', 'millis': 1484368779000, 'sender_name': '"Richard McKenna"', 'subject': '[Stony Brook Game Developers (SBGD)] New link', 'receiver': '"Stony Brook Game Developers (SBGD)" <sbgamedev@groups.facebook.com>'},
            #     {'sender_email': 'no-reply@accounts.google.com', 'sender': 'Google <no-reply@accounts.google.com>', 'millis': 1484366367000, 'sender_name': 'Google', 'subject': 'New sign-in from Safari on iPhone', 'receiver': 'ryancentralorg@gmail.com'},
            #     {'sender_email': 'notification+kjdmmk_1v73_@facebookmail.com', 'sender': '"Richard McKenna" <notification+kjdmmk_1v73_@facebookmail.com>', 'millis': 1484271805000, 'sender_name': '"Richard McKenna"', 'subject': '[Stony Brook Computing Society] 10 games in 10 days. Today\'s game is "Jet LIfe"....', 'receiver': 'Stony Brook Computing Society <sb.computing@groups.facebook.com>'},
            #     {'sender_email': 'noreply@sendowl.com', 'sender': 'imitone sales <noreply@sendowl.com>', 'millis': 1484240836000, 'sender_name': 'imitone sales', 'subject': 'A new version of imitone is available!', 'receiver': 'ryancentralorg@gmail.com'}]'''
            #     # The following code I got of the web somewhere and modified a lot, I don't remember where though. Whatevs.
            #     import datetime
            #     import email
            #     import imaplib

            #     with imaplib.IMAP4_SSL('imap.gmail.com') as mail:
            #         # ptoc()
            #         mail.login(gmail_address,password)
            #         # ptoc()
            #         mail.list()
            #         # ptoc()
            #         mail.select('inbox')
            #         # ptoc()
            #         result,data=mail.uid('search',None,"UNSEEN" if just_unread_emails else "ALL")  # (ALL/UNSEEN)
            #         # ptoc()

            #         email_summaries=[]  # A list of dictionaries. Will be added to in the for loop shown below.
            #         ↈ_emails=len(data[0].split())
            #         for x in list(reversed(range(ↈ_emails)))[:min(ↈ_emails,max_ↈ_emails)]:
            #             latest_email_uid=data[0].split()[x]
            #             result,email_data=mail.uid('fetch',latest_email_uid,'(RFC822)')
            #             # result, email_data = conn.store(num,'-FLAGS','\\Seen')
            #             # this might work to set flag to seen, if it doesn't already
            #             raw_email=email_data[0][1]
            #             raw_email_string=raw_email.decode('utf-8')
            #             email_message=email.message_from_string(raw_email_string)

            #             # Header Details
            #             date_tuple=email.utils.parsedate_tz(email_message['Date'])
            #             if date_tuple:
            #                 local_date=datetime.datetime.fromtimestamp(email.utils.mktime_tz(date_tuple))
            #                 # local_message_date=local_date.ctime()# formats the date in a nice readable way
            #                 local_message_date=local_date.timestamp()  # Gets seconds since 1970
            #                 local_message_date=int(1000 * local_message_date)  # millis since 1970
            #             email_from=str(email.header.make_header(email.header.decode_header(email_message['From'])))
            #             email_to=str(email.header.make_header(email.header.decode_header(email_message['To'])))
            #             subject=str(email.header.make_header(email.header.decode_header(email_message['Subject'])))
            #             # noinspection PyUnboundLocalVariable
            #             email_summaries.append(dict(millis=local_message_date,sender=email_from,receiver=email_to,subject=subject,sender_email=email_from[1 + email_from.find('<'):-1] if '<' in email_from else email_from,sender_name=email_from[:email_from.find('<') - 1]))
            #             # print('\n'.join(map(str,email_summaries)))//⟵Would display all email summaries in console
            #     return email_summaries
            # def _default_what_to_do_with_unread_emails(x):
            #     # An arbitrary default as an example example so that 'continuously_scan_gmail_inbox' can be run with no arguments
            #     # Example: continuously_scan_gmail_inbox()
            #     # By default, the continuous email scan will print out the emails and also read their subjects aloud via text-to-speech. (Assumes you're using a mac for that part).
            #     print(x)
            #     text_to_speech_via_apple(x['subject'],run_as_thread=False)
            #     send_gmail_email(x['sender_email'],'EMAIL RECEIVED: ' + x['subject'])
            # def continuously_scan_gmail_inbox(what_to_do_with_unread_emails: callable = _default_what_to_do_with_unread_emails,gmail_address: str = default_gmail_address,password: str = default_gmail_password,max_ↈ_emails: int = default_max_ↈ_emails,include_old_but_unread_emails: bool = False):
            #     # returns a new thread that is ran constantly unless you kill it. It will constantly scan the subjects of all emails received
            #     #  …AFTER the thread has been started. When it received a new email, it will run the summary of that email through the
            #     #  …'what_to_do_with_unread_emails' method, as a triggered event. It returns the thread it's running on so you can do stuff with it later on.
            #     #  …Unfortunately, I don't know how to make it stop though...
            #     # include_old_but_unread_emails: If this is false, we ignore any emails that were sent before this method was called. Otherwise, if include_old_but_unread_emails is true, …
            #     #  …we look at all emails in the inbox (note: this is only allowed to be used in this context because python marks emails as 'read' when it accesses them, …
            #     #  …and we hard-code just_unread_emails=True in this method so thfat we never read an email twice.)
            #     return run_as_new_thread(_continuously_scan_gmail_inbox,what_to_do_with_unread_emails,gmail_address,password,max_ↈ_emails,include_old_but_unread_emails)
            # def _continuously_scan_gmail_inbox(what_to_do_with_unread_emails,gmail_address,password,max_ↈ_emails,include_old_but_unread_emails):
            #     # This is a helper method because it loops infinitely and is therefore run on a new thread each time.
            #     exclusive_millis_min=millis()

            #     # times=[] # ⟵ For debugging. Look at the end of the while loop block to see more.
            #     while True:
            #         tic()
            #         # max_millis=exclusive_millis_min
            #         for x in gmail_inbox_summary(gmail_address,password,max_ↈ_emails):
            #             assert isinstance(x,dict)  # x's type is determined by gmail_inbox_summary, which is a blackbox that returns dicts. This assertion is for type-hinting.
            #             if x['millis'] > exclusive_millis_min or include_old_but_unread_emails:
            #                 #     if x['millis']>max_millis:
            #                 #         max_millis=x['millis']
            #                 what_to_do_with_unread_emails(x)
            #                 # exclusive_millis_min=max_millis

            #                 # times.append(toc())
            #                 # line_graph(times)
            #                 # ptoctic()# UPDATE: It's fine. Original (disproved) thought ﹦ (I don't know why, but the time here just keeps growing and growing...)
# 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 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 = {
    # 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):
    """
    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
    """
    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
    )

    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 get_current_timezone():
    """
    EXAMPLE:
        >>> get_current_timezone()
        ans = PST
    """
    if _default_timezone is not None:
        # If we manually set an override in rprc...
        return _default_timezone

    date = get_current_date()
    target = format_date(date, "")
    for timezone in _timezone_translations:
        result = format_date(date, timezone)
        if result.startswith(target):
            return timezone


_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,*,fansi=fansi) -> None:  # r.inspect
    # 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 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')
            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 Arduino: ［arduino，read_line］
def arduino(baudrate: int = 115200,port_description_keywords:list=['arduino','USB2.0-Serial'],timeout: float = .1,manually_chosen_port: str = None,shutup: bool = False,return_serial_instead_of_read_write=False,marco_polo_timeout=0) -> (callable,callable):# 'USB2.0-Serial' is for a cheap knock-off arduino I got
    """
    NOTE: This function uses a library called 'serial', got from 'pip install pyserial'.
    BUT THERE'S A SECOND LIBRARY: 'pip install serial' will give errors, as it's module is also called 'serial'. If you get this error, uninstall 'pip uninstall serial' then 'pip install pyserial'
     Finds an arduino, connects to it, and returns the read/write methods you use to communicate with it.
     Example: read,write=arduino()
     read() ⟵ Returns a single byte (of length 1)
     write(x:bytes) ⟵ Writes bytes to the arduino, which reads them as individual characters (the 'char' primitive)
     If you don't want this method to automatically locate an arduino, set manually_chosen_port to the port name you wish to connect to.
     marco_polo_timeout is optional: It's used for a situation where the arduino responds marco-polo style with the python code
    """
    '''
    //Simple example code for the arduino to go along with this method: It simply parrots back the bytes you write to it.
    void setup()
    {
      Serial.begin(115200);// set the baud rate
    }
    void loop()
    {
      if (Serial.available())// only send data back if data has been sent
      {
        char inByte = Serial.read(); // read the incoming data
        Serial.write(inByte); // send the data back as a single byte.
      }
    }
    '''
    serial=pip_import('serial','pyserial')
    def speak(x: str) -> None:
        if not shutup:
            print("r.arduino: " + x)
    def find_arduino_port(keywords: list = port_description_keywords) -> str:
        # Attempts to automatically determine which port the arduino is on.
        import serial.tools.list_ports
        port_list=serial.tools.list_ports.comports()
        port_descriptions=[port.description for port in port_list]
        keyword_in_port_descriptions=[any(keyword.lower() in port_description.lower()for keyword in keywords) for port_description in port_descriptions]
        number_of_arduinos_detected=sum(keyword_in_port_descriptions)
        assert number_of_arduinos_detected > 0,'r.arduino: No arduinos detected! Port descriptions = ' + str(port_descriptions)
        arduino_port_indices=max_valued_indices(keyword_in_port_descriptions)  # All ports that have 'arduino' in their description.
        if number_of_arduinos_detected > 1:
            speak("Warning: Multiple arduinos detected. Choosing the leftmost of these detected arduino ports: " + str(gather(port_descriptions,arduino_port_indices)))
        chosen_arduino_device=port_list[arduino_port_indices[0]]
        speak("Chosen arduino device: " + chosen_arduino_device.device)
        return chosen_arduino_device.device
    ser=serial.Serial(manually_chosen_port or find_arduino_port(),baudrate=baudrate,timeout=timeout)  # Establish the connection on a specific port. NOTE: manually_chosen_port or find_arduino_port() ≣ manually_chosen_port if manually_chosen_port is not None else find_arduino_port()
    if return_serial_instead_of_read_write:
        return ser
    read_bytes,_write_bytes=ser.read,ser.write  # NOTE: If read_bytes()==b'', then there is nothing to read at the moment.
    def write_bytes(x,new_line=False):
        _write_bytes(printed((x if isinstance(x,bytes) else str(x).encode())+(b'\n'if new_line else b'')))
    start=tic()
    # (next 4 lines) Make sure that the arduino is able to accept write commands before we release it into the wild (the return function):
    arbitrary_bytes=b'_'  # It doesn't matter what this is, as long as it's not empty
    assert arbitrary_bytes != b''  # ⟵ This is the only requirement for that read_bytes must be.
    if marco_polo_timeout:
        while not read_bytes() and start()<marco_polo_timeout: write_bytes(arbitrary_bytes)  # ≣ while read_bytes()==b''
        while read_bytes() and start()<marco_polo_timeout: pass  # ≣ while read_bytes()!=b''. Basically the idea is to clear the buffer so it's primed and ready-to-go as soon as we return it.
        if start()>marco_polo_timeout and not shutup:
            print("Marco Polo Timed Out")
    speak("Connection successful! Returning read and write methods.")
    return read_bytes,write_bytes  # Returns the methods that you use to read and write from the arduino
    # NOTE: read_bytes() returns 1 byte; but read_byte(n ∈ ℤ) returns n bytes (all in one byte―string)!
    # Future: Possibly helpful resources: http://stackoverflow.com/questions/24420246/c-function-to-convert-float-to-byte-array  ⨀ ⨀ ⨀   http://forum.arduino.cc/index.php?topic=43222.0
def read_line(getCharFunction,return_on_blank=False) -> bytes:
    # Example: read,write=arduino();print(read_line(read))
    f=getCharFunction
    t=tic()
    o=b''
    while True:
        n=new=f()
        if n == b'\n' or return_on_blank and n == b'':
            return o
        o+=n
# 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():
    while True:
        yield load_image_from_webcam()

def load_image_from_screenshot():
    """
    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):  # Controller_number is custom integer, and value is between 0 and 1
    MIDI_output([176,controller_number,int(float_clamp(value,0,1) * 127)])
def MIDI_control_precisely(coarse_controller_number: int,fine_controller_number: int,value: float):  # TWO bytes of data!!
    value=float_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,int_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):  # Δsemitones ∈ [-2,6] ⟵ ACCORDING TO FL STUDIO
    Δsemitones=float_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):
    MIDI_output([0x02,int(float_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(file_name: str):
    # Filenames are relative to the current file path
    return pickle.load(open(file_name,"rb"))
def save_pickled_value(file_name: str,*variables):
    # Filenames are relative to the current file path
    pickle.dump(detuple(variables),open(file_name,'wb'))
    # load_pickled_value=lambda file_name:pickle.load(open(file_name,"rb"))
# endregion
# region  .txt ⟷ str: ［string_to_text_file，text_file_to_string］
def string_to_text_file(file_path: str,string: str,) -> None:
    "string_to_text_file(file_path, string) writes text file"
    file_path=get_absolute_path(file_path)#Make sure it recognizes ~/.vimrc AKA with the ~ attached
    file=open(file_path,"w")
    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"
    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:
    "text_file_to_string(file_path) reads text file"
    #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 """
    return line_split(text_file_to_string(file_path, use_cache))

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 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 as_easydict(*args, **kwargs):
    pip_import('easydict') #I might make this a pip requirement of rp...its so useful!
    from easydict import EasyDict
    return EasyDict(*args, **kwargs)


def load_json(path, *, use_cache=False):
    text=text_file_to_string(path, use_cache=use_cache)
    import json
    out = json.loads(text)
    if not isinstance(out, dict):
        return 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)
    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,*,pretty=False,default=None):
    import 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={}
def load_tsv(file_path, *, show_progress=False, header=0, use_cache=False, sep="\t"):
    """
    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

    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'

    pip_import("pandas")
    import pandas as pd
    import csv

    args_hash = handy_hash((file_path, header))
    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
            
        iterator = tqdm(iterator, total=total_lines // chunk_size)

    df = pd.concat(iterator, ignore_index=True)

    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_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')
    from easydict import EasyDict
    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 MATLAB Integration: ［matlab_session，matlab，matlab_pseudo_terminal］
def matlab_session(matlabroot: str = '/Applications/MATLAB_R2016a.app/bin/matlab',print_matlab_stdout: bool = True):  # PLEASE NOTE: this 'matlabroot' was created on my Macbook Pro, and is unlikely to work on your computer unless you specify your own matlab path!
    """
    This method is used as an easy-to-use wrapper for creating MATLAB sessions using the pymatbridge module
    Worth noting: There's a legit purpose for creating a new matlab session before using it:
      Each session you create will be separate and will have a separate namespace!
      In other words, you can run them simultaneously/separately. For example:
            >>> sess1=matlab_session();sess2=matlab_session();
            >>> sess1.run_code("x=1");sess2.run_code("x=1");
            >>> sess1.get_variable("x"),sess2.get_variable("x")
            ans=(1,2)
    Also worth noting: You can use whatever functions you normally use in MATLAB, including .m files that you wrote and kept in your default matlab function/script saving directory.
    """
    fansi_print("(A message from Ryan): About to try connecting to MATLAB. Please be a patient, this can take a few seconds! (There is a timeout though, so you won't be kept waiting forever if it fails). Another message will be printed when it's done loading.",None,'bold')
    pip_import('pymatbridge')
    import pymatbridge  # pip3 install pymatbridge     (see https://arokem.github.io/python-matlab-bridge/ )
    session=pymatbridge.Matlab(executable=matlabroot,maxtime=60)  # maxtime=60-->Wait 1 minute to get a connection before timing out. I got this 'matlabroot' parameter by running "matlabroot" ﹙without quotes﹚in my Matlab IDE (and copy/pasting the output)
    session.start()  # If wait_for_matlab_to_load is true, then this method won't return anything until it'_s made a connection, which will time out if it takes more than max_loading_time_before_giving_up_in_seconds seconds.
    assert session.is_connected(),'(A message from Ryan): MATLAB failed to connect! (So we gotta stop here). I made this assertion error to prevent any further confusion if you try to write methods that use me. If I get too annoying, feel free to delete me (the assertion). \n' \
                                  'Troubleshooting: Perhaps the path you specified in the "matlabroot" argument of this method isn\'t really your matlab root? See the comments in this method for further information.'

    print_matlab_stdout=[print_matlab_stdout]  # Turn the value into a list make it mutable
    def handle_matlab_stdout(x: dict):
        # x will look something like this: ans = {'result': [], 'success': True, 'content': {'datadir': '/private/tmp/MatlabData/', 'stdout': 'a =\n     5\n', 'figures': []}}
        nonlocal print_matlab_stdout
        is_error=not x['success']  # Is a boolean.
        if print_matlab_stdout[0]:
            if is_error:
                fansi_print("MATLAB ERROR: ",'red','bold',new_line=False)
            fansi_print(x['content']['stdout'],'red' if is_error else'gray')
        else:
            return x  # If we're not printing out the output, we give them ALL the data
    def wrapper(code: str = '',**assignments):
        assert isinstance(code,str),'The "Code" parameter should always be a string. If you wish to assign values to variables in the MATLAB namespace, use this method\'_s kwargs instead.'
        assert len(assignments) == 1 or not assignments,'Either one variable assignment or no variable assignments.'
        assert not (code and assignments),'You should either use this method as a way to get values/execute code, XOR to assign variables to non-strings like numpy arrays. NOT both! That could be very confusing to read, and make it difficult for new people to learn how to use this function of the r class. NOTE: This method limits you to a single variable assignment because sessions returns things when you do that, and this wrapper has to return that output. '
        # Note that code and va can be used like booleans, because we know that code is a string and we know that va is a dict that has string-based keys (because of the nature of kwargs).
        nonlocal session,handle_matlab_stdout
        if code:
            eval_attempt=session.get_variable(code)
            return handle_matlab_stdout(session.run_code(code)) if eval_attempt is None else eval_attempt  # If eval_attempt is None, it means MATLAB didn't return a value for the code you gave it (like saying disp('Hello World')), or resulted in an error or something (like saying a=1/0).
        if assignments:
            for var_name in assignments:
                return handle_matlab_stdout(session.set_variable(var_name,assignments[var_name]))
        return session  # If we receive no arguments, return the raw session (generated by the pymatbridge module).

    session.print_matlab_stdout=[print_matlab_stdout]  # A list to make it mutable
    def enable_stdout():  # Enables the pseudo-matlab to print out, on the python console, what a real matlab would print.
        nonlocal print_matlab_stdout
        print_matlab_stdout[0]=True
    def disable_stdout():
        nonlocal print_matlab_stdout
        print_matlab_stdout[0]=False
    wrapper.disable_stdout=disable_stdout
    wrapper.enable_stdout=enable_stdout
    wrapper.reboot=lambda *_:[fansi_print("Rebooting this MATLAB session...",None,'bold'),session.stop(),session.start(),fansi_print("...reboot complete!",None,'bold')] and None  # wrapper.reboot() in case you accidentally call an infinite loop or something
    wrapper.stop=session.stop  # I put this here explicitly, so you don't have to hunt around before figuring out that wrapper().stop() does the same thing as (what now is) wrapper.stop()
    wrapper.start=session.start  # This exists for the same reason that the one above it exists.

    return wrapper

_static_matlab_session=matlab_disable_stdout=matlab_enable_stdout=matlab_reboot=matlab_stop=matlab_start=None  # Should be None by default. This is the default Matlab session, which is kept in the r module.
# noinspection PyUnresolvedReferences
def _initialize_static_matlab_session():
    global _static_matlab_session,matlab_disable_stdout,matlab_enable_stdout,matlab_reboot,matlab_stop,matlab_start
    _static_matlab_session=matlab_session()
    matlab_disable_stdout=_static_matlab_session.disable_stdout
    matlab_enable_stdout=_static_matlab_session.enable_stdout
    matlab_reboot=_static_matlab_session.reboot
    matlab_stop=_static_matlab_session.stop
    matlab_start=_static_matlab_session.start
# noinspection PyUnresolvedReferences
def matlab(*code,**assignments):  # Please note: you can create simultaneous MATLAB sessions by using the matlab_session method!
    """ This method seriously bends over-back to make using matlab in python more convenient. You don't even have to create a new session when using this method, it takes care of that for you ya lazy bastard! (Talking about myself apparently...) """
    global _static_matlab_session,matlab_disable_stdout,matlab_enable_stdout,matlab_reboot,matlab_stop,matlab_start
    if _static_matlab_session is None:
        fansi_print("r.matlab: Initializing the static matlab session...",None,'bold')
        _initialize_static_matlab_session()
    return _static_matlab_session(*code,**assignments)

def matlab_pseudo_terminal(pseudo_terminal):  # Gives a flavour to a given pseudo_terminal function
    # Example usage: matlab_pseudo_terminal(pseudo_terminal)
    _initialize_static_matlab_session()
    pseudo_terminal("pseudo_terminal() --> Entering interactive MATLAB console! (Running inside of the 'r' module)",lambda x:"matlab('" + x + "')")
# 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
# region socketWrapper: ［socket_writer，socket_reader，socket_read，socket_write，socket_reading_thread，get_my_ip］
default_socket_port=13000
_socket_writers={}# A whole bunch of singletons
def socket_writer(targetIP: str,port: int = None):
    if (targetIP,port) in _socket_writers:
        return _socket_writers[(targetIP,port)]
    from socket import AF_INET,SOCK_DGRAM,socket
    # Message Sender
    host=targetIP  # IP address of target computer. Find yours with print_my_ip
    port=port or default_socket_port
    addr=(host,port)
    UDPSock=socket(AF_INET,SOCK_DGRAM)  # UDPSock.close()
    def write(asciiData: str):
        UDPSock.sendto(str(asciiData).encode("ascii"),addr)
    write.targetIP=targetIP# A bit of decorating...
    write.port=port# A bit of decorating...
    _socket_writers[(targetIP,port)]=write
    assert socket_writer(targetIP,port) is write  # Should have been added to _socket_writers
    return write
def socket_write(targetIP,port,message):
    socket_writer(targetIP,port)(message)# Takes advantage of the singleton structure of _socket_writers
_socket_readers={}# A whole bunch of singletons
def socket_reader(port: int = None):# Blocks current thread until it gets a response
    if port in _socket_readers:
        return _socket_readers[port]
    # Message Receiver
    from socket import AF_INET,socket,SOCK_DGRAM
    host=""
    port=port or default_socket_port
    buf=1024
    addr=(host,port)
    UDPSock=socket(AF_INET,SOCK_DGRAM)  # UDPSock.close()
    UDPSock.bind(addr)
    # UDPSock.close()
    def read(just_data_if_true_else_tuple_with_data_then_ip_addr:bool=True):
        data,addr=UDPSock.recvfrom(buf)
        data=data.decode("ascii")
        return data if just_data_if_true_else_tuple_with_data_then_ip_addr else (data,addr[0])# addr[0] is a string for ip. addr=tuple(string,int)
    read.port=port# A bit of decorating
    _socket_readers[port]=read
    assert socket_reader(port) is read
    return read
def socket_read(port,just_data_if_true_else_tuple_with_data_then_ip_addr:bool=True):
    return socket_reader(port)(just_data_if_true_else_tuple_with_data_then_ip_addr) # Takes advantage of the singleton structure of _socket_readers
def socket_reading_thread(handler,port:int=None,just_data_if_true_else_tuple_with_data_then_ip_addr:bool=True):
    read=socket_reader(port)
    def go():
        while True:
            handler(read(just_data_if_true_else_tuple_with_data_then_ip_addr=just_data_if_true_else_tuple_with_data_then_ip_addr))
    return run_as_new_thread(go)
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():
    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
# region OSC≣'Open Sound Control' Output ［OSC_output］:
default_OSC_port=12345
try:default_OSC_ip=get_my_local_ip_address()
except Exception:pass
_OSC_client=None# This is a singleton
_OSC_values={}
def OSC_output(address,value):
    address=str(address)
    if not address[0]=='/':
        address='/'+address
    global default_OSC_ip
    default_OSC_ip=default_OSC_ip or get_my_local_ip_address()
    from rp.TestOSC import SimpleUDPClient
    global _OSC_client
    if not _OSC_client:
        _OSC_client=SimpleUDPClient(address=default_OSC_ip,port=default_OSC_port)
    _OSC_client.send_message(address=address,value=value)
    _OSC_values[address]=value# Attempt to keep track of them (though it might sometimes drift out of sync etc idk i haven't tested it as of writing this)
def OSC_jiggle(address):
    address=str(address)
    if address in _OSC_values:
        original_value=_OSC_values[address]
    OSC_output(address,1)
    sleep(.1)
    OSC_output(address,0)
    sleep(.1)
    if address in _OSC_values:
        # noinspection PyUnboundLocalVariable
        OSC_output(address,original_value)
# endregion
# Intended for use everywhere; including inside other functions (places with variables that pseudo_terminal can't reach)
mini_terminal_for_pythonista="""
_history=[]
print("Ryan's Mini-Terminal For Pythonista: A microscopic pseudo-terminal for running inside functions; optimized for Pythonista!")
print("\\tValid commands: ［PASTE，END，HISTORY］")
while True:
    _header=">>> "
    _s=input(_header).replace(_header,"").lstrip()
    if not _s:
        continue
    if _s == "PASTE":
        import clipboard
        print("PASTE: Entering command from clipboard",'blue')
        _s=clipboard.get()
    if _s == 'END':
        print("END: Ending mini-terminal session",'blue')
        break
    elif _s == 'HISTORY':
        print("HISTORY: Printing out list of commands you entered that didn't cause errors",'blue')
        print('\\n'.join(_history))
    else:
        try:
            _temp=eval(_s)
            if _temp is not None:
                _=_temp
                print('_ = ' + str(_))
            _history.append(_s)
        except:
            try:
                exec(_s)
                _history.append(_s)
            except BaseException as _error:
                print("ERROR: " + str(_error))"""
# endregion
# Other stuff I don't know which category to put in:
def k_means_analysis(data_vectors,k_or_initial_centroids,iterations,tries):
    pip_import('scipy')
    from scipy.cluster.vq import kmeans,vq
    centroids,total_distortion=kmeans(obs=data_vectors,k_or_guess=k_or_initial_centroids,iter=iterations)  # [0] returns a list of the centers of the means of each centroid. TRUE. [1] returns the 'distortion' ＝ ∑||𝓍﹣μ(𝓍ʹs cluster)||² ＝ the sum of the squared distances between each point and it's respective cluster's mean
    for _ in range(tries - 1):
        proposed_centroids,proposed_total_distortion=kmeans(obs=data_vectors,k_or_guess=k_or_initial_centroids,iter=iterations)
        if proposed_total_distortion < total_distortion:
            total_distortion=proposed_total_distortion
            centroids=proposed_centroids
    parent_centroid_indexes,parent_centroid_distances=vq(data_vectors,centroids)  # ⟵ assign each sample to a cluster
    # The rCode Identities section should answer most questions you may have about this def.
    # rCode Identities: Let c≣centroids  ⋀  i≣parent_centroid_indexes  ⋀  d≣parent_centroid_distances …
    # … ⋀  v≣data_vectors  ⋀  dist(a,b)≣﹙the euclidean distance between vectors a and b﹚  ⋀  k≣k_or_initial_centroids
    #   ∴ len(v) == len(i) == len(d)
    #   ∴ ∀ 𝓍 ∈ i， d[𝓍] == dist(v[𝓍],c[𝓍])
    #   ∴ total_distortion == ∑d²
    #   ∴ len(c) == k ⨁ len(c) == len(k)
    return centroids,total_distortion,parent_centroid_indexes,parent_centroid_distances

def is_iterable(x):
    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

def space_split(x: str) -> list:
    """ Please don't use this - it's old and made it before I knew python well. Just use x.split().  """
    return list(filter(lambda y:y != '',x.split(" ")))  # Splits things by spaces but doesn't allow empty parts
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 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 get_system_commands(*,use_cache=False):
    """
    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 _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 update_sys_commands():
        #It doesn't delete anything - that should hopefully prevent any thread collision fuckyness
        global _get_sys_commands_cache
        _get_sys_commands_cache|=set(rp.get_system_commands())

    if not _get_sys_commands_cache:
        #If it's empty populate it for the first time
        update_sys_commands()
    else:
        rp.run_as_new_thread(update_sys_commands)

    return _get_sys_commands_cache

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'):  # For debugging...perhaps this is obsolete now that I have pseudo_terminal though.
    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 blob_coords(image,small_end_radius=10,big_start_radius=50):
    #TODO: wtf is this? lollll should I delete it?
    # small_end_radius is the 'wholeness' that we look for. Without it we might-as-well pickthe global max pixel we start with, which is kinda junky.
    assert big_start_radius >= small_end_radius
    if len(image.shape) == 3:
        image=tofloat(_rgb_to_grayscale(image))
    def global_max(image):
        # Finds max-valued coordinates. Randomly chooses if multiple equal maximums. Assumes image is SINGLE CHANNEL!!
        assert isinstance(image,np.ndarray)
        assert len(image.shape) == 2  # SHOULD BE SINGLE CHANNEL!!
        return random_element(np.transpose(np.where(image == image.max()))).tolist()
    def get(x,y):
        try:
            return image[x,y]
        except IndexError:
            return 0
    def local_max(image,x0,y0):
        # Gradient ascent pixel-wise. Assumes image is SINGLE CHANNEL!!
        assert isinstance(image,np.ndarray)
        assert len(image.shape) == 2  # SHOULD BE SINGLE CHANNEL!!
        def get(x,y):
            try:
                return image[x,y]
            except IndexError:
                return 0
        def step(x,y):  # A single gradient ascent step
            best_val=0  # We're aiming to maximize this
            best_x=x
            best_y=y
            for Δx in [-1,0,1]:
                for Δy in [-1,0,1]:
                    if get(x + Δx,y + Δy) > best_val:
                        best_val=get(x + Δx,y + Δy)
                        best_x,best_y=x + Δx,y + Δy
            return best_x,best_y
        while step(x0,y0) != (x0,y0):
            x0,y0=step(x0,y0)
        return x0,y0
    # image is now a single channel.
    def blurred(radius):
        return gauss_blur(image,radius,single_channel=True)  # ,mode='constant')
    x,y=global_max(blurred(big_start_radius))
    for r in reversed(range(small_end_radius,big_start_radius)):
        x,y=local_max(blurred(r + 1),x,y)
    return x,y

def tofloat(ndarray):
    """
    Things like np.int16 or np.int64 will all be scaled down by their max values; resulting in
    elements that in sound files would be floats ∈ [-1,1] and in images [0,255] ⟶ [0-1]
    """
    return np.ndarray.astype(ndarray,float) / np.iinfo(ndarray.dtype).max

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 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):
    play_sound_from_samples(full_range(min=-1,x=sum(sampler(semitone_to_hz(x),T=t)for x in semitones)),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):
    return min([max([min_value,x]),max_value])

def int_clamp(x: int,min_value: int,max_value: int) -> int:
    return clamp(x,min_value,max_value)

def float_clamp(x: float,min_value: float,max_value: float) -> float:
    # noinspection PyTypeChecker
    return clamp(x,min_value,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=None, *, extra_lines=5, show_locals=False):
    """ Use the 'rich' library to print a stack trace """
    if error is None: error=get_current_exception()
    assert isinstance(error, BaseException)
    pip_import("rich")
    import rich.traceback

    exc_type = type(error)
    trace = error.__traceback__

    return rich.print(
        rich.traceback.Traceback.from_exception(
            exc_type=type(error),
            exc_value=error,
            traceback=error.__traceback__,
            width=get_terminal_width(),
            extra_lines=extra_lines,
            theme=None,
            show_locals=show_locals,
            locals_max_length=rich.traceback.LOCALS_MAX_LENGTH,
            locals_max_string=rich.traceback.LOCALS_MAX_STRING,
        )
    )


#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 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

def indentify(s:str,indent='\t'):
    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."""
    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 lrstrip_all_lines(s:str):
    return '\n'.join([x.lstrip().rstrip()for x in s.split('\n')])

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()
    # response_json=json.loads(response.content)
    # file_name=list(response_json['files'])[0]
    # return response_json['files'][file_name]['content']
    
def shorten_github_url(url,title=None):
    """
    Doesn't work anymore! git.io was discontinued for some god forsaken reason :(
    Use rp.shorten_url instead (for backwards compatibility, this function now simply calls that)

    Uses git.io to shorten a url
    This method specifically only works for Github URL's; it doesn't work for anything else
    If title is specified, it will try to get you a particular name for your url (such as git.io/labinacube)
    """

    return shorten_url(url) # git.io was discontinued :(

    if not is_valid_url(url):
        #Try to make it valid
        url='https://'+url
    assert is_valid_url(url)
    # print(url)
    pip_import('requests')
    import requests
    data = {'url': url, 'code':title}
    if not title: del data['code']
    r = requests.post('https://git.io/', data=data)
    out= r.headers.get('Location')
    if out is None:
        print("rp.shorten_github_url failed! Please update it; github must have changed somehow. Returning the response for debugging purposes.")
        return r    
    # print(out)
    return out

#def post_gist(content:str,
#              file_name:str='',
#              description:str='',
#              api_token:str='d65866e83aac7fc09093220a795ca66a5f7cc18d'):
#    # Note: Please don't be a dick, this api_token is meant for everybody using this library to share. Don't abuse it.

#    # Example:          
#    #     >>> post_gist('Hello World!')                                          
#    #    ans = https://api.github.com/gists/92d158541ae4f3732267194b1f1ac14d     
#    #     >>> load_gist(ans)                                                     
#    #    ans = Hello World!                                                      

#    #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.
#    content=content.replace(api_token,api_token[::-1])#Let's just reverse it.


#    import urllib
#    import json
#    import datetime
#    import time

#    access_url = "https://api.github.com/gists"
    
#    data={
#            'description':description,
#            'public':True,
#            'files':{
#                file_name:
#                {
#                    'content':content
#                }
#            }
#        }
        
#    json_data=bytes(json.dumps(data),'UTF-8');
    
#    req = urllib.request.Request(access_url) #Request
#    req.add_header("Authorization", "token {}".format(api_token))
#    req.add_header("Content-Type", "application/json")
    
#    res = urllib.request.urlopen(req, data=json_data) #Response
#    res_json = json.loads(res.readline())
    
#    return res_json['url']

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=shorten_github_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

    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()


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):
    """
    Uses unicode, and is black-and-white
    EXAMPLE: while True: display_image_in_terminal(load_image_from_webcam())
    
    EXAMPLE: Starfield
         def stars(density=.001,size=256):
             return as_float_image(np.random.rand(size,size)<density)
         def zoom(image,factor):
             return (crop_image(cv_resize_image(image,factor),*get_image_dimensions(image),origin='center'))
         image=stars()
         for _ in range(10000):
             image=image+stars()
             image=zoom(image,1.05)
             image*=.99
             scene=image
             scene=as_binary_image(image,dither=True)
             scene=bordered_image_solid_color(scene)
             scene=as_binary_image(scene)
             display_image_in_terminal(image**1,bordered=True)
             display_image(image)
    
    """
    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())

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:
        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
    """
    USE_OPENCV=True
    pip_import('timg')
    if file_exists(image) or is_valid_url(image):
        image=load_image(image)
    image=as_numpy_image(image,copy=False)
    assert is_image(image)
    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=(0,0,0,0))
    image=as_rgb_image(image)
    image=as_byte_image(image)
    temp_file=temporary_file_path('png')
    try:
        import subprocess
        width=min(get_terminal_width(),get_image_width(image))
        height=int(get_image_height(image)*width/get_image_width(image))
        if width!=get_image_width(image):
            image=(cv_resize_image if USE_OPENCV else resize_image)(image,(height,width))
        save_image(image,temp_file)
        subprocess.run([sys.executable,'-m','timg']+['-m','a8h']*(not truecolor)+['-s',str(width),temp_file])
    finally:
        if file_exists(temp_file):
            delete_file(temp_file)


def display_image_in_terminal_imgcat(image):
    """
    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
        image = resize_image_to_fit(image, width=1024, height=1024, allow_growth=False)
        
        image = as_pil_image(image)

    imgcat.imgcat(image)
        
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

# 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))

import rp.rp_ptpython.prompt_style as ps
ps.__all__+=("PseudoTerminalPrompt",)

_prompt_style_path=__file__+'.rp_prompt_style'
_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 _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?
_cd_history_path=__file__+'.rp_cd_history.txt'
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] 
    entries=_get_cd_history()
    entries.append(path)
    entries=unique(entries)
    string_to_text_file(_cd_history_path,line_join(entries))

def _update_cd_history():
    # print()
    # print("OLD HISTORY")
    # fansi_print(text_file_to_string(_cd_history_path),'magenta')
    # fansi_print(_get_cd_history(),'magenta')
    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)]
    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 python_input(namespace):
#     try:
#         from rp.prompt_toolkit.shortcuts import create_eventloop
#         from ptpython.python_input import PythonCommandLineInterface,PythonInput as Pyin
#         global default_python_input_eventloop
#         pyin=Pyin(get_globals=lambda:namespace)
#         pyin.enable_mouse_support=False
#         pyin.enable_history_search=True
#         pyin.highlight_matching_parenthesis=True
#         pyin.enable_input_validation=False
#         pyin.enable_auto_suggest=False
#         pyin.show_line_numbers=True
#         pyin.enable_auto_suggest=True
#         # exec(mini_terminal)
#         pyin.all_prompt_styles['Pseudo Terminal']=ps.PseudoTerminalPrompt()
#         # ps.PseudoTerminalPrompt=PseudoTerminalPrompt
#         pyin.prompt_style='Pseudo Terminal'
#
#         default_python_input_eventloop=default_python_input_eventloop or PythonCommandLineInterface(create_eventloop(),python_input=pyin)
#         #
#         # try:
#         code_obj = default_python_input_eventloop.run()
#         if code_obj.text is None:
#             print("THE SHARKMAN SCREAMS")
#         return code_obj.text
#     except Exception as E:
#         print_stack_trace(E)
#     # except BaseException as re:
#     # print_stack_trace(re)
#     # print("THE DEMON SCREAMS")

def split_into_sublists(l, sublist_len: int, *, strict=False, keep_remainder=True):
    """
    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.
    EXAMPLES:
    >>> split_into_sublists([1,2,3,4,5,6,7,8,9］,3 ,0)   -> [(1,2,3),(4,5,6),(7,8,9)]
    >>> split_into_sublists([1,2,3,4,5,6,7,8,9］,4 ,0)   -> [(1,2,3,4),(5,6,7,8),(9,)]
    >>> split_into_sublists([1,2,3,4,5,6,7,8,9］,5 ,0)   -> [(1,2,3,4,5),(6,7,8,9)]
    >>> split_into_sublists([1,2,3,4,5,6,7,8,9］,6 ,0)   -> [(1,2,3,4,5,6),(7,8,9)]
    >>> split_into_sublists([1,2,3,4,5,6,7,8,9］,66,0)   -> [(1,2,3,4,5,6,7,8,9)]
    >>> split_into_sublists([1,2,3,4,5,6,7,8,9］,66,0,1) -> [(1,2,3,4,5,6,7,8,9)]
    >>> split_into_sublists([1,2,3,4,5,6,7,8,9］,66,0,0) -> []
    >>> split_into_sublists([1,2,3,4,5,6,7,8,9］,5 ,0,0) -> [(1,2,3,4,5)]
    >>> split_into_sublists([1,2,3,4,5,6,7,8,9］,4 ,0,0) -> [(1,2,3,4),(5,6,7,8)]
    >>> split_into_sublists([1,2,3,4,5,6,7,8,9］,3 ,0,0) -> [(1,2,3),(4,5,6),(7,8,9)]
    >>> split_into_sublists([1,2,3,4,5,6,7,8,9］,4 ,1,0) -> ERROR: ¬ 4 | 9
    """

    assert is_number(sublist_len),'sublist_len should be an integer, but got type '+repr(type(sublist_len))
    if strict:
        assert not len(l)%sublist_len,'len(l)=='+str(len(l))+' and sublist_len=='+str(sublist_len)+': strict mode is turned on but the sublist size doesnt divide the list input evenly. len(l)%sublist_len=='+str(len(l)%sublist_len)+'!=0'
    n=sublist_len

    #This line is rather dense, but it makes sense.
    output=list(zip(*(iter(l),) * n))+([tuple(l[len(l)-len(l)%n:])] if len(l)%n and keep_remainder else [])
    
    if isinstance(l,str):
        output=[''.join(substring) for substring 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)
    [[], [], []]
    """

    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(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

    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, lazy=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)

    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)

    if not lazy:
        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):
    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 "+repr(__file__))
    system("killall Python\nsleep 2\npython3 "+repr(__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
    
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

class eta:
    """
    Example:
        >>> a = eta(2000,title='test')
        ... for i in range(2000):
        ...     sleep(.031)
        ...     a(i)

    Example:
        >>> for i in eta(range(100)):
        ...     sleep(.1)
    """

    def __init__(self, x, min_interval=.3,title="r.eta"):
        assert isinstance(x, int) or hasattr(x, '__len__')

        if hasattr(x, '__len__'):
            self.elements = x
            x = len(x)
        else:
            assert is_number(x)
            self.elements = range(x)

        self.display_eta = _eta(x, min_interval, title)

    def __call__(self, n, print_out=True):
        self.display_eta(n, print_out)

    def __iter__(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 shell_command(editor_command +" " + repr(file_or_object),as_subprocess=True)# 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):
    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

def xo(file_or_object):
    # FYI: 'xo' stands for 'exofrills', a console editor. I haven't used it much though. I don't really use console based editors much…
    import xo
    try:
        if not isinstance(file_or_object,str):
            file_or_object=get_source_file(file_or_object)
        xo.main([file_or_object])
    except Exception:
        print("Failed to start exofrills editor")
del xo #I don't ever use this lol. Undelete this if I encounter any code that needs it.

# endregion

def graph_resistance_distance(n, d, x, y):
    """
    Originally from Fodor's CSE307 HW 2, Spring 2018
    d is dictionary to contain graph edges
    n is number of nodes
    x is entry node
    y is exit node
    Reference: wikipedia.org/wiki/Resistance_distance
    Example from acmgnyr.org/year2017/problems/G-SocialDist.pdf
        graph_resistance_distance(6,{2:(0,1,3),3:(1,4,5),4:(1,5)},1,0) ⟶ 34/21
    """
    e=[[] for _ in range(n)]
    for k in d:
        for i in d[k]:
            e[k].append(i)
            e[i].append(k)
    c = []
    s = len(e)
    for i, l in enumerate(e):
        v = [0]*s
        for j in l:
            v[i] += 1
            v[j] -= 1
        c.append(v)
    r = [0] * s
    r[x] =  1
    r[y] = -1
    m = max(x,y)
    c = [x[:m] + x[m + 1:] for x in c]
    c.pop(0)
    r.pop(0)
    M = [c[i] + [r[i]] for i in range(len(c))]
    M=reduced_row_echelon_form(M)
    return abs(M[min(x,y)][-1])

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'):
    """
    Returns True if the code is valid python syntax, False otherwise.
    The 'mode' specifies the type of python code - 'exec' is a superset of 'eval'.
    """
    assert isinstance(code,str),'Code should be a string'
    import ast, traceback
    valid = True
    try:
        ast.parse(code,mode=mode)
    # except SyntaxError: #ValueError: source code string cannot contain null bytes
    except Exception:
        valid = False
    return valid

def _is_valid_exeval_python_syntax(code, mode):
    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 _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
    """

    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

def _pterm_exeval(code,*dicts,exec=exec,eval=eval,tictoc=False,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")
    """
    if len(dicts)==0:
      dicts=[get_scope(1)]
    merged_dict=dicts[0]
    # endregion

    if profile:
        pyinstrument=pip_import('pyinstrument')#https://github.com/joerick/pyinstrument
        profiler = pyinstrument.Profiler()
        profiler.start()

    if ipython:
        exec=eval=_ipython_exeval
    import rp.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)
                # ans=eval(code,merged_dict,merged_dict)
                _end_time=_time()
            else:
                _start_time=_time()
                ans=patch.run_code(code,'exec',merged_dict,exec)
                # ans=exec(code,merged_dict,merged_dict)# ans = None unless using ipython, in which case it might not be
                _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 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 profilers running...

_PROF_DEEP=True

# 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)
import rp.rp_ptpython.prompt_style as ps
from rp.prompt_toolkit.shortcuts import create_eventloop#Unless this can be sped up (inlining just pushes the problem to the next imoprt)
        # def create_eventloop(inputhook=None, recognize_win32_paste=True):
        #     """
        #     Create and return an
        #     :class:`~prompt_toolkit.eventloop.base.EventLoop` instance for a
        #     :class:`~prompt_toolkit.interface.CommandLineInterface`.
        #     """
        #     def is_windows():
        #         """
        #         True when we are using Windows.
        #         """
        #         return sys.platform.startswith('win')  # E.g. 'win32', not 'darwin' or 'linux2'

        #     if is_windows():
        #         from rp.prompt_toolkit.eventloop.win32 import Win32EventLoop as Loop
        #         return Loop(inputhook=inputhook, recognize_paste=recognize_win32_paste)
        #     else:
        #         from rp.prompt_toolkit.eventloop.posix import PosixEventLoop as Loop
        #         return Loop(inputhook=inputhook)
from rp.rp_ptpython.python_input import PythonCommandLineInterface,PythonInput as Pyin
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='dracula',

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,
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,
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,

session_title='',
    )
_pyin_settings_file_path=__file__+'.rp_pyin_settings'
_globa_pyin=[None]


_rprc_pterm_settings_overrides={}#Modify this as you wish in an rprc.

def _load_pyin_settings_file():
    # print("BOOTLEGER",pyin)
    _globa_pyin[0]=pyin

    try:
        settings=eval(text_file_to_string(_pyin_settings_file_path))
        assert isinstance(settings,dict)
        settings.update(_rprc_pterm_settings_overrides)
        # for setting in _default_pyin_settings:
            # assert setting in settings
    except Exception:
        settings=_default_pyin_settings.copy()

    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])

    _load_pyin_settings_from_dict(settings)
    _set_default_session_title()

def _save_pyin_settings_file():
    settings={}
    for attr in _default_pyin_settings:
        settings[attr]=getattr(pyin,attr)
    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 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
    
    if ui_theme_name   is None:ui_theme_name=_current_ui_style_name
    if code_theme_name is None:ui_theme_name=_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
history_filename=__file__ + ".history.txt"
def python_input(scope,header='',enable_ptpython=True,iPython=False):
    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.
    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
            default_python_input_eventloop=default_python_input_eventloop or PythonCommandLineInterface(create_eventloop(),python_input=pyin)
        #
        # try:

            code_obj = default_python_input_eventloop.run()
        # gotta_save=False#Sorry about this clusterfuck of code. I'm really tired, and this code really doesn't affect anybody else in the whole world but me...and I know how it works, despite how yucky it is. (my ide makes it really easy to write this way with multiple cursors)
        # if _pseudo_terminal_settings["pyin.enable_history_search"]!=pyin.enable_history_search:_pseudo_terminal_settings["pyin.enable_history_search"]=pyin.enable_history_search;gotta_save=True;print("CHANGESD")
        # if _pseudo_terminal_settings["pyin.highlight_matching_parenthesis"]!=pyin.highlight_matching_parenthesis:_pseudo_terminal_settings["pyin.highlight_matching_parenthesis"]=pyin.highlight_matching_parenthesis;gotta_save=True;print("CHANGESD")
        # if _pseudo_terminal_settings["pyin.enable_input_validation"]!=pyin.enable_input_validation:_pseudo_terminal_settings["pyin.enable_input_validation"]=pyin.enable_input_validation;gotta_save=True;print("CHANGESD")
        # if _pseudo_terminal_settings["pyin.enable_auto_suggest"]!=pyin.enable_auto_suggest:_pseudo_terminal_settings["pyin.enable_auto_suggest"]=pyin.enable_auto_suggest;gotta_save=True;print("CHANGESD")
        # if _pseudo_terminal_settings["pyin.show_line_numbers"]!=pyin.show_line_numbers:_pseudo_terminal_settings["pyin.show_line_numbers"]=pyin.show_line_numbers;gotta_save=True;print("CHANGESD")
        # if _pseudo_terminal_settings["pyin.show_signature"]!=pyin.show_signature:_pseudo_terminal_settings["pyin.show_signature"]=pyin.show_signature;gotta_save=True;print("CHANGESD")
        # if gotta_save:_save_pseudo_terminal_settings_to_file()

        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')
            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

        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.pseudo_terminal() in %s: Welcome! "%version+timestamp()
"""
TODO:
    - Does NOT return anything
    - Can be used like MiniTerminal
    - But should be able to accept arguments for niche areas! Not sure how yet; should be modular though somehow...
    - History for every variable
    - Scope Hierarchy: [globals(),locals(),
    others()]:
        - Create new dict that's the composed of all the others then update them accordingly
    - HIST: Contains a list of dicts, whose differences can be seen

"""

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,lrstrip_all_lines
        lines=line_split(lrstrip_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():
    #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.
    """
    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):
    return string.count('\n')+1 #This is probably more efficient than the line below this one...
    return len(line_split(string))

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):
    """
    Returns the path a symlink points to
    """
    assert isinstance(path,str)
    assert is_symlink(path), 'Not a symlink: '+path
    if path.endswith('/'):
        # turn 'folder/' into 'folder'
        path=path[:-1]
    import os
    return os.readlink(path)

def symlink_is_broken(path:str):
    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):
    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 is_a_folder(original_path) or is_symbolic_link(original_path), "Can't create a hardlink to a folder, only to files: " + original_path
    assert not path_exists(hardlink_path), "Can't create hardlink at %s because a file already exists there!" % hardlink_path
    
    import os
    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='.'):

    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 path_exists(original_path), "Can't create symlink to %s because that path does not exist!"%original_path
    assert not path_exists(symlink_path), "Can't create symlink at %s because a file already exists there!"%symlink_path
    
    import os
    os.symlink(original_path,symlink_path)
    
    return symlink_path

def is_symbolic_link(path:str):
    """
    Returns whether or not a given path is a symbolic link
    """
    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):
    """
    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)
    to_path=move_path(from_path,to_path)
    make_symlink(from_path,to_path)
    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):
    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):
    return _guess_mimetype(file_path)=='video'

def is_sound_file(file_path):
    return _guess_mimetype(file_path)=='audio'

def is_utf8_file(path):
    """
    Returns True iff the file path is a UTF-8 file
    Faster than trying to use text_file_to_string(path), because it doesn't need to read the whole 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):
    #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):
        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]
    for path in paths:
        method(path,'.')

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
    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 _shell_which(cmd):
        
    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(_shell_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):
    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):
    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):
    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):
    history=text_file_to_string(history_filename)
    paragraphs=history.split('\n\n')
    paragraphs=paragraphs[::-1]
    def process_paragraph(paragraph):
        lines=paragraph.splitlines()
        lines[1:]=[x[1:] for x in lines[1:]]
        return line_join(lines)
    paragraphs=[process_paragraph(x) for x in paragraphs]

    # 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 '%(
                json.dumps(sys.executable),
                json.dumps(get_module_path("rp.experimental.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'%(
                json.dumps(sys.executable),
                json.dumps(get_module_path("rp.experimental.stdin_python_highlighter")),
                preview_width,
                "diff_mode",
                json.dumps(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.experimental.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.experimental.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)

    return out

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]
    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='/'
    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.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


            



        

def _pterm_cd(dir,repeat=1):
    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():
    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.experimental import process_powerpoint_file
    return process_powerpoint_file(path)


def _write_default_gitignore():
    types_to_ignore='pyc swo swp un~ 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"

    git_repo = get_parent_folder(get_git_repo_root())
    file = path_join(git_repo, '.gitignore')

    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 _get_pterm_verbose():
    return False
    return True


class _PtermLevelTitleContext:
    def __init__(self, level_title):
        import rp.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)


_user_created_var_names=set()
_cd_history=[]
def pseudo_terminal(
    *dicts,
    get_user_input=python_input,
    modifier=None,
    style=pseudo_terminal_style(),
    enable_ptpython=True,
    eval=eval,
    exec=exec,
    rprc="",
    level_title=""
):
  """An interactive terminal session, powered by RP """
  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.r_iterm_comm# Used to talk to ptpython
    def level_label(change=0):
        return (("(Level "+str(rp.r_iterm_comm.pseudo_terminal_level+change)+")")if rp.r_iterm_comm.pseudo_terminal_level else "")
    try:
        fansi_print(style.message() +' '+ level_label(),'blue','bold')
        rp.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.undo_stack=[]
                self.redo_stack=[]
                self.clear_redo_on_do=clear_redo_on_do

            def can_undo(self):
                return len(self.undo_stack)!=0

            def can_redo(self):
                return len(self.redo_stack)!=0

            def undo(self):
                output=self.undo_stack.pop()
                self.redo_stack.insert(0,output)
                return output
                
            def redo(self):
                output=self.redo_stack.pop(0)
                self.undo_stack.append(output)
                return output
                
            def do(self,value):
                if self.clear_redo_on_do:
                    self.redo_stack.clear()
                self.undo_stack.append(value)

            def do_if_new(self,value):
                if self.undo_stack and self.undo_stack[-1]==value:
                    return
                self.do(value)

        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):
            try:    
                import rp.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:
                    import numpy as np
                    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 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
        _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 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.r_iterm_comm
            rp.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

        <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
        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

        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('../../../../../../../../../../../../../../../../../../../..')

        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())


        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

        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)

        DA CDA

        RU RUN
        SSRA SSRUNA
        SSA  SSRUNA
        SSR  SSRUNA
        SS   SSRUNA
        SRA   SRUNA
        SA    SRUNA
        SR    SRUNA
        RA     RUNA
        EA     RUNA

        CPR $check_pip_requirements()

        BAA  $os.system('bash '+str(ans))
        ZSHA $os.system('bash '+str(ans))

        #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)
        GFN $get_file_name(ans) if isinstance(ans,str) else $get_file_names(ans)
        GPN $get_path_name(ans)


        # GO GC

        MON MONITOR

        VD VDA

        TRAD treealldir
        TRD treedir
        TR tree
        TRA treeall

        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
        RRNG  RYAN RANGERRC

        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
        
        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 

        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

        quit() RETURN
        exit() RETURN

        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

        GOO  $open_google_search_in_web_browser(str(ans))
        GOOP $open_google_search_in_web_browser($string_from_clipboard())

        SMI $os.system("nvidia-smi");
        NVT $r._ensure_nvtop_installed();$os.system("nvtop");#sudo_apt_install_nvtop
        NVI $pip_import('nvitop');$pip_import('nvitop.__main__').main()
        ZSH $r._ensure_zsh_installed();$os.system("zsh");
        BOP TOP

        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^

        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()

        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('.')
        SUA $sublime(ans)
        COH $vscode('.')
        COA $vscode(ans)

        SG $save_gist(ans)
        LG $load_gist(input($fansi('URL:','blue','bold')))
        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')))))

        # CAH  $copy_path(ans,'.')
        # CPAH $copy_path(ans,'.')
        CAH  $r._cpah(ans)
        CPAH $r._cpah(ans)
        HLAH $r._cpah(ans,method=make_hardlink)#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())
        GCLA $git_clone(ans,show_progress=True)
        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)

        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 ",".join(map(str,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)
        FCH print("FCH->FileCopyHere");$web_copy_path($get_absolute_path('.'))
        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)

        UZA $unzip_to_folder(ans)
        ZIH $make_zip_file_from_folder($get_absolute_path('.'))
        ZIA $make_zip_file_from_folder(ans)

        RWC $web_copy($get_source_code($r))

        RST __import__('os').system('reset')
        RS  __import__('os').system('reset')

        BLA $r._autoformat_python_code_via_black(str(ans))
        SIM $r.sort_imports_via_isort(ans)
        CBP ans=$string_from_clipboard();ans=$r.autoformat_python_via_black_macchiato(ans);$string_to_clipboard(ans)
        CSP ans=$string_from_clipboard();ans=$sort_imports_via_isort(ans);$string_to_clipboard(ans)
        RMS $r._removestar(ans)

        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()
        NGP $print_notebook_gpu_summary()
        
        LEA  [eval(str(x)) for x in ans]
        EVLA [eval(str(x)) for x in ans]

        CLS CLEAR
        VV !vim

        RCLAHF $os.system($printed("rclone copy --progress --transfers 128 --metadata %s ."%('"'+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 $os.system($printed("rclone copy --checksum --progress --transfers 128 --metadata %s ."%('"'+ans+'"'))); #Quickly copy a network drive folder. Copies the contents, not the folder itself!

        WEV import rp.web_evaluator as wev

        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

        GITIGNORE $r._write_default_gitignore()
        GITIGN    $r._write_default_gitignore()
        IGN       $r._write_default_gitignore()
        IGNORE    $r._write_default_gitignore()
        GIG       $r._write_default_gitignore()

        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 !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

        HOSTLAB !python -m jupyter lab --ip 0.0.0.0 --port 5678 --NotebookApp.password='' --NotebookApp.token='' --allow-root

        '''.replace('$',rp_import)
        # 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)

        
        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.r_iterm_comm
            rp.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.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
                    try:
                        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.r_iterm_comm.try_eval_mem_text:
                                return rp.r_iterm_comm.rp_evaluator_mem# Text hasn't changed, so don't evaluate it again
                            rp.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.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.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.r_iterm_comm.rp_evaluator_mem=out
                                return str(out)+"\n"
                            except Exception as E:
                                return str(rp.r_iterm_comm.rp_evaluator_mem)+"\nERROR: "+str(E)
                            finally:
                                sys.stdout.write=temp
                        rp.r_iterm_comm.rp_evaluator=try_eval
                        rp.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)
                        try: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':
                        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 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 None#Sometimes, calling get_ans() fails

                    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=rp.__file__
                        ans=get_parent_directory(ans)
                        ans=path_join(ans,'documentation.py')
                        ans=text_file_to_string(ans)
                        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=lrstrip_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', '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 == '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=='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 BaseException 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")
                        _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":
                        fansi_print("PT --> PROMPT TOOLKIT TOGGLE --> enable_ptpython=not enable_ptpython (Toggles between PT ON and PT OFF)","blue",'bold')
                        enable_ptpython=not enable_ptpython
                        use_modifier=True

                        if enable_ptpython:
                            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")
                            _printed_a_big_annoying_pseudo_terminal_error=False

                    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:
                            #Can copy text to clipboard
                            fansi_print("COPY --> r.string_to_clipboard(str(ans))","blue")
                            copy(str(get_ans()))

                    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())
                        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()
                        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 ans_history:
                            ans_redo_history.append(ans_history.pop())
                        if not ans_history:
                            fansi_print("    [Cannot get PREV ans because ans_history is empty]",'red')
                        else:
                            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(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(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(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(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(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(user_message,scope())
                        _view_interactive_json(value)
                    elif user_message=='?i':
                        fansi_print("?i --> PyPI Package Inspection:","blue",'bold')
                        import rp.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')
                        value=eval(user_message,scope())
                        import rp.pypi_inspection as pi
                        pi.display_module_pypi_info(value)

                    elif user_message=='?e':
                        fansi_print("Running peepdis.peep(ans):","blue",'bold')
                        pip_import('peepdis')
                        from 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')
                        pip_import('peepdis')
                        from 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(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 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=='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:
                            if is_symbolic_link(item):
                                if is_a_directory(item):
                                    print_line(fansi(item,'green','bold'))
                                else:
                                    print_line(fansi(item,'green'))
                            elif is_a_directory(item):
                                print_line(fansi(item,'cyan','bold'))
                            elif is_a_file(item):
                                print_line(fansi(item,'gray'))
                            else:
                                print_line(fansi(item,'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':
                        fansi_print("WANS -> Write ans to a file (can be text, bytes, or an image)","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_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()))
                                fansi_print("WANS: Wrote text file to "+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=='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':
                            fansi_print("FCOPY --> Web File Copy --> rp.web_copy_path() --> Copying a file or folder to the internet","blue",'bold')
                            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)
                            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 is_iterable(values):
                                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()
                            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 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()

                            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
                            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_directory(get_parent_folder(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=='RYAN VIMRC' or user_message=='RYAN VIMRC YES' or user_message=='RYAN VIMRC NO':

                            confirm = not 'YES' in user_message
                            if 'NO' in user_message:
                                confirm = 'NO'
                            _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 = [
                                    "",
                                    "# < 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()",
                                    "__import__('rp').r._pip_import_autoyes=True",
                                    "__import__('rp').r._pip_install_needs_sudo=False",

                                    "# < Ryan RPRC End >",
                                    "",
                                ]
                                for line in rprc_lines:
                                    append_line_to_file(line,rprc_file_path)
                                user_message=line_join(rprc_lines+['ans='+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("NEXTMORE --> sets the error to the next 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="run_as_new_thread(exec,"+repr(user_message[5:].strip())+",globals(),locals())"
                        elif user_message=='RANT':
                            user_message="run_as_new_thread(exec,"+repr(get_ans())+",globals(),locals());"
                        elif user_message.startswith("RANP "):
                            user_message="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':
                            fansi_print("PASTE --> ans=str(string_from_clipboard()):",'blue','underlined')
                            user_message=repr(string_from_clipboard())
                        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 = user_message[len("LSS "):]
                                import rp.r_iterm_comm as ric
                                if not path_exists(lss_name) and ric.current_candidates and fuzzy_string_match(lss_name, ric.current_candidates[0], case_sensitive=False):
                                    #Don't need tab to autocomplete these paths, which is why it's fast...
                                    candidate_0 = ric.current_candidates[0]
                                    new_lss_name = candidate_0.strip('/').strip('\\')
                                    fansi_print(command_name+": Completed "+repr(lss_name)+" to "+repr(new_lss_name), 'blue')
                                    lss_name=new_lss_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=='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 '):]
                            vim(path)
                            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=user_message[len('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')
                            path=input_select_path(message='Please select the file or folder to be renamed')

                            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(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 == "CDP"
                                or not is_valid_python_syntax(user_message) and folder_exists(user_message)
                            )
                            or user_message == "CDB"
                            or user_message == "CDU"
                            or user_message == "CDA"
                            or user_message in "CDZ"
                            or user_message == "CDQ"
                        ):
                            if not is_valid_python_syntax(user_message) and folder_exists(user_message):
                                #Pasting a folder path and entering CD's to it
                                user_message = "CD "+user_message

                            if user_message.startswith('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
                                    result=_iterfzf((line.replace('\n',' ').replace('\r',' ') for line in breadth_first_path_iterator('.') if is_a_folder(line)),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=='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
                            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')
                                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
                            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.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__'):
                            # 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:
                            if user_message.startswith("!!"):# For shell commands
                                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
                                # 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.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.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 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,
                                                      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
                                    _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.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
                        
                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 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()
    finally:
        rp.r_iterm_comm.pseudo_terminal_level-=1
        if level_label():
            fansi_print("    - Exiting pseudo-terminal at "+level_label(),'blue' ,'bold')

# @formatter:off
try:from setproctitle import setproctitle as set_process_title \
        ,getproctitle as get_process_title
except Exception:pass
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):
    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_directory(get_path_parent(path))
    save_animated_png(path, frames, delay=delay)

    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
    """

    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.
    """
    
    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):
    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):
    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, background_color=None, style=None, reset=True):
    """
    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(
        fansi(
            line_join(map(str, args)),
            text_color=None,
            background_color=None,
            style=None,
            reset=reset,
        )
    )

def reduced_row_echelon_form(M):
    pip_import('sympy')
    import sympy
    return sympy.matrix2numpy(sympy.Matrix(M).rref()[0])

def qterm():
    # Enables both vispy and
    def _exeval(f,*x,**y):
        nonlocal _error
        assert _done == _todo == []
        # _todo.insert(0,fog(print,'Hello wurlzy'))
        _todo.insert(0,fog(f,*x,**y))
        while not _done and not _error:
            pass
        assert _todo == []
        if _error:
            assert not _done
            temp=_error
            _error=None
            raise temp
        out=_done.pop()
        assert not _done
        return out
    def _exec(*x,**y):
        return _exeval(exec,*x,**y)
    def _eval(*x,**y):
        return _exeval(eval,*x,**y)

    _error=None
    _todo=[]
    _done=[]  # Results of _todo

    import rp.r_iterm_comm as ric
    _level=ric.pseudo_terminal_level
    run_as_new_thread(pseudo_terminal,globals(),exec=_exec,eval=_eval)
    while ric.pseudo_terminal_level==_level:
        pass
    while 1:
        if ric.pseudo_terminal_level==_level:
            break
        try:
            from vispy import app
            app.process_events()
        except:
            print("harry potwar strikes again! keep chuggin...")
            pass
        if _todo:
            try:
                _done.append(_todo.pop()())
            except BaseException as e:
                _error=e
        assert not _todo
    print('...aaaannndddd were DONE chuggin.')
    app.quit()  # NOT nessecary but PERHAPS its nicer than having a crashy window...make this optional though!!!

def UCB1(w,n,N,c=2**.5):
    """
    w ÷ n + c √(㏑(N) ÷ n)
    From wikipedia.org/wiki/Monte_Carlo_tree_search:
       · w﹦number of wins for the node
       · n﹦number of simulations for the node
       · N﹦total number of simulations among all nodes
       · c﹦the exploration parameter—theoretically equal to √2; in practice usually chosen empirically
    """
    from math import log as ln
    return w/n+c*(ln(N)/n)**.5

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):
    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)
    #  >>> 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]
    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
    return psutil.pid_exists(pid)
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 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 number
    Verified to work with numpy values as well as vanilla Python values
    Also works with torch tensors
    Examples:
       is_number(float)              ==True
       is_number(np.uint8)           ==True
       is_number(123)                ==True
       is_number(5.6)                ==True
       is_number(np.int32(123))      ==True
       is_number("Hello")            ==False
       is_number("123")              ==False
       is_number(np.asarray([1,2,3]))==False
    """
    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'):
    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):
    """
    Works for both images AND video
    Opencv has an annoying feature: it uses BGR instead of RGB. Heckin' hipsters. This swaps RGB to BGR, vice-versa.
    """
    image_or_video=np.asarray(image_or_video)
    image_or_video=image_or_video.copy()
    temp=image_or_video.copy()
    image_or_video[...,0],image_or_video[...,2]=temp[...,2],temp[...,0]
    return image_or_video
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=10,#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'
        ):

    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
try:
    class Contour(np.ndarray):
        # __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
except Exception:
    pass
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:]


    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 cv_simplify_contour(contour, epsilon=0.001):
    """
    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. Default is 0.03.

    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.
    """
    pip_import("cv2")
    import cv2

    big_number = 100000
    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):
    """
    TODO: Important: This must somehow preserve whether the contour is closed or not??
    """
    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,*args,**kwargs):
    return cv_draw_contours(image,[contour],*args,**kwargs)

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):
    cv2=pip_import('cv2')
    contour=as_cv_contour(contour)
    return cv2.arcLength(contour,closed=closed)

def cv_contour_area(contour):#,closed=False):
    cv2=pip_import('cv2')
    contour=as_cv_contour(contour)
    return cv2.contourArea(contour)

# def cv_draw_circle(image,x,y,radius=5,color=(255,255,255),*,antialias=True,copy=True):
#     if is_binary_image(image):image=rp.as_rgb_image(image)
#     image = as_byte_image(image)
#     x=int(x)
#     y=int(y)
#     cv2=pip_import('cv2')
#     image,kwargs=_cv_helper(image=image,copy=copy,antialias=antialias)
#     cv2.circle(image,(x,y),radius,color,-1,**kwargs)
#     return image

def cv_draw_circle(
    image,
    x,
    y,
    radius=5,
    
    color='white',
    *,
    antialias=True,
    copy=True
):
    "Draws a filled circle with center x,y on a given image. If copy=False, it will mutate the original image."
    
    cv2 = pip_import("cv2")
    
    color=as_rgb_float_color(color)
    color=float_color_to_byte_color(color)
    
    if is_binary_image(image):
        image = rp.as_rgb_image(image)
    image = as_byte_image(image)

    radius = int(radius)        
    x      = int(x)
    y      = int(y)
    
    image, kwargs = _cv_helper(image=image, copy=copy, antialias=antialias)
    
    cv2.circle(image, (x, y), radius, color, -1, **kwargs)
    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.
        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

    # 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))
    width = width or len(y_values)

    # 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)

    # Create the output image with the specified background color
    graph = np.full((height, width, 4), background_color, dtype=np.uint8)

    # 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 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):
#     #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):
#     #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
    """
    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']
    """
    return string.splitlines()
def line_join(lines):
    """ EXAMPLE: line_join(['hello','world'])=='hello\nworld' """
    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,...] """
    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],...] """
    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 """
    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):
    """ Automatically convert path path data """
    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 """
    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):
    """ Automatically convert path data """
    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 handy_hash(value,fallback=None):
    """
    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 default_fallback(value):
        # fansi_print('Warning: fallback_hash was called on value where repr(value)=='+repr(value),'yellow') #This is annoying
        return id(value)
    fallback=fallback or default_fallback
    value_type=type(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)
    try:return hash(('DILL HASH',object_to_bytes(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

try:#Attempt to add numpy arrays to the hashers used by handy_hash
    import numpy as np
    def _numpy_hash(x):
        assert isinstance(x,np.ndarray)
        return hash((__secret_number,'_numpy_hash',x.tobytes()))#Strings such as '_numpy_hash' are put in here to distinguish this function's output from some other hasher which, by some strange coincidence, generate a (__secret_number,‹same bytestring›) but isnt a numpy array. This same technique is also used in the other hasher functions near this one.
    __hashers[np.ndarray]=_numpy_hash
except ImportError:pass
except BaseException as e:
    try:
        fansi_print('Upon booting rp, failed to import numpy. Stack trace shown below:','red','bold')
        print_stack_trace(e)
    except:
        pass#Don't prevent rp from booting no matter what

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.
    """
    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_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
#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.

    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_sha256_hash(source, *, show_progress: bool = False, as_int: bool = False) -> str:
    """
    Calculate the SHA-256 hash of the provided data or the contents of a file specified by its path.
    If as_int is True, returns the hash as an integer.

    :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 as_int: Keyword-only argument that returns the hash as an integer instead of a hexadecimal string.
    :return: The SHA-256 hash as a hexadecimal string or an integer.

    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', as_int=True)
        69026873886289201118877060911792184041901559641924946149006354401672729373074
    """
    import hashlib
    import os
    import io

    hash_obj = hashlib.sha256()

    if isinstance(source, bytes):
        byte_stream = io.BytesIO(source)
        total_size = len(source)
        progress_desc = "Hashing data"
    elif isinstance(source, str) and os.path.isfile(source):
        byte_stream = open(source, 'rb')
        total_size = os.path.getsize(source)
        progress_desc = "Hashing file"
    else:
        raise TypeError("rp.get_sha256_hash: 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()

    hash_result = hash_obj.hexdigest()
    if as_int:
        hash_result = int(hash_result, 16)

    return hash_result

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_all_ttf_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)

    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)
            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={'left'  :'top left'    ,
                                                         'right' :'bottom right',
                                                         'center':'center'      ,}[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 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
    """
    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,
            )
        )

    origin = {"left": "top left", "center": "center", "right": "bottom right"}[align]
    return grid_concatenated_images([[x] for x in images], origin=origin)

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):
    """
    TODO: use this all over RP!

    EXAMPLE:

        >>> as_rgba_float_color(1)
        ans = (1, 1, 1, 1)
        >>> as_rgba_float_color((1,.5,0))
        ans = (1, 0.5, 0, 1)

        >>> 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 = color + (1,)
    assert len(color) == 4 
    if clamp:
        color = tuple(max(0,min(1,x)) for x in color)
    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 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", "Calibri"]:
        ...     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_all_ttf_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"))
    """
    
    if isinstance(font,str):
        if font.startswith("G:"):
            font = download_google_font(font[len("G:"):])
        elif is_valid_url(font):
            font = download_font(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={"left": "top left", "center": "center", "right": "bottom right"}[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

def download_google_font(font_name, *, skip_existing=True):
    """
    Original code from: https://gist.github.com/ravgeetdhillon/0063aaee240c0cddb12738c232bd8a49
    
    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)

        # 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

    fonts_folder = path_join(rp.r._rp_downloads_folder, "google_fonts",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):
    """
    https://github.com/ctrlcctrlv/lcd-font/raw/master/otf/LCD14.otf
    """
    _download_font_dir=path_join(_rp_folder,"downloads/fonts")
    make_directory(_download_font_dir)
    return download_url(url, _download_font_dir, skip_existing=True)

def download_google_fonts(*font_names,skip_existing=True):
    """ See download_google_font's docstring. This is it's plural form. """
    font_names=detuple(font_names)
    return [download_google_font(font_name,skip_existing=skip_existing) for font_name in font_names]

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
    """
    file_path = _get_file_path(file_path)
    return os.path.splitext(file_path)[1].rpartition('.')[2]

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
    
    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):
    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
   """

    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)
    return path_join(parent_folder,file_name)

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_directory=None):
    """
    Take an absolute path, and turn it into a relative path starting from root_directory
    root_directory's default is get_current_directory()
    """
    if root_directory is None:
        root_directory=get_current_directory()
    assert isinstance(root_directory,str),'root_directory must be a string representing the root path to compare the given path against'
    return os.path.relpath(path,root_directory)

def get_relative_paths(*paths, root_directory=None):
    "Plural of get_relative_path"
    return [get_relative_path(path, root_directory) for path in detuple(paths)]

def get_absolute_path(path,*,physical=True):
    """
    Given a relative path, return its absolute path
    If physical, expand all symlinks in the path
    """
    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 get_absolute_paths(*paths,physical=True):
    "Plural of get_absolute_path"
    return [get_absolute_path(path, physical=physical) for path in detuple(paths)]

def has_file_extension(file_path):
    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' ):
    #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):
    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_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):
    return get_all_paths(*args,**{'include_folders':True,'include_files':False,**kwargs})
get_all_directories=get_all_folders

def get_file_paths(*args,**kwargs):
    assert False,'This function is deprecated. Use get_all_files instead - its the same function with a new name.'

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):
    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(*args,**kwargs):
    """ See rp_iglob's docstring """
    return list(rp_iglob(*args,**kwargs))



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.)
    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.


    TODO: Implement the origin argument for both this func and vertically_concatenated_images
    """

    #Right now crop_images doesn't support origins such as simply 'top' and 'bottom'
    if origin=='top': origin='top left'
    if origin=='bottom': origin='bottom right'

    image_list=detuple(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(img.shape[0]for img in image_list)
        def heightify(img):
            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=='right': origin='bottom right'
    if origin=='left' : origin=None
    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) 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
    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
    """
    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):
    try:
        image=as_numpy_array(image)
    except Exception:
        return False
    return len(image.shape)==2

def is_rgb_image(image):
    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):
    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):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):return as_numpy_array(image).copy()
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):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) """
    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) """
    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
    """
    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
    """
    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)
    """
    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)
    image=np.minimum(image,1)
    image=np.maximum(image,0)
    return image

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)
def _float_image_to_binary_image (image):return np.round(_clamp_float_image(image)).astype(bool)
def _byte_image_to_float_image   (image):return np.asarray(image,dtype=float)/255
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)
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) """
    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 """
    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):

    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 (images*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
                # return ... Fill this in. Duplicate the grayscale along all RGB channels
                pass #Not implemented yet
            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 RGB tensors
                # return ... Fill this in. Alpha channel is 1 if floating point else 255 if uint8 else True if bool, the other 3 are duplciated from the grayscale matrices...
                pass #Not implemented yet
            assert len(images.shape)==4, images.shape #BHWC
            C=images.shape[3]
            if C==3:
                # return ... add a 4th channel 
                pass #Not implemented yet
            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...
            pass #Not implemented yet


        #TODO: Handle all edge cases of as_rgb_images, as_rgba_images, 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): return _images_conversion(as_float_image    , images, copy=copy, copy_check=is_float_image    )  
def as_byte_images     (images,*,copy=True): return _images_conversion(as_byte_image     , images, copy=copy, copy_check=is_byte_image     )  
def as_binary_images   (images,*,copy=True): return _images_conversion(as_binary_image   , images, copy=copy, copy_check=is_binary_image   )  
def as_rgb_images      (images,*,copy=True): return _images_conversion(as_rgb_image      , images, copy=copy, copy_check=is_rgb_image      )  
def as_rgba_images     (images,*,copy=True): return _images_conversion(as_rgba_image     , images, copy=copy, copy_check=is_rgba_image     )  
def as_grayscale_images(images,*,copy=True): 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

def random_rgb_byte_color():
    return (randint(255),randint(255),randint(255))
def random_rgba_byte_color():
    return (randint(255),randint(255),randint(255),randint(255))
def random_grayscale_byte_color():
    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())

def is_color(color):
    return is_iterable(color) and all(is_number(x) for x in color)
def is_binary_color(color):
    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):
    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):
    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):
    return tuple(x/255 for x in byte_color)

def float_color_to_byte_color(float_color):
    return tuple(round(clamp(x*255,0,255)) for x in float_color)

def float_color_to_hex_color(float_color):
    return byte_color_to_hex_color(float_color_to_byte_color(float_color))

_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(),
)

_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:
        _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):
    return float_color_to_byte_color(color_name_to_float_color(color_name))

def color_name_to_hex_color(color_name):
    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_image_dimensions(image, *, as_dict=False):
    """ Return (height,width) of an image """
    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
    """
    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
    return image.shape[0]

def get_image_width(image):
    """
    Return the image's width measured in pixels
    """
    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
    return image.shape[1]

def get_video_height(video):
    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_width(video):
    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))

#TODO: Finish color conversions

#endregion

def running_in_ipython():
    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:
    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 'shellinaboxd' not in get_system_commands():
        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 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):

    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

    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, 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
    """
    try:return np.asarray(x)#For numpy arrays and python lists (and anything else that work with np.asarray)
    except Exception:pass

    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.asarray([as_numpy_array(y) for y in x])

    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.
    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):

    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()
        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 _get_video_file_duration_via_moviepy(path):
    #https://stackoverflow.com/questions/3844430/how-to-get-the-duration-of-a-video-in-python
    #pip install moviepy
    pip_import('moviepy')
    from moviepy.editor import VideoFileClip
    return 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]

    pip_import('moviepy')
    from moviepy.editor import VideoFileClip

    with 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...
        from moviepy.editor import VideoFileClip

        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):
    """
    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 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,
    transpose=False,

    show_progress=True,
    use_cache=False,
    num_threads=None,
    strict=True,
    lazy=False,
    buffer_limit=None
):
    """ Plural of load_video_stream """

    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)

    streams = gather_args_call(load_files, load, range(len(paths)))
        
    if transpose:
        streams = zip(*streams)

        lengths = [(len(x) if hasattr(x, '__len__') else None) for x in 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):
    """
    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.

    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)
    """
    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.
    
    if use_cache and path in _load_video_cache:
        return _load_video_cache[path]
    
    stream = load_video_stream(path, start_frame = start_frame, with_length=show_progress)
    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[path] = out
    
    return out


def load_videos(
    *paths,
    start_frame=0,
    length=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,
        )

    return gather_args_call(load_files, load, paths)

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)
        
    return path

def _get_default_video_path(extension='mp4'):
    return get_unique_copy_path('video.'+extension)

class VideoWriterMP4:
    #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')

        if isinstance(video_bitrate,str) and video_bitrate in 'low medium high max':
            video_bitrate = {'low':100000,'medium':1000000,'high':10000000,'max':10000000000}[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)
        if not path_exists(get_parent_directory(self.path)):
            make_directory(get_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)
        frame=rp.as_rgb_image (frame)
        frame=rp.as_byte_image(frame)

        self.process.stdin.write(frame.tobytes())


    def finish(self):
        assert self.started
        self.process.stdin.close()
        self.process.wait()
        self.finished=True
        
def save_video_mp4(frames, path=None, framerate=60, *, video_bitrate='high', height=None, width=None, show_progress=True):
    """
    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.
    
    EXAMPLE BITRATES (used for the Sunkist soda example):
     100000    : ( 345KB) is decent, and very compressed. It starts out a bit mushy though
     1000000   : ( 3.3MB) I believe this is close to ffmpeg's default rate. It looks okay, but it does look a tiny bit mushy
     10000000  : (32.7MB)
     100000000 : (93.0MB)
     1000000000: (93.0MB) It seems to be the maximum size
    """
    assert not isinstance(frames, str), 'The first argument should be the sequence of video frames, not the path!'
    
    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 
    
    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_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,
    )

    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 _ensure_ffmpeg_installed():
    if "ffmpeg" not in get_system_commands():
        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_nvtop_installed():
    if "nvtop" not in get_system_commands():
        assert False, "Please install nvtop! sudo apt install nvtop"

def _ensure_zsh_installed():
    if "zsh" not in get_system_commands():
        assert False, "Please install zsh! sudo apt install zsh"


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_folder(get_path_parent(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
    """
    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'.split(), 'This function currently supports .mp4, .avi, .png 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)


    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='.avi',framerate=30):
    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 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 directory_exists(path):
    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):
    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):
    if not isinstance(path,str): return False
    return os.path.isfile(path)
is_a_file=file_exists

def path_exists(path):
    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):
    """
    EXAMPLE:
       rename_path("apple/bananna/cherry.jpg","coconut.png")
           is equivalent to (in bash)
       mv .apple/bananna/cherry.jpg apple/bananna/coconut.png
    """
    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
    """
    
    make_directory(get_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):
    """
    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.
    """
    import os
    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
    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.



def delete_folder(path,*,recursive=True,permanent=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
    assert os.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
    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)  
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):
    """
    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...
    for path in paths:
        if isinstance(path,str):
            delete_function(path,permanent=permanent)

def delete_paths(*paths,permanent=True):
    _delete_paths_helper(*paths,permanent=permanent,delete_function=delete_path)

def delete_files(*paths,permanent=True):
    _delete_paths_helper(*paths,permanent=permanent,delete_function=delete_file)

def delete_folders(*paths,permanent=True):
    _delete_paths_helper(*paths,permanent=permanent,delete_function=delete_folders)
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)

    return to_path


copy_folder=copy_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_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 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
    """
    try:
        if not directory_exists(path):
            os.mkdir(path)
        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 take_directory(path):
    "ZSH's take command combined mkdir with cd"
    path=make_directory(path)
    set_current_directory(path)
    return path

def make_directories(*paths):
    paths=detuple(paths)
    if isinstance(paths, str):
        paths=[paths]
    for path in paths:
        make_directory(path)
make_folders=make_directories

def delete_all_paths_in_directory(directory,*,permanent=True,include_files=True,include_folders=True,recursive=False):
    assert directory_exists(directory)
    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)
    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):
    """
    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
    import itertools

    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)

        product = itertools.product(*[p if is_non_str_iterable(p) else [p] for p in paths])
        return [os.path.join(*path_tuple) for path_tuple in product]
    else:
        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"

    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

_old_gists_path=path_join(get_parent_folder(__file__),'old_gists.txt') #This has to come after path_join and get_parent_folder are defined

_get_cutscene_frame_numbers_cache={}
def get_cutscene_frame_numbers(video_path,*,use_cache=False):
    """
    Returns a list of ints containing all the framenumers of the cutscenes in a video
    Confirmed to work with mp4 files
    Note: Right now this only supports reading from a video file, as opposed to reading from a numpy array containing
    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')

    video_path=get_absolute_path(video_path) #This is important for caching. 
    if video_path in _get_cutscene_frame_numbers_cache:
        return _get_cutscene_frame_numbers_cache[video_path]

    # Standard PySceneDetect imports:
    from scenedetect.video_manager import VideoManager
    from scenedetect.scene_manager import SceneManager
    # For caching detection metrics and saving/loading to a stats file
    from scenedetect.stats_manager import StatsManager

    # For content-aware scene detection:
    from scenedetect.detectors.content_detector import ContentDetector

    # type: (str) -> List[Tuple[FrameTimecode, FrameTimecode]]
    video_manager = VideoManager([video_path])
    stats_manager = StatsManager()
    # Construct our SceneManager and pass it our StatsManager.
    scene_manager = SceneManager(stats_manager)

    # Add ContentDetector algorithm (each detector's constructor
    # takes detector options, e.g. threshold).
    scene_manager.add_detector(ContentDetector())
    base_timecode = video_manager.get_base_timecode()

    # We save our stats file to {VIDEO_PATH}.stats.csv.

    scene_list = []

    try:
        # Set downscale factor to improve processing speed.
        video_manager.set_downscale_factor()

        # Start video_manager.
        video_manager.start()

        # Perform scene detection on video_manager.
        scene_manager.detect_scenes(frame_source=video_manager)

        # Obtain list of detected scenes.
        scene_list = scene_manager.get_scene_list(base_timecode)
        # Each scene is a tuple of (start, end) FrameTimecodes.

    finally:
        video_manager.release()

    output = [x[1].frame_num for x in scene_list]
    _get_cutscene_frame_numbers_cache[video_path]=output#The output of this is so small that it's probably ok to store it even if use_cache is False. It's unlikely our memory will run out because of this...
    return output

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)
    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
        #Todo: This can probably be more elegant, but whatever...
        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)

    origins=['top left','center','bottom right'] #TODO: Possibly add more origins
    assert origin in origins,'Invalid origin: %s. Please select from %s'%(repr(origin),repr(origins))

    if origin=='bottom right':
        out = image

        out = horizontally_flipped_image(out)
        out = vertically_flipped_image  (out)

        out = crop_image(out, height = height, width = width)

        out = horizontally_flipped_image(out)
        out = vertically_flipped_image  (out)

        return out

    if origin=='center':
        out = image
        out = crop_image(out, height = (height+image_height)//2, width = (width+image_width)//2 )
        out = crop_image(out, height = height, width = width, origin = 'bottom right')
        return out

    blank_pixel=np.zeros(image.shape[2:],dtype=image.dtype)
    out=blank_pixel
    out=np.expand_dims(out,0)
    out=np.repeat(out,width,0)
    out=np.expand_dims(out,0)
    out=np.repeat(out,height,0)
    
    common_width =min(width ,image_width )
    common_height=min(height,image_height)
    
    out[:common_height,:common_width]+=image[:common_height,:common_width]
    
    return out

def crop_images(images, height:int = None, width:int=None, origin='top left'):
    return [crop_image(image, height=height, width=width, origin=origin) for image in images]

def crop_image_zeros(image,*,output='image'):
    """
    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).

    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
    """
    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()'
    if is_grayscale_image(image):
        points=np.argwhere(image)#Crop out the black regions
    elif is_rgb_image(image):
        points=np.argwhere(np.any(image,axis=2))#Crop out the black regions
    elif is_rgba_image(image):
        points=np.argwhere(image[:,:,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
    if output=='bounds':
        return top,bottom,left,right

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):
    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
        socket.create_connection(("www.google.com", 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.  """
    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):
    click=pip_import('click')
    click.echo_via_pager(string)

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)
    _string_pager_via_click(string)
    # _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


#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():
    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 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'
    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]

_all_module_names=set()
def get_all_importable_module_names(use_cache=True):
    """
    Returns a set of all names that you can use 'import <name>' on
    """
    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)
    return _all_module_names

def get_module_path_from_name(module_name):
    """
    Gets the file path of a module without importing it, given the module's name
    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
    FROM: https://stackoverflow.com/questions/4693608/find-path-of-module-without-importing-in-python
    """
    import importlib
    try:
        return importlib.util.find_spec(module_name).origin
    except AttributeError:
        assert module_exists(module_name),'r.get_module_path_from_name: module %s doesnt exist!'%repr(module_name)
        raise

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))
    return inspect.getfile(module)

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!

    https://stackoverflow.com/questions/466345/converting-string-into-datetime

    >>> string_to_date('monday, aug 15th 2015 at 8:40 pm')
    datetime.datetime(2015, 8, 15, 20, 40)
    >>> format_date(string_to_date('now'))
    ans = Tue Aug 22, 2023 at 1:55:59PM
    >>> format_date(string_to_date('7pm'))
    ans = Tue Aug 22, 2023 at 7:00:00PM
    >>> format_date(string_to_date('7am'))
    ans = Tue Aug 22, 2023 at 7:00:00AM
    >>> format_date(string_to_date('tomorrow 7am'))
    ans = Wed Aug 23, 2023 at 7:00:00AM
    >>> format_date(string_to_date('jan 18 1975'))
    ans = Sat Jan 18, 1975 at 12:00:00AM
    >>> format_date(string_to_date('jan 18 1975 at 4:30pm 35 seconds'))
    ans = Sat Jan 18, 1975 at 4:30:35PM
    >>> format_date(string_to_date('Sat Jan 18, 1975 at 4:30:35PM'))
    ans = Sat Jan 18, 1975 at 4:30:35AM
    >>> format_date(string_to_date('2023-08-28 00:00:00'))
    ans = Mon Aug 28, 2023 at 12:00:00AM
    """
    timestring=pip_import('timestring')
    return timestring.Date(string).date

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 download_url(url, path=None, *, skip_existing=False, show_progress=False):
    """
    Works with both HTTP and Aws S3 Urls
    Download a file from a url and return the path it downloaded to. It 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
    EXAMPLE: open_file_with_default_application(download_url('https://i.imgur.com/qSmVyCo.jpg'))#Show a picture of a cat
    """
    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))

    if is_a_folder(path):
        root = path
        path = path_join(root, get_file_name(url))

    else:
        if path is None:
            path=get_file_name(url)

        #Create the parent directory of the destination if it doesn't already exist
        root = get_path_parent(path)
        if not path_exists(root):
            make_directory(root)

    if skip_existing and path_exists(path):
        #Don't download anything - skip it if it already exists
        return path

    if is_s3_url(url):
        import subprocess

        try:
            subprocess.check_call(['aws', 's3', 'cp', url, path, '--quiet'])
        except subprocess.CalledProcessError as e:
            raise Exception("rp.download_url failed to download s3 url %s to path %s: "%(url,path) + str(e))

        return path

    elif _is_youtube_video_url(url):
        return download_youtube_video(url, path, skip_existing=skip_existing, show_progress=show_progress)

    elif is_valid_url(url):
        pip_import('requests')
        import requests
        response = requests.get(url, stream=True)
        total_size = int(response.headers.get('content-length', 0))
        chunk_size = 1024**2  # 1 MB per iteration
        with open(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 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
):
    """
    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,
    )
    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):
    """
    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,
        )
    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,
            )
        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
):
    """ 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,
        )

    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):
    """Plural of get_cache_file_path, supporting a `lazy` option"""
    func = gather_args_wrap(get_cache_file_path)
    out = map(func, urls)
    if not lazy: out = list(out)
    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_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()

from rp.experimental.debug_comment import debug_comment


def _tensorify(x):
    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):
    try:
        return get_english_synonyms_via_datamuse(word)
    except Exception:
        return get_english_synonyms_via_nltk(word)

from .tracetraptest import * #A few experimental debugging features. These things mostly need to be renamed.

@memoized
def fibonacci(n):
    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)
    """

    #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 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]


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

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
    """
    
    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_folder(root=None,sort_by='name',reverse=True)->str:
#    #Asks the user to select a folder
#    #If reverse. put option 0 on the bottom instead of the top (might make it easier to read)
#    assert root is None or is_a_folder(root)
#    if root is None:
#        root=get_current_directory()
#    assert is_a_folder(root)
#    print('Current Folder:',fansi(get_absolute_path(root),'yellow','bold'))
#    folders=get_all_paths(root,include_files=False,include_folders=True,sort_by=sort_by)
#    parent=get_parent_directory(root)
#    select_button=None
#    paths=[select_button,parent]+folders
#    def format_path(path:str):
#        if path==select_button:
#            return fansi(root,'yellow','bold')
#        else:
#            if path==parent:
#                path='..'
#            else:
#                path=get_folder_name(path)
#            path=fansi(path,'cyan','bold')
#            return path
#    selected=input_select('Please select a folder to navigate into it, or select '+fansi('0','yellow','bold')+' to choose the current folder:',options=paths,stringify=format_path,reverse=reverse)
#    if selected==select_button:
#        selected=root
#        print('You selected the following folder: '+fansi(selected,'cyan','bold')) 
#        return selected
#    else:
#        try:
#            return input_select_folder(selected,sort_by=sort_by)
#        except PermissionError as error:
#            print(fansi('ERROR: ','red','bold')+fansi(error,'red'))
#            return input_select_folder(root    ,sort_by=sort_by)

#def input_select_file(root=None,sort_by='name',reverse=True)->str:
#    #Asks the user to select a file
#    #If reverse. put option 0 on the bottom instead of the top (might make it easier to read)
#    assert root is None or is_a_folder(root)
#    if root is None:
#        root=get_current_directory()
#    print('Current Folder:',fansi(get_absolute_path(root),'cyan','bold'))
#    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+files
#    def format_path(path:str):
#        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)
#            path=fansi(path,'yellow','bold')
#            return path
#    selected=input_select('Please select a file:',options=paths,stringify=format_path,reverse=reverse)
#    if is_a_folder(selected):
#        try:
#            return input_select_file(selected,sort_by=sort_by,reverse=reverse)
#        except PermissionError as error:
#            print(fansi('ERROR: ','red','bold')+fansi(error,'red'))
#            return input_select_file(root    ,sort_by=sort_by,reverse=reverse)
#    print('You selected the following file: '+fansi(selected,'cyan','bold')) 
#    return selected

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
    """
    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

@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 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 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])



____file=path_join(get_parent_directory(__file__),'.'+get_file_name(__file__))#/usr/local/lib/python3.7/site-packages/rp/.r.py
rprc_file_path=strip_file_extension(____file)+'.rprc.py'
rprc_file_path=path_join(get_parent_directory(__file__),'.rprc')
## /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

## Import the rp library's whole namespace. It's not nessecary, but it exposes a lot of useful functions without
#from rp import *

## 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.
#set_cursor_to_bar()

#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/' 
]

"""%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():
    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 is a janky argument rn lol 'NO' means we install packages but do not replace vimrc
    True means we ask for confirmation before replacing vimrc file
    False means we don't ask and replace the vimrc file
    In all cases right now we still install pip dependenceis
    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.
    """

    packages = 'isort black-macchiato pyflakes removestar ropevim drawille pudb'.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))

    # if not confirm or input_yes_no("Overwrite current vimrc?"):
    if not confirm=='NO' and (not confirm or input_yes_no('Would you like to add Ryan Burgert\'s vim settings to your ~/.vimrc?')):
        vimrc_path=get_absolute_path('~/.vimrc')
        if file_exists(vimrc_path):
            copy_file(vimrc_path, vimrc_path+'___rp_auto_backup___'+format_current_date()) #Create a backup of the current vimrc

        vimrc=text_file_to_string(get_module_path_from_name('rp.ryan_vimrc'))
        string_to_text_file(vimrc_path,vimrc)
        shell_command('git clone https://github.com/VundleVim/Vundle.vim.git ~/.vim/bundle/Vundle.vim')
        import os
        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 _set_ryan_tmux_conf():
    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


#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'


#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

#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
    #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/
    #IMAGES:
        #Allow images to be displayed through tmux
        #https://github.com/wookayin/python-imgcat/issues/4
        set -gq allow-passthrough on
    #THEME:
        #STATUSBAR:
            #COLOR:
                #Pro tip: To see all 256 colors, use rp.print_fansi_reference_table()
                # set -g status-style bg=colour97 #Set the color of the status bak
                # 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
            #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"
        #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
    '''
    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,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 _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_directory(get_path_parent(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,path)

    return path

def _install_lazygit(force=False):
    #https://github.com/jesseduffield/lazygit/tree/master?tab=readme-ov-file#installation
    
    with SetCurrentDirectoryTemporarily(make_directory(temporary_file_path())):
        if 'lazygit' in get_system_commands() and not force:
            print('lazygit is already installed. Not installing because force==False.')
            return
        
        if currently_running_mac():
            os.system('brew install jesseduffield/lazygit/lazygit')
        elif currently_running_linux():
            os.system(
                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
            os.system("""winget install -e --id=JesseDuffield.lazygit""")
        
    
    
def _install_filebrowser():
    """https://filebrowser.org/installation"""

    system_commands = get_system_commands()

    if "filebrowser" in system_commands:
        print("r._install_filebrowser: 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"

    os.system(command)

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():
            os.system("""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 _configure_filebrowser():
    #https://filebrowser.org/configuration/authentication-method
    os.system('filebrowser config set --auth.method=noauth')
    
def _run_filebrowser(port=8080, root='.'):
    _install_filebrowser()
    _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

def get_next_free_port(port):
    """
    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.
    """

    assert isinstance(port, int)
    while get_port_is_taken(port):
        port+=1
    return port



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 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'
    dill=pip_import('dill')
    return dill.dumps(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(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

_web_clipboard_url = 'https://ryanpythonide.pythonanywhere.com'#By sqrtryan@gmail.com account
def web_copy(data:object)->None:
    """ Send an object to RyanPython's server's clipboard """
    assert connected_to_internet(),"Can't connect to the internet"
    # assert can_convert_object_to_bytes(data),'rp.web_copy error: Cannot turn the given object into a bytestring! Maybe this type isnt supported? See can_convert_object_to_bytes for more help. The type of object you gave: '+repr(type(data))
    pip_import('requests')
    import requests
    response=requests.post(_web_clipboard_url,data=object_to_bytes(data))
    assert response.status_code==200,'Got bad status code that wasnt 200: '+str(response.status_code)

def web_paste():
    """ Get an object from RyanPython's server's clipboard """
    assert connected_to_internet(),"Can't connect to the internet"
    pip_import('requests')
    import requests         
    response=requests.get(_web_clipboard_url) 
    assert response.status_code==200,'Got bad status code that wasnt 200: '+str(response.status_code)
    return bytes_to_object(response.content)

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

_local_dill_clipboard_string_path=__file__+'.rp_local_dill_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_dill_clipboard_string_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_dill_clipboard_string_path,'rb')
    return bytes_to_object(file.read())
    
def _run_tmux_command(command):
    """Utility function to run a tmux command."""
    import subprocess
    result = subprocess.run(command, text=True, capture_output=True)
    return result.stdout.strip()

def _get_current_tmux_window():
    """Returns the index of the current tmux window."""
    return int(_run_tmux_command(['tmux', 'display-message', '-p', '#{window_index}']))

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 = _get_current_tmux_window()
    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 = _get_current_tmux_session()
    all_sessions = _get_all_tmux_sessions()
    sessions_to_close = [s for s in all_sessions if s != current_session]

    # Close each session
    for session in sessions_to_close:
        _run_tmux_command(['tmux', 'kill-session', '-t', session])

tmux_kill_other_sessions = tmux_close_other_sessions

def _get_current_tmux_session():
    """Returns the name of the current tmux session."""
    return _run_tmux_command(['tmux', 'display-message', '-p', '#{session_name}'])

def _get_all_tmux_sessions():
    """Returns a list of all session names in tmux."""
    sessions = _run_tmux_command(['tmux', 'list-sessions', '-F', '#{session_name}']).split()
    return list(sessions)

def tmux_detach_other_clients():
    """Detaches all other clients from the current tmux session."""
    current_session = _get_current_tmux_session()
    all_clients = _get_all_tmux_clients(current_session)
    current_client = _get_current_tmux_client()
    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 _get_current_tmux_client():
    """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_current_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_current_session_name()
        'mysession'
    """
    assert running_in_tmux(), 'rp.tmux_get_current_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).
    """
    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)

#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:
            command = command[0]
        
        #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()
        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']




    
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

@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 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 _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):
    if   is_binary_color(color):
        return tuple(not x for x in color)
    elif is_byte_color(color):
        return tuple(255-x for x in color)
    elif is_float_color(color):
        return tuple(1-x for x in color)
    else:
        raise TypeError('Unknown color format')

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

def make_zip_file_from_folder(src_folder:str=None, dst_zip_file:str=None)->str:
    """
    Creates a .zip file on your hard drive.
    Zip the contents of some src_folder and return the output zip file's path
    """
    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:
        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):
    """
    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.

    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:
            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:
            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.
    
    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

def _maybe_display_string_in_pager(string,with_line_numbers=True):
    #Display the string in the pager if it's too long
    import rp.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')
    from moviepy.editor import VideoFileClip

    with 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].
    """
    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].
    """
    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 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 apply_colormap_to_image(image,colormap_name='viridis'):
    """
        https://stackoverflow.com/questions/52498777/apply-matplotlib-or-custom-colormap-to-opencv-image/52626636
        EXAMPLE:
            image=load_image('https://www.gaytimes.co.uk/wp-content/uploads/2018/05/Kim-Petras-Thom-Kerr-header.jpg')
           styles='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 twilight twilight_r twilight_shifted twilight_shifted_r viridis viridis_r winter winter_r'.split()
           for style in styles:
               display_image(apply_colormap_to_image(image,style))
               input(style)
    """
    pip_import('cmapy')
    pip_import('cv2')
    import cv2
    import cmapy
    image=as_numpy_image(image)
    image=as_rgb_image(image)
    image=as_byte_image(image)
    image_colorized = cv2.applyColorMap(image, cmapy.cmap(colormap_name))
    image_colorized = cv_rgb_bgr_swap(image_colorized)
    return image_colorized

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):
    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_directory(get_parent_directory(path))
        return helper()


_file_to_bytes_cache={}
def file_to_bytes(path: str, use_cache=False):
    if use_cache and path in _file_to_bytes_cache:
        return _file_to_bytes_cache[path]

    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):
    return bytes_to_base64(file_to_bytes(path, use_cache))

_file_to_object_cache={}
def file_to_object(path:str, use_cache=False):
    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]=_file_to_object_cache
        
    return output

def object_to_file(object,path:str):
    return bytes_to_file(object_to_bytes(object),path)

def bytes_to_base64(bytestring: bytes) -> str:
    import base64
    return base64.b64encode(bytestring).decode('utf-8')

def base64_to_bytes(base64_string: str) -> bytes:
    import base64
    return base64.b64decode(base64_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)->str:
    """
    Meant to imitate the 'curl' command in linux
    Sends a get request to the given URL and returns the result string
    """
    pip_import('requests')
    import requests
    response=requests.request('GET',url)
    return response.text

def curl_bytes(url):
    """
    Fetches a file from a specified URL and returns its bytes

    Parameters:
        url (str): The URL of the file to fetch.

    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

    # Fetch the file from the URL
    response = requests.get(url)
    response.raise_for_status()  # Raises an HTTPError for bad responses if any

    # Convert the file contents to a bytestring and return it
    return BytesIO(response.content).getvalue()


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_history_folder=path_join(get_parent_directory(__file__),'pterm_history')
# pterm_history_filename=path_join(pterm_history_folder,str(millis())+'.txt')
pterm_history_filename=path_join(get_parent_directory(__file__),'HISTORY')
_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)

    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
    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
    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

    TODO: Add an alpha-aware arg, like cv_alpha_weighted_gauss_blur. Propogate arg to all funcs that use this.
          How to do it with expansion aka bilinear interp?

    If copy==False, and the size doesn't change the image, just returns the original image as is without copying 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)

    #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]

    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)

    if is_number(size):
        assert size>=0, 'Cannot resize an image by a negative factor' #Technically, I suppose this would mean flipping the image...maybe I'll implement that some other day
        height=np.ceil(get_image_height(image)*size)
        width =np.ceil(get_image_width (image)*size)
    else:
        height,width=size
        height=get_image_height(image) if height is None else height
        width =get_image_width (image) if width  is None else width
    
    height=int(height)
    width =int(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) == rp.get_image_dimensions(image):
        if copy: return image.copy()
        else   : return image
    
    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 cv_resize_images(
    *images,
    size,
    interp="auto",
    alpha_weighted=False,
    show_progress=False,
    copy=True,
    lazy=False
):

    images=detuple(images)
    assert all(is_image(x) for x in images), 'Not all given images satisfy rp.is_image'

    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: 
        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
    ):

    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 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
    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))

    if is_number(size):
        assert size>=0, 'Cannot resize an image by a negative factor'
        height=np.ceil(get_image_height(image)*size)
        width =np.ceil(get_image_width (image)*size)
    else:
        height,width=size
        height=get_image_height(image) if height is None else height
        width =get_image_width (image) if width  is None else width
    
    height=int(height)
    width =int(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) == rp.get_image_dimensions(image):
        if copy: return image.clone()
        else   : return image

    if interp=='auto': return _auto_interp_for_resize_image(torch_resize_image, image, (height, width), 'clone')
    
    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_remap_image(image, x, y, *, relative=False, interp='bilinear', add_alpha_mask=False, use_cached_meshgrid=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


#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 _randn_like(x):
    """ Works across libraries - such as numpy, torch """
    if is_numpy_array(x):
        return np.random.randn(*x.shape)
    elif is_torch_tensor:
        import torch
        return torch.randn_like(x)
    else:
        raise ValueError(get_current_function_name() + " does not support type "+str(type(tensor)))

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 -accumulate_flows([-f for f in flows][::-1])

    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: os.append(np.stack((ox, oy)))
        if reduce: return np.stack((ox, oy))  #In  [XY]HW form
        else:      return np.stack(os)        #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 accumulate_flows(flows)
        
    elif all(map(is_torch_tensor,flows)):
        import torch
        return accumulate_flows(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
):
    """ 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, images)

    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
):
    """ 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, images)

    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())

    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())

    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 _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(iterable, *args,**kwargs)

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
    """
    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:
    #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):
    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):
    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 """
    r=as_grayscale_image(r)
    g=as_grayscale_image(g)
    b=as_grayscale_image(b)
    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)
    g=as_float_image(g)
    b=as_float_image(b)
    return np.stack((r,g,b),axis=2)

def compose_rgba_image(r,g,b,a):
    """ Create an RGBA image from four separate channels """
    r=as_grayscale_image(r)
    g=as_grayscale_image(g)
    b=as_grayscale_image(b)
    a=as_grayscale_image(a)
    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)
    g=as_float_image(g)
    b=as_float_image(b)
    a=as_float_image(a)
    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(image,function):
    """
    Apply a grayscale funcion on every image channel individually
    """
    assert is_image(image)
    if is_grayscale_image(image):
        return function(image)
    channels=extract_image_channels(image)
    return compose_image_from_channels(*(function(channel) 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
    
    Will output an RGBA float image

    TODO: Mutate image if copy=False
    """
    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 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')
    import os,ydiff,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')
        string_to_text_file(file_name,before)
        os.system('git add '+file_name)
        os.system('git commit -am '+repr(commit_message))
        string_to_text_file(file_name,after)
        os.system('ydiff -s '+file_name)
    finally:
        set_current_directory(original_dir)
        delete_directory(temp_dir)

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
    
    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):
    """
    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)
        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):
        self.is_file=is_file
        self.is_folder=not is_file
        self.data=data
        self.path=path

def web_paste_path(path=None,*,ask_to_replace=True):
    """ FP (file paste) """
    data=web_paste()
    try:
        data=bytes_to_object(data)
        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)
            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
                move_path(path,'.')
        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:str=None):
    """ FC (file copy) """
    if path is None:
        path=input_select_path(message='Select a file or folder for web_copy_path:')
        path=get_relative_path(path)
    assert path_exists(path),'Path does not exist: '+str(path)
    data=file_to_bytes(path) if is_a_file(path) else zip_folder_to_bytes(path)
    web_copy(object_to_bytes(_BundledPath(is_a_file(path),data,path)))
    return path

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:
    #Don't pester people to death to download icrecream...
    from icecream import ic#This is a nice library...I reccomend it for debugging. It's really simple to use, too. EXAMPLE: a=1;b=2;ic(a,b)
except Exception:pass

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 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
    EXAMPLE: import_all_submodules('sklearn',verbose=True)
    """
        
    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):
    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.experimental.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
    

fdt=_fzf_multi_grep


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
    
    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
    
    image=as_rgb_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

_rp_folder = get_parent_folder(__file__)
_rp_downloads_folder = path_join(_rp_folder, "downloads")

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 ."
                    )
                    os.system(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):
    """
    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
    """
    import torch
    cotracker = torch.hub.load("facebookresearch/co-tracker", "cotracker3_offline")
    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
        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 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
        video = video.float()
    
    cotracker = _get_cotracker_model(device)
    
    if device is not None:
        video = video.to(device)
        if queries is not None:
            queries = rearrange(queries, "N TXY -> 1 N TXY") # B N 3
            queries = queries.to(device)
        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)
    
    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 _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:
                    os.system(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

    EXAMPLE:
        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 = rp.load_images(image_0, image_1, use_cache=True)
        image_0, image_1 = rp.as_float_images([image_0, image_1])
        image_0, image_1 = rp.as_rgba_images([image_0, image_1])
        
        dx, dy = get_optical_flow_via_pyflow(image_0, image_1)
        rp.display_image(optical_flow_to_image(dx, dy))
        
        image_1_pred = cv_remap_image(image_0, -dx, -dy, relative=True)
        image_1_pred = rp.with_alpha_checkerboard(image_1_pred)
        
        rp.display_image_slideshow([image_0, image_1_pred, image_1])
    """
    _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
    """
    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:
    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):
    values=detuple(values)
    return [get_apriltag_image(value=v,family=family,size=size) for v in values]

def _display_filetype_size_histogram(root='.'):
    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
    """
    # 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'
    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

_is_a_git_repo_cache=dict()
def is_a_git_repo(folder='.', use_cache=False):
    """Returns False if it's not a git repo, returns the root .git folder if it is"""
    if not is_a_folder(folder):
        return False
    pip_import('git')
    import git
    try:
        _ = git.Repo(folder,search_parent_directories=True).git_dir
        output= _
    except git.exc.InvalidGitRepositoryError:
        output= False
    _is_a_git_repo_cache[folder]=output
    return output

def get_git_repo_root(folder='.', use_cache=False):
    "Returns the git root of folder (the .git folder). If it's not a git repo, it throws an error."
    assert isinstance(folder, str)
    output = is_a_git_repo(folder, use_cache)
    assert output, 'Is not a git repo: '+str(folder)
    return output

def git_clone(url,path=None,show_progress=False):
    def get_repo_name_from_url(url):
        #Url should look like: https://github.com/gabrielloye/RNN-walkthrough/
        assert is_valid_url(url)
        url=url.strip()
        if url.endswith('/'):
            url=url[:-1]
        url=url.split('/')
        url=url[-1]
        return url
    if path is None:
        path=get_repo_name_from_url(url)
        path=get_absolute_path(path)

    if show_progress:
        command='git clone '+shlex.quote(url)+' '+shlex.quote(path)
        print(command)
        os.system(command)  
    else:
        pip_import('git')
        import git
        git.Repo.clone_from(url,path)
        return path

def get_git_info(folder='.'):
    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)
    """
    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 _autoformat_python_code_via_black(code:str):
    pip_import('black')
    import black
    return black.format_str(code,mode=black.Mode(line_length=1000))

autoformat_python_via_black = _autoformat_python_code_via_black #Legacy compatibility


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_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 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):
    """ Will convert an a PIL images if it isn't already - supports BCHW torch tensors, numpy images, etc """
    assert is_image(image), 'as_pil_image: Input is not an image as defined by rp.is_image'

    if is_pil_image(image):
        return image.copy()

    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):
    """ 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) 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 """
    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):
        return as_numpy_array(image)
    else:
        assert False,'Unsupported image type: '+str(type(image))
    
def as_torch_images(images):
    """ Plural of rp.as_torch_image """
    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)
        import torch
        images=torch.Tensor(images)
        return images
    elif is_torch_tensor(images):
        #Not creating a copy. GPU tensors are expensive.
        return images
    else:
        raise TypeError('Unsupported image datatype: %s'%type(images))

def as_torch_image(image):
    """ 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 as_torch_image(as_numpy_image(image,copy=False))
    elif isinstance(image,np.ndarray):
        return as_torch_images(image[None])[0]
    else:
        assert False,'Unsupported image type: '+str(type(image))

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
            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_all_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 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 _removestar(code:str,max_line_length=100,quiet=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
    """
    pip_import('removestar')
    from removestar.removestar import fix_code
    return fix_code(code,file='filename is irrelevant',max_line_length=max_line_length,quiet=True)
        
#def file_line_iterator(file_name):
#    #Opens a file and iterates through its lines
#    #Needs a better name
#    file=open(file_name)
#    while True:
#        line=file.readline()
#        if not line:
#            return
#        #I DONT KNOW WHY THIS ASSERTION DOESN'T ALWAYS WORK BUT SOMETIMES IT FAILS...
#        if line.endswith('\n'):
#            yield line[:-1]
#        else:
#            yield line

def file_line_iterator(file_name, *, with_len=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.
    
    Returns:
        Iterator: An iterator that yields lines from the given file.
    """
    file = open(file_name)
    if with_len:
        length = number_of_lines_in_file(file_name)
        iterator = IteratorWithLen(_file_line_gen(file), length)
    else:
        iterator = _file_line_gen(file)
    return iterator

def _file_line_gen(file):
    while True:
        line = file.readline()
        if not line:
            file.close()
            return
        if line.endswith('\n'):
            yield line[:-1]
        else:
            yield line

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
    
@memoized
def get_all_ttf_fonts():
    """ Returns a list of paths to all .ttf fonts on this computer """
    if currently_running_mac():
        #https://apple.stackexchange.com/questions/35852/list-of-activated-fonts-with-shell-command-in-os-x
        out=[]
        out+=['/System/Library/Fonts/'+x for x in shell_command('ls -R /System/Library/Fonts | grep ttf').splitlines()]
        out+=['/Library/Fonts/'       +x for x in shell_command('ls -R /Library/Fonts | grep ttf'       ).splitlines()]
        out+=['~/Library/Fonts/'      +x for x in shell_command('ls -R ~/Library/Fonts | grep ttf'      ).splitlines()]
        out=[get_absolute_path(x) for x in out]
        out=[x for x in out if file_exists(x)]
        return list(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 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]

if __name__ == "__main__":
    print(end='\r')
    _pterm()


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)
    as_numpy = is_numpy_array(arrays)

    output = [resize_list(array, length) for array in arrays]

    if as_numpy: output = as_numpy_array(output)
    return output

def resize_lists_to_max_len(*lists):
    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_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 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]]
    """

    #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_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:
        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 _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_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()
        ┏━━━━━━━━┳━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━┳━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
        ┃ 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 │                             │
        └────────┴──────────────────┴────────────────┴────────┴────────┴──────┴─────────────────────────────┘
    """
    pip_import('rich')
    from rich.console import Console
    from collections import defaultdict
    from rich.table import Table
    import rich

    console = Console(record=True)
    console.begin_capture()

    # 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")
    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 = ""

        table.add_row(
            str(gpu_id),
            name,
            used_vram_text,
            free_vram,
            total_vram,
            temperature_text if include_temperature else "",
            utilization if include_utilization else "",
            process_column_text,
        )


    console.print(table)

    console.end_capture()
    output = console.export_text(styles=True)
    if silent:
        return output
    else:
        rich.print(table)


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):

    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()

        # 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("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):
    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

_pip_install_needs_sudo=True
def pip_install(pip_args:str):
    assert isinstance(pip_args,str),'pip_args must be a string like "numpy --upgrade" or "rp --upgrade --no-cache --user" etc'
    import os
    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.
    os.system(sys.executable+' -m pip install '+pip_args)
    _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():
    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',
    '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',
    # '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!)
    'cython': 'Cython',
    'deprecate': 'pyDeprecate',
    'diff_match_patch': 'diff-match-patch',
    'dns': 'dnspython',
    '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',
    'getmac': 'get-mac',
    'git': 'GitPython',
    'github': 'PyGithub',
    'glances': 'Glances',
    'google': 'protobuf',
    'google_auth_oauthlib': 'google-auth-oauthlib',
    '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?
    'sphinx': 'Sphinx',
    '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',
    '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",
}


#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_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
    """

    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:
        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():
            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

_rp_git_token=None #Set in RPRC
_rp_git_dir=path_join(get_parent_directory(__file__),'git')
def git_import(repo,token=None):
    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
    
    
    try:
        return __import__(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)
        
    path = path_join(_rp_git_dir, module_name)
    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)
    except ImportError:
        fansi_print('rp_git_import: Failed to import repo='+repr(repo), 'red', 'bold')
        raise

def check_pip_requirements(file='requirements.txt'):
    """
    Test availability of required packages from given requirements file.
    
    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)

    import pkg_resources
    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 = []

    installed_packages = {pkg.key: pkg.version for pkg in pkg_resources.working_set}

    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()
    console.print(table)

    if not conflicted and not missing:
        console.print("[bold green]All required packages are already installed[/bold green]")



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."""
    assert len(collection) == 1, "Expected length of 1"
    return next(iter(collection))


def killport(port: int):
    "Kills any process using that port"
    import subprocess
    import os
    import signal

    # Find processes listening on the specified port
    command = "lsof -i tcp:%s -t"%port
    process = subprocess.Popen(command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
    pids, errors = process.communicate()

    if errors:
        print("Error:", errors.decode())
        return

    # Extract PIDs from the output
    pids = pids.decode().strip().split('\n')
    
    # Kill each process
    for pid in pids:
        if pid.isdigit():
            os.kill(int(pid), signal.SIGKILL)
            print("Killed process with PID: "+str(pid))
        else:
            print("No process found on port "+str(port))


#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']=get_path_parent(get_path_parent(__file__))#For PUDB editing in vim

del re

#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


