# -*- coding: UTF-8 -*- import gc import os import shlex import sys from asciistuff import get_banner, get_quote from bdb import BdbQuit from datetime import datetime from inspect import isfunction from itertools import chain from prompt_toolkit import print_formatted_text, PromptSession from prompt_toolkit.auto_suggest import AutoSuggestFromHistory from prompt_toolkit.formatted_text import ANSI, FormattedText from prompt_toolkit.history import FileHistory from prompt_toolkit.styles import Style from .command import * from .components import * from .entity import * from .model import * from .module import * from ..utils.docstring import parse_docstring from ..utils.path import Path __all__ = [ "Entity", # subclassable main entities "BaseModel", "Model", "StoreExtension", "Command", "Console", "Module", # console-related classes "Config", "ConsoleExit", "ConsoleDuplicate", "FrameworkConsole", "Option", ] dcount = lambda d, n=0: sum([dcount(v, n) if isinstance(v, dict) else n + 1 \ for v in d.values()]) class MetaConsole(MetaEntity): """ Metaclass of a Console. """ _has_config = True class Console(Entity, metaclass=MetaConsole): """ Base console class. """ # convention: mangled attributes should not be customized when subclassing # Console... _files = FilesManager() _jobs = JobsPool() _recorder = Recorder() _sessions = None #FIXME: SessionsPool _state = {} # state shared between all the consoles _storage = StoragePool(StoreExtension) # ... by opposition to public class attributes that can be tuned appname = "" config = Config() exclude = [] level = ROOT_LEVEL message = PROMPT_FORMAT motd = """ """ parent = None sources = SOURCES style = PROMPT_STYLE def __init__(self, parent=None, **kwargs): super(Console, self).__init__() # determine the relevant parent self.parent = parent if self.parent is not None and self.parent.level == self.level: while parent is not None and parent.level == self.level: parent = parent.parent # go up of one console level # raise an exception in the context of command's .run() execution, # to be propagated to console's .run() execution, setting the # directly higher level console in argument raise ConsoleDuplicate(self, parent) # back-reference the console self.config.console = self # configure the console regarding its parenthood if self.parent is None: if Console.parent is not None: raise Exception("Only one parent console can be used") Console.parent = self Console.parent._start_time = datetime.now() Console.appdispname = Console.appname Console.appname = Console.appname.lower() self.__init(**kwargs) else: self.parent.child = self # reset commands and other bound stuffs self.reset() # setup the session with the custom completer and validator completer, validator = CommandCompleter(), CommandValidator() completer.console = validator.console = self message, style = self.prompt hpath = Path(self.config.option("WORKSPACE").value).joinpath("history") self._session = PromptSession( message, completer=completer, history=FileHistory(hpath), validator=validator, style=Style.from_dict(style), ) CustomLayout(self) def __init(self, **kwargs): """ Initialize the parent console with commands and modules. """ Console._dev_mode = kwargs.pop("dev", False) # setup banners bsrc = self._sources("banners") if bsrc is not None: print_formatted_text("") # display a random banner from the banners folder get_banner_func = kwargs.get('get_banner_func', get_banner) banner_colors = kwargs.get('banner_section_styles', {}) text = get_banner_func(self.appdispname, bsrc, styles=banner_colors) if text: print_formatted_text(ANSI(text)) # display a random quote from quotes.csv (in the banners folder) get_quote_func = kwargs.get('get_quote_func', get_quote) try: text = get_quote_func(os.path.join(bsrc, "quotes.csv")) if text: print_formatted_text(ANSI(text)) except ValueError: pass # setup libraries lsrc = self._sources("libraries") if lsrc is not None: if isinstance(lsrc, str): lsrc = [lsrc] if isinstance(lsrc, (list, tuple, set)): for lib in map(lambda p: os.path.abspath(p), lsrc[::-1]): sys.path.insert(0, lib) # setup entities load_entities( [BaseModel, Command, Console, Model, Module, StoreExtension], *self._sources("entities"), include_base=kwargs.get("include_base", True), select=kwargs.get("select", {'command': Command._functionalities}), exclude=kwargs.get("exclude", {}), backref=kwargs.get("backref", BACK_REFERENCES), docstr_parser=kwargs.get("docstr_parser", parse_docstring), ) Console._storage.models = Model.subclasses + BaseModel.subclasses # display module stats print_formatted_text(FormattedText([("#00ff00", Module.get_summary())])) # setup the prompt message self.message.insert(0, ('class:appname', self.appname)) # display warnings self.reset() if Entity.has_issues(): self.logger.warning("There are some issues ; use 'show issues' to " "see more details") # console's components back-referencing for attr in ["_files", "_jobs"]: setattr(getattr(Console, attr), "console", self) def _close(self): """ Gracefully close the console. """ self.logger.debug("Exiting {}[{}]".format(self.__class__.__name__, id(self))) if hasattr(self, "close") and isfunction(self.close): self.close() # cleanup references for this console self.detach() # important note: do not confuse '_session' (refers to prompt session) # with sessions (sessions manager) if hasattr(self, "_session"): delattr(self._session.completer, "console") delattr(self._session.validator, "console") # remove the singleton instance of the current console c = self.__class__ if hasattr(c, "_instance"): del c._instance if self.parent is not None: del self.parent.child # rebind entities to the parent console self.parent.reset() # remove all finished jobs from the pool self._jobs.free() else: # gracefully close every DB in the pool self._storage.free() # terminate all running jobs self._jobs.terminate() def _get_tokens(self, text, suffix=("", "\"", "'")): """ Recursive token split function also handling ' and " (that is, when 'text' is a partial input with a string not closed by a quote). """ text = text.lstrip() try: tokens = shlex.split(text + suffix[0]) except ValueError: return self._get_tokens(text, suffix[1:]) except IndexError: return [] if len(tokens) > 0: cmd = tokens[0] if len(tokens) > 2 and \ getattr(self.commands.get(cmd), "single_arg", False): tokens = [cmd, " ".join(tokens[1:])] elif len(tokens) > 3: tokens = [cmd, tokens[1], " ".join(tokens[2:])] return tokens def _reset_logname(self): """ Reset logger's name according to console's attributes. """ try: self.logger.name = "{}:{}".format(self.level, self.logname) except AttributeError: self.logger.name = self.__class__.name def _run_if_defined(self, func): """ Run the given function if it is defined at the module level. """ if hasattr(self, "module") and hasattr(self.module, func) and \ not (getattr(self.module._instance, func)() is None): self.logger.debug("{} failed".format(func)) return False return True def _sources(self, items): """ Return the list of sources for the related items [banners|entities|libraries], first trying subclass' one then Console class' one. """ try: return self.sources[items] except KeyError: return Console.sources[items] def attach(self, eccls, directref=False, backref=True): """ Attach an entity child to the calling entity's instance. """ # handle direct reference from self to eccls if directref: # attach new class setattr(self, eccls.entity, eccls) # handle back reference from eccls to self if backref: setattr(eccls, "console", self) # create a singleton instance of the entity eccls._instance = getattr(eccls, "_instance", None) or eccls() def detach(self, eccls=None): """ Detach an entity child class from the console and remove its back-reference. """ # if no argument, detach every class registered in self._attached if eccls is None: for subcls in Entity._subclasses: self.detach(subcls) elif eccls in ["command", "module"]: for ec in [Command, Module][eccls == "module"].subclasses: if ec.entity == eccls: self.detach(ec) else: if hasattr(eccls, "entity") and hasattr(self, eccls.entity): delattr(self, eccls.entity) # remove the singleton instance of the entity previously opened if hasattr(eccls, "_instance"): del eccls._instance def execute(self, cmd, abort=False): """ Alias for run. """ return self.run(cmd, abort) def reset(self): """ Setup commands for the current level, reset bindings between commands and the current console then update store's object. """ self.detach("command") # setup level's commands, starting from general-purpose commands self.commands = {} # add commands for n, c in chain(Command.commands.get("general", {}).items(), Command.commands.get(self.level, {}).items()): self.attach(c) if self.level not in getattr(c, "except_levels", []) and c.check(): self.commands[n] = c else: self.detach(c) root = self.config.option('WORKSPACE').value # get the relevant store and bind it to loaded models p = Path(root).joinpath("store.db") Console.store = Console._storage.get(p) # update command recorder's root directory self._recorder.root_dir = root def run(self, cmd, abort=False): """ Run a framework console command. """ # assign tokens (or abort if tokens' split gives []) tokens = self._get_tokens(cmd) try: name, args = tokens[0], tokens[1:] except IndexError: return True # get the command singleton instance (or abort if name not in # self.commands) ; if command arguments should not be split, adapt args try: obj = self.commands[name]._instance except KeyError: return True # now handle the command (and its validation if existing) try: if hasattr(obj, "validate"): obj.validate(*args) if name != "run" or self._run_if_defined("prerun"): obj.run(*args) if name == "run": self._run_if_defined("postrun") return True except BdbQuit: # when using pdb.set_trace() return True except ConsoleDuplicate as e: # pass the higher console instance attached to the exception raised # from within a command's .run() execution to console's .start(), # keeping the current command to be reexecuted raise ConsoleDuplicate(e.current, e.higher, cmd if e.cmd is None else e.cmd) except ConsoleExit: return False except ValueError as e: if str(e).startswith("invalid width ") and \ str(e).endswith(" (must be > 0)"): self.logger.warning("Cannot display ; terminal width too low") else: (self.logger.exception if self.config.option('DEBUG').value \ else self.logger.failure)(e) return abort is False except Exception as e: self.logger.exception(e) return abort is False finally: gc.collect() def start(self): """ Start looping with console's session prompt. """ reexec = None self._reset_logname() self.logger.debug("Starting {}[{}]".format(self.__class__.__name__, id(self))) # execute attached module's pre-load function if relevant self._run_if_defined("preload") # now start the console loop while True: self._reset_logname() try: _ = reexec if reexec is not None else \ self._session.prompt( auto_suggest=AutoSuggestFromHistory(), #bottom_toolbar="This is\na multiline toolbar", # important note: this disables terminal scrolling #mouse_support=True, ) reexec = None Console._recorder.save(_) if not self.run(_): break # console run aborted except ConsoleDuplicate as e: # stop raising duplicate when reaching a console with a # different level, then reset associated commands not to rerun # the erroneous one from the context of the just-exited console if self == e.higher: reexec = e.cmd self.reset() continue self._close() # reraise up to the higher (level) console raise e except EOFError: Console._recorder.save("exit") break except (KeyboardInterrupt, ValueError): continue # execute attached module's post-load function if relevant self._run_if_defined("postload") # gracefully close and chain this console instance self._close() return self @property def logger(self): try: return Console.logger except: return null_logger @property def modules(self): return Module.modules @property def prompt(self): if self.parent is None: return self.message, self.style # setup the prompt message by adding child's message tokens at the # end of parent's one (parent's last token is then re-appended) pmessage, pstyle = self.parent.prompt message = pmessage.copy() # copy parent message tokens t = message.pop() message.extend(self.message) message.append(t) # setup the style, using this of the parent style = pstyle.copy() # copy parent style dict style.update(self.style) return message, style @property def root(self): return Console.parent @property def state(self): """ Getter for the shared state. """ return Console._state @property def uptime(self): """ Get application's uptime. """ t = datetime.now() - Console.parent._start_time s = t.total_seconds() h, _ = divmod(s, 3600) m, s = divmod(_, 60) return "{:02}:{:02}:{:02}".format(int(h), int(m), int(s)) class ConsoleDuplicate(Exception): """ Dedicated exception class for exiting a duplicate (sub)console. """ def __init__(self, current, higher, cmd=None): self.cmd, self.current, self.higher = cmd, current, higher super(ConsoleDuplicate, self).__init__("Another console of the same " "level is already running") class ConsoleExit(Exception): """ Dedicated exception class for exiting a (sub)console. """ pass class FrameworkConsole(Console): """ Framework console subclass for defining specific config options. """ _entity_class = Console aliases = [] config = Config({ Option( 'APP_FOLDER', "folder where application assets (i.e. logs) are saved", True, set_callback=lambda o: o.root._set_app_folder(), ): "~/.{appname}", ROption( 'DEBUG', "debug mode", False, bool, set_callback=lambda o: o.root._set_logging(o.value), ): "false", Option( 'ENCRYPT_PROJECT', "ask for a password to encrypt a project when archiving", True, bool, ): "true", Option( 'WORKSPACE', "folder where results are saved", True, set_callback=lambda o: o.root._set_workspace(), ): "~/Notes", }) def __init__(self, appname=None, *args, **kwargs): Console.appname = appname or \ getattr(self, "appname", Console.appname) o, v = self.config.option('APP_FOLDER'), str(self.config['APP_FOLDER']) self.config[o] = Path(v.format(appname=self.appname.lower())) o.old_value = None self._set_app_folder() self._set_workspace() super(FrameworkConsole, self).__init__(*args, **kwargs) def __set_folder(self, option, subpath=""): """ Set a new folder, moving an old to the new one if necessary. """ o = self.config.option(option) old, new = o.old_value, o.value if old == new: return try: if old is not None: os.rename(old, new) except Exception as e: pass Path(new).joinpath(subpath).mkdir(parents=True, exist_ok=True) return new def _set_app_folder(self): """ Set a new APP_FOLDER, moving an old to the new one if necessary. """ self._files.root_dir = self.__set_folder("APP_FOLDER", "files") self._set_logging() def _set_logging(self, debug=False, to_file=True): """ Set a new logger with the input logging level. """ l, p = ["INFO", "DEBUG"][debug], None if to_file: # attach a logger to the console lpath = self.app_folder.joinpath("logs") lpath.mkdir(parents=True, exist_ok=True) p = str(lpath.joinpath("main.log")) Console.logger = get_logger(self.__class__.name, p, l) def _set_workspace(self): """ Set a new APP_FOLDER, moving an old to the new one if necessary. """ self.__set_folder("WORKSPACE") @property def app_folder(self): """ Shortcut to the current application folder. """ return Path(self.config.option('APP_FOLDER').value) @property def workspace(self): """ Shortcut to the current workspace. """ return Path(self.config.option("WORKSPACE").value)