Last active
January 5, 2023 05:32
-
-
Save securisec/9f472485bb52c63f2d041300926459bb to your computer and use it in GitHub Desktop.
How to build a fully dynamic, self documention fuzzy cli for any class in python
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env python3 | |
import inspect | |
import argparse | |
import fire | |
from docstring_parser import parse | |
from prompt_toolkit.completion import Completer, Completion, FuzzyCompleter | |
from prompt_toolkit import PromptSession | |
from search.lsgrep import SearchFiles | |
# writeup at https://medium.com/@securisec/building-a-dynamic-and-self-documenting-python-cli-af4dd12eb91 | |
# twitter: @securisec | |
def patch_fire(possbile_options): | |
for method in possbile_options: | |
if not method.startswith("_") and not isinstance( | |
getattr(SearchFiles, method), property | |
): | |
fire.decorators._SetMetadata( | |
getattr(SearchFiles, method), | |
fire.decorators.ACCEPTS_POSITIONAL_ARGS, | |
False, | |
) | |
possbile_options = dir(SearchFiles) | |
options = [] | |
def get_options(): | |
global possbile_options | |
options = dict() | |
for method in possbile_options: | |
available_methods = getattr(SearchFiles, method) | |
if not method.startswith("_"): | |
args = inspect.getfullargspec(available_methods).args | |
parsed_doc = parse(available_methods.__doc__) | |
options[method] = { | |
"options": list( | |
map( | |
lambda d: { | |
"flag": d[1], | |
"meta": parsed_doc.params[d[0]].description, | |
}, | |
enumerate(args[1:]), | |
) | |
), | |
"meta": parsed_doc.short_description, | |
"returns": parsed_doc.returns.type_name, | |
} | |
return options | |
class CustomCompleter(Completer): | |
def get_completions(self, document, complete_event): | |
global options | |
method_dict = get_options() | |
word = document.get_word_before_cursor() | |
methods = list(method_dict.items()) | |
selected = document.text.split() | |
if len(selected) > 0: | |
selected = selected[-1] | |
if not selected.startswith("--"): | |
current = method_dict.get(selected) | |
if current is not None: | |
has_options = method_dict.get(selected)["options"] | |
if has_options is not None: | |
options = [ | |
("--{}".format(o["flag"]), {"meta": o["meta"]}) | |
for o in has_options | |
] | |
methods = options + methods | |
else: | |
methods = options | |
for m in methods: | |
method_name, flag = m | |
if method_name.startswith(word): | |
meta = ( | |
flag["meta"] if isinstance(flag, dict) and flag.get("meta") else "" | |
) | |
yield Completion( | |
method_name, start_position=-len(word), display_meta=meta, | |
) | |
def main(): | |
global possbile_options | |
parse = argparse.ArgumentParser() | |
parse.add_argument("path", nargs=1) | |
args = parse.parse_args() | |
base_command = '--path "{path}"'.format(path="".join(args.path)) | |
session = PromptSession() | |
try: | |
while True: | |
prompt = session.prompt( | |
"\n<x> ", completer=FuzzyCompleter(CustomCompleter()), | |
) | |
base_command += " " + prompt | |
patch_fire(possbile_options) | |
fire_obj = fire.Fire(SearchFiles, command=base_command) | |
except KeyboardInterrupt: | |
print("\n\nBye!!\n\n") | |
exit() | |
if __name__ == "__main__": | |
main() |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import re | |
from pathlib import Path | |
class SearchFiles(object): | |
def __init__(self, path="."): | |
self.path = Path(path).absolute() | |
self.out = "" | |
def __str__(self): | |
return self.out | |
def ls(self, owner: bool = False, size: bool = False): | |
"""List files in the specified directory | |
Args: | |
owner (bool, optional): Show owner of file. Defaults to False. | |
size (bool, optional): Show size of file. Defaults to False. | |
Returns: | |
SearchFiles: SearchFiles object | |
""" | |
found = [] | |
files = self.path.glob("**/*") | |
for file in files: | |
details = [] | |
if owner: | |
details.append(str(file.owner())) | |
if size: | |
details.append(str(file.stat().st_size)) | |
details.append(str(file)) | |
found.append(" ".join(details)) | |
self.out = "\n".join(found) | |
return self | |
def grep(self, pattern: str): | |
"""Search for match in list of files | |
Args: | |
pattern (str): Pattern to search for | |
Returns: | |
SearchFiles: SearchFiles object | |
""" | |
pattern = ".*" + pattern + ".*" | |
self.out = "\n".join(re.findall(pattern, self.out)) | |
return self |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment