Skip to content

Commit

Permalink
updated docstrings
Browse files Browse the repository at this point in the history
  • Loading branch information
Napam committed Oct 17, 2021
1 parent b5b8d7d commit a679a58
Show file tree
Hide file tree
Showing 8 changed files with 121 additions and 78 deletions.
5 changes: 2 additions & 3 deletions example/cases.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,10 @@ def subcase2():
from time import sleep

import meny
from meny import menu

import cases_nested

meny.config.default_frontend = "fancy" # Set default frontend here
meny.set_default_frontend("fancy")

import time
@meny.ignore
Expand Down Expand Up @@ -164,4 +163,4 @@ def simple_func(a=1, b: str="2", c=3.0):
if __name__ == "__main__":
case_args = {programmatic: (1, 2)}
case_kwargs = {programmatic: {"d": 4, "c": 3}}
menu(locals(), case_args=case_args, case_kwargs=case_kwargs)
meny.menu(locals(), case_args=case_args, case_kwargs=case_kwargs)
2 changes: 1 addition & 1 deletion meny/__init__.py
Original file line number Diff line number Diff line change
@@ -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
from .utils import clear_screen, input_splitter, list_local_cases, set_default_frontend
4 changes: 3 additions & 1 deletion meny/config.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# Wait time for invalid input message
MSG_WAIT_TIME = 0.3
default_frontend = "auto"
DEFAULT_ON_INTERRUPT = "raise"
DEFAULT_ON_BLANK = "return"
DEFAULT_FRONTEND = "auto"
_CASE_TITLE = "__case_title__"
_CASE_IGNORE = "__case_ignore__"
6 changes: 3 additions & 3 deletions meny/curses_interface.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import curses
import curses.ascii
from meny.utils import input_splitter
from typing import Any, Callable, Dict, Optional, Tuple, Type, Union
import meny
import inspect
from functools import wraps
from typing import Optional, Union

import meny


def recover_cursor(f):
Expand Down
2 changes: 1 addition & 1 deletion meny/funcmap.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@

def _get_case_name(func: FunctionType) -> str:
"""
First unwraps function (if wrapped in decorators). Assumes that wrapped functions has the
First unwraps function (if wrapped in decorators). Assumes that wrapped functions has the
__wrapped__ attribute (which will be handled by using functools.wraps). Then returns case
title if set, else just function name
"""
Expand Down
133 changes: 69 additions & 64 deletions meny/menu.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,22 @@
menu()
"""
from ast import literal_eval
from inspect import (getfullargspec, getmodule, isfunction,
signature, unwrap)
from inspect import getfullargspec, getmodule, isfunction, signature, unwrap
from time import sleep
from types import FunctionType, ModuleType
from typing import Callable, Dict, Iterable, List, Optional, Union
from typing import Dict, Iterable, List, Optional, Union

import meny.config as cng
import meny.strings as strings
from meny.config import _CASE_IGNORE
from meny import strings as strings
from meny import config as cng
from meny.funcmap import _get_case_name, construct_funcmap
from meny.utils import (RE_ANSI, clear_screen, input_splitter,
list_local_cases, print_help)
from meny.utils import (
RE_ANSI,
clear_screen,
input_splitter,
list_local_cases,
print_help,
_assert_supported,
)


def raise_interrupt(*args, **kwargs) -> None:
Expand All @@ -37,15 +41,14 @@ class MenuQuit(Exception):
"""


def _handle_args(func: Callable, args: Iterable) -> List:
def _handle_args(func: FunctionType, args: Iterable[str]) -> List:
"""
Handles list of strings that are the arguments using ast.literal_eval.
Handles list of strings that are the arguments using ast.literal_eval.
E.g. return is [1, "cat", 2.0, False]
int str float bool
"""
# Unwrap in case the function is wrapped
# TODO: Update this to use inspect.signature
func = unwrap(func)
argsspec = getfullargspec(func)
params = argsspec.args
Expand All @@ -60,12 +63,14 @@ def _handle_args(func: Callable, args: Iterable) -> List:
for i, arg in enumerate(args):
typed_arglist[i] = literal_eval(arg)
except (ValueError, SyntaxError) as e:
raise MenuError(f"Got arguments: {args}\n"
f"But could not evaluate argument at position {i}:\n\t {arg}") from e
raise MenuError(
f"Got arguments: {args}\n"
f"But could not evaluate argument at position {i}:\n\t {arg}"
) from e
return typed_arglist


def _error_info_case(error: Exception, func: Callable) -> None:
def _error_info_case(error: Exception, func: FunctionType) -> None:
"""
Used to handle error for cases
Expand All @@ -79,7 +84,9 @@ def _error_info_case(error: Exception, func: Callable) -> None:
f"Function signature: {signature(func)}"
)
lenerror = max(map(len, str(error).split("\n")))
lenerror = max(lenerror, max(map(len, RE_ANSI.sub("", selected_case_str).split("\n"))))
lenerror = max(
lenerror, max(map(len, RE_ANSI.sub("", selected_case_str).split("\n")))
)
print(strings.BOLD + strings.RED + f"{' ERROR ':#^{lenerror}}" + strings.END)
print(selected_case_str)
print(f'{f" Error message ":=^{lenerror}}')
Expand All @@ -92,7 +99,12 @@ def _error_info_case(error: Exception, func: Callable) -> None:

def _error_info_parse(error: Exception):
lenerror = max(map(len, str(error).split("\n")))
print(strings.BOLD + strings.RED + f"{' ARGUMENT PARSE ERROR ':#^{lenerror}}" + strings.END)
print(
strings.BOLD
+ strings.RED
+ f"{' ARGUMENT PARSE ERROR ':#^{lenerror}}"
+ strings.END
)
print(f'{f" Error message ":=^{lenerror}}')
print(error)
print(f'{f"":=^{lenerror}}')
Expand All @@ -114,39 +126,30 @@ def __init__(
self,
cases: Iterable[FunctionType],
title: str = strings.LOGO_TITLE,
on_blank: Union[str, Callable] = "return",
on_kbinterrupt: str = "raise",
decorator: Optional[Callable] = None,
case_args: Optional[Dict[Callable, tuple]] = None,
case_kwargs: Optional[Dict[Callable, dict]] = None,
on_blank: str = cng.DEFAULT_ON_BLANK,
on_kbinterrupt: str = cng.DEFAULT_ON_INTERRUPT,
decorator: Optional[FunctionType] = None,
case_args: Optional[Dict[FunctionType, tuple]] = None,
case_kwargs: Optional[Dict[FunctionType, dict]] = None,
frontend: Optional[str] = "auto",
):
"""
Input
-----
cases:
if given a module: module containing functions that serves as cases a
user can pick from terminal interface. the module should not implement
any other functions.
cases: Iterable of case functions
if given a list: will simply use function in list as cases.
title: String to print on top
First line of docstring becomes case description
ALL CASES MUST CONTAIN DOCSTRINGS
on_blank: What to do when given blank input (defaults to return). Options:
pass (do nothing)
return
title: String to print over alternatives
on_blank: What to do when given blank input (defaults to
stopping current view (without exiting)). See
docstring for menu() for more info.
See menu function for more info
See docstring of menu function for more info
"""
assert on_kbinterrupt in (
"raise",
"return",
), "Invalid choice for on_kbinterrupt"
_assert_supported(on_kbinterrupt, "on_kbinterrupt", ("raise", "return"))
_assert_supported(on_blank, "on_blank", ("return", "pass"))
_assert_supported(frontend, "frontend", ("simple", "fancy", "auto"))

self.funcmap = construct_funcmap(cases, decorator=decorator)
self.title = title
self.on_kbinterrupt = on_kbinterrupt
Expand Down Expand Up @@ -199,10 +202,10 @@ def _quit(self):
def _pass(self):
pass

def _handle_case(self, casefunc: Callable, args: List[str]):
programmatic_args = self.case_args.get(casefunc, ())
programmatic_kwargs = self.case_kwargs.get(casefunc, {})
def _handle_case(self, casefunc: FunctionType, args: List[str]):
programmatic_args = self.case_args.get(casefunc, ())
programmatic_kwargs = self.case_kwargs.get(casefunc, {})

try:
if programmatic_args or programmatic_kwargs: # If programmatic arguments
if args:
Expand Down Expand Up @@ -311,26 +314,24 @@ def run(self):
Menu._depth -= 1


def __get_module_cases(module: ModuleType) -> List[Callable]:
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)]


def menu(
cases: Union[Callable, Iterable[Callable], Dict[str, Callable], ModuleType],
cases: Union[Iterable[FunctionType], Dict[str, FunctionType], ModuleType],
title: str = strings.DEFAULT_TITLE,
on_blank: str = "return",
on_kbinterrupt: str = "raise",
decorator: Optional[Callable] = None,
on_blank: str = cng.DEFAULT_ON_BLANK,
on_kbinterrupt: str = cng.DEFAULT_ON_INTERRUPT,
decorator: Optional[FunctionType] = None,
run: bool = True,
case_args: Optional[Dict[Callable, tuple]] = None,
case_kwargs: Optional[Dict[Callable, dict]] = None,
frontend: Optional[str] = None,
case_args: Optional[Dict[FunctionType, tuple]] = None,
case_kwargs: Optional[Dict[FunctionType, dict]] = None,
frontend: str = cng.DEFAULT_FRONTEND,
):
"""¨
TODO: Update docstring for newapi branch
Factory function for the CLI class. This function initializes a menu.
Parameters
Expand All @@ -349,22 +350,21 @@ def 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 exits, and
returns.
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.
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
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)
Expand Down Expand Up @@ -392,10 +392,9 @@ def menu(
else:
raise TypeError(f"Invalid type for cases, got: {type(cases)}")

cases_to_send = filter(lambda case: _CASE_IGNORE not in vars(case), cases_to_send)

if frontend is None:
frontend = cng.default_frontend
cases_to_send = filter(
lambda case: cng._CASE_IGNORE not in vars(case), cases_to_send
)

cli = Menu(
cases=cases_to_send,
Expand All @@ -411,3 +410,9 @@ def menu(
cli.run()

return cli


if __name__ == "__main__":
import subprocess

subprocess.call(["python3", "example/cases.py"])
35 changes: 30 additions & 5 deletions meny/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@
"""
import os
import re
from meny import config as cng
from inspect import isfunction
from shlex import shlex
from types import FunctionType
from typing import Callable, Dict, List, Union
from typing import Any, Callable, Container, Dict, List
from meny import strings

# *Nix uses clear, windows uses cls
__CLEAR_COMMAND = "cls" if os.name == "nt" else "clear"
Expand All @@ -15,14 +16,38 @@
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):
"""
Assert if argument is supported by checking if 'arg' is in 'supported'
Example
--------
>>> _assert_supported("cat", "animal", ("dog", "rabbit"))
AssertionError: Got unsupported argument for parameter "animal". Available options are: ('dog', 'rabbit')
"""
assert arg in supported, (
'Got unsupported argument for parameter "'
+ strings.YELLOW
+ paramname
+ strings.END
+ f'". Available options are: {supported}'
)


def set_default_frontend(frontend: str):
_assert_supported(frontend, "frontend", ("simple", "fancy", "auto"))
cng.DEFAULT_FRONTEND = frontend


def clear_screen() -> None:
"""Obvious"""
os.system(__CLEAR_COMMAND)


def list_local_cases(locals_: Dict[str, Callable]) -> List[Callable]:
def list_local_cases(locals_: Dict[str, Callable]) -> List[FunctionType]:
"""
Parameters
-------------
Expand All @@ -31,7 +56,7 @@ def list_local_cases(locals_: Dict[str, Callable]) -> List[Callable]:
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.
standard for Python. Thus the functions should come ib definition order.
See:
https://softwaremaniacs.org/blog/2020/02/05/dicts-ordered/
Expand Down
12 changes: 12 additions & 0 deletions tests/test.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,5 +37,17 @@ def test_RE_ANSI(self):
output = meny.utils.RE_ANSI.sub("", string)
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"))

gotException = False
try:
meny.utils._assert_supported("dog", "animal", ("dog", "rabbit")) # Shuold be no error
except AssertionError:
gotException = True
self.assertFalse(gotException)

if __name__ == '__main__':
unittest.main(verbosity=2)

0 comments on commit a679a58

Please sign in to comment.