Source code for esc.stack

"""
stack.py - the stack maintains all the numbers the calculator is handling

The stack is the heart of the RPN calculator; it's what all the functions
operate on.
"""

from contextlib import contextmanager
import copy
import decimal
from decimal import Decimal

from .consts import STACKDEPTH, STACKWIDTH
from . import history
from .oops import RollbackTransaction
from .status import status


[docs]class StackItem: """ An item placed on esc's stack. At its root, this is a number, but it gets more complicated than that! For one, we need a numeric value for calculations as well as a string value to display on the screen. The method :meth:`finish_entry` updates the numeric representation from the string representation. We could dynamically compute the string representation with reasonable performance, but see the next paragraph for why this isn't helpful. For another, a stack item may be *incomplete* (:attr:`is_entered` attribute = ``False``). That's because the user doesn't enter a number all at once -- it will typically consist of multiple keystrokes. If so, there won't be a decimal representation until we call :meth:`finish_entry`. The StackState is in charge of calling this method if needed before trying to do any calculations with the number. """ def __init__(self, firstchar=None, decval=None): """ We can create an item on the stack either by the user entering it (in which case we have methods for gradually building up a string repr of the value and then converting it to the Decimal value that is used for calculations) or by directly entering a Decimal value (for instance, as the result of a calculation). Use one of the two parameters: :param firstchar: If specified, initialize the string representation to this string and prepare for more characters to be entered with :meth:`add_character`. :param decval: If specified, make this Decimal the value of the StackItem and create a string representation from it. """ #: Whether the number has been fully entered. If not entered, #: many methods will not work as we don't have a Decimal representation yet. self.is_entered = None #: Decimal representation. #: This is ``None`` if :attr:`is_entered` is ``False``. self.decimal = None #: String representation. self.string = None if firstchar is not None: self._init_partial(firstchar) elif decval is not None: self._init_full(decval) else: raise AssertionError("No valid argument to constructor of StackItem!") def __repr__(self): return f"<StackItem: Decimal({self.decimal}) String({self.string})>" def __str__(self): return self.string def __eq__(self, other): if isinstance(other, self.__class__): return self.__dict__ == other.__dict__ return NotImplemented @staticmethod def _remove_exponent(d): """ Remove exponent and trailing zeros. Modified from the version in the decimal docs: if there are not enough digits of precision to express the coefficient without scientific notation, don't do anything. >>> StackItem._remove_exponent(decimal.Decimal('5E+3')) Decimal('5000') """ retval = d.normalize() if d == d.to_integral(): try: retval = d.quantize(decimal.Decimal(1)) except decimal.InvalidOperation: pass return retval def _init_partial(self, firstchar): "Initialize a partial item from a string being entered by the user." self.is_entered = False self.decimal = None self.string = firstchar def _init_full(self, decval): "Initialize an item from a Decimal." if not isinstance(decval, Decimal): raise TypeError("Only Decimals are valid initial values " "for the decval constructor parameter.") self.is_entered = True self.decimal = decval self._string_repr_from_value() def _string_repr_from_value(self): "(Re)set the string representation based on the attribute /value/." self.string = \ str(self._remove_exponent(self.decimal.normalize())).replace('E', 'e')
[docs] def add_character(self, nextchar): """ Add a character to the running string of the number being entered on the stack. Calling :meth:`add_character` is illegal and will raise an ``AssertionError`` if the number has already been entered completely. :return: ``True`` if successful, ``False`` if the stack width (``esc.consts.STACKWIDTH``) has been exceeded. """ assert not self.is_entered, "Number already entered!" if len(self.string) < STACKWIDTH: self.string += nextchar return True else: return False
[docs] def backspace(self, num_chars=1): """ Remove the last character(s) from the string being entered. Calling :meth:`backspace` is illegal and will raise an ``AssertionError`` if the number has already been entered completely. """ assert not self.is_entered, "Cannot backspace an already-entered string!" self.string = self.string[0:-1*num_chars]
[docs] def finish_entry(self): """ Signal that the user is done entering a string and it should be converted to a Decimal value. If successful, return ``True``; if the entered string does not form a valid number, return ``False``. This should be called only by the ``enter_number`` method of ``StackState``. """ try: self.decimal = Decimal(self.string) except decimal.InvalidOperation: return False else: self._string_repr_from_value() self.is_entered = True return True
class StackState: """ An object containing the current state of the stack: a stack pointer, the screen cursor's position across on the current value, the stack itself as a list, and whether we are currently editing a number. Values may be manipulated directly as convenient. There are also several helper methods for convenience. Generally, a StackState should be initialized at the beginning of execution and used until the program exits. Checkpointing and undoing operate by exporting and restoring mementos consisting of this object's __dict__. """ def __init__(self): self.s = [] self.operation_history = [] self.stack_posn = -1 self._editing_last_item = False def __repr__(self): vals = [repr(item) if idx != self.stack_posn else f"({item!r})" for idx, item in enumerate(self.s)] if self.editing_last_item: vals[-1] += "..." return f"<StackState: {', '.join(vals)}>" def __iter__(self): for i in self.s: yield i def __eq__(self, other): if isinstance(other, self.__class__): return self.__dict__ == other.__dict__ return NotImplemented @property def editing_last_item(self): return self._editing_last_item @editing_last_item.setter def editing_last_item(self, value): self._editing_last_item = value #TODO: This really shouldn't be here but we can't put the status logic # in display either or we have a circular dependency if self._editing_last_item: status.entering_number() else: status.ready() @property def bos(self): "Bottom of Stack -- the last item, or None if the stack is empty." try: return self.s[self.stack_posn] except IndexError: return None @bos.setter def bos(self, value): try: self.s[self.stack_posn] = value except IndexError: self.s.append(value) @bos.deleter def bos(self): self.s.pop() # raises IndexError if nothing here self.editing_last_item = False self.stack_posn -= 1 @property def cursor_posn(self): if self.bos is None or not self.editing_last_item: return 0 else: return len(self.bos.string) @property def free_stack_spaces(self): return STACKDEPTH - self.stack_posn - 1 @property def is_empty(self): return not bool(self.s) @property def last_operation(self): return self.operation_history[-1] if self.operation_history else None def _new_entry_stack_item(self, c): """ Create a new stack item for the user to type into beginning with the character /c/. """ if not self.has_push_space(1): return False self.stack_posn += 1 self.s.append(StackItem(firstchar=c)) self.editing_last_item = True return True def as_decimal(self): """ Return the stack as a list of its Decimal values. """ return [i.decimal for i in self.s] def add_character(self, c): """ Append to an existing incomplete item on the stack with the given character (or string) /c/. If bos is not incomplete, start a new stack item beginning with the character /c/. Return True if successful, False if we have exceeded the maximum capacity of the stack. """ if self.editing_last_item: return self.s[self.stack_posn].add_character(c) else: return self._new_entry_stack_item(c) def backspace(self): """ Backspace one character in the current item on the stack. Return 0 if a character was backspaced, 1 if the whole item was wiped out, and -1 if a stack item was not being edited at all. """ if not self.editing_last_item: return -1 assert self.bos is not None, "Editing last item without an item on the stack" if self.cursor_posn == 1: # remove the stack item in progress del self.bos return 1 else: self.bos.backspace() return 0 def clear(self): "Clear the stack." self.s.clear() self.stack_posn = -1 self.editing_last_item = False def enter_number(self, running_op=None): """ Finish the entry of a number. Return: True if a number was finished. False if bos was already finished. Raises: ValueError if the value was invalid and the number couldn't be finished. The exception message can be displayed on the status bar. Set running_op to an operation name if you're entering prior to running an operation (go figure) for a more helpful error message in that case. """ # Even if we were *not* editing the stack, checkpoint the stack state. # This ensures that we will get a checkpoint anytime we call an # operation as well as when we enter a number onto the stack. # Multiple checkpoints in a row do nothing, so this is safe. history.hs.checkpoint_stack(self) if self.editing_last_item: if self.s[self.stack_posn].finish_entry(): self.editing_last_item = False return True else: if running_op: msg = 'Cannot run "%s": invalid value on bos.' % running_op else: msg = 'Bottom of stack is not a valid number.' raise ValueError(msg) else: return False def has_push_space(self, spaces): return STACKDEPTH >= len(self.s) + spaces def push(self, vals, description=None): """ Push an iterable of decimals or StackItems onto the stack. If a /description/ of the operation is specified, it will be recorded for display as a step in the history pane. """ if not self.has_push_space(len(vals)): return False if description is not None: self.record_operation(description) for i in vals: if isinstance(i, StackItem): self.s.append(i) else: self.s.append(StackItem(decval=i)) self.stack_posn += len(vals) return True def pop(self, num=1, retain=False): """ Pop /num/ StackItems off the end of the stack and return them as a list. If there are too few items on the stack, return None. If /retain/ is true, don't remove the items from the stack. """ if not retain: self.stack_posn -= num if len(self.s) < num: return None elif num == 0: # Needs a special case, as s[:-0] will wipe out the stack return [] else: stack_slice = self.s[-num:] if not retain: self.s = self.s[:-num] return stack_slice def last_n_items(self, n): """ Return the last /n/ items from the stack, with special meaning for 0 and -1. n > 0 => the last n items on the stack n == 0 => the empty list n == -1 => (a shallow copy of) the entire stack This matches the meaning of the pop and push values used in the operation execution logic. An operation requests push=n>0 when it wants to push n items, 0 if it pushes nothing, or -1 if whatever it pushes should replace the entire stack. """ if n == -1: return self.s[:] elif n == 0: return [] else: return self.s[-n:] def record_operation(self, description): """ Record that an operation happened to the stack. Typically this will be accomplished from the client by providing the description as an argument to push() when pushing back results, but the client may also wish to record an operation that doesn't push anything (for instance, when clearing the stack, or mutating the entire stack with a push=-1). """ self.operation_history.append(description) def memento(self): """ Generate a memento object for the current state of the stack. The stack can later be restored to this state by calling restore() on the memento. """ return copy.deepcopy(self.__dict__) def restore(self, memento): """ Restore the current object to a prior state checkpointed by calling memento(). """ self.__dict__.clear() self.__dict__.update(memento) self.editing_last_item = self._editing_last_item # force property logic to run @contextmanager def transaction(self): """ Context manager to start a stack transaction. If an exception occurs during the run, any changes made to the StackState will be rolled back before re-raising the exception. Otherwise, changes will persist. Raising the special exception RollbackTransaction causes the transaction to be rolled back but no exception to be raised. In addition, the message provided on RollbackTransaction will be set as a status-bar error. It's difficult for the caller to do this themselves because the act of rolling back will set the status to "ready" or "insert". """ checkpoint = self.memento() try: yield except RollbackTransaction as e: self.restore(checkpoint) if e.status_message: status.error(e.status_message) except Exception: self.restore(checkpoint) raise