From a549a91396a80db0f227e132774883ae7f111dfe Mon Sep 17 00:00:00 2001 From: Naphat Amundsen Date: Sun, 17 Oct 2021 13:28:24 +0200 Subject: [PATCH] Updated docs, can now specify title in dict to be passed to meny.menu, refactor --- README.md | 245 +++++++++++++++++++------------------ example/cases.py | 85 ++++++------- example/readme_examples.py | 51 ++++++++ meny/__init__.py | 4 +- meny/config.py | 5 +- meny/decorators.py | 6 +- meny/funcmap.py | 19 +-- meny/menu.py | 48 +++++--- meny/utils.py | 48 +++++--- tests/test.py | 9 +- 10 files changed, 298 insertions(+), 222 deletions(-) create mode 100644 example/readme_examples.py diff --git a/README.md b/README.md index 1f94286..9f92029 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,3 @@ -# MUST UPDATE DOCS!!!!!!! - # Meny A simple and sexy way to make an console interface @@ -10,7 +8,7 @@ First install the package with the command (make sure you have Python 3.6 or hig ``` pip install meny ``` -Then you can import ``meny`` in Python. The package lets you import three functions: ``menu``, ``list_local_cases`` and ``clear_screen``. Usage of ``menu`` will be illustrated below. ``list_local_cases`` takes the output from ``locals()`` and lists all the functions in the local scope. ``clear_screen`` clears your screen, hence the name. +Then you can import ``meny`` in Python. The most central functions in this package are `meny.menu` and `meny.title`, which will be covered below. This package has only been tested on Windows 10 and Ubuntu (18.04, 20.04) with Python 3.6, 3.7, and 3.8 @@ -31,66 +29,57 @@ from meny import menu . . -menu(locals(), title=' Main menu title here ', main=True) +menu(locals(), title=' Main menu title here ') ``` -You can import whatever modules, classes and functions you want in the file without them interfering with the functions defined your file. You will need to implement docstrings to every case. The first line of text in the docstring will be used as the description in the console interface. - -The order of the cases is alphabetically sorted by the function name. +The `locals()` function is a Python built-in function which returns a dictionary with variable names as keys and the corresponding objects as values from the local scope. You can import whatever modules, classes and functions you want in the file without them interfering with the functions defined your file. The order of the cases is by definition order. The function signature of ``menu`` along with its docstring is as follows: ```python def menu( - cases: Union[List[Callable], Dict[str, Callable], ModuleType], - title: str = strings.DEFAULT_TITLE, - blank_proceedure: Union[str, Callable] = "return", - on_kbinterrupt: str = "raise", - decorator: Optional[Callable] = None, + cases: Union[Iterable[FunctionType], Dict[str, FunctionType], ModuleType], + title: Optional[str] = None, + on_blank: Optional[str] = None, + on_kbinterrupt: Optional[str] = None, + decorator: Optional[FunctionType] = None, run: bool = True, - main: bool = False, - case_args: Optional[Dict[Callable, tuple]] = None, - case_kwargs: Optional[Dict[Callable, dict]] = None, + case_args: Optional[Dict[FunctionType, tuple]] = None, + case_kwargs: Optional[Dict[FunctionType, dict]] = None, frontend: Optional[str] = None, ): - """ + """¨ Factory function for the CLI class. This function initializes a menu. Parameters ------------ - cases: Can be output of locals() (a dictionary) from the scope of the cases + cases: a dictionary where keys are functions names and values are functions - Or a list functions + Or an iterable of functions - Or a module containing the case functions + Or a module containing functions title: title of menu - blank_proceedure: What to do the when given blank input. Can be user defined - function, or it can be a string. Available string options - are: + on_blank: What to do the when given blank input. Available options are: + 'return', will return to parent menu - 'return', will return to parent menu + 'pass', does nothing. This should only be used for the main menu. - 'pass', does nothing. This should only be used for the - main menu + on_kbinterrupt: Behavior when encountering KeyboardInterrupt exception when the menu is running. + If "raise", then will raise KeyboardInterrupt, if "return" the menu returns. - decorator: Decorator for case functions + decorator: Decorator to applied for all case functions. run: To invoke .run() method on CLI object or not. - main: Tells the function whether or not the menu is the main menu (i.e. the - first ("outermost") menu) or not. This basically sets the behavior on how the menu - should behave. It is equivalent to give the argumnts on_kbinterrupt="return" and - blank_proceedure="pass" - - cases_args: Optional[Dict[Callable, tuple]], dictionary with function as key and tuple of + cases_args: Optional[Dict[FunctionType, tuple]], dictionary with function as key and tuple of positional arguments as values - cases_kwargs: Optional[Dict[Callable, dict]], dictionary with function as key and dict of + cases_kwargs: Optional[Dict[FunctionType, dict]], dictionary with function as key and dict of keyword arguments as values frontend: str, specify desired frontend: - "auto": Will try to use fancy frontend if curses module is available, else - use simple frontend + "auto": Will try to use fancy frontend if curses module is available, else + use simple frontend (default) "fancy": Use fancy front end (if on Windows, install windows-curses first or Python will not be able to find the required "curses" package that the fancy frontend uses) @@ -101,21 +90,15 @@ def menu( """ ``` -### Examples +## Simple examples Say we are editing console.py ```python from random import randint from time import sleep -from meny import menu - -def case1(): - ''' - FizzBuzz! +import meny - When you get the urge to fizz your buzz - if you know what I mean - ''' +def fizzbuzz(): for i in range(21): stringy = '' @@ -132,33 +115,75 @@ def case1(): print(stringy) sleep(0.1) -def case2(): - ''' - Print a small random integer - ''' +def random_integer(): print(randint(0,9)) - sleep(0.5) + sleep(1) -menu(locals(), title=' Main menu ', main=True, blank_proceedure='pass') +meny.menu(locals(), title=' Main menu ') ``` will result with this when running: ```python console.py```: +``` +-------------- Main menu --------------- +1. fizzbuzz +2. random_integer + +Input: +``` +You then specify which case you want to run by entering the input number as the first token. The tokens after (delimited by space) will be passed to the case function as positional arguments. The argument tokens will be evaluated as Python literals. + +## Case names +By default it will use the function names as the case names. However you can use the `meny.title` decorator to apply a title that will be used instead: +```python +from random import randint +from time import sleep +import meny + +@meny.title("FizzBuzz!") +def fizzbuzz(): + for i in range(21): + stringy = '' + + fizz = i % 3 == 0 + buzz = i % 5 == 0 + + if fizz: + stringy = stringy + 'Fizz' + if buzz: + stringy = stringy + 'Buzz' + if not (fizz or buzz): + stringy = i + + print(stringy) + sleep(0.1) + +@meny.title("Get random integer") +def random_integer(): + print(randint(0,9)) + sleep(1) + +meny.menu(locals(), title=' Main menu ') +``` +Which will produce: ``` -------------- Main menu --------------- 1. FizzBuzz! -2. Print a small random integer +2. Get random integer Input: ``` + + + ## Special cases -Entering ``..`` will exit the current menu, effectively moving you to the parent menu if you are implementing nested cases. If you are in the main menu it will exit the program. +Entering ``..`` will exit the current menu, effectively moving you to the parent menu if you are implementing nested cases. If you are in the main menu it will exit the program. This has the same effect as just pressing enter when there is nothing in the input field. -Entering ``q`` will raise a keyboard interrupt, menus can either return or raise KeyboardInterrupt in this case. See the docstring for `menu`. +Entering ``q`` will exit the menu interface. Entering ``h`` will display this text that explains the special cases. -Enter ``-1`` or any integer will "reverse" the choices, such that you take the last choice. This is motivated by Python lists where you can index like list[-1] +Enter ``-1`` or any integer will "reverse" the choices, such that you take the last choice. This is inspired by Python lists where you can index like list[-1] ## Frontend and usage There are two frontends implemented; the simple frontend and the fancy frontend. The selection of frontend will be selected based on the detected operating system. One can pass the choice of frontend: `menu(..., frontend="auto")`. The possible choices are @@ -173,36 +198,38 @@ There are two frontends implemented; the simple frontend and the fancy frontend. It possible to override the default frontend throughout the Python program by doing ```python import meny -meny.config.default_frontend = "auto" # auto, fancy, or simple +meny.set_default_frontend("auto") # auto, fancy, or simple ``` -as opposed to specifying the choice of frontend for every `menu(..., frontend="...")` call. +as opposed to specifying the choice of frontend for every `meny.menu(..., frontend="...")` call. ## Arguments -The cases can take arguments as well! Simply implement them as functions with type hints (type hints are mandatory for the case functions): +The cases can take arguments as well! ````python -from meny import menu +import meny +from time import sleep -def case1(a: int, b: int): - '''Add two integers''' +def addints(a, b): print(a+b) + sleep(1) -def case1(a: str, b: str): - '''Append two strings''' +def appendstrings(a, b): print(a+b) + sleep(1) -def case3(a: list): - '''Print elements in list and their types''' - [print(f'Element {i}: {elem}, type: {type(elem)}') for i, elem - in enumerate(a)] +# Type hints won't interfere with meny, and will actually be displayed when using the fancy frontend +@meny.title("Print elements and their types") +def displaylist(a: list): + [print(f'Element {i}: {elem}, type: {type(elem)}') for i, elem in enumerate(a)] + sleep(1) -menu(locals(), title=' Main menu ', main=True) +meny.menu(locals(), title=' Main menu ') ```` Then simply give the arguments along with the choice: ```` -------------- Main menu --------------- -1. Add two integers -2. Append two strings -3. Print elements in list and their types +1. addints +2. appendstrings +3. Print elements and their types Input: 1 60 9 @@ -213,68 +240,45 @@ Input: 1 60 9 Input: 2 "cat and dog" mathemathics cat and dogmathemathics - -Note: Single token strings don't even need quotes ```` ```` -Input: 3 ['cat',69,420.0] +Input: 3 ['cat', 69, 420.0] Element 0: cat, type: Element 1: 69, type: Element 2: 420.0, type: - -Note: You cannot have any spaces when giving lists, tuples, dicts and such as the input parser will break them. ```` -The program will read the desired types from the function signature, then it will convert the input into the appropriate types. The only supported types are the built in Python types: -- str -- int -- float -- tuple -- list -- set -- dict - -However, if you need proper handling of argument types, I suggest you specify your inputs to be strings then manually handle them yourself to ensure expected behavior, as the process of turning the case inputs to -their designated types is done using `eval` with som string sanitation. - ## Programmatic Arguments You can supply arguments programmtically to your case functions: ```python -from meny import menu -def case6(a, b, c, d): - """ - Programmatic arguments - """ - print(a, b, c, 4) - sleep(0.5) +import meny -case_args = {case6: (1, 2)} -case_kwargs = {case6: {"d": 4, "c": 3}} -menu(locals(), case_args=case_args, case_kwargs=case_kwargs) -``` -Case functions that takes arguments programmatically are not required to have type hints unlike case functions that are supposed take arguments through the cli. +def programmatic_args(a, b, c, d): + print(a, b, c, d) + sleep(1) +case_args = {programmatic_args: (1, 2)} +case_kwargs = {programmatic_args: {"d": 4, "c": 3}} +meny.menu(locals(), case_args=case_args, case_kwargs=case_kwargs) +``` Functions that takes arguments programmatically cannot take arguments through the cli, that is you cannot both supply programmatic arguments as well as arguments through the cli. ## Nested cases -If you want to implement nested cases, then you can simply reuse the menu function in the function scope. When doing nested cases, you should not give the keyword ``main=True`` to the ``menu`` function. +If you want to implement nested cases, then you can simply reuse the menu function in the function scope. ```python from meny import menu -def parentcase1(): - '''Fizz''' - def subcase1(): - '''docstring1''' +def parent(): + def child1(): pass - def subcase2(): - '''docstring2''' + def child2(): pass menu(locals(), title= ' Title here ') -menu(locals(), title=' Main menu ', main=True) +menu(locals(), title=' Main menu ') ``` You can create another module for the other cases and pass them as well: ```python @@ -289,24 +293,27 @@ menu(other_cases, title= ' Main menu ', main=True) or you can give a list of functions, which will enable you to force the ordering of the cases as well: ```python -from meny import menu +import meny -def parentcase1(): - '''Fizz''' - def subcase1(): - '''docstring1''' +def parent2(): + def child1(): pass - def subcase2(): - '''docstring2''' + def child2(): pass - menu([subcase2, subcase1], title= ' Title here ') -menu(locals(), title=' Main menu ') + meny.menu([subcase2, subcase1], title= ' Title here ') +meny.menu(locals(), title=' Main menu ') ``` -## What if want to define functions without having them displayed in the menu? -Of what I can think of: you can either define your functions in another python file and import that, or you can create a class (in the same file as the case functions) that consists of your functions as static methods. +## What if I want to define functions without having them displayed in the menu? +Easy! Simply apply the `meny.ignore` decorator on functions to make `meny` ignore them. You can also create a class of static methods to hide functions within a class since classes will be ignored by `meny` anyways. This problem is also naturally avoided if just specifies the functions manually either using a `dict` or `list`. +```python +import meny +@meny.ignore +def ignored(): + pass +``` ## Optional: Decorator To enforce a common behavior when entering and leaving a case within a menu, you give a decorator to the ``menu`` function. However, it is important that the decorator implements the ``__wrapped__`` attribute (this is to handle docstrings of wrappers as arguments for wrapped functions). Generally, it should look like this @@ -329,7 +336,7 @@ def case_decorator(func): return retobj return case_wrapper ``` -Since the decorator is a function, you cannot have it in the same namespace as the case functions, so you can for example implement it in another file. To use it you do as following: +It can then easily be applied to all functions like so: ```python from meny import menu from case_decorator import case_decorator diff --git a/example/cases.py b/example/cases.py index 159cff5..8355e3e 100644 --- a/example/cases.py +++ b/example/cases.py @@ -3,39 +3,37 @@ Each case should be a function. The ordering of the cases in the console interface -will be by the function names. So a() will be -first then b() etc. +will be by the function definition orders. -Name of cases in console interface will be first line of -docstring +Name of cases in menu interface will be the function names by default. One can specify alternative +titles by using the meny.case decorator Feel free to import whatever -If you want to implement nested cases, then simply import -reuse the main function +If you want to implement nested cases, then simply reuse the main function +Example +-------- from meny import menu -then you can either create another module with the nested -cases: +# Then you can either create another module with the nested cases: -menu(consolecases_nested, title= ' Title here '). +import cases_nested + +menu(cases_nested, title= ' Title here '). or you can give a list functions: def case3(): def subcase1(): - '''docstring1''' pass def subcase2(): - '''docstring''' pass - menu([subcase1, subcase2], title= ' Title here ') + meny.menu([subcase1, subcase2], title= ' Title here ') """ -from functools import wraps from time import sleep import meny @@ -44,17 +42,8 @@ def subcase2(): meny.set_default_frontend("fancy") -import time -@meny.ignore -def wait(func): - @wraps(func) - def wrapper(*args, **kwargs): - func(*args, **kwargs) - time.sleep(0.5) - return wrapper - -@wait -@meny.case("FizzBuzz!") + +@meny.title("FizzBuzz!") def fizzbuzz(n: int = 10, waittime: float = 0.1): """ When you get the urge to fizz your buzz @@ -75,78 +64,77 @@ def fizzbuzz(n: int = 10, waittime: float = 0.1): print(stringy) sleep(waittime) + sleep(1) -@wait -@meny.case("Append two strings") +@meny.title("Append two strings") def appendstrings(a: str, b: str): print(a + b) + sleep(1) -@meny.case("A nested module menu") +@meny.title("A nested module menu") def nestedmodulemenu(): """ This nested menu loads cases from a module """ - menu(cases_nested, title=" Nested! ") + meny.menu(cases_nested, title=" Nested! ") -@meny.case("Math menu") +@meny.title("Math menu") def mathmenu(): """ This nested menu gets the cases from a user defined list. """ - @wait - @meny.case("Multiply two floats") + @meny.title("Multiply two floats") def multiply(x: float, y: float): print(x * y) + sleep(1) - @wait - @meny.case("Divide two floats") + @meny.title("Divide two floats") def divide(x: float, y: float): if y == 0: print("You can't divide by zero!!!") return print(x / y) + sleep(1) - menu(locals(), title=" Quick maths ") + meny.menu(locals(), title=" Quick maths ") -@wait -@meny.case("Even another nested menu") +@meny.title("Even another nested menu") def anothernested(): """ This menu obtains the nested case functions by - sending the return value of locals() into menu() + sending the return value of locals() into meny.menu() """ - @wait - @meny.case("Print triangle") + @meny.title("Print triangle") def triangle(): for j in range(10): print("*" * j) + sleep(1) - - @wait - @meny.case("Print rectangle") + @meny.title("Print rectangle") def rectangle(): for i in range(10): print("#" * 10) + sleep(1) - @wait - @meny.case("Print list") + @meny.title("Print list") def printlist(a: list): print(a) + sleep(1) - menu(locals(), title=" Shapes ") + meny.menu(locals(), title=" Shapes ") -@wait -@meny.case("Programmatic arguments") +@meny.title("Programmatic arguments") def programmatic(a, b, c, d): print(a, b, c, 4) + sleep(1) def just_function_name(arg: str = "Hello World"): @@ -155,9 +143,10 @@ def just_function_name(arg: str = "Hello World"): print("Press enter to return") input() -@wait + def simple_func(a=1, b: str="2", c=3.0): print(a, b, c) + sleep(1) if __name__ == "__main__": diff --git a/example/readme_examples.py b/example/readme_examples.py new file mode 100644 index 0000000..04ed41c --- /dev/null +++ b/example/readme_examples.py @@ -0,0 +1,51 @@ +from random import randint +from time import sleep +import meny + +meny.set_default_frontend("simple") + +@meny.title("FizzBuzz!") +def fizzbuzz(): + for i in range(21): + stringy = '' + + fizz = i % 3 == 0 + buzz = i % 5 == 0 + + if fizz: + stringy = stringy + 'Fizz' + if buzz: + stringy = stringy + 'Buzz' + if not (fizz or buzz): + stringy = i + + print(stringy) + sleep(0.1) + +@meny.title("Get random integer") +def random_integer(): + print(randint(0,9)) + sleep(1) + +def add_ints(a, b): + print(a+b) + sleep(1) + +def append_strings(a, b): + print(a+b) + sleep(1) + +# Type hints won't interfere with meny, and will actually be displayed when using the fancy frontend +@meny.title("Print elements and their types") +def print_types_in_list(a: list): + [print(f'Element {i}: {elem}, type: {type(elem)}') for i, elem in enumerate(a)] + sleep(1) + + +def programmatic_args(a, b, c, d): + print(a, b, c, d) + sleep(1) + +case_args = {programmatic_args: (1, 2)} +case_kwargs = {programmatic_args: {"d": 4, "c": 3}} +meny.menu(locals(), title=' Main menu ', case_args=case_args, case_kwargs=case_kwargs) \ No newline at end of file diff --git a/meny/__init__.py b/meny/__init__.py index 5b730d8..6d3b7e5 100644 --- a/meny/__init__.py +++ b/meny/__init__.py @@ -1,4 +1,4 @@ from .menu import cng as config from .menu import menu, Menu -from .decorators import case, ignore -from .utils import clear_screen, input_splitter, list_local_cases, set_default_frontend +from .decorators import title, ignore +from .utils import clear_screen, input_splitter, set_default_frontend diff --git a/meny/config.py b/meny/config.py index cde5db1..37093b3 100644 --- a/meny/config.py +++ b/meny/config.py @@ -3,5 +3,6 @@ DEFAULT_ON_INTERRUPT = "raise" DEFAULT_ON_BLANK = "return" DEFAULT_FRONTEND = "auto" -_CASE_TITLE = "__case_title__" -_CASE_IGNORE = "__case_ignore__" +_CASE_TITLE = "__meny_title__" +_CASE_IGNORE = "__meny_ignore__" +_DICT_KEY = "__meny_key_from_input_dict__" diff --git a/meny/decorators.py b/meny/decorators.py index 8ce2fa3..61e2f10 100644 --- a/meny/decorators.py +++ b/meny/decorators.py @@ -3,7 +3,7 @@ from meny.config import _CASE_IGNORE, _CASE_TITLE -def case(title: str): +def title(title: str): """ Sets case title """ @@ -28,11 +28,11 @@ def ignore(func: FunctionType): if __name__ == "__main__": - @case("Catdog") + @title("Catdog") def testFunc1() -> str: pass - @case("Catdog1") + @title("Catdog1") def testFunc2() -> str: pass diff --git a/meny/funcmap.py b/meny/funcmap.py index 3a72486..8dce8c2 100644 --- a/meny/funcmap.py +++ b/meny/funcmap.py @@ -9,7 +9,7 @@ from types import FunctionType from typing import Callable, Dict, Iterable, Optional, Tuple -from meny.config import _CASE_TITLE +from meny.config import _CASE_TITLE, _DICT_KEY def _get_case_name(func: FunctionType) -> str: @@ -18,13 +18,12 @@ def _get_case_name(func: FunctionType) -> str: __wrapped__ attribute (which will be handled by using functools.wraps). Then returns case title if set, else just function name """ - # Unwrap in case the function is wrapped - func = unwrap(func) - - if _CASE_TITLE in func.__dict__: - return func.__dict__[_CASE_TITLE] - else: - return func.__name__ + funcvars = vars(unwrap(func)) + return ( + funcvars.get(_CASE_TITLE, False) + or funcvars.get(_DICT_KEY, False) + or func.__name__ + ) def construct_funcmap( @@ -61,4 +60,6 @@ def construct_funcmap( if __name__ == "__main__": - pass + import subprocess + + subprocess.call(["python3", "example/cases.py"]) diff --git a/meny/menu.py b/meny/menu.py index 8c67c13..b81bf0b 100644 --- a/meny/menu.py +++ b/meny/menu.py @@ -6,7 +6,7 @@ from inspect import getfullargspec, getmodule, isfunction, signature, unwrap from time import sleep from types import FunctionType, ModuleType -from typing import Dict, Iterable, List, Optional, Union +from typing import Any, Dict, Iterable, List, Optional, Union from meny import strings as strings from meny import config as cng @@ -15,9 +15,10 @@ RE_ANSI, clear_screen, input_splitter, - list_local_cases, + _extract_and_preprocess_functions, print_help, _assert_supported, + _get_default_if_none, ) @@ -125,13 +126,13 @@ class Menu: def __init__( self, cases: Iterable[FunctionType], - title: str = strings.LOGO_TITLE, - on_blank: str = cng.DEFAULT_ON_BLANK, - on_kbinterrupt: str = cng.DEFAULT_ON_INTERRUPT, + title: str, + on_blank: str, + on_kbinterrupt: str, + frontend: str, decorator: Optional[FunctionType] = None, case_args: Optional[Dict[FunctionType, tuple]] = None, case_kwargs: Optional[Dict[FunctionType, dict]] = None, - frontend: Optional[str] = "auto", ): """ Input @@ -140,9 +141,7 @@ def __init__( title: String to print on top - on_blank: What to do when given blank input (defaults to return). Options: - pass (do nothing) - return + on_blank: what to do on empty string (press enter without any input) See docstring of menu function for more info """ @@ -314,7 +313,7 @@ def run(self): Menu._depth -= 1 -def __get_module_cases(module: ModuleType) -> List[FunctionType]: +def _get_module_cases(module: ModuleType) -> List[FunctionType]: """Get all functions defined in module""" inModule = lambda f: isfunction(f) and (getmodule(f) == module) return [func for func in vars(module).values() if inModule(func)] @@ -322,25 +321,25 @@ def __get_module_cases(module: ModuleType) -> List[FunctionType]: def menu( cases: Union[Iterable[FunctionType], Dict[str, FunctionType], ModuleType], - title: str = strings.DEFAULT_TITLE, - on_blank: str = cng.DEFAULT_ON_BLANK, - on_kbinterrupt: str = cng.DEFAULT_ON_INTERRUPT, + title: Optional[str] = None, + on_blank: Optional[str] = None, + on_kbinterrupt: Optional[str] = None, decorator: Optional[FunctionType] = None, run: bool = True, case_args: Optional[Dict[FunctionType, tuple]] = None, case_kwargs: Optional[Dict[FunctionType, dict]] = None, - frontend: str = cng.DEFAULT_FRONTEND, + frontend: Optional[str] = None, ): """¨ Factory function for the CLI class. This function initializes a menu. Parameters ------------ - cases: Can be output of locals() (a dict) from the scope of the cases + cases: a dictionary where keys are functions names and values are functions - Or an iterable functions + Or an iterable of functions - Or a module containing the case functions + Or a module containing functions title: title of menu @@ -373,10 +372,16 @@ def menu( -------- CLI (Command Line Interface) object. Use .run() method to activate menu. """ + + title = _get_default_if_none(title, strings.DEFAULT_TITLE) + on_blank = _get_default_if_none(on_blank, cng.DEFAULT_ON_BLANK) + on_kbinterrupt = _get_default_if_none(on_kbinterrupt, cng.DEFAULT_ON_INTERRUPT) + frontend = _get_default_if_none(frontend, cng.DEFAULT_FRONTEND) + if isinstance(cases, ModuleType): - cases_to_send = __get_module_cases(cases) + cases_to_send = _get_module_cases(cases) elif isinstance(cases, dict): - cases_to_send = list_local_cases(cases) + cases_to_send = _extract_and_preprocess_functions(cases) # If this menu is the first menu initialized, and is given the locally # defined functions, then must filter the functions that are defined # in __main__ @@ -388,10 +393,13 @@ def menu( ] elif isinstance(cases, Iterable): - cases_to_send = cases + # Looks kinda stupid, but it reuses the code, which is nice + cases_to_send = _extract_and_preprocess_functions({case.__name__:case for case in cases}) else: raise TypeError(f"Invalid type for cases, got: {type(cases)}") + cases_to_send: Iterable[FunctionType] + cases_to_send = filter( lambda case: cng._CASE_IGNORE not in vars(case), cases_to_send ) diff --git a/meny/utils.py b/meny/utils.py index 5e72bfe..9515531 100644 --- a/meny/utils.py +++ b/meny/utils.py @@ -6,7 +6,7 @@ from meny import config as cng from inspect import isfunction from types import FunctionType -from typing import Any, Callable, Container, Dict, List +from typing import Any, Container, Dict, List from meny import strings # *Nix uses clear, windows uses cls @@ -16,7 +16,7 @@ r"\x1b\[[;\d]*[A-Za-z]" ) # Taken from tqdm source code, matches escape codes -RE_INPUT = re.compile("\w+|\[.*?\]|\{.*?\}|\(.*?\)|\".*?\"|'.*?'") +RE_INPUT = re.compile("[\w.-]+|\[.*?\]|\{.*?\}|\(.*?\)|\".*?\"|'.*?'") def _assert_supported(arg: Any, paramname: str, supported: Container): @@ -29,15 +29,24 @@ def _assert_supported(arg: Any, paramname: str, supported: Container): AssertionError: Got unsupported argument for parameter "animal". Available options are: ('dog', 'rabbit') """ assert arg in supported, ( - 'Got unsupported argument for parameter "' + f'Got unsupported argument "' + + strings.YELLOW + + str(arg) + + strings.END + + '" for parameter "' + strings.YELLOW + paramname + strings.END - + f'". Available options are: {supported}' + + f'".\nAvailable options are: {supported}' ) +def _get_default_if_none(val: Any, default: Any) -> Any: + return default if val is None else val + + def set_default_frontend(frontend: str): + """Options: (simple, fancy, auto)""" _assert_supported(frontend, "frontend", ("simple", "fancy", "auto")) cng.DEFAULT_FRONTEND = frontend @@ -47,21 +56,30 @@ def clear_screen() -> None: os.system(__CLEAR_COMMAND) -def list_local_cases(locals_: Dict[str, Callable]) -> List[FunctionType]: +def _extract_and_preprocess_functions( + dict_: Dict[str, FunctionType] +) -> List[FunctionType]: """ Parameters ------------- - locals_: return value of locals() + dict_: Dict[str, FunctionType]) + + Extract functions from dictionary. Will also add the dictionary key to the function vars + (i.e. the __dict__ attribute) - Returns a list of functions. Orders are whatever is from locals() or globals(). Python parses - top down, and inserts the functions in a dictionary. CPython's dict implementation in Python 3.6 - iterates through dict items in insertion order. As of Python 3.7 said behavior become a - standard for Python. Thus the functions should come ib definition order. + Python parses top down, and inserts the functions in a dictionary. CPython's dict implementation + in Python 3.6 iterates through dict items in insertion order. As of Python 3.7 said behavior + become a standard for Python. Thus the functions should come in definition order. See: https://softwaremaniacs.org/blog/2020/02/05/dicts-ordered/ """ - return [pairs[1] for pairs in list(locals_.items()) if isfunction(pairs[1])] + funcs = [] + for key, val in dict_.items(): + if isfunction(val): + vars(val)[cng._DICT_KEY] = key + funcs.append(val) + return funcs def input_splitter(argstring: str) -> List[str]: @@ -74,11 +92,11 @@ def input_splitter(argstring: str) -> List[str]: def print_help(*args, **kwargs) -> None: print( """ - To exit or return from console: Enter q + To exit or return from menu interface (even if you are in a nested menu): Enter q To return to parent menu: Enter blank (press enter without giving input) or enter '..'. If you are in main menu, this - will exit the program as well. + will exit the menu as well. Press enter to exit help screen """ @@ -87,4 +105,6 @@ def print_help(*args, **kwargs) -> None: if __name__ == "__main__": - pass + import subprocess + + subprocess.call(["python3", "example/cases.py"]) diff --git a/tests/test.py b/tests/test.py index 4e1f85c..5b843e1 100644 --- a/tests/test.py +++ b/tests/test.py @@ -16,7 +16,7 @@ def quadratic(x: int): def cubic(x: int): return x*x*x - cases = meny.list_local_cases(locals()) + cases = meny._list_local_cases(locals()) self.assertEqual(len(cases), 3) self.assertIs(cases[0], linear) self.assertIs(cases[1], quadratic) @@ -24,12 +24,12 @@ def cubic(x: int): def test_input_splitter(self): """input_splitter parses input correctly""" - inputlist = ["12", "abcd", '("1, 2", 3)', '["1, 2", 3]', + inputlist = ["..", "12", "abcd", '("1, 2", 3)', '["1, 2", 3]', '{"hello": "world", 666:"number of the beast"}', '{"set", "of", "strings"}', - "'apostrophe string'"] + "'apostrophe string'", '"quote string"', "-123"] args = meny.input_splitter(" ".join(inputlist)) - self.assertEqual(inputlist, args) + self.assertSetEqual(set(inputlist), set(args)) def test_RE_ANSI(self): """RE_ANSI manages to match ANSI escape characters""" @@ -38,7 +38,6 @@ def test_RE_ANSI(self): self.assertEqual(output, "Red Green Blue \nTo be erased") def test__assert_supported(self): - """Test _assert_supported""" with self.assertRaises(AssertionError): meny.utils._assert_supported("cat", "animal", ("dog", "rabbit"))