# coding=utf-8 """ History management classes """ import re from typing import List, Union import attr from . import utils from .parsing import Statement @attr.s(frozen=True) class HistoryItem(): """Class used to represent one command in the History list""" _listformat = ' {:>4} {}' _ex_listformat = ' {:>4}x {}' statement = attr.ib(default=None, validator=attr.validators.instance_of(Statement)) idx = attr.ib(default=None, validator=attr.validators.instance_of(int)) def __str__(self): """A convenient human readable representation of the history item""" return self.statement.raw @property def raw(self) -> str: """Return the raw input from the user for this item""" return self.statement.raw @property def expanded(self) -> str: """Return the command as run which includes shortcuts and aliases resolved plus any changes made in hooks""" return self.statement.expanded_command_line def pr(self, script=False, expanded=False, verbose=False) -> str: """Represent a HistoryItem in a pretty fashion suitable for printing. If you pass verbose=True, script and expanded will be ignored :return: pretty print string version of a HistoryItem """ if verbose: raw = self.raw.rstrip() expanded = self.expanded ret_str = self._listformat.format(self.idx, raw) if raw != expanded: ret_str += '\n' + self._ex_listformat.format(self.idx, expanded) else: if expanded: ret_str = self.expanded else: ret_str = self.raw.rstrip() # In non-verbose mode, display raw multiline commands on 1 line if self.statement.multiline_command: # This is an approximation and not meant to be a perfect piecing together of lines. # All newlines will be converted to spaces, including the ones in quoted strings that # are considered literals. Also if the final line starts with a terminator, then the # terminator will have an extra space before it in the 1 line version. ret_str = ret_str.replace('\n', ' ') # Display a numbered list if not writing to a script if not script: ret_str = self._listformat.format(self.idx, ret_str) return ret_str class History(list): """A list of HistoryItems that knows how to respond to user requests. Here are some key methods: select() - parse user input and return a list of relevant history items str_search() - return a list of history items which contain the given string regex_search() - return a list of history items which match a given regex get() - return a single element of the list, using 1 based indexing span() - given a 1-based slice, return the appropriate list of history items """ def __init__(self, seq=()) -> None: super().__init__(seq) self.session_start_index = 0 def start_session(self) -> None: """Start a new session, thereby setting the next index as the first index in the new session.""" self.session_start_index = len(self) # noinspection PyMethodMayBeStatic def _zero_based_index(self, onebased: Union[int, str]) -> int: """Convert a one-based index to a zero-based index.""" result = int(onebased) if result > 0: result -= 1 return result def append(self, new: Statement) -> None: """Append a HistoryItem to end of the History list. :param new: command line to convert to HistoryItem and add to the end of the History list """ history_item = HistoryItem(new, len(self) + 1) super().append(history_item) def clear(self) -> None: """Remove all items from the History list.""" super().clear() self.start_session() def get(self, index: Union[int, str]) -> HistoryItem: """Get item from the History list using 1-based indexing. :param index: optional item to get (index as either integer or string) :return: a single HistoryItem """ index = int(index) if index == 0: raise IndexError('The first command in history is command 1.') elif index < 0: return self[index] else: return self[index - 1] # This regular expression parses input for the span() method. There are five parts: # # ^\s* matches any whitespace at the beginning of the # input. This is here so you don't have to trim the input # # (?P-?[1-9]{1}\d*)? create a capture group named 'start' which matches an # optional minus sign, followed by exactly one non-zero # digit, and as many other digits as you want. This group # is optional so that we can match an input string like '..2'. # This regex will match 1, -1, 10, -10, but not 0 or -0. # # (?P:|(\.{2,}))? create a capture group named 'separator' which matches either # a colon or two periods. This group is optional so we can # match a string like '3' # # (?P-?[1-9]{1}\d*)? create a capture group named 'end' which matches an # optional minus sign, followed by exactly one non-zero # digit, and as many other digits as you want. This group is # optional so that we can match an input string like ':' # or '5:'. This regex will match 1, -1, 10, -10, but not # 0 or -0. # # \s*$ match any whitespace at the end of the input. This is here so # you don't have to trim the input # spanpattern = re.compile(r'^\s*(?P-?[1-9]\d*)?(?P:|(\.{2,}))?(?P-?[1-9]\d*)?\s*$') def span(self, span: str, include_persisted: bool = False) -> List[HistoryItem]: """Return an index or slice of the History list, :param span: string containing an index or a slice :param include_persisted: if True, then retrieve full results including from persisted history :return: a list of HistoryItems This method can accommodate input in any of these forms: a -a a..b or a:b a.. or a: ..a or :a -a.. or -a: ..-a or :-a Different from native python indexing and slicing of arrays, this method uses 1-based array numbering. Users who are not programmers can't grok 0 based numbering. Programmers can usually grok either. Which reminds me, there are only two hard problems in programming: - naming - cache invalidation - off by one errors """ if span.lower() in ('*', '-', 'all'): span = ':' results = self.spanpattern.search(span) if not results: # our regex doesn't match the input, bail out raise ValueError('History indices must be positive or negative integers, and may not be zero.') sep = results.group('separator') start = results.group('start') if start: start = self._zero_based_index(start) end = results.group('end') if end: end = int(end) # modify end so it's inclusive of the last element if end == -1: # -1 as the end means include the last command in the array, which in pythonic # terms means to not provide an ending index. If you put -1 as the ending index # python excludes the last item in the list. end = None elif end < -1: # if the ending is smaller than -1, make it one larger so it includes # the element (python native indices exclude the last referenced element) end += 1 if start is not None and end is not None: # we have both start and end, return a slice of history result = self[start:end] elif start is not None and sep is not None: # take a slice of the array result = self[start:] elif end is not None and sep is not None: if include_persisted: result = self[:end] else: result = self[self.session_start_index:end] elif start is not None: # there was no separator so it's either a positive or negative integer result = [self[start]] else: # we just have a separator, return the whole list if include_persisted: result = self[:] else: result = self[self.session_start_index:] return result def str_search(self, search: str, include_persisted: bool = False) -> List[HistoryItem]: """Find history items which contain a given string :param search: the string to search for :param include_persisted: if True, then search full history including persisted history :return: a list of history items, or an empty list if the string was not found """ def isin(history_item): """filter function for string search of history""" sloppy = utils.norm_fold(search) inraw = sloppy in utils.norm_fold(history_item.raw) inexpanded = sloppy in utils.norm_fold(history_item.expanded) return inraw or inexpanded search_list = self if include_persisted else self[self.session_start_index:] return [item for item in search_list if isin(item)] def regex_search(self, regex: str, include_persisted: bool = False) -> List[HistoryItem]: """Find history items which match a given regular expression :param regex: the regular expression to search for. :param include_persisted: if True, then search full history including persisted history :return: a list of history items, or an empty list if the string was not found """ regex = regex.strip() if regex.startswith(r'/') and regex.endswith(r'/'): regex = regex[1:-1] finder = re.compile(regex, re.DOTALL | re.MULTILINE) def isin(hi): """filter function for doing a regular expression search of history""" return finder.search(hi.raw) or finder.search(hi.expanded) search_list = self if include_persisted else self[self.session_start_index:] return [itm for itm in search_list if isin(itm)] def truncate(self, max_length: int) -> None: """Truncate the length of the history, dropping the oldest items if necessary :param max_length: the maximum length of the history, if negative, all history items will be deleted :return: nothing """ if max_length <= 0: # remove all history del self[:] elif len(self) > max_length: last_element = len(self) - max_length del self[0:last_element]