Last active
January 22, 2021 20:16
-
-
Save 0xpizza/5518eaee784d9e6d8e8670df21d23d1e to your computer and use it in GitHub Desktop.
bc on steroids
This file contains hidden or 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 ast | |
| import math | |
| import cmath | |
| import itertools | |
| import functools | |
| import statistics | |
| import tkinter as tk | |
| from tkinter.ttk import Progressbar | |
| from tkinter.scrolledtext import ScrolledText | |
| import concurrent.futures | |
| NAMESPACE_MATH = { | |
| f:eval(f'math.{f}') for f in dir(math) if not f.startswith('_') | |
| } | |
| NAMESPACE_CMATH = { | |
| f:eval(f'cmath.{f}') for f in dir(cmath) if not f.startswith('_') | |
| } | |
| NAMESPACE_STATISTICS = { | |
| f:eval(f'statistics.{f}') for f in dir(statistics) if not f.startswith('_') | |
| } | |
| NAMESPACE_ITERTOOLS = { | |
| f:eval(f'itertools.{f}') for f in dir(itertools) if not f.startswith('_') | |
| } | |
| NAMESPACE_BUILTINS = { | |
| f:eval(f) for f in ( | |
| 'abs bin chr complex divmod float hex int len ' | |
| 'list map max min oct ord pow range round sum zip' | |
| ).split() | |
| } | |
| NAMESPACE_BUILTINS['__builtins__'] = None | |
| def safe_eval(expr, whitelist) -> object: | |
| """Evaluate an expression in a way that attempts to | |
| eliminate the possibility of arbitrary code execution. | |
| Only the basic data and object types are available, and | |
| the user's ability to create arbitray objects is severly | |
| hindered (and hopefully eliminated). | |
| """ | |
| # all errors are homogenized as ValueErrors to simplify upstream code | |
| if len(expr) > 100: | |
| raise ValueError('Expression is too long.') | |
| try: | |
| nodes = ast.parse(expr) | |
| except SyntaxError as e: | |
| raise ValueError('Invalid syntax') | |
| for node in ast.walk(nodes): | |
| if isinstance(node, ast.Attribute): | |
| raise ValueError('Attribute access not allowed') | |
| try: | |
| return eval(expr, {**whitelist, **NAMESPACE_BUILTINS}, {}) | |
| except (TypeError, SyntaxError): | |
| raise ValueError('Invalid expression') | |
| except NameError as e: | |
| raise ValueError(''.join(e.args)) | |
| class ReadOnlyText(ScrolledText): | |
| """Text widget that can only be modified programatically. | |
| """ | |
| def __init__(self, master=None, **kwargs): | |
| super().__init__(master, **kwargs) | |
| self.insert = self._unlock(super().insert) | |
| self.delete = self._unlock(super().delete) | |
| self.configure(state=tk.DISABLED) | |
| def _unlock(self, f): | |
| @functools.wraps(f) | |
| def _wrap(*args, **kwargs): | |
| nonlocal self | |
| nonlocal f | |
| self.configure(state=tk.NORMAL) | |
| r = f(*args, **kwargs) | |
| self.configure(state=tk.DISABLED) | |
| return r | |
| return _wrap | |
| class CalcDisplay(ReadOnlyText): | |
| def __init__(self, master=None, **kwargs): | |
| super().__init__(master, **kwargs) | |
| self.config( | |
| height=1, width=1, background='black', | |
| foreground='lime', borderwidth=0 | |
| ) | |
| self.vbar.pack_forget() # hide the scrollbar | |
| self.pack(fill=tk.BOTH, expand=tk.YES, padx=5, pady=5) | |
| self.tag_config('cjust', justify=tk.CENTER) | |
| self.tag_config('ljust', justify=tk.LEFT) | |
| self.tag_config('rjust', justify=tk.RIGHT, background='#000d00') | |
| def insert(self, *args): | |
| """This method doesn't ever get called for some reason...""" | |
| super().insert(*args) | |
| self.see(tk.END) | |
| def write_l(self, text): | |
| self.insert(tk.END, text, 'ljust') | |
| self.see(tk.END) | |
| def write_r(self, text): | |
| self.insert(tk.END, text, 'rjust') | |
| self.see(tk.END) | |
| def write_c(self, text): | |
| self.insert(tk.END, text, 'cjust') | |
| self.see(tk.END) | |
| class CalcInput(tk.Text): | |
| def __init__(self, master=None, **kwargs): | |
| self.submit_cb = kwargs.pop('submitcallback') | |
| super().__init__(master, **kwargs) | |
| self.config( | |
| background='black',foreground='lime', #relief=tk.FLAT, | |
| borderwidth=1, highlightbackground='white', height=1, | |
| insertbackground='lime' | |
| ) | |
| self.pack(fill=tk.X) | |
| self.focus_set() | |
| self.seekable = False | |
| self.allow_submit = tk.IntVar(self) | |
| self.shift_position = tk.IntVar(self) # 1 = down, 0 = up | |
| self.bind('<Return>', self.submit) | |
| # Use Shift + Enter to insert a literal newline | |
| self.bind('<BackSpace>', self.check_height) | |
| self.bind('<Delete>', self.check_height) | |
| self.bind('<Shift-Return>', self.check_height) | |
| self.bind('<KeyPress-Shift_L>', self.shift_depressed) | |
| self.bind('<KeyRelease-Shift_L>', self.shift_released) | |
| # Ctrl + C clears input field | |
| self.bind('<Control-c>', functools.partial( | |
| self.after, 1, (lambda _:self.delete(1.0, tk.END)) | |
| )) | |
| # Use the up arrow key to copy the last expression | |
| #self.bind('<Up>', self.set_last_expression) | |
| #TODO: figure out the height handler. | |
| def shift_depressed(self, event=None): | |
| self.shift_position.set(1) | |
| self.allow_submit.set(0) | |
| def shift_released(self, event=None): | |
| self.shift_position.set(0) | |
| self.allow_submit.set(1) | |
| self.check_height() | |
| def submit(self, event=None): | |
| if self.allow_submit.get(): | |
| txt = self.get(1.0, tk.END) | |
| self.last_expression = txt | |
| self.current_expression = '' | |
| self.configure(height=1) | |
| self.delete(1.0, tk.END) | |
| self.submit_cb(txt) | |
| self.check_height() | |
| def check_height(self, event=None): | |
| """Buffer update happens after keypresses are processed, | |
| so basically all content checks are 1 input cycle behind. | |
| We can force the buffers to update before the keyboard | |
| events by cycling the event loop once before checking. | |
| """ | |
| self.after(1, self._check_height) | |
| def _check_height(self): | |
| h = ( | |
| int(self.index(tk.END).split('.')[0]) - | |
| self.get(1.0, tk.END).count('\n',-2) | |
| ) | |
| print(h, self.get(1.0, 'end').encode()) | |
| if h < 1: | |
| h = 1 | |
| if h > 1: | |
| self.seekable = True | |
| else: | |
| self.seekable = False | |
| self.configure(height=h) | |
| class Calculator(tk.Frame): | |
| """A GUI calculator with a command line interface. | |
| Calculations are sent off to another process where | |
| they can be computed safely without the overhead | |
| of the GUI, and can be cancelled if needed. | |
| The calculator also | |
| """ | |
| def __init__(self, master=None, **kwargs): | |
| super().__init__(master, **kwargs) | |
| self.config(background='black') | |
| # TODO: add ability to switch modes | |
| self.modes = { | |
| 'math': NAMESPACE_MATH, | |
| 'cmath': NAMESPACE_CMATH, | |
| 'stats':NAMESPACE_STATISTICS | |
| } | |
| # special variable representing the result of the last computation | |
| self.ANS = 0 | |
| self.stored_answers = { | |
| chr(c):0 for c in range(65,91) | |
| } | |
| self.current_mode = self.modes.get('math') | |
| self.display = CalcDisplay(self) | |
| self.text_input = CalcInput(self, submitcallback=self.submit) | |
| # initialize the process pool asynchronously to improve startup time | |
| # TODO: use multiprocessing for performance and job cancellation | |
| self._init_m = concurrent.futures.ThreadPoolExecutor() | |
| self.display.write_c('\n>>> CalcuPy <<<\nsee /? or /help\n') | |
| def parse_command(self, expr): | |
| return 'No commands yet. Carry on!' | |
| def submit(self, expr): | |
| """Handle input from text_input, compute the answer, then | |
| display the input in the display widget. | |
| """ | |
| expr = expr.strip() | |
| # process as an application command | |
| if expr.startswith('/'): | |
| result = self.parse_command(expr) | |
| self.display.write_l(expr + '\n') | |
| self.display.write_l(result) | |
| return | |
| # attempt proprocess a storage variable | |
| var = None | |
| if expr[1:2] == '=': | |
| if not (var:=ord(expr[:1])) in range(65,91): | |
| expr = expr[2:] | |
| if not expr: | |
| self.write_r(var + ' cleared') | |
| return | |
| else: | |
| self.write_l('No such variable. See /? for more information') | |
| return | |
| # remove all unecessary whitespace | |
| #(sadly, this inflates the length of legit strings :/) | |
| oneliner = ' '.join( | |
| filter(bool, | |
| map(str.strip, | |
| expr.split() | |
| ))) | |
| try: | |
| result = safe_eval( | |
| oneliner, { | |
| **self.current_mode, | |
| **NAMESPACE_ITERTOOLS, | |
| **self.stored_answers, | |
| 'ANS':self.ANS | |
| }) | |
| self.ANS = result | |
| self.stored_answers[var] = result | |
| except ValueError as e: | |
| result = ''.join(e.args) | |
| self.display.write_l(expr) | |
| self.display.write_l('\n') | |
| self.display.write_r(result) | |
| self.display.write_l('\n') | |
| class Root(tk.Tk): | |
| def __init__(self, master=None, **kwargs): | |
| super().__init__(master, **kwargs) | |
| self.geometry('400x300') | |
| self.bind('<Escape>', lambda *_:self.destroy()) | |
| Calculator(self).pack(fill=tk.BOTH, expand=tk.YES) | |
| def main(): | |
| Root().mainloop() | |
| if __name__ == '__main__': | |
| main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment