From 8321483b5a26f5acdfe685de323e09f169dcfb5d Mon Sep 17 00:00:00 2001 From: Marc Culler Date: Wed, 2 Feb 2022 13:36:02 -0600 Subject: [PATCH] Add main.py, with management of jupyter pid files, and adjust paths. --- Sage_framework/build_big_sage.sh | 1 + Sage_framework/files/sage-env-config | 4 +- main.py | 380 +++++++++++++++++++++++++++ 3 files changed, 383 insertions(+), 2 deletions(-) create mode 100755 main.py diff --git a/Sage_framework/build_big_sage.sh b/Sage_framework/build_big_sage.sh index 5be8cc2..cda30b1 100644 --- a/Sage_framework/build_big_sage.sh +++ b/Sage_framework/build_big_sage.sh @@ -59,6 +59,7 @@ CONFIG_OPTIONS="--with-system-python3=no \ --enable-tdlib \ --enable-tides" if [ "$1" != "noconfig" ]; then + make configure ./configure $CONFIG_OPTIONS > /tmp/configure.out fi make build diff --git a/Sage_framework/files/sage-env-config b/Sage_framework/files/sage-env-config index a74ec39..54e0c00 100755 --- a/Sage_framework/files/sage-env-config +++ b/Sage_framework/files/sage-env-config @@ -28,10 +28,10 @@ export SAGE_ENV_CONFIG_SOURCED=1 # SAGE_LOCAL is the installation prefix and can be customized by using # ./configure --prefix -export SAGE_LOCAL="/var/tmp/sage-__VERSION__-current/local" +export SAGE_LOCAL="/var/tmp/sage-9.5-current/local" # SAGE_ROOT is the location of the Sage source/build tree. -export SAGE_ROOT="/var/tmp/sage-__VERSION__-current/local" +export SAGE_ROOT="/var/tmp/sage-9.5-current" ####################################### # Compilers set at configuration time. diff --git a/main.py b/main.py new file mode 100755 index 0000000..b752129 --- /dev/null +++ b/main.py @@ -0,0 +1,380 @@ +import sys +import re +import os +from os.path import pardir, abspath, join as path_join +import subprocess +import signal +import json +import time +import plistlib +import tkinter +from configparser import ConfigParser +from tkinter import ttk +from tkinter.font import Font +from tkinter.simpledialog import Dialog +from tkinter.filedialog import askdirectory +from tkinter.messagebox import showerror, showwarning, askyesno + +pid_re = re.compile('nbserver-([0-9]*)\.json') +jupyter_id = re.compile('nbserver-([0-9]+)-open.html') +contents_dir = abspath(path_join(sys.argv[0], pardir, pardir)) +framework_dir = path_join(contents_dir, 'Frameworks') +info_plist = path_join(contents_dir, 'Info.plist') +current = path_join(framework_dir, 'Sage.framework', 'Versions', 'Current') +sage_executable = path_join(current, 'venv', 'bin', 'sage') + +def get_version(): + with open(info_plist, 'rb') as plist_file: + info = plistlib.load(plist_file) + return info['CFBundleShortVersionString'] + +def clean_jupyters(): + home_dir = os.getenv('HOME') + jupyter_dir = os.path.join(home_dir, 'Library', 'Application Support', 'SageMath', '9.5', 'Jupyter') + for filename in os.listdir(jupyter_dir): + match = pid_re.match(filename) + if match: + pid = int(match[1]) + try: + # Killing a process with signal 0 does not send a signal but raises an exception + # if the process does not exist. See man 2 kill. + os.kill(int(pid), 0) + except ProcessLookupError: + try: + os.unlink(os.path.join(jupyter_dir, 'nbserver-%d.json'%pid)) + os.unlink(os.path.join(jupyter_dir, 'nbserver-%d-open.html'%pid)) + except FileNotFoundError: + pass + +sagemath_version = get_version() +app_support_dir = path_join(os.environ['HOME'], 'Library', 'Application Support', + 'SageMath', sagemath_version) +jupyter_runtime_dir = path_join(app_support_dir, 'Jupyter') +config_file = path_join(app_support_dir, 'config') + +class PopupMenu(ttk.Menubutton): + def __init__(self, parent, variable, values): + ttk.Menubutton.__init__(self, parent, textvariable=variable, + direction='flush') + self.parent = parent + self.variable = variable + self.update(values) + + def update(self, values): + self.variable.set(values[0]) + self.menu = tkinter.Menu(self.parent, tearoff=False) + for value in values: + self.menu.add_radiobutton(label=value, variable=self.variable) + self.config(menu=self.menu) + +class Launcher: + sage_cmd = 'clear ; %s ; exit'%sage_executable + terminal_script = """ + set command to "%s" + tell application "System Events" + set terminalProcesses to application processes whose name is "Terminal" + end tell + if terminalProcesses is {} then + set terminalIsRunning to false + else + set terminalIsRunning to true + end if + if terminalIsRunning then + tell application "Terminal" + activate + do script command + end tell + else + -- avoid opening two windows + tell application "Terminal" + activate + do script command in window 1 + end tell + end if + """%sage_cmd + + iterm_script = """ + set sageCommand to "/bin/bash -c '%s'" + tell application "iTerm" + set sageWindow to (create window with default profile command sageCommand) + select sageWindow + end tell + """%sage_cmd + + find_app_script = """ + set appExists to false + try + tell application "Finder" to get application file id "%s" + set appExists to true + end try + return appExists + """ + + def launch_terminal(self, app): + if app == 'Terminal.app': + subprocess.run(['osascript', '-'], input=self.terminal_script, text=True, + capture_output=True) + elif app == 'iTerm.app': + subprocess.run(['open', '-a', 'iTerm'], capture_output=True) + subprocess.run(['osascript', '-'], input=self.iterm_script, text=True, + capture_output=True) + return True + + def launch_notebook(self, url=None): + environ = {'JUPYTER_RUNTIME_DIR': jupyter_runtime_dir} + environ.update(os.environ) + if url is None: + if not self.check_notebook_dir(): + return False + jupyter_notebook_dir = self.notebooks.get() + if not jupyter_notebook_dir: + jupyter_notebook_dir = os.environ['HOME'] + subprocess.Popen([sage_executable, '--jupyter', 'notebook', + '--notebook-dir=%s'%jupyter_notebook_dir], env=environ) + else: + subprocess.run(['open', url], env=environ, capture_output=True) + return True + + def find_app(self, bundle_id): + script = self.find_app_script%bundle_id + result = subprocess.run(['osascript', '-'], input=script, text=True, + capture_output=True) + return result.stdout.strip() == 'true' + +class LaunchWindow(tkinter.Toplevel, Launcher): + def __init__(self, root): + Launcher.__init__(self) + self.get_state() + self.root = root + tkinter.Toplevel.__init__(self) + self.tk.call('::tk::unsupported::MacWindowStyle', 'style', self._w, + 'document', 'closeBox') + self.protocol("WM_DELETE_WINDOW", self.quit) + self.title('SageMath') + self.columnconfigure(0, weight=1) + frame = ttk.Frame(self, padding=10, width=300) + frame.columnconfigure(0, weight=1) + frame.grid(row=0, column=0, sticky=tkinter.NSEW) + self.update_idletasks() + # Logo + resource_dir = abspath(path_join(sys.argv[0], pardir, pardir, 'Resources')) + logo_file = path_join(resource_dir, 'sage_logo_256.png') + try: + self.logo_image = tkinter.PhotoImage(file=logo_file) + logo = ttk.Label(frame, image=self.logo_image) + except tkinter.TclError: + logo = ttk.Label(frame, text='Logo Here') + # Interfaces + checks = ttk.Labelframe(frame, text="Available User Interfaces", padding=10) + self.radio_var = radio_var = tkinter.Variable(checks, + self.config['state']['interface_type']) + self.use_cli = ttk.Radiobutton(checks, text="Command line", variable=radio_var, + value='cli', command=self.update_radio_buttons) + self.terminals = ['Terminal.app'] + if self.find_app('com.googlecode.iterm2'): + if self.config['state']['terminal_app'] == 'iTerm.app': + self.terminals.insert(0, 'iTerm.app') + else: + self.terminals.append('iTerm.app') + self.terminal_var = tkinter.Variable(self, self.terminals[0]) + self.terminal_option = PopupMenu(checks, self.terminal_var, self.terminals) + self.use_jupyter = ttk.Radiobutton(checks, text="Jupyter notebook from folder:", + variable=radio_var, value='nb', command=self.update_radio_buttons) + notebook_frame = ttk.Frame(checks) + self.notebooks = ttk.Entry(notebook_frame, width=24) + self.notebooks.insert(tkinter.END, self.config['state']['notebook_dir']) + self.notebooks.config(state='readonly') + self.browse = ttk.Button(notebook_frame, text='Select ...', padding=(-8, 0), + command=self.browse_notebook_dir, state=tkinter.DISABLED) + self.notebooks.grid(row=0, column=0) + self.browse.grid(row=0, column=1) + # Launch button + self.launch = ttk.Button(frame, text="Launch", command=self.launch_sage) + # Build the interfaces frame + self.use_cli.grid(row=0, column=0, sticky=tkinter.W, pady=5) + self.terminal_option.grid(row=1, column=0, sticky=tkinter.W, padx=10, pady=5) + self.use_jupyter.grid(row=2, column=0, sticky=tkinter.W, pady=5) + notebook_frame.grid(row=3, column=0, sticky=tkinter.W, pady=5) + # Build the window + logo.grid(row=0, column=0, pady=5) + checks.grid(row=1, column=0, padx=10, pady=10, sticky=tkinter.EW) + self.launch.grid(row=2, column=0) + self.geometry('380x350+400+400') + self.update_radio_buttons() + + def quit(self): + self.destroy() + self.root.destroy() + + def get_state(self): + self.config = ConfigParser() + if os.path.exists(config_file): + self.config.read(config_file) + else: + self.config['state'] = { + 'interface_type': 'cli', + 'terminal_app': 'Terminal.app', + 'notebook_dir': '', + } + + def save_state(self): + try: + config = self.config + config['state']['interface_type'] = self.radio_var.get() + config['state']['terminal_app'] = self.terminal_var.get() + config['state']['notebook_dir'] = self.notebooks.get() + with open(config_file, 'w') as outfile: + config.write(outfile) + except: + pass + + def update_radio_buttons(self): + radio = self.radio_var.get() + if radio == 'cli': + self.notebooks.config(state=tkinter.DISABLED) + self.browse.config(state=tkinter.DISABLED) + self.terminal_option.config(state=tkinter.NORMAL) + elif radio == 'nb': + self.notebooks.config(state='readonly') + self.browse.config(state=tkinter.NORMAL) + self.terminal_option.config(state=tkinter.DISABLED) + + def launch_sage(self): + interface = self.radio_var.get() + if interface == 'cli': + launched = self.launch_terminal(app=self.terminal_var.get()) + elif interface == 'nb': + jupyter_openers = [f for f in os.listdir(jupyter_runtime_dir) + if f[-4:] == 'html'] + if not jupyter_openers: + launched = self.launch_notebook(None) + else: + html_file = path_join(jupyter_runtime_dir, jupyter_openers[0]) + launched = self.launch_notebook(html_file) + if launched: + self.save_state() + self.quit() + + def check_notebook_dir(self): + notebook_dir = self.notebooks.get() + if not notebook_dir.strip(): + showwarning(parent=self, + message="Please choose or create a folder for your Jupyter notebooks.") + return False + if not os.path.exists(notebook_dir): + answer = askyesno(message='May we create the folder %s?'%notebook_dir) + if answer == tkinter.YES: + os.makedirs(notebook_dir, exist_ok=True) + else: + return False + try: + os.listdir(notebook_dir) + except: + showerror(message='Sorry. We do not have permission to read %s'%directory) + return False + return True + + def browse_notebook_dir(self): + json_files = [filename for filename in os.listdir(jupyter_runtime_dir) + if os.path.splitext(filename)[1] == '.json'] + if json_files: + answer = askyesno(message='You already have a Jupyter server running with ' + 'the notebook directory shown. Do you want to stop ' + 'that server and start a new one?') + if answer == tkinter.YES: + for json_file in json_files: + with open(os.path.join(jupyter_runtime_dir, json_file)) as in_file: + try: + pid = int(json.load(in_file)['pid']) + os.kill(pid, signal.SIGINT) + time.sleep(2) + os.kill(pid, signal.SIGINT) + except: + pass + else: + return + directory = askdirectory(parent=self, initialdir=os.environ['HOME'], + message='Choose or create a folder for Jupyter notebooks') + if directory: + self.notebooks.config(state=tkinter.NORMAL) + self.notebooks.delete(0, tkinter.END) + self.notebooks.insert(tkinter.END, directory) + self.notebooks.config(state='readonly') + +class AboutDialog(Dialog): + def __init__(self, master, title='', content=''): + self.content = content + self.style = ttk.Style(master) + resource_dir = abspath(path_join(sys.argv[0], pardir, pardir, 'Resources')) + logo_file = path_join(resource_dir, 'sage_logo_256.png') + try: + self.logo_image = tkinter.PhotoImage(file=logo_file) + except tkinter.TclError: + self.logo_image = None + Dialog.__init__(self, master, title=title) + + def body(self, master): + self.resizable(False, False) + frame = ttk.Frame(self) + if self.logo_image: + logo = ttk.Label(frame, image=self.logo_image) + else: + logo = ttk.Label(frame, text='Logo Here') + logo.grid(row=0, column=0, padx=20, pady=20, sticky=tkinter.N) + message = tkinter.Message(frame, text=self.content) + message.grid(row=1, column=0, padx=20, sticky=tkinter.EW) + frame.pack() + + def buttonbox(self): + frame = ttk.Frame(self, padding=(0, 0, 0, 20)) + ok = ttk.Button(frame, text="OK", width=10, command=self.ok, + default=tkinter.ACTIVE) + ok.grid(row=2, column=0, padx=5, pady=5) + self.bind("", self.ok) + self.bind("", self.ok) + frame.pack() + +class SageApp(Launcher): + resource_dir = abspath(path_join(sys.argv[0], pardir, pardir, 'Resources')) + icon_file = abspath(path_join(resource_dir, 'sage_icon_1024.png')) + about = """ +SageMath is a free open-source mathematics software system licensed under the GPL. Please visit sagemath.org for more information about SageMath. + +This SageMath app contains a subset of the SageMath binary distribution available from sagemath.org. It is packaged as a component of the 3-manifolds project by Marc Culler, Nathan Dunfield, and Matthias Gӧrner. It is licensed under the GPL License, version 2 or later, and can be downloaded from +https://github.com/3-manifolds/Sage_macOS/releases. + +The app is copyright © 2021 by Marc Culler, Nathan Dunfield, Matthias Gӧrner and others. +""" + + def __init__(self): + self.root_window = root = tkinter.Tk() + root.withdraw() + os.chdir(os.environ['HOME']) + os.makedirs(jupyter_runtime_dir, mode=0o755, exist_ok=True) + self.icon = tkinter.Image("photo", file=self.icon_file) + root.tk.call('wm','iconphoto', root._w, self.icon) + self.menubar = menubar = tkinter.Menu(root) + apple_menu = tkinter.Menu(menubar, name="apple") + apple_menu.add_command(label='About SageMath ...', command=self.about_sagemath) + menubar.add_cascade(menu=apple_menu) + root.config(menu=menubar) + ttk.Label(root, text="SageMath 9.4").pack(padx=20, pady=20) + + def about_sagemath(self): + AboutDialog(self.root_window, 'SageMath', self.about) + + def run(self): + clean_jupyters() + symlink = path_join(os.path.sep, 'var', 'tmp', 'sage-%s-current'%sagemath_version) + self.launcher = LaunchWindow(root=self.root_window) + if not os.path.islink(symlink): + try: + os.symlink(current, symlink) + except Exception as e: + showwarning(parent=self.root_window, + message="%s Cannot create %s; SageMath must exit."%(e, symlink)) + sys.exit(1) + self.root_window.mainloop() + +if __name__ == '__main__': + SageApp().run()